bepad 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bepad/__init__.py +17 -0
- bepad/__main__.py +206 -0
- bepad/associator.py +339 -0
- bepad/build/__init__.py +11 -0
- bepad/build/config.py +116 -0
- bepad/build/geo.py +64 -0
- bepad/build/labels.py +341 -0
- bepad/build/pool.py +392 -0
- bepad/build/release.py +256 -0
- bepad/build/utdquake.py +76 -0
- bepad/build/windows.py +408 -0
- bepad/data.py +185 -0
- bepad/leaderboard.py +368 -0
- bepad/metrics.py +324 -0
- bepad/plots.py +950 -0
- bepad/stats.py +140 -0
- bepad/torch.py +159 -0
- bepad-0.1.0.dist-info/METADATA +85 -0
- bepad-0.1.0.dist-info/RECORD +23 -0
- bepad-0.1.0.dist-info/WHEEL +5 -0
- bepad-0.1.0.dist-info/entry_points.txt +2 -0
- bepad-0.1.0.dist-info/licenses/LICENSE +674 -0
- bepad-0.1.0.dist-info/top_level.txt +1 -0
bepad/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
|