| |
| """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() |
|
|