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/pool.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Pool: quality-controlled events, picks and stations, one folder per network.
|
|
2
|
+
|
|
3
|
+
data/pool/
|
|
4
|
+
pool_config.json QC + split config every network was built with
|
|
5
|
+
qc_report.csv QC funnel per network
|
|
6
|
+
label_model.json fitted by bepad.build.labels (all networks)
|
|
7
|
+
network=<name>/events.parquet one row per event (with its split)
|
|
8
|
+
network=<name>/picks.parquet one row per pick (P/S, travel time, distances)
|
|
9
|
+
network=<name>/stations.parquet
|
|
10
|
+
network=<name>/manifest.json UTDQuake version + revision, source hashes, QC report
|
|
11
|
+
|
|
12
|
+
<name> is the UTDQuake network (``utdq_net``); a station is ``NET.STA`` (see bepad.build.utdquake).
|
|
13
|
+
|
|
14
|
+
QC mirrors hypogt test/09252026 v8:
|
|
15
|
+
1. event filters: >= 4 P-family picks, >= 3 stations, catalog gap <= 270 deg,
|
|
16
|
+
depth, RMS, horizontal/vertical uncertainty
|
|
17
|
+
2. P/S families only; per-pick abs(z-score) <= 3 trimming, reverted if the
|
|
18
|
+
event would drop below 3 stations or above 270 deg gap
|
|
19
|
+
3. drop negative travel times; earliest pick per (event, station, P/S);
|
|
20
|
+
re-check >= 4 P picks on >= 3 stations
|
|
21
|
+
|
|
22
|
+
Split: random per event, sha256(salt | network | event_id) -> train/val/test;
|
|
23
|
+
a network with fewer than ``split.test_only_below`` events is test only.
|
|
24
|
+
Networks are treated independently, and adding a network never moves an
|
|
25
|
+
existing event.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import datetime as dt
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
from collections import OrderedDict
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import pandas as pd
|
|
39
|
+
|
|
40
|
+
from .geo import P_PHASES, S_PHASES, azimuthal_gap_deg, haversine_km, stable_uniform
|
|
41
|
+
|
|
42
|
+
_FAMILY = {p: "P" for p in P_PHASES} | {s: "S" for s in S_PHASES}
|
|
43
|
+
|
|
44
|
+
EVENT_COLUMNS = [
|
|
45
|
+
"event_id", "network", "time", "latitude", "longitude", "depth", "magnitude",
|
|
46
|
+
"magnitude_type", "catalog_azimuthal_gap", "standard_error",
|
|
47
|
+
"horizontal_uncertainty", "vertical_uncertainty", "n_p", "n_s", "n_picks",
|
|
48
|
+
"n_stations", "gap_deg", "duration_s", "max_dist_km", "last_station_km", "split",
|
|
49
|
+
]
|
|
50
|
+
PICK_COLUMNS = [
|
|
51
|
+
"event_id", "network", "station", "phase", "phase_raw", "time", "travel_time",
|
|
52
|
+
"distance_km", "hyp_distance_km", "travel_time_zscore", "evaluation_mode", "pick_id",
|
|
53
|
+
]
|
|
54
|
+
STATION_COLUMNS = ["network", "station_network", "station", "latitude", "longitude", "elevation"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pool_dir(data_root) -> Path:
|
|
58
|
+
return Path(data_root) / "pool"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ----------------------------------------------------------------------
|
|
62
|
+
# Stations
|
|
63
|
+
# ----------------------------------------------------------------------
|
|
64
|
+
def _station_table(stations, network):
|
|
65
|
+
"""One row per available, located station (``NET.STA``): confirmed coordinates, else the
|
|
66
|
+
ones UTDQuake calculated from the picks (elevation 0)."""
|
|
67
|
+
st = stations[stations["available"].fillna(False).astype(bool)]
|
|
68
|
+
conf = st["confirmed"].fillna(False).astype(bool).to_numpy()
|
|
69
|
+
out = pd.DataFrame({
|
|
70
|
+
"network": network,
|
|
71
|
+
"station_network": st["station_network"].astype(str).to_numpy(),
|
|
72
|
+
"station": st["station"].to_numpy(),
|
|
73
|
+
"latitude": np.where(conf, st["confirmed_latitude"], st["calculated_latitude"]).astype(float),
|
|
74
|
+
"longitude": np.where(conf, st["confirmed_longitude"], st["calculated_longitude"]).astype(float),
|
|
75
|
+
"elevation": np.where(conf, st["confirmed_elevation"], 0.0).astype(float),
|
|
76
|
+
})
|
|
77
|
+
out["elevation"] = out["elevation"].fillna(0.0)
|
|
78
|
+
out = out.dropna(subset=["latitude", "longitude"])
|
|
79
|
+
return out.drop_duplicates(subset="station").reset_index(drop=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ----------------------------------------------------------------------
|
|
83
|
+
# QC
|
|
84
|
+
# ----------------------------------------------------------------------
|
|
85
|
+
def _passes(ev, col, max_value, keep_missing):
|
|
86
|
+
values = pd.to_numeric(ev[col], errors="coerce") if col in ev else pd.Series(np.nan, index=ev.index)
|
|
87
|
+
return (values <= max_value) | (values.isna() & keep_missing)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_qc(events, picks, stations, qc, network):
|
|
91
|
+
"""Return (events, picks, report) after the three QC stages."""
|
|
92
|
+
rep = OrderedDict()
|
|
93
|
+
rep["events_raw"] = len(events)
|
|
94
|
+
rep["picks_raw"] = len(picks)
|
|
95
|
+
|
|
96
|
+
ev = events.dropna(subset=["time", "latitude", "longitude", "depth"])
|
|
97
|
+
ev = ev.drop_duplicates(subset="event_id").copy()
|
|
98
|
+
ev["event_id"] = ev["event_id"].astype(str)
|
|
99
|
+
rep["events_with_origin"] = len(ev)
|
|
100
|
+
|
|
101
|
+
# some networks already carry event_* columns in their picks; ours come from the events table
|
|
102
|
+
pk = picks.drop(columns=["event_time", "event_latitude", "event_longitude", "latitude", "longitude"],
|
|
103
|
+
errors="ignore").copy()
|
|
104
|
+
pk["event_id"] = pk["event_id"].astype(str)
|
|
105
|
+
pk = pk.merge(stations[["station", "latitude", "longitude"]], on="station", how="inner")
|
|
106
|
+
pk = pk[pk["event_id"].isin(ev["event_id"])]
|
|
107
|
+
rep["picks_with_station_coords"] = len(pk)
|
|
108
|
+
|
|
109
|
+
# --- stage 1: event filters (hypogt pre_filter) -------------------
|
|
110
|
+
fam = pk["phase"].map(_FAMILY)
|
|
111
|
+
counts = pd.DataFrame({
|
|
112
|
+
"n_p": (fam == "P").groupby(pk["event_id"]).sum(),
|
|
113
|
+
"n_s": (fam == "S").groupby(pk["event_id"]).sum(),
|
|
114
|
+
"n_sta": pk.groupby("event_id")["station"].nunique(),
|
|
115
|
+
})
|
|
116
|
+
ev = ev.join(counts, on="event_id")
|
|
117
|
+
ev[["n_p", "n_s", "n_sta"]] = ev[["n_p", "n_s", "n_sta"]].fillna(0)
|
|
118
|
+
|
|
119
|
+
keep_missing = qc["keep_missing"]
|
|
120
|
+
steps = [
|
|
121
|
+
("min_p_s", (ev["n_p"] >= qc["min_p"]) & (ev["n_s"] >= qc["min_s"])),
|
|
122
|
+
("min_stations", ev["n_sta"] >= qc["min_stations"]),
|
|
123
|
+
("max_gap", _passes(ev, "azimuthal_gap", qc["max_gap_deg"], keep_missing)),
|
|
124
|
+
("max_depth", _passes(ev, "depth", qc["max_depth_m"], keep_missing)),
|
|
125
|
+
("max_rms", _passes(ev, "standard_error", qc["max_rms_s"], keep_missing)),
|
|
126
|
+
("max_h_unc", _passes(ev, "horizontal_uncertainty", qc["max_horizontal_uncertainty_m"], keep_missing)),
|
|
127
|
+
("max_v_unc", _passes(ev, "vertical_uncertainty", qc["max_vertical_uncertainty_m"], keep_missing)),
|
|
128
|
+
]
|
|
129
|
+
mask = pd.Series(True, index=ev.index)
|
|
130
|
+
for name, m in steps:
|
|
131
|
+
mask &= m.fillna(False)
|
|
132
|
+
rep[f"events_after_{name}"] = int(mask.sum())
|
|
133
|
+
ev = ev[mask].drop(columns=["n_p", "n_s", "n_sta"])
|
|
134
|
+
|
|
135
|
+
# --- stage 2: P/S family + z-score trimming with revert ------------
|
|
136
|
+
pk = pk[pk["event_id"].isin(ev["event_id"])].copy()
|
|
137
|
+
pk["phase_raw"] = pk["phase"]
|
|
138
|
+
pk["phase"] = pk["phase_raw"].map(_FAMILY)
|
|
139
|
+
pk = pk[pk["phase"].notna()]
|
|
140
|
+
rep["picks_ps_family"] = len(pk)
|
|
141
|
+
|
|
142
|
+
pk = pk.merge(
|
|
143
|
+
ev[["event_id", "time", "latitude", "longitude"]].rename(
|
|
144
|
+
columns={"time": "event_time", "latitude": "event_latitude", "longitude": "event_longitude"}),
|
|
145
|
+
on="event_id", how="left",
|
|
146
|
+
)
|
|
147
|
+
rep["zscore_coverage"] = round(float(pd.to_numeric(pk["travel_time_zscore"], errors="coerce").notna().mean())
|
|
148
|
+
if "travel_time_zscore" in pk and len(pk) else 0.0, 4)
|
|
149
|
+
pk = _zscore_trim(pk, qc)
|
|
150
|
+
rep["picks_after_zscore"] = len(pk)
|
|
151
|
+
|
|
152
|
+
# --- stage 3: BEPAD cleaning --------------------------------------
|
|
153
|
+
pk["travel_time"] = (pk["time"] - pk["event_time"]).dt.total_seconds()
|
|
154
|
+
pk = pk[pk["travel_time"] >= 0]
|
|
155
|
+
rep["picks_after_positive_tt"] = len(pk)
|
|
156
|
+
pk = pk.sort_values(["event_id", "station", "phase", "time"])
|
|
157
|
+
pk = pk.drop_duplicates(subset=["event_id", "station", "phase"], keep="first")
|
|
158
|
+
rep["picks_after_one_per_station_phase"] = len(pk)
|
|
159
|
+
|
|
160
|
+
final = pd.DataFrame({
|
|
161
|
+
"n_p": (pk["phase"] == "P").groupby(pk["event_id"]).sum(),
|
|
162
|
+
"n_s": (pk["phase"] == "S").groupby(pk["event_id"]).sum(),
|
|
163
|
+
"n_stations": pk.groupby("event_id")["station"].nunique(),
|
|
164
|
+
})
|
|
165
|
+
ok = final.index[(final["n_p"] >= qc["min_p"]) & (final["n_s"] >= qc["min_s"])
|
|
166
|
+
& (final["n_stations"] >= qc["min_stations"])]
|
|
167
|
+
ev = ev[ev["event_id"].isin(ok)]
|
|
168
|
+
pk = pk[pk["event_id"].isin(ok)]
|
|
169
|
+
rep["events_final"] = len(ev)
|
|
170
|
+
rep["picks_final"] = len(pk)
|
|
171
|
+
rep["p_picks_final"] = int((pk["phase"] == "P").sum())
|
|
172
|
+
rep["s_picks_final"] = int((pk["phase"] == "S").sum())
|
|
173
|
+
|
|
174
|
+
pk["distance_km"] = haversine_km(pk["event_latitude"], pk["event_longitude"],
|
|
175
|
+
pk["latitude"], pk["longitude"])
|
|
176
|
+
depth_km = pk["event_id"].map(ev.set_index("event_id")["depth"]).to_numpy(float) / 1e3
|
|
177
|
+
pk["hyp_distance_km"] = np.hypot(pk["distance_km"].to_numpy(float), depth_km)
|
|
178
|
+
pk["network"] = network
|
|
179
|
+
for col in ("travel_time_zscore", "evaluation_mode", "pick_id"):
|
|
180
|
+
if col not in pk.columns:
|
|
181
|
+
pk[col] = pd.NA
|
|
182
|
+
|
|
183
|
+
ev = _event_summary(ev, pk, network)
|
|
184
|
+
return ev, pk[PICK_COLUMNS].reset_index(drop=True), rep
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _zscore_trim(pk, qc):
|
|
188
|
+
z = pd.to_numeric(pk["travel_time_zscore"], errors="coerce") if "travel_time_zscore" in pk else \
|
|
189
|
+
pd.Series(np.nan, index=pk.index)
|
|
190
|
+
keep = (z.abs() <= qc["zscore_sigma"]) | (z.isna() & qc["keep_nan_zscore"])
|
|
191
|
+
touched = set(pk.loc[~keep, "event_id"])
|
|
192
|
+
if not touched:
|
|
193
|
+
return pk
|
|
194
|
+
|
|
195
|
+
revert = set()
|
|
196
|
+
trimmed = pk[keep & pk["event_id"].isin(touched)]
|
|
197
|
+
n_sta = trimmed.groupby("event_id")["station"].nunique()
|
|
198
|
+
for eid in touched:
|
|
199
|
+
if n_sta.get(eid, 0) < qc["min_stations"]:
|
|
200
|
+
revert.add(eid)
|
|
201
|
+
for eid, grp in trimmed[~trimmed["event_id"].isin(revert)].groupby("event_id"):
|
|
202
|
+
sta = grp.drop_duplicates(subset="station")
|
|
203
|
+
gap = azimuthal_gap_deg(sta["event_latitude"].iloc[0], sta["event_longitude"].iloc[0],
|
|
204
|
+
sta["latitude"].to_numpy(), sta["longitude"].to_numpy())
|
|
205
|
+
if gap > qc["max_gap_deg"]:
|
|
206
|
+
revert.add(eid)
|
|
207
|
+
|
|
208
|
+
return pk[keep | pk["event_id"].isin(revert)]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _event_summary(ev, pk, network):
|
|
212
|
+
g = pk.groupby("event_id")
|
|
213
|
+
summ = pd.DataFrame({
|
|
214
|
+
"n_p": (pk["phase"] == "P").groupby(pk["event_id"]).sum(),
|
|
215
|
+
"n_s": (pk["phase"] == "S").groupby(pk["event_id"]).sum(),
|
|
216
|
+
"n_picks": g.size(),
|
|
217
|
+
"n_stations": g["station"].nunique(),
|
|
218
|
+
"duration_s": g["travel_time"].max(),
|
|
219
|
+
"max_dist_km": g["distance_km"].max(),
|
|
220
|
+
"last_station_km": g["hyp_distance_km"].max(),
|
|
221
|
+
})
|
|
222
|
+
gaps = {}
|
|
223
|
+
for eid, grp in pk.drop_duplicates(subset=["event_id", "station"]).groupby("event_id"):
|
|
224
|
+
gaps[eid] = azimuthal_gap_deg(grp["event_latitude"].iloc[0], grp["event_longitude"].iloc[0],
|
|
225
|
+
grp["latitude"].to_numpy(), grp["longitude"].to_numpy())
|
|
226
|
+
summ["gap_deg"] = pd.Series(gaps)
|
|
227
|
+
|
|
228
|
+
ev = ev.rename(columns={"azimuthal_gap": "catalog_azimuthal_gap"}).join(summ, on="event_id")
|
|
229
|
+
ev["network"] = network
|
|
230
|
+
for col in EVENT_COLUMNS:
|
|
231
|
+
if col not in ev.columns:
|
|
232
|
+
ev[col] = pd.NA
|
|
233
|
+
return ev
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ----------------------------------------------------------------------
|
|
237
|
+
# Split
|
|
238
|
+
# ----------------------------------------------------------------------
|
|
239
|
+
SPLITS = ("train", "val", "test")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def assign_split(network, event_ids, salt="bepad-v1", fractions=None, test_only_below=0):
|
|
243
|
+
"""Random split per event: sha256(salt | network | event_id) vs cumulative fractions.
|
|
244
|
+
|
|
245
|
+
A network with fewer than ``test_only_below`` events is test only (too few to train on).
|
|
246
|
+
"""
|
|
247
|
+
if len(event_ids) < test_only_below:
|
|
248
|
+
return np.full(len(event_ids), "test")
|
|
249
|
+
fractions = fractions or {"train": 0.7, "val": 0.1, "test": 0.2}
|
|
250
|
+
names = [s for s in SPLITS if fractions.get(s, 0) > 0]
|
|
251
|
+
edges = np.cumsum([fractions[s] for s in names])
|
|
252
|
+
if not np.isclose(edges[-1], 1.0):
|
|
253
|
+
raise ValueError(f"split fractions must sum to 1, got {fractions}")
|
|
254
|
+
u = np.array([stable_uniform(f"{network}|{e}", salt) for e in event_ids])
|
|
255
|
+
return np.array(names)[np.minimum(np.searchsorted(edges, u, side="right"), len(names) - 1)]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ----------------------------------------------------------------------
|
|
259
|
+
# Build / manifest
|
|
260
|
+
# ----------------------------------------------------------------------
|
|
261
|
+
def _sha256(path):
|
|
262
|
+
h = hashlib.sha256()
|
|
263
|
+
with open(path, "rb") as f:
|
|
264
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
265
|
+
h.update(chunk)
|
|
266
|
+
return h.hexdigest()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _source_fingerprint(name):
|
|
270
|
+
try:
|
|
271
|
+
from utdquake.core.config import get_utdq_paths
|
|
272
|
+
paths = get_utdq_paths(name)
|
|
273
|
+
return {k: _sha256(paths[k]) for k in ("events", "picks", "stations") if Path(paths[k]).exists()}
|
|
274
|
+
except Exception as exc: # fingerprint is informative only
|
|
275
|
+
return {"error": repr(exc)}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def read_manifest(data_root):
|
|
279
|
+
"""{"pool_config": ..., "networks": {name: entry}} assembled from per-network files."""
|
|
280
|
+
root = pool_dir(data_root)
|
|
281
|
+
cfg_path = root / "pool_config.json"
|
|
282
|
+
if not cfg_path.exists():
|
|
283
|
+
return None
|
|
284
|
+
with open(cfg_path) as f:
|
|
285
|
+
manifest = {"pool_config": json.load(f), "networks": {}}
|
|
286
|
+
for m in sorted(root.glob("network=*/manifest.json")):
|
|
287
|
+
with open(m) as f:
|
|
288
|
+
manifest["networks"][m.parent.name.split("=", 1)[1]] = json.load(f)
|
|
289
|
+
return manifest
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def write_qc_report(data_root):
|
|
293
|
+
manifest = read_manifest(data_root)
|
|
294
|
+
rows = [{"network": n, **e["qc_report"]} for n, e in sorted(manifest["networks"].items())]
|
|
295
|
+
pd.DataFrame(rows).to_csv(pool_dir(data_root) / "qc_report.csv", index=False)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _check_pool_config(data_root, cfg_key):
|
|
299
|
+
root = pool_dir(data_root)
|
|
300
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
path = root / "pool_config.json"
|
|
302
|
+
if not path.exists():
|
|
303
|
+
tmp = path.with_suffix(f".tmp{os.getpid()}")
|
|
304
|
+
with open(tmp, "w") as f:
|
|
305
|
+
json.dump(cfg_key, f, indent=2)
|
|
306
|
+
os.replace(tmp, path)
|
|
307
|
+
with open(path) as f:
|
|
308
|
+
existing = json.load(f)
|
|
309
|
+
if json.loads(json.dumps(cfg_key)) != existing:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"Pool config differs from the one this pool was built with ({path}). "
|
|
312
|
+
"Use a new data_root, or rebuild every network with the new config.")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def build_network(name, pool_cfg, force=False):
|
|
316
|
+
"""QC one UTDQuake network into the pool and register it in the manifest."""
|
|
317
|
+
import utdquake
|
|
318
|
+
|
|
319
|
+
from . import utdquake as U
|
|
320
|
+
|
|
321
|
+
data_root = pool_cfg["data_root"]
|
|
322
|
+
root = pool_dir(data_root)
|
|
323
|
+
_check_pool_config(data_root, {"qc": pool_cfg["qc"], "split": pool_cfg["split"]})
|
|
324
|
+
out = root / f"network={name}"
|
|
325
|
+
if (out / "manifest.json").exists() and not force:
|
|
326
|
+
print(f"[pool] {name} already in pool -- skipping (use --force to rebuild)")
|
|
327
|
+
with open(out / "manifest.json") as f:
|
|
328
|
+
return json.load(f)
|
|
329
|
+
|
|
330
|
+
print(f"[pool] loading {name} from UTDQuake ...")
|
|
331
|
+
events, picks, stations = U.load_network(name)
|
|
332
|
+
stations = _station_table(stations, name)
|
|
333
|
+
ev, pk, rep = run_qc(events, picks, stations, pool_cfg["qc"], name)
|
|
334
|
+
ev["split"] = assign_split(name, ev["event_id"], **pool_cfg["split"])
|
|
335
|
+
ev = ev[EVENT_COLUMNS].sort_values("time").reset_index(drop=True)
|
|
336
|
+
stations = stations[stations["station"].isin(pk["station"].unique())]
|
|
337
|
+
|
|
338
|
+
out.mkdir(exist_ok=True)
|
|
339
|
+
ev.to_parquet(out / "events.parquet", index=False)
|
|
340
|
+
pk.to_parquet(out / "picks.parquet", index=False)
|
|
341
|
+
stations[STATION_COLUMNS].to_parquet(out / "stations.parquet", index=False)
|
|
342
|
+
|
|
343
|
+
entry = {
|
|
344
|
+
"built": dt.datetime.now().isoformat(timespec="seconds"),
|
|
345
|
+
"utdquake_version": utdquake.__version__,
|
|
346
|
+
"utdquake_revision": U.revision() or "main",
|
|
347
|
+
"utdquake_network": U.network_info(name),
|
|
348
|
+
"source_sha256": _source_fingerprint(name),
|
|
349
|
+
"n_events": len(ev),
|
|
350
|
+
"n_picks": len(pk),
|
|
351
|
+
"n_stations": len(stations),
|
|
352
|
+
"split_counts": ev["split"].value_counts().to_dict(),
|
|
353
|
+
"qc_report": rep,
|
|
354
|
+
}
|
|
355
|
+
with open(out / "manifest.json", "w") as f:
|
|
356
|
+
json.dump(entry, f, indent=2, default=str)
|
|
357
|
+
write_qc_report(data_root)
|
|
358
|
+
print(f"[pool] {name}: {rep['events_raw']} -> {len(ev)} events, "
|
|
359
|
+
f"{rep['picks_raw']} -> {len(pk)} picks")
|
|
360
|
+
return entry
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def remove_network(name, pool_cfg):
|
|
364
|
+
import shutil
|
|
365
|
+
|
|
366
|
+
d = pool_dir(pool_cfg["data_root"]) / f"network={name}"
|
|
367
|
+
if not d.exists():
|
|
368
|
+
print(f"[pool] {name} not in pool")
|
|
369
|
+
return
|
|
370
|
+
shutil.rmtree(d)
|
|
371
|
+
write_qc_report(pool_cfg["data_root"])
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def missing_networks(pool_cfg):
|
|
375
|
+
"""UTDQuake networks (non-DAS) that are not in the pool yet."""
|
|
376
|
+
from .utdquake import list_networks
|
|
377
|
+
|
|
378
|
+
listed = set(list_networks())
|
|
379
|
+
manifest = read_manifest(pool_cfg["data_root"]) or {"networks": {}}
|
|
380
|
+
return sorted(listed - set(manifest["networks"]))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def load_network(data_root, name):
|
|
384
|
+
d = pool_dir(data_root) / f"network={name}"
|
|
385
|
+
return (pd.read_parquet(d / "events.parquet"),
|
|
386
|
+
pd.read_parquet(d / "picks.parquet"),
|
|
387
|
+
pd.read_parquet(d / "stations.parquet"))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def list_networks(data_root):
|
|
391
|
+
manifest = read_manifest(data_root)
|
|
392
|
+
return sorted(manifest["networks"]) if manifest else []
|
bepad/build/release.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Build a fixed BEPAD release from a benchmark config.
|
|
2
|
+
|
|
3
|
+
Layout (under <data_root>/releases/<release>/), sharded by split and network
|
|
4
|
+
so networks can be built in parallel (Slurm array) and the folders map 1:1 to
|
|
5
|
+
Hugging Face dataset configs/splits:
|
|
6
|
+
|
|
7
|
+
<split>/windows/network=<name>.parquet one row per window (metadata)
|
|
8
|
+
<split>/picks/network=<name>.parquet one row per pick (label: event_idx, -1 noise)
|
|
9
|
+
<split>/events/network=<name>.parquet one row per event in a window
|
|
10
|
+
<split>/window_stations/network=<name>.parquet stations available in each window
|
|
11
|
+
_parts/<name>.json per-network build record
|
|
12
|
+
--- written by finalize_release ---
|
|
13
|
+
stations.parquet station coordinates
|
|
14
|
+
label_model.json q(M, r), sigma(r), travel-time curves (bepad.build.labels)
|
|
15
|
+
metadata/windows_<split>.csv window metadata as CSV
|
|
16
|
+
metadata/networks.csv per network x split summary
|
|
17
|
+
scenarios.json scenario -> window parameters
|
|
18
|
+
manifest.json config, pool, code version, counts
|
|
19
|
+
|
|
20
|
+
``windows_per_scenario`` is the number of windows per network, split AND
|
|
21
|
+
scenario (v1: 1,000 train, 200 val, 100 test). Every window's seed is
|
|
22
|
+
sha256(seed_namespace, network, split, scenario, i); ``seed_namespace``
|
|
23
|
+
defaults to the release name. A later release that only ADDS networks keeps
|
|
24
|
+
the namespace of the first one, so the windows of existing networks stay
|
|
25
|
+
identical.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import datetime as dt
|
|
31
|
+
import itertools
|
|
32
|
+
import json
|
|
33
|
+
import multiprocessing as mp
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import pandas as pd
|
|
39
|
+
|
|
40
|
+
from .. import __version__
|
|
41
|
+
from .config import REPO_ROOT, WINDOW_DEFAULTS
|
|
42
|
+
from .geo import stable_seed
|
|
43
|
+
from .labels import LabelModel
|
|
44
|
+
from .pool import list_networks, load_network, pool_dir, read_manifest
|
|
45
|
+
from .windows import NetworkIndex, generate_window
|
|
46
|
+
|
|
47
|
+
TABLES = ("windows", "picks", "events", "window_stations")
|
|
48
|
+
_INDEX = {} # network -> NetworkIndex, filled before forking workers
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def release_dir(cfg) -> Path:
|
|
52
|
+
return Path(cfg["data_root"]) / "releases" / cfg["release"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def expand_scenarios(cfg):
|
|
56
|
+
"""{scenario_name: full window config}, in a fixed order."""
|
|
57
|
+
base = {**WINDOW_DEFAULTS, **cfg.get("window", {})}
|
|
58
|
+
axes = cfg.get("axes", {})
|
|
59
|
+
out = {}
|
|
60
|
+
if cfg.get("grid"):
|
|
61
|
+
names = cfg["grid"]
|
|
62
|
+
level_lists = [list(axes[a]["levels"].items()) for a in names]
|
|
63
|
+
for combo in itertools.product(*level_lists):
|
|
64
|
+
name = "__".join(f"{a}-{lvl}" for a, (lvl, _) in zip(names, combo))
|
|
65
|
+
over = {axes[a]["param"]: v for a, (_, v) in zip(names, combo)}
|
|
66
|
+
out[name] = {**base, **over}
|
|
67
|
+
for name, over in (cfg.get("extra_scenarios") or {}).items():
|
|
68
|
+
out[name] = {**base, **over}
|
|
69
|
+
if not out:
|
|
70
|
+
out["default"] = base
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def allocate(total, scenarios):
|
|
75
|
+
"""Spread ``total`` windows evenly over scenarios -> {scenario: count}."""
|
|
76
|
+
n = len(scenarios)
|
|
77
|
+
base, extra = divmod(int(total), n)
|
|
78
|
+
return {s: base + (1 if k < extra else 0) for k, s in enumerate(scenarios)}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_networks(cfg):
|
|
82
|
+
nets = cfg["networks"]
|
|
83
|
+
nets = list_networks(cfg["data_root"]) if nets == "all" else list(nets)
|
|
84
|
+
return [n for n in nets if n not in set(cfg.get("exclude_networks") or [])]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _git_commit():
|
|
88
|
+
try:
|
|
89
|
+
return subprocess.check_output(["git", "-C", str(REPO_ROOT), "rev-parse", "HEAD"],
|
|
90
|
+
stderr=subprocess.DEVNULL, text=True).strip()
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _job(args):
|
|
96
|
+
network, split, scenario, wcfg, i, namespace = args
|
|
97
|
+
seed = stable_seed(namespace, network, split, scenario, i)
|
|
98
|
+
wid = f"{network}.{split}.{scenario}.{i:05d}"
|
|
99
|
+
return generate_window(_INDEX[network], split, wcfg, seed, wid, scenario)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_network(network, cfg, scenarios, workers):
|
|
103
|
+
out = release_dir(cfg)
|
|
104
|
+
ev, pk, st = load_network(cfg["data_root"], network)
|
|
105
|
+
model = LabelModel.load(pool_dir(cfg["data_root"]) / "label_model.json")
|
|
106
|
+
_INDEX.clear()
|
|
107
|
+
_INDEX[network] = NetworkIndex(ev, pk, st, network=network, label_model=model)
|
|
108
|
+
|
|
109
|
+
min_events = cfg.get("min_events_per_split", 20)
|
|
110
|
+
part = {"network": network, "splits": {}, "skipped_splits": {}}
|
|
111
|
+
for split, per_scenario in cfg["windows_per_scenario"].items():
|
|
112
|
+
n_split_events = int((ev["split"] == split).sum())
|
|
113
|
+
if n_split_events < min_events:
|
|
114
|
+
part["skipped_splits"][split] = f"{n_split_events} events < min_events_per_split={min_events}"
|
|
115
|
+
print(f"[build] {network}/{split}: skipped ({part['skipped_splits'][split]})")
|
|
116
|
+
continue
|
|
117
|
+
counts = {sc: int(per_scenario) for sc in scenarios}
|
|
118
|
+
namespace = cfg.get("seed_namespace") or cfg["release"]
|
|
119
|
+
jobs = [(network, split, s, scenarios[s], i, namespace)
|
|
120
|
+
for s in scenarios for i in range(counts[s])]
|
|
121
|
+
if workers > 1:
|
|
122
|
+
with mp.get_context("fork").Pool(workers) as p:
|
|
123
|
+
results = p.map(_job, jobs, chunksize=16)
|
|
124
|
+
else:
|
|
125
|
+
results = [_job(j) for j in jobs]
|
|
126
|
+
skipped = [f"{j[2]}.{j[4]:05d}" for j, r in zip(jobs, results) if r is None]
|
|
127
|
+
results = [r for r in results if r is not None]
|
|
128
|
+
if not results:
|
|
129
|
+
part["skipped_splits"][split] = "no window could be generated"
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
frames = {
|
|
133
|
+
"windows": pd.DataFrame([r.window for r in results]),
|
|
134
|
+
"picks": pd.concat([r.picks for r in results], ignore_index=True),
|
|
135
|
+
"events": pd.concat([r.events for r in results], ignore_index=True),
|
|
136
|
+
"window_stations": pd.concat([r.stations for r in results], ignore_index=True),
|
|
137
|
+
}
|
|
138
|
+
for t, df in frames.items():
|
|
139
|
+
d = out / split / t
|
|
140
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
df.to_parquet(d / f"network={network}.parquet", index=False)
|
|
142
|
+
|
|
143
|
+
w, e = frames["windows"], frames["events"]
|
|
144
|
+
uses = e["src_event_id"].value_counts()
|
|
145
|
+
part["splits"][split] = {
|
|
146
|
+
"windows": len(w),
|
|
147
|
+
"skipped_windows": skipped,
|
|
148
|
+
"picks": int(w["n_picks"].sum()),
|
|
149
|
+
"real_picks": int(w["n_real_picks"].sum()),
|
|
150
|
+
"generated_picks": int(w["n_generated_picks"].sum()),
|
|
151
|
+
"noise_picks": int(w["n_noise_picks"].sum()),
|
|
152
|
+
"events_in_windows": len(e),
|
|
153
|
+
"scorable_events": int(e["scorable"].sum()),
|
|
154
|
+
"pool_events": n_split_events,
|
|
155
|
+
"unique_events_used": int(len(uses)),
|
|
156
|
+
"event_reuse_median": float(uses.median()),
|
|
157
|
+
"event_reuse_max": int(uses.max()),
|
|
158
|
+
}
|
|
159
|
+
print(f"[build] {network}/{split}: {len(w)} windows, {part['splits'][split]['picks']} picks, "
|
|
160
|
+
f"{len(uses)}/{n_split_events} events used (median reuse {uses.median():.0f})")
|
|
161
|
+
|
|
162
|
+
(out / "_parts").mkdir(parents=True, exist_ok=True)
|
|
163
|
+
with open(out / "_parts" / f"{network}.json", "w") as f:
|
|
164
|
+
json.dump(part, f, indent=2)
|
|
165
|
+
return part
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_release(cfg, networks=None, workers=None, finalize=True):
|
|
169
|
+
"""Build shards for ``networks`` (default: all of the config) and optionally finalize."""
|
|
170
|
+
out = release_dir(cfg)
|
|
171
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
networks = networks or resolve_networks(cfg)
|
|
173
|
+
if not networks:
|
|
174
|
+
raise RuntimeError("Pool is empty -- run `python -m bepad pool add <network>` first.")
|
|
175
|
+
scenarios = expand_scenarios(cfg)
|
|
176
|
+
workers = workers or cfg.get("workers", 1)
|
|
177
|
+
print(f"[build] {cfg['release']}: {len(networks)} networks x {len(scenarios)} scenarios, "
|
|
178
|
+
f"per network and scenario {cfg['windows_per_scenario']}")
|
|
179
|
+
for n in networks:
|
|
180
|
+
_build_network(n, cfg, scenarios, workers)
|
|
181
|
+
if finalize:
|
|
182
|
+
finalize_release(cfg)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def finalize_release(cfg):
|
|
187
|
+
"""Assemble stations, metadata CSVs, scenarios and manifest from the built shards."""
|
|
188
|
+
out = release_dir(cfg)
|
|
189
|
+
parts = {}
|
|
190
|
+
for p in sorted((out / "_parts").glob("*.json")):
|
|
191
|
+
with open(p) as f:
|
|
192
|
+
parts[p.stem] = json.load(f)
|
|
193
|
+
networks = sorted(parts)
|
|
194
|
+
if not networks:
|
|
195
|
+
raise RuntimeError(f"nothing to finalize: no built networks in {out / '_parts'}")
|
|
196
|
+
|
|
197
|
+
pd.concat([load_network(cfg["data_root"], n)[2] for n in networks], ignore_index=True) \
|
|
198
|
+
.to_parquet(out / "stations.parquet", index=False)
|
|
199
|
+
|
|
200
|
+
(out / "metadata").mkdir(exist_ok=True)
|
|
201
|
+
counts = {}
|
|
202
|
+
for split in cfg["windows_per_scenario"]:
|
|
203
|
+
files = sorted((out / split / "windows").glob("network=*.parquet"))
|
|
204
|
+
if not files:
|
|
205
|
+
continue
|
|
206
|
+
w = pd.concat([pd.read_parquet(f) for f in files], ignore_index=True)
|
|
207
|
+
w.to_csv(out / "metadata" / f"windows_{split}.csv", index=False)
|
|
208
|
+
counts[split] = {"windows": len(w), "picks": int(w["n_picks"].sum()),
|
|
209
|
+
"networks": int(w["network"].nunique())}
|
|
210
|
+
|
|
211
|
+
rows = [{"network": n, "split": s, **{k: v for k, v in d.items() if k != "skipped_windows"},
|
|
212
|
+
"skipped_windows": len(d["skipped_windows"])}
|
|
213
|
+
for n, p in parts.items() for s, d in p["splits"].items()]
|
|
214
|
+
pd.DataFrame(rows).to_csv(out / "metadata" / "networks.csv", index=False)
|
|
215
|
+
|
|
216
|
+
with open(out / "scenarios.json", "w") as f:
|
|
217
|
+
json.dump(expand_scenarios(cfg), f, indent=2)
|
|
218
|
+
shutil.copy(pool_dir(cfg["data_root"]) / "label_model.json", out / "label_model.json")
|
|
219
|
+
pool_manifest = read_manifest(cfg["data_root"])
|
|
220
|
+
manifest = {
|
|
221
|
+
"release": cfg["release"],
|
|
222
|
+
"finalized": dt.datetime.now().isoformat(timespec="seconds"),
|
|
223
|
+
"bepad_version": __version__,
|
|
224
|
+
"git_commit": _git_commit(),
|
|
225
|
+
"config": cfg,
|
|
226
|
+
"pool_config": pool_manifest["pool_config"],
|
|
227
|
+
"pool_networks": {n: {k: pool_manifest["networks"][n].get(k)
|
|
228
|
+
for k in ("utdquake_version", "utdquake_revision", "utdquake_network",
|
|
229
|
+
"source_sha256", "n_events", "built")}
|
|
230
|
+
for n in networks},
|
|
231
|
+
"counts": counts,
|
|
232
|
+
"skipped_splits": {n: p["skipped_splits"] for n, p in parts.items() if p["skipped_splits"]},
|
|
233
|
+
}
|
|
234
|
+
with open(out / "manifest.json", "w") as f:
|
|
235
|
+
json.dump(manifest, f, indent=2, default=str)
|
|
236
|
+
print(f"[finalize] {out}: {counts}")
|
|
237
|
+
return manifest
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def load_release(path, splits=None, networks=None, tables=TABLES):
|
|
241
|
+
"""Load (a subset of) a release into DataFrames, concatenated over shards."""
|
|
242
|
+
path = Path(path)
|
|
243
|
+
splits = splits or [d.name for d in sorted(path.iterdir()) if (d / "windows").is_dir()]
|
|
244
|
+
out = {}
|
|
245
|
+
for t in tables:
|
|
246
|
+
files = []
|
|
247
|
+
for s in splits:
|
|
248
|
+
files += sorted((path / s / t).glob("network=*.parquet"))
|
|
249
|
+
if networks is not None:
|
|
250
|
+
keep = {f"network={n}.parquet" for n in networks}
|
|
251
|
+
files = [f for f in files if f.name in keep]
|
|
252
|
+
out[t] = pd.concat([pd.read_parquet(f) for f in files], ignore_index=True) if files else pd.DataFrame()
|
|
253
|
+
if (path / "stations.parquet").exists():
|
|
254
|
+
out["stations"] = pd.read_parquet(path / "stations.parquet")
|
|
255
|
+
return out
|
|
256
|
+
|