snr_bias / code /scripts /grl_make_observability_real_data_figure.py
cangyeone's picture
Upload GRL reproducibility package
7170296 verified
Raw
History Blame Contribute Delete
20 kB
#!/usr/bin/env python3
"""Create a clean data-driven Figure 1 for SNR observability shifts."""
from __future__ import annotations
import csv
import json
import math
from pathlib import Path
import h5py
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.lines import Line2D
OVERLEAF_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = OVERLEAF_ROOT.parent
OUTPUTS = PROJECT_ROOT / "outputs"
FIG_DIR = OVERLEAF_ROOT / "figures"
PHASE_RECORDS = OUTPUTS / "snr_transfer_phase_balanced_cache" / "records_train_all.json"
PHASE_SNR = OUTPUTS / "snr_transfer_phase_balanced_cache" / "train_phase_snr_db.json"
DISP_CACHE = OUTPUTS / "disp_snr_transfer_seed20260609" / "ncf_snr_cache.json"
DISP_H5 = PROJECT_ROOT / "data" / "ncf_data" / "ncf_disp_dataset_with_disp_image.h5"
OUT_PDF = FIG_DIR / "fig_observability_real_data_v1.pdf"
OUT_PNG = FIG_DIR / "fig_observability_real_data_v1.png"
OUT_CSV = FIG_DIR / "fig_observability_real_data_v1_data.csv"
OUT_JSON = FIG_DIR / "fig_observability_real_data_v1_summary.json"
RED = "#C44E52"
BLUE = "#2F6DB2"
DARK_RED = "#9D1C1C"
GRAY = "#7A7A7A"
DARK = "#222222"
LIGHT = "#E8E8E8"
FILL_GRAY = "#D5D5D5"
VIOLIN = "#9FC6DF"
PHASE_THRESHOLD = 5.0
STRICT_PHASE_THRESHOLD = 10.0
PERIOD_BINS = [(5.0, 10.0), (10.0, 15.0), (15.0, 25.0), (25.0, 40.0)]
def load_json(path: Path):
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def phase_group(name: str) -> str | None:
phase = str(name or "")[:1].upper()
return phase if phase in {"P", "S"} else None
def pick_snr_key(record: dict, idx: int, pick: dict) -> str:
return f"{record['event']}/{record['station']}/{idx}:{pick['phase']}:{pick['index']}:{pick['source']}"
def finite_float(value, default=float("nan")) -> float:
try:
out = float(value)
except Exception:
return default
return out if math.isfinite(out) else default
def read_phase_data() -> tuple[list[dict], list[dict]]:
records = load_json(PHASE_RECORDS)["records"]
snr_by_pick = {key: float(value) for key, value in load_json(PHASE_SNR).items()}
pick_rows: list[dict] = []
record_rows: list[dict] = []
for record in records:
distance = finite_float(record.get("distance_km"))
if not math.isfinite(distance) or distance <= 0:
continue
record_snrs = []
for idx, pick in enumerate(record.get("phases", [])):
phase = phase_group(pick.get("phase", ""))
if phase is None:
continue
snr = snr_by_pick.get(pick_snr_key(record, idx, pick), float("nan"))
if not math.isfinite(snr):
continue
record_snrs.append(snr)
pick_rows.append({"phase": phase, "distance": distance, "snr": snr})
if record_snrs:
record_rows.append({"distance": distance, "max_snr": max(record_snrs)})
return pick_rows, record_rows
def sample_phase_points(points: list[dict], per_phase_bin: int = 30) -> dict[str, np.ndarray]:
rng = np.random.default_rng(20260627)
bins = np.array([4, 8, 15, 30, 60, 120, 240, 480, 1000], dtype=float)
sampled = []
for phase in ["P", "S"]:
phase_points = [p for p in points if p["phase"] == phase]
distances = np.asarray([p["distance"] for p in phase_points], dtype=float)
for lo, hi in zip(bins[:-1], bins[1:]):
idx = np.where((distances >= lo) & (distances < hi))[0]
if idx.size == 0:
continue
take = min(per_phase_bin, idx.size)
chosen = rng.choice(idx, size=take, replace=False)
sampled.extend(phase_points[i] for i in chosen)
return {
"distance": np.asarray([p["distance"] for p in sampled], dtype=float),
"snr": np.asarray([p["snr"] for p in sampled], dtype=float),
"phase": np.asarray([p["phase"] for p in sampled]),
}
def read_dispersion_snr_by_period(max_per_bin: int = 6000) -> tuple[list[np.ndarray], list[dict]]:
raw_values = [[] for _ in PERIOD_BINS]
cache = load_json(DISP_CACHE)
with h5py.File(DISP_H5, "r") as h5:
paths = h5["paths"]
for key, row in cache.items():
if row.get("split") != "train":
continue
snr = finite_float(row.get("snr_db"))
if not math.isfinite(snr) or key not in paths:
continue
group = paths[key]
try:
periods = np.asarray(group["disp_periods"][()], dtype=float)
velocities = np.asarray(group["disp_velocity"][()], dtype=float)
mask = np.asarray(group["disp_mask"][()], dtype=bool)
except Exception:
continue
valid = mask & np.isfinite(periods) & np.isfinite(velocities) & (periods > 0)
for period in periods[valid]:
for i, (lo, hi) in enumerate(PERIOD_BINS):
in_bin = (lo <= period < hi) or (i == len(PERIOD_BINS) - 1 and period <= hi)
if in_bin:
raw_values[i].append(snr)
break
rng = np.random.default_rng(20260627)
plot_values = []
summaries = []
for (lo, hi), values in zip(PERIOD_BINS, raw_values):
arr = np.asarray(values, dtype=float)
arr = arr[np.isfinite(arr)]
if arr.size > max_per_bin:
plot_arr = rng.choice(arr, size=max_per_bin, replace=False)
else:
plot_arr = arr
plot_values.append(plot_arr)
summaries.append(
{
"period_left": lo,
"period_right": hi,
"n": int(arr.size),
"q05": float(np.percentile(arr, 5)),
"q25": float(np.percentile(arr, 25)),
"median": float(np.percentile(arr, 50)),
"q75": float(np.percentile(arr, 75)),
"q95": float(np.percentile(arr, 95)),
"fraction_ge_5db": float(np.mean(arr >= PHASE_THRESHOLD)),
}
)
return plot_values, summaries
def style_axis(ax: plt.Axes, grid: bool = False) -> None:
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
for side in ["left", "bottom"]:
ax.spines[side].set_color("#8A8A8A")
ax.spines[side].set_linewidth(0.65)
ax.tick_params(labelsize=6.5, width=0.6, length=2.6, color="#666666")
if grid:
ax.grid(color=LIGHT, lw=0.45, alpha=0.9)
ax.set_axisbelow(True)
def add_header(ax: plt.Axes, label: str, title: str) -> None:
ax.axis("off")
ax.text(0.00, 0.66, label, fontsize=12.8, fontweight="bold", color=DARK, ha="left", va="center")
ax.text(0.18, 0.68, title, fontsize=8.2, fontweight="bold", color=DARK, ha="left", va="center")
def set_log_distance_axis(ax: plt.Axes) -> None:
ax.set_xscale("log")
ax.set_xlim(4, 1000)
ax.set_xticks([10, 100, 1000])
ax.get_xaxis().set_major_formatter(mpl.ticker.ScalarFormatter())
ax.xaxis.set_minor_locator(mpl.ticker.NullLocator())
def draw_phase_scatter(ax: plt.Axes, points: dict[str, np.ndarray], threshold: bool = False) -> None:
distance = points["distance"]
snr = points["snr"]
phase = points["phase"]
kept = snr >= PHASE_THRESHOLD
if threshold:
for phase_name, color in [("P", RED), ("S", BLUE)]:
removed_mask = (phase == phase_name) & ~kept
ax.scatter(
distance[removed_mask],
np.clip(snr[removed_mask], -2, 22),
s=13,
facecolors="none",
edgecolors=color,
alpha=0.42,
linewidth=0.55,
rasterized=True,
)
for phase_name, color in [("P", RED), ("S", BLUE)]:
kept_mask = (phase == phase_name) & kept
ax.scatter(
distance[kept_mask],
np.clip(snr[kept_mask], -2, 22),
s=13,
color=color,
alpha=0.86,
linewidth=0,
rasterized=True,
)
ax.axhline(PHASE_THRESHOLD, color="#B00000", linestyle=(0, (4, 2)), linewidth=1.0)
ax.text(4.7, PHASE_THRESHOLD + 0.75, "SNR = 5 dB", color="#B00000", fontsize=7.1, fontweight="bold")
ax.text(520, PHASE_THRESHOLD + 2.2, "Kept", color="#B00000", fontsize=7.4, fontweight="bold", ha="center")
ax.text(520, PHASE_THRESHOLD - 2.0, "Removed", color=GRAY, fontsize=7.0, ha="center")
ax.set_yticks([0, PHASE_THRESHOLD, 15])
ax.set_yticklabels(["Low", "5", "High"])
else:
for phase_name, color, label in [("P", RED, "P"), ("S", BLUE, "S")]:
mask = phase == phase_name
ax.scatter(
distance[mask],
np.clip(snr[mask], -2, 22),
s=10,
color=color,
alpha=0.72,
linewidth=0,
rasterized=True,
label=label,
)
ax.set_yticks([0, 10, 20])
set_log_distance_axis(ax)
ax.set_ylim(-2.5, 22)
ax.set_xlabel("Epicentral distance (km)", fontsize=7.0)
ax.set_ylabel("Pick SNR (dB)", fontsize=7.0)
style_axis(ax, grid=True)
def draw_dispersion_violin(ax: plt.Axes, values: list[np.ndarray]) -> None:
clipped = [np.clip(arr, -8, 16) for arr in values]
parts = ax.violinplot(clipped, positions=np.arange(1, len(values) + 1), widths=0.72, showextrema=False)
for body in parts["bodies"]:
body.set_facecolor(VIOLIN)
body.set_edgecolor("none")
body.set_alpha(0.55)
for x, arr in enumerate(values, start=1):
q05, q25, q50, q75, q95 = np.percentile(arr, [5, 25, 50, 75, 95])
q05, q25, q50, q75, q95 = np.clip([q05, q25, q50, q75, q95], -8, 16)
ax.plot([x, x], [q05, q95], color="#1F77B4", lw=1.15)
ax.plot([x - 0.10, x + 0.10], [q05, q05], color="#1F77B4", lw=1.15)
ax.plot([x - 0.10, x + 0.10], [q95, q95], color="#1F77B4", lw=1.15)
ax.plot([x - 0.17, x + 0.17], [q25, q25], color="#1F77B4", lw=0.75, alpha=0.75)
ax.plot([x - 0.17, x + 0.17], [q75, q75], color="#1F77B4", lw=0.75, alpha=0.75)
ax.scatter([x], [q50], s=9, color="#1F77B4", zorder=3)
labels = [f"{int(lo)}-{int(hi)}" for lo, hi in PERIOD_BINS]
ax.set_xticks(np.arange(1, len(values) + 1))
ax.set_xticklabels(labels)
ax.set_ylim(-8, 16)
ax.set_ylabel("Dispersion SNR (dB)", fontsize=7.0)
ax.set_xlabel("Period bin (s)", fontsize=7.0)
style_axis(ax, grid=True)
def distance_histogram(distances: np.ndarray, mask: np.ndarray, bins: np.ndarray) -> np.ndarray:
counts, _ = np.histogram(distances[mask], bins=bins)
total = counts.sum()
return counts / total if total else counts.astype(float)
def draw_distance_statistics(ax_dist: plt.Axes, ax_ret: plt.Axes, records: list[dict]) -> tuple[dict, list[dict]]:
distances = np.asarray([row["distance"] for row in records], dtype=float)
snr = np.asarray([row["max_snr"] for row in records], dtype=float)
bins = np.geomspace(4, 1000, 22)
centers = np.sqrt(bins[:-1] * bins[1:])
full_mask = np.ones_like(snr, dtype=bool)
med_mask = snr >= PHASE_THRESHOLD
strict_mask = snr >= STRICT_PHASE_THRESHOLD
full_fraction = distance_histogram(distances, full_mask, bins)
med_fraction = distance_histogram(distances, med_mask, bins)
strict_fraction = distance_histogram(distances, strict_mask, bins)
ax_dist.stairs(full_fraction, bins, fill=True, color=FILL_GRAY, alpha=0.65, label="Unfiltered")
ax_dist.stairs(med_fraction, bins, color=RED, lw=1.15, label="SNR >= 5 dB")
ax_dist.stairs(strict_fraction, bins, color=DARK_RED, lw=1.15, label="SNR >= 10 dB")
ax_dist.set_xscale("log")
ax_dist.set_xlim(4, 1000)
ax_dist.set_xticks([10, 100, 1000])
ax_dist.get_xaxis().set_major_formatter(mpl.ticker.ScalarFormatter())
ax_dist.xaxis.set_minor_locator(mpl.ticker.NullLocator())
ax_dist.set_ylabel("Fraction of\nrecords", fontsize=7.0)
ax_dist.legend(frameon=False, fontsize=5.9, loc="upper right", handlelength=1.3)
style_axis(ax_dist, grid=False)
full_counts, _ = np.histogram(distances, bins=bins)
retained_rows = []
for mask, color, label in [(med_mask, RED, "SNR >= 5 dB"), (strict_mask, DARK_RED, "SNR >= 10 dB")]:
kept_counts, _ = np.histogram(distances[mask], bins=bins)
with np.errstate(divide="ignore", invalid="ignore"):
retained = np.where(full_counts > 0, kept_counts / full_counts, np.nan)
ax_ret.plot(centers, retained, color=color, lw=1.25, marker="o", ms=2.2, label=label)
for lo, hi, frac, full_n, kept_n in zip(bins[:-1], bins[1:], retained, full_counts, kept_counts):
retained_rows.append(
{
"condition": label,
"bin_left": float(lo),
"bin_right": float(hi),
"retained_fraction": float(frac) if math.isfinite(frac) else float("nan"),
"full_count": int(full_n),
"kept_count": int(kept_n),
}
)
ax_ret.set_xscale("log")
ax_ret.set_xlim(4, 1000)
ax_ret.set_ylim(0, 1.03)
ax_ret.set_xticks([10, 100, 1000])
ax_ret.get_xaxis().set_major_formatter(mpl.ticker.ScalarFormatter())
ax_ret.xaxis.set_minor_locator(mpl.ticker.NullLocator())
ax_ret.set_yticks([0, 0.5, 1.0])
ax_ret.set_xlabel("Epicentral distance (km)", fontsize=7.0)
ax_ret.set_ylabel("Retained\nfraction", fontsize=7.0)
style_axis(ax_ret, grid=False)
summary = {
"record_count": int(distances.size),
"retained_record_fraction_5db": float(np.mean(med_mask)),
"retained_record_fraction_10db": float(np.mean(strict_mask)),
"median_distance_unfiltered_km": float(np.median(distances)),
"median_distance_5db_km": float(np.median(distances[med_mask])),
"median_distance_10db_km": float(np.median(distances[strict_mask])),
}
hist_rows = []
for condition, fraction in [
("unfiltered", full_fraction),
("snr_ge_5db", med_fraction),
("snr_ge_10db", strict_fraction),
]:
for lo, hi, frac in zip(bins[:-1], bins[1:], fraction):
hist_rows.append({"condition": condition, "bin_left": float(lo), "bin_right": float(hi), "fraction": float(frac)})
return summary, hist_rows + retained_rows
def write_exports(
phase_points: dict[str, np.ndarray],
all_pick_count: int,
disp_summaries: list[dict],
distance_summary: dict,
distance_rows: list[dict],
) -> None:
rows = []
for distance, snr, phase in zip(phase_points["distance"], phase_points["snr"], phase_points["phase"]):
rows.append(
{
"panel": "phase_scatter",
"condition": "sampled",
"metric": f"{phase}_pick_snr_db",
"x": float(distance),
"y": float(snr),
"value": "",
}
)
for summary in disp_summaries:
label = f"{int(summary['period_left'])}-{int(summary['period_right'])}s"
for key in ["n", "q05", "q25", "median", "q75", "q95", "fraction_ge_5db"]:
rows.append(
{
"panel": "dispersion_period_snr",
"condition": label,
"metric": key,
"x": "",
"y": "",
"value": summary[key],
}
)
for row in distance_rows:
rows.append(
{
"panel": "distance_coverage",
"condition": row.get("condition", ""),
"metric": "fraction" if "fraction" in row else "retained_fraction",
"x": row["bin_left"],
"y": row["bin_right"],
"value": row.get("fraction", row.get("retained_fraction")),
}
)
with OUT_CSV.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["panel", "condition", "metric", "x", "y", "value"], lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
summary = {
"phase_pick_count": int(all_pick_count),
"phase_points_sampled": int(len(phase_points["distance"])),
"phase_thresholds_db": [PHASE_THRESHOLD, STRICT_PHASE_THRESHOLD],
"dispersion_period_bins": disp_summaries,
"distance_summary": distance_summary,
}
with OUT_JSON.open("w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
def make_figure() -> None:
FIG_DIR.mkdir(parents=True, exist_ok=True)
pick_rows, record_rows = read_phase_data()
phase_points = sample_phase_points(pick_rows)
disp_values, disp_summaries = read_dispersion_snr_by_period()
plt.rcParams.update(
{
"font.family": "DejaVu Sans",
"font.size": 7,
"axes.linewidth": 0.65,
"pdf.fonttype": 42,
"ps.fonttype": 42,
}
)
fig = plt.figure(figsize=(7.25, 4.08), dpi=300)
outer = fig.add_gridspec(
1,
3,
width_ratios=[1.04, 1.22, 1.04],
left=0.055,
right=0.985,
top=0.875,
bottom=0.12,
wspace=0.28,
)
panel_a = outer[0, 0].subgridspec(3, 1, height_ratios=[0.18, 1.0, 0.88], hspace=0.72)
panel_b = outer[0, 1].subgridspec(2, 1, height_ratios=[0.18, 1.0], hspace=0.20)
panel_c = outer[0, 2].subgridspec(3, 1, height_ratios=[0.18, 0.82, 0.70], hspace=0.34)
ax_a_head = fig.add_subplot(panel_a[0])
ax_a_phase = fig.add_subplot(panel_a[1])
ax_a_disp = fig.add_subplot(panel_a[2])
add_header(ax_a_head, "A", "Unfiltered observations")
draw_phase_scatter(ax_a_phase, phase_points, threshold=False)
ax_a_phase.set_title("Phase picks", loc="left", fontsize=7.1, pad=1, fontweight="bold")
ax_a_phase.legend(frameon=False, fontsize=6.0, loc="upper right", handletextpad=0.2, borderpad=0.1)
draw_dispersion_violin(ax_a_disp, disp_values)
ax_a_disp.set_title("Dispersion by period", loc="left", fontsize=7.1, pad=1, fontweight="bold")
ax_b_head = fig.add_subplot(panel_b[0])
ax_b = fig.add_subplot(panel_b[1])
add_header(ax_b_head, "B", "Hard SNR cutoff")
draw_phase_scatter(ax_b, phase_points, threshold=True)
ax_b.set_title("Example phase-pick threshold", loc="left", fontsize=7.4, pad=2, fontweight="bold")
legend_handles = [
Line2D([0], [0], marker="o", color="none", markerfacecolor=RED, markeredgecolor=RED, markersize=4.5, label="P"),
Line2D([0], [0], marker="o", color="none", markerfacecolor=BLUE, markeredgecolor=BLUE, markersize=4.5, label="S"),
Line2D([0], [0], marker="o", color="none", markerfacecolor="none", markeredgecolor=GRAY, markersize=4.5, label="removed"),
]
ax_b.legend(handles=legend_handles, frameon=False, fontsize=6.0, loc="upper right", handletextpad=0.3)
ax_c_head = fig.add_subplot(panel_c[0])
ax_c_dist = fig.add_subplot(panel_c[1])
ax_c_ret = fig.add_subplot(panel_c[2], sharex=ax_c_dist)
add_header(ax_c_head, "C", "Distance coverage changes")
distance_summary, distance_rows = draw_distance_statistics(ax_c_dist, ax_c_ret, record_rows)
ax_c_dist.set_title("Phase-picking records", loc="left", fontsize=7.2, pad=2, fontweight="bold")
plt.setp(ax_c_dist.get_xticklabels(), visible=False)
fig.suptitle("Hard SNR thresholds reshape seismic observability", fontsize=10.3, fontweight="bold", y=0.98)
fig.savefig(OUT_PDF, bbox_inches="tight")
fig.savefig(OUT_PNG, bbox_inches="tight", dpi=300)
write_exports(phase_points, len(pick_rows), disp_summaries, distance_summary, distance_rows)
print(f"Wrote {OUT_PDF}")
print(f"Wrote {OUT_PNG}")
print(f"Wrote {OUT_CSV}")
print(f"Wrote {OUT_JSON}")
if __name__ == "__main__":
make_figure()