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/config.py ADDED
@@ -0,0 +1,116 @@
1
+ """YAML configs with defaults.
2
+
3
+ - configs/pool.yaml quality control + split (shared by every release)
4
+ - configs/<release>.yaml scenarios, window parameters, window counts
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ REPO_ROOT = Path(__file__).resolve().parents[2]
15
+
16
+ POOL_DEFAULTS = {
17
+ "data_root": "data",
18
+ # Same thresholds as hypogt test/09252026 v8 (strict edges, Student-t).
19
+ "qc": {
20
+ "min_p": 4,
21
+ "min_s": 0,
22
+ "min_stations": 3,
23
+ "max_gap_deg": 270.0,
24
+ "max_depth_m": 700_000.0,
25
+ "max_rms_s": 1.8,
26
+ "max_horizontal_uncertainty_m": 200_000.0,
27
+ "max_vertical_uncertainty_m": 30_000.0,
28
+ "zscore_sigma": 3.0,
29
+ "keep_nan_zscore": True,
30
+ "keep_missing": True, # a missing catalog field does not reject an event
31
+ },
32
+ # Random split per event: hash(salt | network | event_id). Networks are independent.
33
+ "split": {
34
+ "salt": "bepad-v1",
35
+ "fractions": {"train": 0.7, "val": 0.1, "test": 0.2},
36
+ "test_only_below": 200, # a network with fewer QC'd events is test only
37
+ },
38
+ # Label model (bepad.build.labels)
39
+ "labels": {
40
+ "gamma": 0.5, # prob = 0.5 + 0.5 * q ** gamma
41
+ "band_k": 2.0, # band = predicted arrival +/- band_k * sigma(r)
42
+ "max_gap_frac": 0.3, # generated picks only where neighbouring real picks are <= 30% of r apart
43
+ "sigma_floor_s": 0.05,
44
+ "active_days": 30, # station "available" for an event if it picked anything within +/- 30 days
45
+ "events_per_network": 1500, # events sampled per network for the detectability fit
46
+ },
47
+ }
48
+
49
+ WINDOW_DEFAULTS = {
50
+ "n_events": [3, 5],
51
+ # "auto" -> length_factor * longest event duration among the sampled events
52
+ "length_s": "auto",
53
+ "length_factor": 2.0,
54
+ "tail_frac": 0.05, # origins in [0, (1 - tail_frac) * length]
55
+ # events drawn within +/- this many days of an anchor event (null = anywhere)
56
+ "event_time_neighborhood_days": 365,
57
+ "edge_event_prob": 0.3, # one extra event starting before t = 0
58
+ # noise picks / real picks; noise never falls inside an event's band
59
+ "noise_ratio": 0.0,
60
+ "noise_any_station_frac": 0.1, # share of noise on any station (rest: uniform in distance to the events)
61
+ # generated picks / real picks, drawn per window from [lo, hi]
62
+ "generated_ratio": [0.0, 0.2],
63
+ "station_keep_frac": 1.0,
64
+ "gap_range": None, # event filters on pool columns
65
+ "magnitude_range": None,
66
+ # every event must pass the pool rule INSIDE the window (real picks only)
67
+ "event_min_p": 4,
68
+ "event_min_s": 0,
69
+ "event_min_stations": 3,
70
+ }
71
+
72
+ RELEASE_DEFAULTS = {
73
+ "release": "dev",
74
+ "data_root": "data",
75
+ "networks": "all",
76
+ "exclude_networks": [],
77
+ "min_events_per_split": 20,
78
+ "windows_per_scenario": {"train": 10, "val": 3, "test": 5}, # per network and scenario
79
+ "window": WINDOW_DEFAULTS,
80
+ "axes": {},
81
+ "grid": [],
82
+ "extra_scenarios": {},
83
+ "workers": 1,
84
+ }
85
+
86
+
87
+ def _deep_update(base, override):
88
+ out = copy.deepcopy(base)
89
+ for k, v in (override or {}).items():
90
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
91
+ out[k] = _deep_update(out[k], v)
92
+ else:
93
+ out[k] = v
94
+ return out
95
+
96
+
97
+ def load_yaml(path):
98
+ with open(path) as f:
99
+ return yaml.safe_load(f) or {}
100
+
101
+
102
+ def resolve_path(p):
103
+ p = Path(p)
104
+ return p if p.is_absolute() else REPO_ROOT / p
105
+
106
+
107
+ def load_pool_config(path=None):
108
+ cfg = _deep_update(POOL_DEFAULTS, load_yaml(path) if path else {})
109
+ cfg["data_root"] = str(resolve_path(cfg["data_root"]))
110
+ return cfg
111
+
112
+
113
+ def load_release_config(path):
114
+ cfg = _deep_update(RELEASE_DEFAULTS, load_yaml(path))
115
+ cfg["data_root"] = str(resolve_path(cfg["data_root"]))
116
+ return cfg
bepad/build/geo.py ADDED
@@ -0,0 +1,64 @@
1
+ """Geometry, phase families and deterministic seeding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ import numpy as np
8
+
9
+ EARTH_RADIUS_KM = 6371.0
10
+
11
+ #: Same P/S families hypogt uses (hypogt.graph.utils.P_PHASES/S_PHASES).
12
+ P_PHASES = frozenset({"P", "Pn", "Pg"})
13
+ S_PHASES = frozenset({"S", "Sn", "Sg"})
14
+
15
+
16
+ def phase_family(phase):
17
+ """Map raw phase codes (Pn, Pg, Sg, ...) to "P"/"S"; anything else -> None."""
18
+ if phase in P_PHASES:
19
+ return "P"
20
+ if phase in S_PHASES:
21
+ return "S"
22
+ return None
23
+
24
+
25
+ def haversine_km(lat0, lon0, lat1, lon1):
26
+ lat0, lon0, lat1, lon1 = (np.radians(np.asarray(x, dtype=float)) for x in (lat0, lon0, lat1, lon1))
27
+ a = np.sin((lat1 - lat0) / 2) ** 2 + np.cos(lat0) * np.cos(lat1) * np.sin((lon1 - lon0) / 2) ** 2
28
+ return 2 * EARTH_RADIUS_KM * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
29
+
30
+
31
+ def azimuthal_gap_deg(lat0, lon0, lats, lons):
32
+ """Largest empty angular sector (deg) seen from (lat0, lon0).
33
+
34
+ Same formula as hypogt.data.pre_transform._azimuthal_gap_deg; NaN for
35
+ fewer than 2 stations.
36
+ """
37
+ lats = np.asarray(lats, dtype=float)
38
+ lons = np.asarray(lons, dtype=float)
39
+ if len(lats) < 2:
40
+ return np.nan
41
+ lat0_r, lon0_r = np.radians(lat0), np.radians(lon0)
42
+ lat_r, lon_r = np.radians(lats), np.radians(lons)
43
+ dlon = lon_r - lon0_r
44
+ bearing = np.arctan2(
45
+ np.sin(dlon) * np.cos(lat_r),
46
+ np.cos(lat0_r) * np.sin(lat_r) - np.sin(lat0_r) * np.cos(lat_r) * np.cos(dlon),
47
+ )
48
+ b = np.sort(np.degrees(bearing) % 360.0)
49
+ return float(np.diff(np.append(b, b[0] + 360.0)).max())
50
+
51
+
52
+ def stable_uniform(key: str, salt: str = "") -> float:
53
+ """Deterministic pseudo-random number in [0, 1) from a string key.
54
+
55
+ Uses sha256, never Python's hash() (which is salted per process).
56
+ """
57
+ h = hashlib.sha256(f"{salt}|{key}".encode()).digest()
58
+ return int.from_bytes(h[:8], "big") / 2**64
59
+
60
+
61
+ def stable_seed(*parts) -> int:
62
+ """Deterministic 63-bit seed from any sequence of parts."""
63
+ h = hashlib.sha256("|".join(str(p) for p in parts).encode()).digest()
64
+ return int.from_bytes(h[:8], "big") >> 1
bepad/build/labels.py ADDED
@@ -0,0 +1,341 @@
1
+ """Label model: predicted arrivals, timing uncertainty and pick probabilities.
2
+
3
+ Three kinds of picks in a window::
4
+
5
+ real UTDQuake pick prob = 0.5 + 0.5 * q(M, r) ** gamma (>= 0.5)
6
+ generated artificial pick inside a band prob = 0.5 + 0.5 * (q(M, r) * g(z)) ** gamma
7
+ noise never inside any band prob = 0
8
+
9
+ q(M, r) detectability of magnitude M at hypocentral distance r: sigmoid((M - B(r)) / w),
10
+ B smooth and increasing in log10 r. ONE curve for all networks.
11
+ sigma(r) timing uncertainty of a predicted arrival, non-decreasing with r. ONE curve.
12
+ g(z) exp(-z^2 / 2), z = (t - predicted arrival) / sigma(r)
13
+ band predicted arrival +/- band_k * sigma(r)
14
+
15
+ Predicted arrival of an event at distance r (``EventArrivals.mu``)::
16
+
17
+ - between its first and last real pick of that phase: monotone interpolation of its own picks
18
+ - outside that range: the network's travel-time curve, shifted to join the event's picks
19
+ (network S curve = network P curve x network S/P ratio, clipped to 1.5-2.0; a network
20
+ without enough paired P/S picks uses the S/P ratio of all networks)
21
+ - S for an event without S picks: network S/P ratio x the event's own P prediction
22
+
23
+ Generated picks are allowed only between the event's first and last real pick
24
+ (never beyond its last station: the magnitude limit), and only where its
25
+ neighbouring real picks are at most ``max_gap_frac * r`` apart. Noise is
26
+ rejected inside the band of every event, at every distance.
27
+
28
+ ``fit_label_model`` fits everything from the pool; the result is
29
+ ``data/pool/label_model.json`` and is copied into every release.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from pathlib import Path
36
+
37
+ import numpy as np
38
+ import pandas as pd
39
+ from scipy.optimize import minimize
40
+ from scipy.special import expit
41
+
42
+ from .geo import haversine_km
43
+
44
+ KNOTS = np.arange(0.0, 4.26, 0.25) # log10 r [km] for B(r)
45
+ SIGMA_BINS = np.logspace(0, 4.1, 30) # km
46
+ CURVE_BINS = np.logspace(0, 4.3, 45) # km, network travel-time curves and S/P ratio
47
+
48
+
49
+ # ----------------------------------------------------------------------
50
+ # The model
51
+ # ----------------------------------------------------------------------
52
+ class LabelModel:
53
+ """Evaluate the fitted label model (loaded from label_model.json)."""
54
+
55
+ def __init__(self, params: dict):
56
+ self.params = params
57
+ q = params["q"]
58
+ self._knots = np.asarray(q["knots_log10_km"], float)
59
+ self._B = np.asarray(q["B"], float)
60
+ self._w = float(q["w"])
61
+ s = params["sigma"]
62
+ self._sig_r = np.log(np.asarray(s["r_km"], float))
63
+ self._sig_s = np.log(np.asarray(s["sigma_s"], float))
64
+ self.gamma = float(params["gamma"])
65
+ self.band_k = float(params["band_k"])
66
+ self.max_gap_frac = float(params["max_gap_frac"])
67
+ self.networks = params["networks"]
68
+
69
+ # --- global -----------------------------------------------------------
70
+ def B(self, r):
71
+ return np.interp(np.log10(np.clip(r, 1.0, 2e4)), self._knots, self._B)
72
+
73
+ def q(self, M, r):
74
+ return expit((np.asarray(M, float) - self.B(r)) / self._w)
75
+
76
+ def sigma(self, r):
77
+ return np.exp(np.interp(np.log(np.clip(r, 1e-3, None)), self._sig_r, self._sig_s))
78
+
79
+ def prob_real(self, M, r):
80
+ return 0.5 + 0.5 * self.q(M, r) ** self.gamma
81
+
82
+ def prob_generated(self, M, r, z):
83
+ return 0.5 + 0.5 * (self.q(M, r) * np.exp(-0.5 * np.asarray(z) ** 2)) ** self.gamma
84
+
85
+ # --- per network --------------------------------------------------------
86
+ def _net(self, network):
87
+ if network not in self.networks:
88
+ raise KeyError(f"label model has no curves for network {network!r}; run `bepad pool fit-labels`")
89
+ return self.networks[network]
90
+
91
+ #: smallest slowness [s/km] allowed when extrapolating the P curve (1 / 25 km/s)
92
+ MIN_SLOWNESS = 0.04
93
+ #: physical range of the S/P travel-time ratio
94
+ SP_RANGE = (1.5, 2.0)
95
+
96
+ def tt_curve(self, network, phase, r):
97
+ """Network travel-time curve.
98
+
99
+ P: median of the pool P picks vs hypocentral distance; beyond the last
100
+ node it continues with the mean slope of its last three nodes (never
101
+ flatter than MIN_SLOWNESS). S: the P curve x the network S/P ratio
102
+ (from stations that picked both), which stays well behaved where S
103
+ picks are sparse (e.g. teleseismic distances).
104
+ """
105
+ r = np.asarray(r, float)
106
+ if phase == "S":
107
+ return self.tt_curve(network, "P", r) * self.sp_ratio(network, r)
108
+ c = self._net(network)["tt"]["P"]
109
+ x, y = np.asarray(c["r_km"], float), np.asarray(c["tt_s"], float)
110
+ out = np.interp(r, x, y)
111
+ beyond = r > x[-1]
112
+ if not beyond.any():
113
+ return out
114
+ k = min(3, len(x) - 1)
115
+ slope = (y[-1] - y[-1 - k]) / max(x[-1] - x[-1 - k], 1e-6) if k else self.MIN_SLOWNESS * 4
116
+ return np.where(beyond, y[-1] + max(slope, self.MIN_SLOWNESS) * (r - x[-1]), out)
117
+
118
+ def sp_ratio(self, network, r):
119
+ c = self._net(network)["sp_ratio"] or self.params["sp_ratio"] # too few P/S pairs: all networks' curve
120
+ return np.clip(np.interp(r, c["r_km"], c["ratio"]), *self.SP_RANGE)
121
+
122
+ def median_magnitude(self, network):
123
+ return float(self._net(network)["median_magnitude"])
124
+
125
+ # --- io -----------------------------------------------------------------
126
+ @classmethod
127
+ def load(cls, path):
128
+ return cls(json.loads(Path(path).read_text()))
129
+
130
+ def save(self, path):
131
+ Path(path).write_text(json.dumps(self.params, indent=2))
132
+
133
+
134
+ class EventArrivals:
135
+ """Predicted arrivals of one event, from its own real picks (see module doc)."""
136
+
137
+ def __init__(self, model: LabelModel, network, r_km, tt_s, phase, last_station_km):
138
+ self.m, self.network, self.last = model, network, float(last_station_km)
139
+ self.own = {}
140
+ for ph in ("P", "S"):
141
+ sel = np.asarray(phase) == ph
142
+ r, t = np.asarray(r_km, float)[sel], np.asarray(tt_s, float)[sel]
143
+ o = np.argsort(r)
144
+ self.own[ph] = (r[o], np.maximum.accumulate(t[o]) if len(t) else t)
145
+
146
+ def _from_own(self, ph, r):
147
+ rr, tt = self.own[ph]
148
+ net = self.m.tt_curve(self.network, ph, r)
149
+ if len(rr) == 0:
150
+ return None
151
+ if len(rr) == 1:
152
+ return net + (tt[0] - self.m.tt_curve(self.network, ph, rr[0]))
153
+ inside = np.interp(r, rr, tt)
154
+ lo = net + (tt[0] - self.m.tt_curve(self.network, ph, rr[0]))
155
+ hi = net + (tt[-1] - self.m.tt_curve(self.network, ph, rr[-1]))
156
+ return np.where(r < rr[0], lo, np.where(r > rr[-1], hi, inside))
157
+
158
+ def mu(self, phase, r):
159
+ """Predicted travel time [s] at hypocentral distance r [km]."""
160
+ r = np.asarray(r, float)
161
+ out = self._from_own(phase, r) if len(self.own[phase][0]) >= 2 or phase == "P" else None
162
+ if out is None and phase == "S" and len(self.own["P"][0]):
163
+ out = self._from_own("P", r) * self.m.sp_ratio(self.network, r)
164
+ if out is None:
165
+ out = self.m.tt_curve(self.network, phase, r)
166
+ return out
167
+
168
+ def can_generate(self, phase, r):
169
+ """Where a generated pick of this phase is allowed (range + gap rule)."""
170
+ r = np.asarray(r, float)
171
+ rr = self.own[phase][0] if len(self.own[phase][0]) >= 2 else self.own["P"][0]
172
+ if len(rr) < 2:
173
+ return np.zeros(r.shape, bool)
174
+ j = np.clip(np.searchsorted(rr, r), 1, len(rr) - 1)
175
+ gap = rr[j] - rr[j - 1]
176
+ return (r >= rr[0]) & (r <= min(rr[-1], self.last)) & (gap <= self.m.max_gap_frac * np.maximum(r, 1e-6))
177
+
178
+
179
+ # ----------------------------------------------------------------------
180
+ # Fitting
181
+ # ----------------------------------------------------------------------
182
+ def _robust_sigma(x):
183
+ return 1.4826 * np.median(np.abs(x - np.median(x)))
184
+
185
+
186
+ def _pava(y, w):
187
+ """Non-decreasing (isotonic) regression, pool-adjacent-violators."""
188
+ blocks = [[yi, wi, 1] for yi, wi in zip(y, w)]
189
+ i = 0
190
+ while i < len(blocks) - 1:
191
+ if blocks[i][0] > blocks[i + 1][0]:
192
+ a, b = blocks[i], blocks[i + 1]
193
+ ws = a[1] + b[1]
194
+ blocks[i] = [(a[0] * a[1] + b[0] * b[1]) / ws, ws, a[2] + b[2]]
195
+ del blocks[i + 1]
196
+ i = max(i - 1, 0)
197
+ else:
198
+ i += 1
199
+ return np.concatenate([[v] * n for v, _, n in blocks])
200
+
201
+
202
+ def detection_pairs(ev, pk, st, active_days, n_events, max_pairs, rng):
203
+ """(event, available station) pairs: hypocentral distance, magnitude, picked?"""
204
+ ev = ev.dropna(subset=["magnitude"]).reset_index(drop=True)
205
+ if ev.empty:
206
+ return pd.DataFrame(columns=["r_km", "M", "picked"])
207
+ st = st.set_index("station")
208
+ t_ev = ev["time"].to_numpy("datetime64[s]").astype(np.int64)
209
+ pt = pk[["event_id", "station"]].merge(ev[["event_id", "time"]], on="event_id")
210
+ pt = pt.assign(t=pt["time"].to_numpy("datetime64[s]").astype(np.int64)).sort_values("t")
211
+ ts, ss = pt["t"].to_numpy(), pt["station"].to_numpy()
212
+ picked = pk.groupby("event_id")["station"].apply(set).to_dict()
213
+ rows = []
214
+ for i in rng.choice(len(ev), size=min(n_events, len(ev)), replace=False):
215
+ e = ev.iloc[i]
216
+ lo, hi = np.searchsorted(ts, [t_ev[i] - active_days * 86400, t_ev[i] + active_days * 86400])
217
+ avail = np.unique(ss[lo:hi])
218
+ s = st.loc[avail]
219
+ r = np.hypot(haversine_km(e["latitude"], e["longitude"], s["latitude"], s["longitude"]), e["depth"] / 1e3)
220
+ rows.append(pd.DataFrame({"r_km": r, "M": e["magnitude"],
221
+ "picked": np.isin(avail, list(picked.get(e["event_id"], ())))}))
222
+ d = pd.concat(rows, ignore_index=True)
223
+ return d.sample(max_pairs, random_state=0) if len(d) > max_pairs else d
224
+
225
+
226
+ def fit_q(det, smooth=30.0):
227
+ """Global detectability: every network weighs the same (weights 1/len per network)."""
228
+ logr = np.log10(np.clip(det["r_km"].to_numpy(float), 1, 2e4))
229
+ M, y, wts = det["M"].to_numpy(float), det["picked"].to_numpy(float), det["w"].to_numpy(float)
230
+
231
+ def B_of(p):
232
+ steps = np.log1p(np.exp(p[1:len(KNOTS)]))
233
+ return np.interp(logr, KNOTS, p[0] + np.concatenate([[0.0], np.cumsum(steps)])), steps
234
+
235
+ def nll(p):
236
+ B, steps = B_of(p)
237
+ qv = np.clip(expit((M - B) / np.exp(p[-1])), 1e-9, 1 - 1e-9)
238
+ return (-np.sum(wts * (y * np.log(qv) + (1 - y) * np.log(1 - qv))) / wts.sum()
239
+ + smooth * np.sum(np.diff(steps) ** 2) / len(det) * 1e3)
240
+
241
+ p0 = np.concatenate([[-1.0], np.full(len(KNOTS) - 1, 0.15), [np.log(0.5)]])
242
+ p = minimize(nll, p0, method="L-BFGS-B").x
243
+ steps = np.log1p(np.exp(p[1:len(KNOTS)]))
244
+ return {"knots_log10_km": KNOTS.round(4).tolist(),
245
+ "B": (p[0] + np.concatenate([[0.0], np.cumsum(steps)])).round(4).tolist(),
246
+ "w": round(float(np.exp(p[-1])), 4)}
247
+
248
+
249
+ def loo_residuals(pk):
250
+ """Predict each interior pick from its two neighbours (same event + phase)."""
251
+ d = pk.sort_values(["event_id", "phase", "hyp_distance_km"])
252
+ key = (d["event_id"] + "|" + d["phase"]).to_numpy()
253
+ r, t = d["hyp_distance_km"].to_numpy(float), d["travel_time"].to_numpy(float)
254
+ same_prev = np.r_[False, key[1:] == key[:-1]]
255
+ same_next = np.r_[key[:-1] == key[1:], False]
256
+ r0, t0, r1, t1 = np.roll(r, 1), np.roll(t, 1), np.roll(r, -1), np.roll(t, -1)
257
+ ok = same_prev & same_next & (r1 - r0 > 0)
258
+ pred = t0 + (t1 - t0) * (r - r0) / np.where(r1 - r0 > 0, r1 - r0, 1)
259
+ return pd.DataFrame({"r_km": r[ok], "res": (t - pred)[ok]})
260
+
261
+
262
+ def fit_sigma(res, floor):
263
+ """Per bin: robust sigma per network, averaged in log (networks weighted equally);
264
+ then non-decreasing and lightly smoothed."""
265
+ rows = []
266
+ for k in range(1, len(SIGMA_BINS)):
267
+ s = res[(res.r_km >= SIGMA_BINS[k - 1]) & (res.r_km < SIGMA_BINS[k])]
268
+ per = [_robust_sigma(g.res.to_numpy()) for _, g in s.groupby("network") if len(g) >= 50]
269
+ if per:
270
+ rows.append((np.sqrt(SIGMA_BINS[k - 1] * SIGMA_BINS[k]),
271
+ np.exp(np.mean(np.log(np.maximum(per, 1e-3)))), len(per)))
272
+ r_nodes, sig, w = map(np.array, zip(*rows))
273
+ logs = _pava(np.log(sig), w)
274
+ logs = np.convolve(np.pad(logs, 1, mode="edge"), np.ones(3) / 3, mode="valid")
275
+ sig = np.maximum(np.exp(np.maximum.accumulate(logs)), floor)
276
+ return {"r_km": r_nodes.round(3).tolist(), "sigma_s": sig.round(4).tolist()}
277
+
278
+
279
+ #: Paired P/S picks per network in the S/P curve of all networks.
280
+ SP_PAIRS_PER_NETWORK = 20_000
281
+
282
+
283
+ def network_curves(ev, pk):
284
+ """Median travel time per phase vs hypocentral distance, and the S/P ratio."""
285
+ idx = np.digitize(pk["hyp_distance_km"], CURVE_BINS)
286
+ tt = {}
287
+ for ph in ("P", "S"):
288
+ g = pk[pk.phase == ph].groupby(idx[pk.phase == ph]).agg(
289
+ r=("hyp_distance_km", "median"), t=("travel_time", "median"), n=("travel_time", "size"))
290
+ g = g[g.n >= 20]
291
+ r = np.r_[0.0, g.r.to_numpy()]
292
+ t = np.maximum.accumulate(np.r_[0.0, g.t.to_numpy()])
293
+ tt[ph] = {"r_km": r.round(3).tolist(), "tt_s": t.round(4).tolist()}
294
+ p = pk[pk.phase == "P"][["event_id", "station", "hyp_distance_km", "travel_time"]]
295
+ s = pk[pk.phase == "S"][["event_id", "station", "travel_time"]]
296
+ m = p.merge(s, on=["event_id", "station"], suffixes=("_p", "_s"))
297
+ m = m[m.travel_time_p > 0]
298
+ m = m.assign(ratio=m.travel_time_s / m.travel_time_p)
299
+ mag = pd.to_numeric(ev["magnitude"], errors="coerce").median()
300
+ return {"tt": tt, "sp_ratio": sp_curve(m), "median_magnitude": float(mag) if mag == mag else 2.0}, \
301
+ m[["hyp_distance_km", "ratio"]]
302
+
303
+
304
+ def sp_curve(pairs):
305
+ """Median S/P travel-time ratio per distance bin (bins with >= 30 pairs); None if no bin has enough."""
306
+ g = pairs.groupby(np.digitize(pairs.hyp_distance_km, CURVE_BINS)).agg(
307
+ r=("hyp_distance_km", "median"), ratio=("ratio", "median"), n=("ratio", "size"))
308
+ g = g[g.n >= 30]
309
+ return {"r_km": g.r.round(3).tolist(), "ratio": g.ratio.round(4).tolist()} if len(g) else None
310
+
311
+
312
+ def fit_label_model(data_root, networks, cfg, previous=None, refit_global=True, seed=0):
313
+ """Fit (or extend) the label model from the pool.
314
+
315
+ ``refit_global=False`` keeps q(M, r) and sigma(r) of ``previous`` and only
316
+ adds per-network curves -- used when a network is added to an existing release.
317
+ """
318
+ from .pool import load_network
319
+
320
+ rng = np.random.default_rng(seed)
321
+ params = dict(previous or {})
322
+ params.setdefault("networks", {})
323
+ det, res, sp_pairs = [], [], []
324
+ for n in networks:
325
+ ev, pk, st = load_network(data_root, n)
326
+ params["networks"][n], pairs = network_curves(ev, pk)
327
+ if refit_global or not previous:
328
+ k = min(len(pairs), SP_PAIRS_PER_NETWORK) # every network weighs about the same
329
+ sp_pairs.append(pairs.iloc[rng.choice(len(pairs), k, replace=False)] if k else pairs)
330
+ pk_m = pk.merge(ev[["event_id", "magnitude"]], on="event_id")
331
+ d = detection_pairs(ev, pk_m, st, cfg["active_days"], cfg["events_per_network"],
332
+ cfg.get("pairs_per_network", 200_000), rng)
333
+ det.append(d.assign(network=n, w=1.0 / max(len(d), 1)))
334
+ res.append(loo_residuals(pk).assign(network=n))
335
+ print(f"[labels] {n}: curves done")
336
+ if refit_global or not previous:
337
+ params["q"] = fit_q(pd.concat(det, ignore_index=True))
338
+ params["sigma"] = fit_sigma(pd.concat(res, ignore_index=True), cfg["sigma_floor_s"])
339
+ params["sp_ratio"] = sp_curve(pd.concat(sp_pairs, ignore_index=True))
340
+ params.update({"gamma": cfg["gamma"], "band_k": cfg["band_k"], "max_gap_frac": cfg["max_gap_frac"]})
341
+ return LabelModel(params)