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/plots.py ADDED
@@ -0,0 +1,950 @@
1
+ """Figures for understanding BEPAD windows, labels, scenarios and statistics.
2
+
3
+ Every function returns the matplotlib Figure and accepts ``save_path`` (saved
4
+ at ``dpi=400``, tight bounding box). Sizes follow journal widths: single column
5
+ 3.5 in, double column 7.2 in.
6
+
7
+ Window-level
8
+ plot_window(w) distance-time view of one window (real / generated / noise, bands)
9
+ plot_window_map(w) where the events and stations of one window are
10
+ plot_window_anatomy(w) map + distance-time + pick counts, annotated (the "what is a window" figure)
11
+ plot_event_labels(w) each event: travel time vs distance, band, picks coloured by prob
12
+ plot_prediction(w, pred) truth vs an associator's prediction, errors circled
13
+
14
+ Dataset-level
15
+ plot_scenario_grid(split, network) events-per-window x noise-level examples
16
+ plot_gap_sweep(split, network) out-of-network sweep: maps of the three gap scenarios
17
+ plot_label_model(model) prob vs distance per magnitude, and sigma(r)
18
+ plot_difficulty(split) phase-association difficulty per scenario
19
+ plot_release_stats(split) window length, gap, magnitude, picks per network
20
+ plot_scores(summary) metric heatmap over the scenario grid
21
+ plot_events_overview(split) source earthquakes: map, magnitude, depth, gap, detection distance
22
+ plot_station_geometry(split) stations per window, spacing, aperture
23
+ plot_picks_overview(split) travel times, picks per earthquake, distances, prob
24
+ plot_event_proximity(split) earthquake pairs: distance vs time between origins
25
+ plot_representative_windows(split) easy / typical / hard windows per scale
26
+ plot_core_networks(split) map of each core network: stations, earthquakes by depth
27
+ release_gallery(release_dir, cfg) all of the above for a release
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from pathlib import Path
33
+
34
+ import matplotlib
35
+
36
+ matplotlib.use("Agg")
37
+ import matplotlib.pyplot as plt # noqa: E402
38
+ import numpy as np # noqa: E402
39
+ import pandas as pd # noqa: E402
40
+ from matplotlib.colors import LinearSegmentedColormap, Normalize # noqa: E402
41
+ from matplotlib.lines import Line2D # noqa: E402
42
+
43
+ from .build.geo import haversine_km # noqa: E402
44
+
45
+ # Validated categorical order (dataviz reference palette); events cycle with their index printed.
46
+ SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"]
47
+ NOISE_C = "#8f8f89"
48
+ BAND_C = "#d9d8d2"
49
+ INK, INK2 = "#1f1f1c", "#62625d"
50
+ PROB_CMAP = LinearSegmentedColormap.from_list("prob", ["#cde2fb", "#86b6ef", "#3987e5", "#1c5cab", "#0d366b"])
51
+ COL1, COL2 = 3.5, 7.2 # inches
52
+ DPI = 400
53
+
54
+ STYLE = {
55
+ "font.size": 7, "axes.titlesize": 7.5, "axes.labelsize": 7, "xtick.labelsize": 6.5, "ytick.labelsize": 6.5,
56
+ "legend.fontsize": 6.5, "font.family": "sans-serif",
57
+ "axes.linewidth": 0.6, "axes.edgecolor": INK2, "axes.labelcolor": INK, "xtick.color": INK2, "ytick.color": INK2,
58
+ "xtick.major.width": 0.6, "ytick.major.width": 0.6, "xtick.major.size": 2.5, "ytick.major.size": 2.5,
59
+ "axes.spines.top": False, "axes.spines.right": False, "axes.grid": True, "grid.color": "#ecebe5",
60
+ "grid.linewidth": 0.5, "legend.frameon": False, "savefig.dpi": DPI, "figure.dpi": 120,
61
+ "pdf.fonttype": 42, "ps.fonttype": 42,
62
+ }
63
+ plt.rcParams.update(STYLE)
64
+
65
+
66
+ def _finish(fig, save_path, dpi=DPI):
67
+ if save_path:
68
+ Path(save_path).parent.mkdir(parents=True, exist_ok=True)
69
+ fig.savefig(save_path, dpi=dpi, bbox_inches="tight")
70
+ return fig
71
+
72
+
73
+ def _panel(ax, letter):
74
+ ax.text(-0.02, 1.02, letter, transform=ax.transAxes, fontweight="bold", fontsize=8.5, ha="right", va="bottom",
75
+ color=INK)
76
+
77
+
78
+ def _ecolor(k):
79
+ return SERIES[int(k) % len(SERIES)]
80
+
81
+
82
+ def _stations_xy(w):
83
+ return w.stations.drop_duplicates("station").set_index("station")
84
+
85
+
86
+ def _hyp(e, st_rows):
87
+ return np.hypot(haversine_km(e["latitude"], e["longitude"], st_rows["latitude"].to_numpy(),
88
+ st_rows["longitude"].to_numpy()), e["depth"] / 1e3)
89
+
90
+
91
+ def _nearest_event_dist(w, picks):
92
+ st = _stations_xy(w).loc[picks["station"]]
93
+ d = np.stack([_hyp(e, st) for _, e in w.events.iterrows()])
94
+ return d.min(axis=0)
95
+
96
+
97
+ def _band_curves(w, e, model, network, r_grid):
98
+ """Predicted arrival (absolute window time) of one event from its real picks in the window."""
99
+ from .build.labels import EventArrivals
100
+
101
+ pe = w.picks[(w.picks.event_idx == e.event_idx) & (w.picks.real == 1)]
102
+ st = _stations_xy(w).loc[pe["station"]]
103
+ r = _hyp(e, st)
104
+ arr = EventArrivals(model, network, r, pe["t_rel_s"].to_numpy() - e.t0_rel_s, pe["phase"].to_numpy(),
105
+ e.get("last_station_km", np.nan) if np.isfinite(e.get("last_station_km", np.nan)) else r.max())
106
+ return {ph: e.t0_rel_s + arr.mu(ph, r_grid) for ph in ("P", "S")}
107
+
108
+
109
+ # ----------------------------------------------------------------------
110
+ # Window-level
111
+ # ----------------------------------------------------------------------
112
+ def plot_window(w, model=None, ax=None, show_bands=True, legend=True, title=None, save_path=None, dpi=DPI):
113
+ """Distance-time view of one window.
114
+
115
+ Event picks are drawn at their distance from their own event (y = 0 at its
116
+ origin); noise at its distance from the nearest event. Real picks: filled
117
+ (P) / open (S) circles; generated picks: diamonds; noise: grey crosses.
118
+ With ``model`` (``Release.label_model``) the band of each event is shaded.
119
+ """
120
+ own = ax is None
121
+ if own:
122
+ fig, ax = plt.subplots(figsize=(COL2, 3.0))
123
+ fig = ax.figure
124
+ st = _stations_xy(w)
125
+ ev = w.events.sort_values("event_idx")
126
+ ymax = 1.0
127
+
128
+ for _, e in ev.iterrows():
129
+ k, c = int(e.event_idx), _ecolor(e.event_idx)
130
+ alpha = 1.0 if e.scorable else 0.4
131
+ pe = w.picks[w.picks.event_idx == k]
132
+ y = _hyp(e, st.loc[pe["station"]])
133
+ ymax = max(ymax, float(y.max()) if len(y) else 0)
134
+ if show_bands and model is not None and (pe.real == 1).sum() >= 2:
135
+ rg = np.linspace(0, max(e.get("last_station_km", y.max()), 1), 120)
136
+ sig = model.sigma(rg)
137
+ for ph, mu in _band_curves(w, e, model, w.meta["network"], rg).items():
138
+ ax.fill_betweenx(rg, mu - model.band_k * sig, mu + model.band_k * sig, color=c, alpha=0.08, lw=0)
139
+ for ph, filled in (("P", True), ("S", False)):
140
+ m = ((pe.phase == ph) & (pe.real == 1)).to_numpy()
141
+ ax.scatter(pe.t_rel_s[m], y[m], s=9, lw=0.6, alpha=alpha, zorder=3,
142
+ facecolors=c if filled else "white", edgecolors=c)
143
+ g = ((pe.phase == ph) & (pe.pick_type == "generated")).to_numpy()
144
+ ax.scatter(pe.t_rel_s[g], y[g], s=12, marker="D", lw=0.6, alpha=alpha, zorder=3,
145
+ facecolors="none", edgecolors=c)
146
+ ax.scatter([e.t0_rel_s], [0], marker="*", s=55, color=c, alpha=alpha, edgecolors="white", lw=0.4, zorder=4)
147
+ ax.annotate(str(k), (e.t0_rel_s, 0), xytext=(0, 3.5), textcoords="offset points", ha="center",
148
+ fontsize=5.5, color=INK)
149
+
150
+ noise = w.picks[w.picks.pick_type == "noise"]
151
+ if len(noise):
152
+ yn = _nearest_event_dist(w, noise)
153
+ ax.scatter(noise.t_rel_s, yn, s=7, marker="x", lw=0.5, color=NOISE_C, zorder=2)
154
+ ax.set_ylim(1.12 * ymax, -0.06 * ymax)
155
+ ax.set_xlim(min(0, ev.t0_rel_s.min()) - 1, w.meta["length_s"] + 1)
156
+ ax.set_xlabel("Window time [s]")
157
+ ax.set_ylabel("Hypocentral distance [km]")
158
+ if title:
159
+ ax.set_title(title, loc="left")
160
+ if legend:
161
+ h = [Line2D([], [], marker="o", ls="", ms=3.5, color=INK2, label="real P"),
162
+ Line2D([], [], marker="o", ls="", ms=3.5, mfc="white", color=INK2, label="real S"),
163
+ Line2D([], [], marker="D", ls="", ms=3.5, mfc="none", color=INK2, label="generated"),
164
+ Line2D([], [], marker="x", ls="", ms=3.5, color=NOISE_C, label="noise"),
165
+ Line2D([], [], marker="*", ls="", ms=6, color=INK2, label="origin (event #)")]
166
+ if model is not None and show_bands:
167
+ h.append(plt.Rectangle((0, 0), 1, 1, color=INK2, alpha=0.15, label="band (±2σ)"))
168
+ ax.legend(handles=h, loc="upper center", bbox_to_anchor=(0.5, -0.16), ncol=len(h), fontsize=5.5,
169
+ handletextpad=0.2, columnspacing=0.9)
170
+ return _finish(fig, save_path, dpi) if own else fig
171
+
172
+
173
+ def plot_window_map(w, ax=None, all_stations=None, legend=True, save_path=None, dpi=DPI):
174
+ """Map of one window: epicentres (size ~ magnitude, colour = event), stations that picked
175
+ (dark), silent stations of the window (light) and, optionally, the rest of the network."""
176
+ own = ax is None
177
+ if own:
178
+ fig, ax = plt.subplots(figsize=(COL1, COL1))
179
+ fig = ax.figure
180
+ st = _stations_xy(w)
181
+ active = set(w.picks.loc[w.picks.get("real", pd.Series(1, index=w.picks.index)) == 1, "station"]) \
182
+ if "real" in w.picks else set(w.picks["station"])
183
+ if all_stations is not None:
184
+ ax.scatter(all_stations.longitude, all_stations.latitude, s=4, marker="^", color="#e4e3dc", lw=0, zorder=1)
185
+ silent = st[~st.index.isin(active)]
186
+ ax.scatter(silent.longitude, silent.latitude, s=8, marker="^", color="#c9c8c1", lw=0, zorder=2,
187
+ label="station, no picks")
188
+ act = st[st.index.isin(active)]
189
+ ax.scatter(act.longitude, act.latitude, s=10, marker="^", color=INK2, lw=0, zorder=3, label="station with picks")
190
+ ev = w.events.sort_values("event_idx")
191
+ if ev is not None and len(ev):
192
+ mags = pd.to_numeric(ev.magnitude, errors="coerce").fillna(ev.magnitude.median() if ev.magnitude.notna().any() else 1)
193
+ size = 12 + 18 * (mags - mags.min() + 0.5)
194
+ for (_, e), s_ in zip(ev.iterrows(), size):
195
+ ax.scatter(e.longitude, e.latitude, marker="*", s=s_, color=_ecolor(e.event_idx), edgecolors="white",
196
+ lw=0.4, zorder=4, alpha=1 if e.scorable else 0.45)
197
+ dx, dy = [(4, 3), (-8, 3), (4, -8), (-8, -8)][int(e.event_idx) % 4] # staggered labels
198
+ ax.annotate(str(int(e.event_idx)), (e.longitude, e.latitude), xytext=(dx, dy), textcoords="offset points",
199
+ fontsize=5.5, color=INK)
200
+ focus = pd.concat([act[["longitude", "latitude"]], ev[["longitude", "latitude"]]]) if len(act) else st
201
+ x0, x1 = focus.longitude.min(), focus.longitude.max()
202
+ y0, y1 = focus.latitude.min(), focus.latitude.max()
203
+ pad = 0.12 * max(x1 - x0, y1 - y0, 0.2)
204
+ ax.set_xlim(x0 - pad, x1 + pad)
205
+ ax.set_ylim(y0 - pad, y1 + pad)
206
+ _map_aspect(ax, (y0 + y1) / 2)
207
+ ax.xaxis.set_major_locator(matplotlib.ticker.MaxNLocator(3))
208
+ ax.yaxis.set_major_locator(matplotlib.ticker.MaxNLocator(4))
209
+ ax.set_xlabel("Longitude [°]")
210
+ ax.set_ylabel("Latitude [°]")
211
+ if legend:
212
+ h = [Line2D([], [], marker="^", ls="", ms=4, color=INK2, label="station with picks"),
213
+ Line2D([], [], marker="^", ls="", ms=4, color="#c9c8c1", label="station, no picks"),
214
+ Line2D([], [], marker="*", ls="", ms=7, color=INK2, label="epicentre (size ~ M)")]
215
+ ax.legend(handles=h, loc="upper center", bbox_to_anchor=(0.5, -0.3), ncol=1, fontsize=5.5)
216
+ return _finish(fig, save_path, dpi) if own else fig
217
+
218
+
219
+ def _map_aspect(ax, lat0):
220
+ ax.set_aspect(1 / max(np.cos(np.radians(lat0)), 0.2))
221
+ try: # coastlines when cartopy is available (optional)
222
+ import cartopy.io.shapereader as shpreader # noqa: F401
223
+ from cartopy.feature import COASTLINE
224
+ for geom in COASTLINE.geometries():
225
+ for line in getattr(geom, "geoms", [geom]):
226
+ x, y = line.xy
227
+ ax.plot(x, y, color="#b8b7b0", lw=0.4, zorder=0)
228
+ except Exception:
229
+ pass
230
+
231
+
232
+ def plot_window_anatomy(w, model=None, all_stations=None, save_path=None, dpi=DPI):
233
+ """The 'what is a window' figure: (a) map, (b) distance-time with bands, (c) pick counts."""
234
+ fig = plt.figure(figsize=(COL2, 3.2))
235
+ gs = fig.add_gridspec(1, 3, width_ratios=[1.25, 2.3, 0.55], wspace=0.38)
236
+ ax_m, ax_t, ax_c = fig.add_subplot(gs[0]), fig.add_subplot(gs[1]), fig.add_subplot(gs[2])
237
+ plot_window_map(w, ax=ax_m, all_stations=all_stations, legend=True)
238
+ plot_window(w, model=model, ax=ax_t, legend=True)
239
+ counts = w.picks.pick_type.value_counts().reindex(["real", "generated", "noise"]).fillna(0)
240
+ colors = [INK2, "#86b6ef", NOISE_C]
241
+ ax_c.barh(range(3), counts.values, color=colors, height=0.6)
242
+ for i, v in enumerate(counts.values):
243
+ ax_c.text(v, i, f" {int(v)}", va="center", fontsize=6, color=INK)
244
+ ax_c.set_yticks(range(3), ["real", "generated", "noise"])
245
+ ax_c.invert_yaxis()
246
+ ax_c.set_xlabel("Picks")
247
+ ax_c.grid(axis="y", visible=False)
248
+ ax_c.set_xlim(0, counts.max() * 1.35 + 1)
249
+ for ax, l in zip((ax_m, ax_t, ax_c), "abc"):
250
+ _panel(ax, l)
251
+ m = w.meta
252
+ fig.suptitle(f"{m['window_id']} · {int(m['n_events'])} events · "
253
+ f"{int(m['n_stations'])} stations · {m['length_s']:.0f} s", x=0.01, ha="left", fontsize=7.5,
254
+ color=INK)
255
+ return _finish(fig, save_path, dpi)
256
+
257
+
258
+ def plot_event_labels(w, model, events=None, ncols=3, save_path=None, dpi=DPI):
259
+ """Per event: travel time vs distance, the band (±2σ, only where generation is allowed shaded
260
+ darker), real and generated picks coloured by prob, noise near the event, last station."""
261
+ from .build.labels import EventArrivals
262
+
263
+ ev = w.events.sort_values("event_idx")
264
+ if events is not None:
265
+ ev = ev[ev.event_idx.isin(events)]
266
+ n = len(ev)
267
+ nrows = int(np.ceil(n / ncols))
268
+ fig, axes = plt.subplots(nrows, ncols, figsize=(COL2, 2.1 * nrows), squeeze=False)
269
+ st = _stations_xy(w)
270
+ norm = Normalize(0.5, 1)
271
+ for ax, (_, e) in zip(axes.ravel(), ev.iterrows()):
272
+ pe = w.picks[w.picks.event_idx == e.event_idx]
273
+ r = _hyp(e, st.loc[pe.station])
274
+ tt = pe.t_rel_s.to_numpy() - e.t0_rel_s
275
+ real = (pe.real == 1).to_numpy()
276
+ last = e.get("last_station_km", np.nan)
277
+ last = r[real].max() if not np.isfinite(last) and real.any() else last
278
+ if real.sum() >= 2:
279
+ arr = EventArrivals(model, w.meta["network"], r[real], tt[real], pe.phase.to_numpy()[real], last)
280
+ rg = np.linspace(0, max(last, r.max()) * 1.05, 300)
281
+ sig = model.sigma(rg)
282
+ for ph in ("P", "S"):
283
+ mu = arr.mu(ph, rg)
284
+ ax.fill_between(rg, mu - model.band_k * sig, mu + model.band_k * sig, color=BAND_C, lw=0, zorder=0)
285
+ ok = arr.can_generate(ph, rg)
286
+ ax.fill_between(rg, np.where(ok, mu - model.band_k * sig, np.nan),
287
+ np.where(ok, mu + model.band_k * sig, np.nan), color="#b9d3f1", lw=0, zorder=1)
288
+ ax.plot(rg, mu, color=INK2, lw=0.5, zorder=2)
289
+ for ph, mk in (("P", "o"), ("S", "s")):
290
+ m = (pe.phase == ph).to_numpy()
291
+ for typ, face in (("real", True), ("generated", False)):
292
+ s_ = m & (pe.pick_type == typ).to_numpy()
293
+ if s_.any():
294
+ ax.scatter(r[s_], tt[s_], c=pe.prob.to_numpy()[s_], cmap=PROB_CMAP, norm=norm,
295
+ marker=mk if face else "D", s=10, lw=0.5, zorder=3, edgecolors=INK2)
296
+ noise = w.picks[w.picks.pick_type == "noise"]
297
+ if len(noise):
298
+ rn = _hyp(e, st.loc[noise.station])
299
+ tn = noise.t_rel_s.to_numpy() - e.t0_rel_s
300
+ keep = (tn > -5) & (rn <= max(last, r.max()) * 1.05) & (tn <= tt.max() * 1.3 + 5)
301
+ ax.scatter(rn[keep], tn[keep], marker="x", s=6, lw=0.4, color=NOISE_C, zorder=2)
302
+ if np.isfinite(last):
303
+ ax.axvline(last, color=SERIES[1], lw=0.7, ls="--")
304
+ ax.set_title(f"event {int(e.event_idx)}: {e.magnitude_type} {e.magnitude:.1f}, "
305
+ f"{e.depth / 1e3:.0f} km deep", loc="left", fontsize=6.5)
306
+ ax.set_xlabel("Hypocentral distance [km]")
307
+ ax.set_ylabel("Travel time [s]")
308
+ ax.set_ylim(bottom=min(0, np.nanmin(tt)) - 1)
309
+ for ax in axes.ravel()[n:]:
310
+ ax.set_visible(False)
311
+ sm = matplotlib.cm.ScalarMappable(norm=norm, cmap=PROB_CMAP)
312
+ fig.subplots_adjust(top=0.9 if nrows > 1 else 0.82, hspace=0.55, wspace=0.35, right=0.88)
313
+ cax = fig.add_axes([0.905, 0.15, 0.012, 0.65])
314
+ cb = fig.colorbar(sm, cax=cax)
315
+ cb.set_label("prob (real and generated picks)")
316
+ h = [Line2D([], [], marker="o", ls="", ms=3.5, mfc="#3987e5", mec=INK2, label="real P"),
317
+ Line2D([], [], marker="s", ls="", ms=3.5, mfc="#3987e5", mec=INK2, label="real S"),
318
+ Line2D([], [], marker="D", ls="", ms=3.5, mfc="#86b6ef", mec=INK2, label="generated"),
319
+ Line2D([], [], marker="x", ls="", ms=3.5, color=NOISE_C, label="noise"),
320
+ plt.Rectangle((0, 0), 1, 1, color=BAND_C, label="band: no noise"),
321
+ plt.Rectangle((0, 0), 1, 1, color="#b9d3f1", label="band: generation allowed"),
322
+ Line2D([], [], color=SERIES[1], ls="--", lw=0.8, label="last station")]
323
+ fig.legend(handles=h, loc="upper center", ncol=7, bbox_to_anchor=(0.45, 1.0), fontsize=5.5)
324
+ return _finish(fig, save_path, dpi)
325
+
326
+
327
+ def _pick_distance(w, picks):
328
+ """Distance of each pick to its own event (event picks) or to the nearest event (noise)."""
329
+ st = _stations_xy(w).loc[picks["station"]]
330
+ ev = w.events.set_index("event_idx")
331
+ d = np.stack([_hyp(e, st) for _, e in w.events.sort_values("event_idx").iterrows()]) # (E, N)
332
+ own = picks["event_idx"].to_numpy() if "event_idx" in picks else np.full(len(picks), -1)
333
+ out = d.min(axis=0)
334
+ m = own >= 0
335
+ out[m] = d[np.searchsorted(ev.index.to_numpy(), own[m]), np.flatnonzero(m)]
336
+ return out
337
+
338
+
339
+ def plot_prediction(w, predictions, locations=None, min_shared=3, save_path=None, dpi=DPI):
340
+ """Truth (left) vs an associator's prediction (right) for one window.
341
+
342
+ ``w`` must be loaded with labels; ``predictions`` has window_id, pick_idx,
343
+ pred_event (as written by ``Associator.run`` / ``predictions_frame``).
344
+ Picks keep the same position in both panels; predicted events take the
345
+ colour of the true event they are matched to (black if unmatched). Red
346
+ circles mark errors: a pick in the wrong event, noise put in an event, or
347
+ an event pick left unassociated. With ``locations`` (``result.locations``),
348
+ a map adds the true epicentres (stars) joined to the predicted ones (circles).
349
+ """
350
+ from scipy.optimize import linear_sum_assignment
351
+
352
+ from .metrics import _score_window
353
+
354
+ pk = w.picks.merge(predictions[predictions.window_id == w.window_id][["pick_idx", "pred_event"]],
355
+ on="pick_idx", how="left")
356
+ pk["pred_event"] = pk["pred_event"].fillna(-1).astype(int)
357
+ y = _pick_distance(w, pk)
358
+ true, pred = pk.event_idx.to_numpy(), pk.pred_event.to_numpy()
359
+
360
+ t_ids, p_ids = np.unique(true[true >= 0]), np.unique(pred[pred >= 0])
361
+ cont = np.zeros((len(t_ids), len(p_ids)), int)
362
+ both = (true >= 0) & (pred >= 0)
363
+ np.add.at(cont, (np.searchsorted(t_ids, true[both]), np.searchsorted(p_ids, pred[both])), 1)
364
+ match = {}
365
+ if cont.size:
366
+ for i, j in zip(*linear_sum_assignment(-cont)):
367
+ if cont[i, j] >= min_shared:
368
+ match[p_ids[j]] = t_ids[i]
369
+ mapped = np.array([match.get(p, -2) if p >= 0 else -1 for p in pred]) # -2 = unmatched event
370
+ wrong = (mapped != true) & ~((true < 0) & (pred < 0))
371
+
372
+ scorable = set(w.events.loc[w.events.scorable, "event_idx"])
373
+ sc = _score_window(pk.rename(columns={}), scorable, min_shared)
374
+ ev_f1 = 2 * sc["event_tp"] / max(2 * sc["event_tp"] + sc["event_fp"] + sc["event_fn"], 1)
375
+
376
+ loc = None
377
+ if locations is not None:
378
+ loc = locations[locations.window_id == w.window_id]
379
+ if loc is not None and len(loc):
380
+ fig = plt.figure(figsize=(COL2, 3.0))
381
+ gs = fig.add_gridspec(1, 3, width_ratios=[1, 1, 0.75], wspace=0.3)
382
+ axes = [fig.add_subplot(gs[0])]
383
+ axes.append(fig.add_subplot(gs[1], sharey=axes[0], sharex=axes[0]))
384
+ ax_map = fig.add_subplot(gs[2])
385
+ else:
386
+ fig, axes = plt.subplots(1, 2, figsize=(COL2, 3.0), sharey=True, sharex=True)
387
+ ax_map = None
388
+ t = pk.t_rel_s.to_numpy()
389
+ filled = (pk.phase == "P").to_numpy()
390
+ for ax, lab, title in ((axes[0], true, f"Truth: {len(t_ids)} events"),
391
+ (axes[1], mapped, f"Prediction: {len(p_ids)} events · F1 {ev_f1:.2f} · "
392
+ f"{int(wrong.sum())} errors")):
393
+ noise = lab == -1
394
+ ax.scatter(t[noise], y[noise], marker="x", s=7, lw=0.5, color=NOISE_C, zorder=2)
395
+ for k in np.unique(lab[lab != -1]):
396
+ m = lab == k
397
+ c = INK if k == -2 else _ecolor(k)
398
+ ax.scatter(t[m & filled], y[m & filled], s=9, color=c, lw=0.5, zorder=3)
399
+ ax.scatter(t[m & ~filled], y[m & ~filled], s=9, facecolors="white", edgecolors=c, lw=0.6, zorder=3)
400
+ for _, e in w.events.iterrows():
401
+ ax.scatter([e.t0_rel_s], [0], marker="*", s=45, color=_ecolor(e.event_idx), edgecolors="white",
402
+ lw=0.4, zorder=4, alpha=1 if e.scorable else 0.4)
403
+ ax.set_title(title, loc="left")
404
+ ax.set_xlabel("Window time [s]")
405
+ axes[1].scatter(t[wrong], y[wrong], s=42, facecolors="none", edgecolors=SERIES[7], lw=0.8, zorder=5)
406
+ if ax_map is not None:
407
+ st = _stations_xy(w)
408
+ used = st[st.index.isin(pk.station)]
409
+ ax_map.scatter(used.longitude, used.latitude, marker="^", s=8, color="#c9c8c1", lw=0, zorder=1)
410
+ inv = {tv: pv for pv, tv in match.items()}
411
+ for _, e in w.events.iterrows():
412
+ c = _ecolor(e.event_idx)
413
+ ax_map.scatter(e.longitude, e.latitude, marker="*", s=60, color=c, edgecolors="white", lw=0.4, zorder=4)
414
+ pv = inv.get(e.event_idx)
415
+ row = loc[loc.event == pv] if pv is not None else loc.iloc[0:0]
416
+ if len(row) and np.isfinite(row.latitude.iloc[0]):
417
+ ax_map.plot([e.longitude, row.longitude.iloc[0]], [e.latitude, row.latitude.iloc[0]],
418
+ color=c, lw=0.7, zorder=2)
419
+ for _, r in loc.iterrows():
420
+ c = _ecolor(match[r.event]) if r.event in match else INK
421
+ ax_map.scatter(r.longitude, r.latitude, s=16, facecolors="white", edgecolors=c, lw=0.9, zorder=3)
422
+ _map_aspect(ax_map, used.latitude.mean())
423
+ ax_map.xaxis.set_major_locator(matplotlib.ticker.MaxNLocator(3))
424
+ ax_map.set_xlabel("Longitude [°]")
425
+ ax_map.set_ylabel("Latitude [°]")
426
+ ax_map.set_title("★ true ○ predicted", loc="left")
427
+ axes[0].set_ylabel("Distance [km]")
428
+ axes[0].set_ylim(1.1 * max(y.max(), 1), -0.06 * max(y.max(), 1))
429
+ h = [Line2D([], [], marker="o", ls="", ms=3.5, color=INK2, label="P"),
430
+ Line2D([], [], marker="o", ls="", ms=3.5, mfc="white", color=INK2, label="S"),
431
+ Line2D([], [], marker="x", ls="", ms=3.5, color=NOISE_C, label="noise / unassociated"),
432
+ Line2D([], [], marker="o", ls="", ms=3.5, color=INK, label="predicted event with no true match"),
433
+ Line2D([], [], marker="o", ls="", ms=6, mfc="none", mec=SERIES[7], label="error")]
434
+ fig.legend(handles=h, loc="lower center", ncol=5, bbox_to_anchor=(0.5, -0.04))
435
+ fig.suptitle(w.window_id, x=0.01, ha="left", fontsize=7, color=INK2)
436
+ if ax_map is None:
437
+ fig.tight_layout(rect=(0, 0.05, 1, 1))
438
+ else:
439
+ fig.subplots_adjust(bottom=0.24, top=0.86, left=0.07, right=0.98)
440
+ return _finish(fig, save_path, dpi)
441
+
442
+
443
+ # ----------------------------------------------------------------------
444
+ # Dataset-level
445
+ # ----------------------------------------------------------------------
446
+ def _level(scenario, axis):
447
+ for part in str(scenario).split("__"):
448
+ if part.startswith(axis + "-"):
449
+ return part[len(axis) + 1:]
450
+ return None
451
+
452
+
453
+ def plot_scenario_grid(split, network, ev_levels=("sparse", "low", "med", "high"),
454
+ noise_levels=("none", "med", "extreme"), model=None, index=0, save_path=None, dpi=DPI):
455
+ """One example window per (events-per-window, noise level) for one network."""
456
+ fig, axes = plt.subplots(len(noise_levels), len(ev_levels), figsize=(COL2, 1.55 * len(noise_levels) + 0.3),
457
+ squeeze=False)
458
+ ids = set(split.windows.window_id)
459
+ for i, nl in enumerate(noise_levels):
460
+ for j, el in enumerate(ev_levels):
461
+ ax = axes[i][j]
462
+ wid = f"{network}.{split.name}.ev-{el}__noise-{nl}.{index:05d}"
463
+ if wid not in ids:
464
+ ax.set_visible(False)
465
+ continue
466
+ w = split[wid]
467
+ plot_window(w, model=model, ax=ax, legend=False, show_bands=False)
468
+ ax.set_title(f"{el} ({int(w.meta['n_events'])} ev.) · noise {nl}", loc="left", fontsize=6)
469
+ if i < len(noise_levels) - 1:
470
+ ax.set_xlabel("")
471
+ if j > 0:
472
+ ax.set_ylabel("")
473
+ h = [Line2D([], [], marker="o", ls="", ms=3, color=INK2, label="real P"),
474
+ Line2D([], [], marker="o", ls="", ms=3, mfc="white", color=INK2, label="real S"),
475
+ Line2D([], [], marker="D", ls="", ms=3, mfc="none", color=INK2, label="generated"),
476
+ Line2D([], [], marker="x", ls="", ms=3, color=NOISE_C, label="noise"),
477
+ Line2D([], [], marker="*", ls="", ms=5, color=INK2, label="origin")]
478
+ fig.legend(handles=h, loc="upper center", ncol=5, bbox_to_anchor=(0.5, 1.02))
479
+ fig.suptitle(f"Scenarios — {network}: columns = events per window, rows = noise level", y=1.07, fontsize=7.5,
480
+ color=INK)
481
+ fig.tight_layout(h_pad=0.8, w_pad=0.6)
482
+ return _finish(fig, save_path, dpi)
483
+
484
+
485
+ def plot_gap_sweep(split, network, scenarios=("gap-000-090", "gap-090-180", "gap-180-270"), index=0,
486
+ save_path=None, dpi=DPI):
487
+ """Out-of-network sweep: one window per azimuthal-gap scenario, map (top) and distance-time (bottom)."""
488
+ fig, axes = plt.subplots(2, len(scenarios), figsize=(COL2, 4.6), gridspec_kw={"height_ratios": [1.1, 1]})
489
+ for j, sc in enumerate(scenarios):
490
+ wid = f"{network}.{split.name}.{sc}.{index:05d}"
491
+ if wid not in set(split.windows.window_id):
492
+ axes[0][j].set_visible(False)
493
+ axes[1][j].set_visible(False)
494
+ continue
495
+ w = split[wid]
496
+ plot_window_map(w, ax=axes[0][j], legend=(j == 0))
497
+ lo, hi = sc.split("-")[1:]
498
+ axes[0][j].set_title(f"azimuthal gap {int(lo)}–{int(hi)}°", loc="left")
499
+ plot_window(w, ax=axes[1][j], legend=False, show_bands=False)
500
+ if j:
501
+ axes[0][j].set_ylabel("")
502
+ axes[1][j].set_ylabel("")
503
+ fig.tight_layout()
504
+ return _finish(fig, save_path, dpi)
505
+
506
+
507
+ def plot_label_model(model, magnitudes=range(0, 8), save_path=None, dpi=DPI):
508
+ """(a) prob of a real pick vs distance per magnitude; (b) generated-pick prob vs z;
509
+ (c) timing uncertainty sigma(r) and the band half-width."""
510
+ fig, axes = plt.subplots(1, 3, figsize=(COL2, 2.3), gridspec_kw={"width_ratios": [1.5, 1, 1.2]})
511
+ r = np.logspace(0, 4.2, 300)
512
+ ramp = ["#b7d3f6", "#86b6ef", "#5598e7", "#3987e5", "#2a78d6", "#1c5cab", "#184f95", "#0d366b"]
513
+ for M, c in zip(magnitudes, ramp):
514
+ axes[0].plot(r, model.prob_real(M, r), color=c, lw=1.1, label=f"M{M}")
515
+ axes[0].legend(ncol=2, fontsize=5.5, loc="lower left", handlelength=1.2, columnspacing=0.8)
516
+ for lo, hi, lab in ((1, 150, "local"), (150, 1500, "regional"), (1500, 16000, "global")):
517
+ axes[0].axvspan(lo, hi, color="#f4f3ee" if lab == "regional" else "white", zorder=0)
518
+ axes[0].text(np.sqrt(lo * hi), 1.035, lab, ha="center", fontsize=5.5, color=INK2)
519
+ axes[0].axhline(0.5, color=INK2, lw=0.5, ls="--")
520
+ axes[0].set(xscale="log", xlim=(1, 2.5e4), ylim=(0.45, 1.06), xlabel="Hypocentral distance [km]",
521
+ ylabel="prob of a real pick")
522
+ z = np.linspace(-model.band_k, model.band_k, 200)
523
+ for M, rr, c in ((3, 20, ramp[5]), (3, 300, ramp[3]), (1.5, 300, ramp[1])):
524
+ axes[1].plot(z, model.prob_generated(M, rr, z), color=c, lw=1.1, label=f"M{M}, {rr} km")
525
+ axes[1].axhline(0.5, color=INK2, lw=0.5, ls="--")
526
+ axes[1].set(xlabel="z = (t − predicted) / σ", ylabel="prob of a generated pick", ylim=(0.45, 1.02))
527
+ axes[1].legend(fontsize=5.5, loc="lower center")
528
+ axes[2].plot(r, model.sigma(r), color=INK, lw=1.2, label="σ(r)")
529
+ axes[2].plot(r, model.band_k * model.sigma(r), color=INK, lw=0.8, ls="--", label=f"band half-width ({model.band_k:g}σ)")
530
+ axes[2].set(xscale="log", yscale="log", xlabel="Hypocentral distance [km]", ylabel="Timing uncertainty [s]")
531
+ axes[2].legend(fontsize=5.5, loc="upper left")
532
+ for ax, l in zip(axes, "abc"):
533
+ _panel(ax, l)
534
+ fig.tight_layout(w_pad=1.2)
535
+ return _finish(fig, save_path, dpi)
536
+
537
+
538
+ def _overlap_fraction(picks, events):
539
+ """Per window: fraction of real picks that arrive while >= 2 events are active."""
540
+ out = {}
541
+ ev = events.dropna(subset=["first_pick_s"]).groupby("window_id")
542
+ for wid, p in picks[picks.real == 1].groupby("window_id"):
543
+ if wid not in ev.groups:
544
+ continue
545
+ e = ev.get_group(wid)
546
+ f, l = e.first_pick_s.to_numpy(), e.last_pick_s.to_numpy()
547
+ t = p.t_rel_s.to_numpy()[:, None]
548
+ out[wid] = float((((t >= f) & (t <= l)).sum(axis=1) >= 2).mean())
549
+ return pd.Series(out)
550
+
551
+
552
+ def plot_difficulty(split, ev_levels=("sparse", "low", "med", "high"),
553
+ noise_levels=("none", "low", "med", "high", "extreme"), save_path=None, dpi=DPI):
554
+ """Phase-association difficulty of a split, by scenario level:
555
+ (a) events active at once, (b) real picks arriving during overlap, (c) closest origins,
556
+ (d) picks per window, (e) prob per pick type, (f) distance of each pick type to the nearest event."""
557
+ w = split.windows.copy()
558
+ w["ev"] = w.scenario.map(lambda s: _level(s, "ev"))
559
+ w["noise"] = w.scenario.map(lambda s: _level(s, "noise"))
560
+ w["overlap"] = w.window_id.map(_overlap_fraction(split.picks, split.events))
561
+ fig, axes = plt.subplots(2, 3, figsize=(COL2, 4.4))
562
+
563
+ def box(ax, col, by, order, ylabel, log=False):
564
+ data = [w.loc[w[by] == lv, col].dropna() for lv in order]
565
+ b = ax.boxplot(data, positions=range(len(order)), widths=0.55, patch_artist=True, showfliers=False)
566
+ for p_ in b["boxes"]:
567
+ p_.set(facecolor="#86b6ef", edgecolor="white", lw=0.6)
568
+ for part in ("whiskers", "caps"):
569
+ for line in b[part]:
570
+ line.set(color=INK2, lw=0.6)
571
+ for line in b["medians"]:
572
+ line.set(color=INK, lw=1)
573
+ ax.set_xticks(range(len(order)), order)
574
+ ax.set_ylabel(ylabel)
575
+ if log:
576
+ ax.set_yscale("log")
577
+
578
+ box(axes[0, 0], "max_concurrent_events", "ev", ev_levels, "Events active at once (max)")
579
+ axes[0, 0].set_xlabel("Events per window")
580
+ box(axes[0, 1], "overlap", "ev", ev_levels, "Real picks during overlap")
581
+ axes[0, 1].set_xlabel("Events per window")
582
+ axes[0, 1].yaxis.set_major_formatter(matplotlib.ticker.PercentFormatter(1.0))
583
+ box(axes[0, 2], "min_origin_sep_s", "ev", ev_levels[1:], "Closest origins [s]", log=True)
584
+ axes[0, 2].set_xlabel("Events per window")
585
+ box(axes[1, 0], "n_picks", "noise", noise_levels, "Picks per window", log=True)
586
+ axes[1, 0].set_xlabel("Noise level")
587
+
588
+ p = split.picks
589
+ bins = np.linspace(0, 1, 41)
590
+ bins = np.linspace(0.5, 1, 26)
591
+ for typ, c in (("real", INK2), ("generated", "#3987e5")):
592
+ v = p.loc[p.pick_type == typ, "prob"]
593
+ if len(v):
594
+ axes[1, 1].hist(v, bins=bins, color=c, alpha=0.7, label=f"{typ} ({len(v):,})",
595
+ weights=np.full(len(v), 1 / len(v)))
596
+ n_noise = int((p.pick_type == "noise").sum())
597
+ axes[1, 1].set(xlabel=f"prob (noise: {n_noise:,} picks, all = 0)", ylabel="Fraction of picks")
598
+ axes[1, 1].legend(fontsize=5.5)
599
+
600
+ sample = w.window_id.sample(min(300, len(w)), random_state=0)
601
+ dists = {"real": [], "generated": [], "noise": []}
602
+ for wid in sample:
603
+ wd = split[wid]
604
+ d = _nearest_event_dist(wd, wd.picks)
605
+ for typ in dists:
606
+ dists[typ].append(d[(wd.picks.pick_type == typ).to_numpy()])
607
+ edges = np.logspace(0, 4.2, 40)
608
+ for typ, c in (("real", INK2), ("generated", "#3987e5"), ("noise", NOISE_C)):
609
+ v = np.concatenate(dists[typ]) if dists[typ] else np.array([])
610
+ if len(v):
611
+ axes[1, 2].hist(v, bins=edges, color=c, alpha=0.6, label=typ, weights=np.full(len(v), 1 / len(v)))
612
+ axes[1, 2].set(xscale="log", xlabel="Distance to nearest event [km]", ylabel="Fraction of picks")
613
+ axes[1, 2].legend(fontsize=5.5)
614
+ for ax, l in zip(axes.ravel(), "abcdef"):
615
+ _panel(ax, l)
616
+ fig.tight_layout(h_pad=1.2, w_pad=1.0)
617
+ return _finish(fig, save_path, dpi)
618
+
619
+
620
+ def plot_release_stats(split, save_path=None, dpi=DPI):
621
+ """Per network: windows length, picks per window, gap and magnitude of scorable events."""
622
+ w, ev = split.windows, split.events.merge(split.windows[["window_id", "network"]], on="window_id")
623
+ nets = sorted(w.network.unique())
624
+ if len(nets) > 4:
625
+ w = w.assign(network="all networks")
626
+ ev = ev.assign(network="all networks")
627
+ nets = ["all networks"]
628
+ fig, axes = plt.subplots(1, 4, figsize=(COL2, 1.9))
629
+ specs = [(w, "length_s", "Window length [s]", True), (w, "n_picks", "Picks per window", True),
630
+ (ev[ev.scorable], "gap_deg", "Azimuthal gap [°]", False), (ev[ev.scorable], "magnitude", "Magnitude", False)]
631
+ for ax, (df, col, lab, log) in zip(axes, specs):
632
+ vals = df[col].dropna()
633
+ edges = np.logspace(np.log10(max(vals.min(), 1e-1)), np.log10(vals.max()), 30) if log else 30
634
+ for i, n in enumerate(nets):
635
+ v = df.loc[df.network == n, col].dropna()
636
+ ax.hist(v, bins=edges, color=SERIES[i % 8], alpha=0.65, label=n, weights=np.full(len(v), 1 / max(len(v), 1)))
637
+ ax.set_xlabel(lab)
638
+ if log:
639
+ ax.set_xscale("log")
640
+ axes[0].set_ylabel("Fraction")
641
+ axes[0].legend(fontsize=5.5)
642
+ for ax, l in zip(axes, "abcd"):
643
+ _panel(ax, l)
644
+ fig.tight_layout(w_pad=0.8)
645
+ return _finish(fig, save_path, dpi)
646
+
647
+
648
+ def plot_scores(summary_by_scenario, metric="event_f1", ev_levels=("sparse", "low", "med", "high"),
649
+ noise_levels=("none", "low", "med", "high", "extreme"), title=None, save_path=None, dpi=DPI):
650
+ """Heatmap of one metric over the events x noise grid (from ``Report.by_scenario``)."""
651
+ s = summary_by_scenario.copy()
652
+ s["ev"] = s.scenario.map(lambda x: _level(x, "ev"))
653
+ s["noise"] = s.scenario.map(lambda x: _level(x, "noise"))
654
+ m = s.pivot_table(index="noise", columns="ev", values=metric).reindex(index=list(noise_levels),
655
+ columns=list(ev_levels))
656
+ fig, ax = plt.subplots(figsize=(COL1, 2.3))
657
+ im = ax.imshow(m.to_numpy(float), cmap=PROB_CMAP, vmin=0, vmax=1, aspect="auto")
658
+ for (i, j), v in np.ndenumerate(m.to_numpy(float)):
659
+ if v == v:
660
+ ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=6, color="white" if v > 0.55 else INK)
661
+ ax.set_xticks(range(len(ev_levels)), ev_levels)
662
+ ax.set_yticks(range(len(noise_levels)), noise_levels)
663
+ ax.set(xlabel="Events per window", ylabel="Noise level")
664
+ ax.grid(False)
665
+ ax.set_title(title or metric, loc="left")
666
+ fig.colorbar(im, ax=ax, fraction=0.05, pad=0.02)
667
+ fig.tight_layout()
668
+ return _finish(fig, save_path, dpi)
669
+
670
+
671
+ # ----------------------------------------------------------------------
672
+ # Dataset statistics (bepad.stats)
673
+ # ----------------------------------------------------------------------
674
+ SCALE_COLOR = {"local": SERIES[0], "regional": SERIES[1], "global": SERIES[2]}
675
+
676
+
677
+ def _by_scale(ax, df, col, bins, log=False, label=True):
678
+ for sc, c in SCALE_COLOR.items():
679
+ v = df.loc[df.scale == sc, col].dropna()
680
+ if len(v):
681
+ ax.hist(v, bins=bins, color=c, alpha=0.65, weights=np.full(len(v), 1 / len(v)),
682
+ label=f"{sc} ({len(v):,})" if label else None)
683
+ if log:
684
+ ax.set_xscale("log")
685
+
686
+
687
+ def _logbins(v, n=35):
688
+ v = np.asarray(v, float)
689
+ v = v[np.isfinite(v) & (v > 0)]
690
+ return np.logspace(np.log10(v.min()), np.log10(v.max()), n) if len(v) else 10
691
+
692
+
693
+ def plot_events_overview(split, save_path=None, dpi=DPI):
694
+ """The source earthquakes of a split: (a) map, (b) magnitude, (c) depth, (d) azimuthal gap,
695
+ (e) farthest station against magnitude, (f) windows each earthquake appears in."""
696
+ from .stats import source_events
697
+
698
+ ev = source_events(split)
699
+ fig, axes = plt.subplots(2, 3, figsize=(COL2, 4.6))
700
+ ax = axes[0, 0]
701
+ for sc, c in SCALE_COLOR.items():
702
+ e = ev[ev.scale == sc]
703
+ ax.scatter(e.longitude, e.latitude, s=2, color=c, lw=0, alpha=0.6, label=sc, rasterized=True)
704
+ ax.set(xlim=(-180, 180), ylim=(-90, 90), xlabel="Longitude [°]", ylabel="Latitude [°]")
705
+ ax.set_aspect("equal")
706
+ ax.legend(markerscale=4, fontsize=5.5, loc="lower left")
707
+ _by_scale(axes[0, 1], ev, "magnitude", np.arange(-1, 8.01, 0.25))
708
+ axes[0, 1].set(xlabel="Magnitude", ylabel="Fraction of earthquakes")
709
+ _by_scale(axes[0, 2], ev, "depth_km", _logbins(ev.depth_km.clip(lower=0.1)), log=True, label=False)
710
+ axes[0, 2].set(xlabel="Depth [km]")
711
+ _by_scale(axes[1, 0], ev, "gap_deg", np.linspace(0, 360, 37), label=False)
712
+ axes[1, 0].set(xlabel="Azimuthal gap in window [°]", ylabel="Fraction of earthquakes")
713
+ ax = axes[1, 1]
714
+ for sc, c in SCALE_COLOR.items():
715
+ e = ev[ev.scale == sc]
716
+ ax.scatter(e.magnitude, e.last_station_km, s=3, color=c, lw=0, alpha=0.4, rasterized=True)
717
+ ax.set(yscale="log", xlabel="Magnitude", ylabel="Farthest station [km]")
718
+ ax.set_title("detection distance grows with M", loc="left", fontsize=6, color=INK2)
719
+ _by_scale(axes[1, 2], ev, "n_windows", np.arange(0.5, ev.n_windows.max() + 1.5, 1), label=False)
720
+ axes[1, 2].set(xlabel="Windows per earthquake")
721
+ for a, l in zip(axes.ravel(), "abcdef"):
722
+ _panel(a, l)
723
+ fig.tight_layout(h_pad=1.0, w_pad=0.8)
724
+ return _finish(fig, save_path, dpi)
725
+
726
+
727
+ def plot_station_geometry(split, max_windows=300, save_path=None, dpi=DPI):
728
+ """Station geometry of the windows, per scale: (a) stations available, (b) stations with real
729
+ picks, (c) median spacing between neighbouring stations, (d) aperture (widest separation)."""
730
+ from .stats import window_geometry
731
+
732
+ g = window_geometry(split, max_windows)
733
+ scales = [sc for sc in SCALE_COLOR if (g.scale == sc).any()]
734
+ fig, axes = plt.subplots(1, 4, figsize=(COL2, 2.0))
735
+ specs = [("n_stations", "Stations in window"), ("n_picking_stations", "Stations with real picks"),
736
+ ("spacing_km", "Station spacing [km]"), ("aperture_km", "Aperture [km]")]
737
+ for ax, (col, lab) in zip(axes, specs):
738
+ data = [g.loc[g.scale == sc, col].dropna() for sc in scales]
739
+ b = ax.boxplot(data, positions=range(len(scales)), widths=0.55, patch_artist=True, showfliers=False)
740
+ for patch, sc in zip(b["boxes"], scales):
741
+ patch.set(facecolor=SCALE_COLOR[sc], alpha=0.75, edgecolor="white")
742
+ for part in ("whiskers", "caps"):
743
+ for line in b[part]:
744
+ line.set(color=INK2, lw=0.6)
745
+ for line in b["medians"]:
746
+ line.set(color=INK, lw=1)
747
+ ax.set_yscale("log")
748
+ ax.set_xticks(range(len(scales)), scales)
749
+ ax.set_title(lab, loc="left", fontsize=6.5)
750
+ ax.grid(axis="x", visible=False)
751
+ for a, l in zip(axes, "abcd"):
752
+ _panel(a, l)
753
+ fig.tight_layout(w_pad=0.8)
754
+ return _finish(fig, save_path, dpi)
755
+
756
+
757
+ def plot_picks_overview(split, max_windows=200, save_path=None, dpi=DPI):
758
+ """Picks: (a) travel time against distance (real P and S), (b) real picks per earthquake,
759
+ (c) distance to the earthquake by pick type, (d) prob by pick type."""
760
+ from .stats import pick_distances
761
+
762
+ p = pick_distances(split, max_windows)
763
+ fig, axes = plt.subplots(1, 4, figsize=(COL2, 2.1))
764
+ ax = axes[0]
765
+ r = p[p.pick_type == "real"]
766
+ for ph, mk in (("P", "o"), ("S", "s")):
767
+ q = r[r.phase == ph]
768
+ for sc, c in SCALE_COLOR.items():
769
+ qq = q[q.scale == sc]
770
+ ax.scatter(qq.distance_km, qq.travel_time_s, s=1, marker=mk, color=c, lw=0,
771
+ alpha=0.25 if ph == "P" else 0.12, rasterized=True)
772
+ ax.set(xscale="log", yscale="log", xlabel="Hypocentral distance [km]", ylabel="Travel time [s]")
773
+ ax.text(0.03, 0.95, "real P and S", transform=ax.transAxes, fontsize=5.5, color=INK2, va="top")
774
+ per = r.groupby(["window_id", "event_idx"]).agg(n=("station", "size"), scale=("scale", "first")).reset_index()
775
+ _by_scale(axes[1], per, "n", _logbins(per.n), log=True)
776
+ axes[1].set(xlabel="Real picks per earthquake", ylabel="Fraction")
777
+ axes[1].legend(fontsize=5.5)
778
+ for typ, c in (("real", INK2), ("generated", "#3987e5"), ("noise", NOISE_C)):
779
+ v = p.loc[p.pick_type == typ, "distance_km"].dropna()
780
+ if len(v):
781
+ axes[2].hist(v, bins=_logbins(p.distance_km), color=c, alpha=0.6, label=typ,
782
+ weights=np.full(len(v), 1 / len(v)))
783
+ axes[2].set(xscale="log", xlabel="Distance to its earthquake [km]")
784
+ axes[2].legend(fontsize=5.5)
785
+ for typ, c in (("real", INK2), ("generated", "#3987e5")):
786
+ v = p.loc[p.pick_type == typ, "prob"]
787
+ if len(v):
788
+ axes[3].hist(v, bins=np.linspace(0.5, 1, 26), color=c, alpha=0.7, label=typ,
789
+ weights=np.full(len(v), 1 / len(v)))
790
+ axes[3].set(xlabel="prob (noise = 0)")
791
+ axes[3].legend(fontsize=5.5)
792
+ for a, l in zip(axes, "abcd"):
793
+ _panel(a, l)
794
+ fig.tight_layout(w_pad=0.6)
795
+ return _finish(fig, save_path, dpi)
796
+
797
+
798
+ def plot_event_proximity(split, save_path=None, dpi=DPI):
799
+ """Every pair of earthquakes in the same window: epicentral distance against origin-time
800
+ difference, one panel per scale. Pairs near the lower left (close in space AND time) are
801
+ the hardest: their picks arrive together at the same stations."""
802
+ from matplotlib.colors import LogNorm
803
+
804
+ from .stats import event_pairs
805
+
806
+ d = event_pairs(split)
807
+ scales = [sc for sc in SCALE_COLOR if (d.scale == sc).any()]
808
+ fig, axes = plt.subplots(1, len(scales), figsize=(COL2, 2.3), squeeze=False)
809
+ cmap = LinearSegmentedColormap.from_list("seq", ["#e6eefa", "#86b6ef", "#2a78d6", "#0d366b"])
810
+ for ax, sc in zip(axes[0], scales):
811
+ q = d[(d.scale == sc) & (d.distance_km > 0) & (d.dt_s > 0)]
812
+ xb, yb = _logbins(q.distance_km, 40), _logbins(q.dt_s, 40)
813
+ h = ax.hist2d(q.distance_km, q.dt_s, bins=[xb, yb], cmap=cmap, norm=LogNorm(), rasterized=True)
814
+ ax.set(xscale="log", yscale="log", xlabel="Distance between epicentres [km]")
815
+ ax.set_title(f"{sc}: {len(q):,} pairs", loc="left")
816
+ within = (q.dt_s < 10).mean() if len(q) else 0
817
+ ax.text(0.03, 0.04, f"{within:.0%} of pairs: origins < 10 s apart", transform=ax.transAxes,
818
+ fontsize=5.5, color=INK)
819
+ ax.grid(False)
820
+ axes[0][0].set_ylabel("Time between origins [s]")
821
+ fig.colorbar(h[3], ax=axes[0].tolist(), fraction=0.02, pad=0.01, label="Pairs")
822
+ return _finish(fig, save_path, dpi)
823
+
824
+
825
+ def plot_representative_windows(split, by="max_concurrent_events", quantiles=(0.1, 0.5, 0.9), networks=None,
826
+ model=None, save_path=None, dpi=DPI):
827
+ """Easy, typical and hard windows (rows: quantiles of ``by``) for one network per scale (columns)."""
828
+ from .stats import network_scales
829
+
830
+ scales = network_scales(split)
831
+ nets = networks or [scales[scales == sc].index[0] for sc in SCALE_COLOR if (scales == sc).any()]
832
+ names = {0.1: "easy", 0.5: "typical", 0.9: "hard"}
833
+ fig, axes = plt.subplots(len(quantiles), len(nets), figsize=(COL2, 1.8 * len(quantiles) + 0.3), squeeze=False)
834
+ for j, n in enumerate(nets):
835
+ w = split.windows[split.windows.network == n]
836
+ for i, q in enumerate(quantiles):
837
+ target = w[by].quantile(q)
838
+ wid = w.iloc[(w[by] - target).abs().argsort().iloc[0]].window_id
839
+ ax = axes[i][j]
840
+ win = split[wid]
841
+ plot_window(win, model=model, ax=ax, legend=False, show_bands=False)
842
+ ax.set_title(f"{n} ({scales[n]}) · {names.get(q, q)} · {by.replace('_', ' ')} = {int(win.meta[by])}",
843
+ loc="left", fontsize=5.8)
844
+ if i < len(quantiles) - 1:
845
+ ax.set_xlabel("")
846
+ if j:
847
+ ax.set_ylabel("")
848
+ fig.tight_layout(h_pad=0.8, w_pad=0.6)
849
+ return _finish(fig, save_path, dpi)
850
+
851
+
852
+ # What makes each core network hard (shown under its map).
853
+ CORE_CHALLENGES = {
854
+ "R05": "fracking microseismicity,\ncrowded picks",
855
+ "R07": "dense nodal array,\nevents outside it",
856
+ "R01": "geothermal field (Iceland),\ntiny events, many S picks",
857
+ "nc": "San Andreas and Geysers,\nshallow, almost only P",
858
+ "tx": "induced seismicity,\nwell-spread stations",
859
+ "JMA": "dense national network,\ncrust to slab",
860
+ "CM": "sparse stations,\nintermediate depth",
861
+ "R09": "coastal line of stations,\ndeep events offshore",
862
+ "ak": "long arc,\nlarge gaps offshore",
863
+ "NOA": "land and islands,\nsmall events",
864
+ }
865
+ CORE_NAMES = {"R05": "ToC2ME", "R07": "LASSO", "R01": "COSEISMIQ", "nc": "N. California", "tx": "Texas", "JMA": "Japan",
866
+ "CM": "Colombia", "R09": "N. Chile", "ak": "Alaska", "NOA": "Greece"}
867
+
868
+
869
+ def plot_core_networks(split, networks=None, ncols=5, save_path=None, dpi=DPI):
870
+ """One map per core network: its stations and its test earthquakes coloured by depth, with what
871
+ makes the network hard. ``split``: a loaded split with labels (e.g. ``bepad.load("test",
872
+ networks=["core"])``)."""
873
+ from .data import CORE_NETWORKS
874
+
875
+ networks = list(networks or CORE_NETWORKS)
876
+ ev = split.events.assign(network=split.events.window_id.str.split(".").str[0]) \
877
+ .drop_duplicates(["network", "src_event_id"])
878
+ st = split.window_stations.drop_duplicates(["network", "station"])
879
+ norm = matplotlib.colors.LogNorm(1, 600)
880
+ cmap = plt.get_cmap("viridis_r")
881
+ nrows = int(np.ceil(len(networks) / ncols))
882
+ fig, axes = plt.subplots(nrows, ncols, figsize=(COL2, 1.9 * nrows + 0.4), squeeze=False)
883
+ for ax, n in zip(axes.ravel(), networks):
884
+ e, s_ = ev[ev.network == n], st[st.network == n]
885
+ elon, slon = e.longitude.to_numpy(), s_.longitude.to_numpy()
886
+ glob = n in ("ISC", "R08")
887
+ if not glob and np.ptp(elon % 360) < np.ptp(elon): # across the antimeridian (Aleutians)
888
+ elon, slon = elon % 360, slon % 360
889
+ ax.scatter(slon, s_.latitude, s=1 if glob or len(s_) > 500 else 3, marker="^", color=INK2, lw=0,
890
+ alpha=0.7, zorder=2, rasterized=True)
891
+ ax.scatter(elon, e.latitude, s=1.2, c=(e.depth / 1e3).clip(1, 600), cmap=cmap, norm=norm, lw=0,
892
+ zorder=3, rasterized=True)
893
+ if glob:
894
+ x0, x1, y0, y1 = -180, 180, -70, 80
895
+ else: # zoom on the earthquakes, with stations around
896
+ (x0, x1), (y0, y1) = np.nanpercentile(elon, [1, 99]), np.nanpercentile(e.latitude, [1, 99])
897
+ k = 1 / max(np.cos(np.radians((y0 + y1) / 2)), 0.2)
898
+ half = 0.75 * max((x1 - x0) / k, y1 - y0, 0.01)
899
+ xc, yc = (x0 + x1) / 2, (y0 + y1) / 2
900
+ x0, x1, y0, y1 = xc - half * k, xc + half * k, yc - half, yc + half
901
+ ax.set(xlim=(x0, x1), ylim=(y0, y1), xticks=[], yticks=[])
902
+ ax.set_aspect("auto")
903
+ ax.grid(False)
904
+ for sp in ax.spines.values():
905
+ sp.set_visible(True)
906
+ sp.set_color(BAND_C)
907
+ km = 111.2 * (y1 - y0) # north-south size of the map
908
+ ax.set_title(f"{n} · {CORE_NAMES.get(n, n)}", loc="left", fontweight="bold", fontsize=6.5)
909
+ ax.set_xlabel(CORE_CHALLENGES.get(n, ""), fontsize=5.8, color=INK2, linespacing=1.15)
910
+ ax.text(0.97, 0.04, f"{len(s_):,} stations" + ("" if glob else f"\nscale: ~{km:,.0f} km"),
911
+ transform=ax.transAxes, ha="right", va="bottom", fontsize=4.8, color=INK2)
912
+ for ax in axes.ravel()[len(networks):]:
913
+ ax.axis("off")
914
+ fig.subplots_adjust(wspace=0.08, hspace=0.45, bottom=0.17)
915
+ cax = fig.add_axes([0.3, 0.05, 0.4, 0.018])
916
+ cb = fig.colorbar(matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap), cax=cax, orientation="horizontal")
917
+ cb.set_ticks([1, 10, 100, 600], labels=["1", "10", "100", "600"])
918
+ cb.set_label("Earthquake depth [km] · triangles: stations", fontsize=6)
919
+ return _finish(fig, save_path, dpi)
920
+
921
+
922
+ def release_gallery(release_dir, cfg, out_dir=None, networks=None, split="test"):
923
+ """All gallery figures of a release, written to ``<release>/figures`` (or ``out_dir``)."""
924
+ from .data import Release
925
+
926
+ rel = Release(release_dir)
927
+ out = Path(out_dir or Path(release_dir) / "figures")
928
+ model = rel.label_model
929
+ nets = networks or rel.networks(split)
930
+ axes = cfg.get("axes", {})
931
+ ev_levels = tuple(axes.get("ev", {}).get("levels", {})) or ("sparse", "low", "med", "high")
932
+ noise_levels = tuple(axes.get("noise", {}).get("levels", {})) or ("none", "low", "med", "high", "extreme")
933
+ plot_label_model(model, save_path=out / "label_model.png")
934
+ s_all = rel.load(split)
935
+ plot_difficulty(s_all, ev_levels, noise_levels, save_path=out / "difficulty.png")
936
+ plot_release_stats(s_all, save_path=out / "release_stats.png")
937
+ stations = pd.read_parquet(Path(release_dir) / "stations.parquet")
938
+ for n in nets:
939
+ s = rel.load(split, networks=[n])
940
+ rows = [lv for lv in noise_levels if lv in ("none", "med", "extreme")] or list(noise_levels)
941
+ plot_scenario_grid(s, n, ev_levels, rows, save_path=out / f"scenarios_{n}.png")
942
+ plot_gap_sweep(s, n, save_path=out / f"gap_sweep_{n}.png")
943
+ wid = f"{n}.{split}.ev-low__noise-med.00000"
944
+ if wid in set(s.windows.window_id):
945
+ w = s[wid]
946
+ plot_window_anatomy(w, model=model, all_stations=stations[stations.network == n],
947
+ save_path=out / f"window_anatomy_{n}.png")
948
+ plot_event_labels(w, model, save_path=out / f"event_labels_{n}.png")
949
+ plt.close("all")
950
+ return out