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/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
+