#!/usr/bin/env python3 """Summarize manual P/S pick confidence and pick-level SNR.""" from __future__ import annotations import argparse import csv import json import math import os import sys from collections import Counter, defaultdict from pathlib import Path os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") os.environ.setdefault("OMP_NUM_THREADS", "1") os.environ.setdefault("MKL_NUM_THREADS", "1") import h5py import matplotlib.pyplot as plt import numpy as np import torch ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from models.BRNNPNSN import BRNN DEFAULT_H5 = ROOT / "data" / "credit-x1.h5" DEFAULT_RECORDS = [ ROOT / "outputs" / "snr_transfer_seed20260609" / "records_train_all.json", ROOT / "outputs" / "snr_transfer_seed20260609" / "records_test_all.json", ] DEFAULT_CKPT = ROOT / "outputs" / "snr_transfer_seed20260609" / "pnsn.v3.transfer.full.pt" DEFAULT_OUT_DIR = ROOT / "outputs" / "manual_phase_confidence_snr" PHASE_TO_GROUP = {"Pg": "P", "Pn": "P", "Sg": "S", "Sn": "S"} GROUP_TO_CHANNELS = {"P": [1, 3], "S": [2, 4]} COMPONENT_ORDER = ("BHE", "BHN", "BHZ") DTYPE = np.float32 def component_keys(station) -> tuple[str, str, str] | None: keys = set(station.keys()) if all(k in keys for k in COMPONENT_ORDER): return COMPONENT_ORDER by_suffix: dict[str, str] = {} for key in keys: if key.endswith("HE"): by_suffix["BHE"] = key elif key.endswith("HN"): by_suffix["BHN"] = key elif key.endswith("HZ"): by_suffix["BHZ"] = key if all(k in by_suffix for k in COMPONENT_ORDER): return tuple(by_suffix[k] for k in COMPONENT_ORDER) return None def window_std(wave: np.ndarray, start: int, end: int) -> float | None: if start < 0 or end > len(wave) or end <= start: return None return float(np.std(wave[start:end])) def pick_snr_db(waves: tuple[np.ndarray, np.ndarray, np.ndarray], phase: str, index: int) -> float | None: """Use the same local SNR convention as scripts/snr_transfer_experiment.py.""" east, north, vertical = waves if PHASE_TO_GROUP[phase] == "P": pre = window_std(vertical, index - 50, index) aft = window_std(vertical, index, index + 50) if pre is None or aft is None: return None return 10.0 * math.log10((aft + 1e-6) / (pre + 1e-6)) pre_e = window_std(east, index - 150, index) aft_e = window_std(east, index, index + 150) pre_n = window_std(north, index - 150, index) aft_n = window_std(north, index, index + 150) if pre_e is None or aft_e is None or pre_n is None or aft_n is None: return None snr_e = 10.0 * math.log10((aft_e + 1e-6) / (pre_e + 1e-6)) snr_n = 10.0 * math.log10((aft_n + 1e-6) / (pre_n + 1e-6)) return 0.5 * (snr_e + snr_n) def normalize_wave(wave: np.ndarray) -> np.ndarray: wave = wave.astype(DTYPE, copy=False) wave = wave - wave.mean(axis=0, keepdims=True) denom = np.max(np.abs(wave), axis=0, keepdims=True) + 1e-6 return wave / denom def pick_window(station, comps: tuple[str, str, str], index: int, length: int) -> tuple[np.ndarray, int] | None: station_len = min(int(station[c].shape[0]) for c in comps) if station_len <= 0 or index < 0 or index >= station_len: return None start = int(np.clip(index - length // 2, 0, max(0, station_len - length))) stop = min(start + length, station_len) data = [station[c][start:stop] for c in comps] wave = np.stack(data, axis=1) if len(wave) < length: padded = np.zeros((length, 3), dtype=DTYPE) padded[: len(wave)] = wave wave = padded return normalize_wave(wave), index - start def load_records(paths: list[Path], sample_size: int | None, seed: int) -> list[dict]: rows: list[dict] = [] seen: set[tuple[str, str, str, int]] = set() for path in paths: payload = json.loads(path.read_text()) split = payload.get("split", path.stem) for record in payload["records"]: for pick in record["phases"]: if str(pick.get("source", "")).startswith("MANUAL"): key = (record["event"], record["station"], pick["phase"], int(pick["index"])) if key in seen: continue seen.add(key) rows.append( { "split": split, "event": record["event"], "station": record["station"], "phase": pick["phase"], "phase_group": PHASE_TO_GROUP[pick["phase"]], "index": int(pick["index"]), "distance_km": record.get("distance_km"), } ) if sample_size is not None and sample_size < len(rows): rng = np.random.default_rng(seed) idx = np.sort(rng.choice(len(rows), size=sample_size, replace=False)) rows = [rows[int(i)] for i in idx] return rows def scalar_attr(value): if hasattr(value, "item"): value = value.item() if isinstance(value, bytes): return value.decode("utf-8") return value def model_confidence( ckpt_path: Path, windows: np.ndarray, rel_indices: list[int], groups: list[str], batch_size: int, device: torch.device, ) -> list[float]: model = BRNN().to(device) model.load_state_dict(torch.load(ckpt_path, map_location="cpu")) model.eval() values: list[float] = [] with torch.no_grad(): for start in range(0, len(windows), batch_size): batch = torch.from_numpy(windows[start : start + batch_size]).to(device).permute(0, 2, 1) out = model(batch).detach().cpu().numpy() for j in range(out.shape[0]): row_i = start + j rel = rel_indices[row_i] chans = GROUP_TO_CHANNELS[groups[row_i]] values.append(float(out[j, chans, rel].max())) return values def collect_pick_rows( h5_path: Path, rows: list[dict], ckpt_path: Path, length: int, batch_size: int, device: torch.device, ) -> list[dict]: out: list[dict] = [] windows: list[np.ndarray] = [] rel_indices: list[int] = [] groups: list[str] = [] missing = Counter() with h5py.File(h5_path, "r") as h5: for i, row in enumerate(rows, start=1): try: station = h5[row["event"]][row["station"]] except KeyError: missing["station"] += 1 continue comps = component_keys(station) if comps is None: missing["components"] += 1 continue wt_key = f"MANUAL.TRAVTIME.{row['phase']}.WT" if wt_key not in station.attrs: missing["manual_wt"] += 1 continue try: manual_wt = float(scalar_attr(station.attrs[wt_key])) except (TypeError, ValueError): missing["manual_wt_numeric"] += 1 continue waves = tuple(station[c][:] for c in comps) snr = pick_snr_db(waves, row["phase"], row["index"]) if snr is None or not math.isfinite(snr): missing["snr"] += 1 continue window = pick_window(station, comps, row["index"], length) if window is None: missing["model_window"] += 1 continue wave_window, rel_index = window out.append( { **row, "manual_wt": manual_wt, "snr_db": float(snr), } ) windows.append(wave_window) rel_indices.append(rel_index) groups.append(row["phase_group"]) if i % 25000 == 0: print(f"processed {i}/{len(rows)} manual picks; usable={len(out)}", flush=True) if missing: print("skipped:", dict(missing), flush=True) if out: conf = model_confidence( ckpt_path=ckpt_path, windows=np.stack(windows, axis=0), rel_indices=rel_indices, groups=groups, batch_size=batch_size, device=device, ) for row, value in zip(out, conf): row["model_confidence"] = value return out def describe(values: list[float]) -> dict: arr = np.asarray(values, dtype=float) if arr.size == 0: return {"n": 0} q = np.percentile(arr, [0, 5, 25, 50, 75, 95, 100]) return { "n": int(arr.size), "mean": float(np.mean(arr)), "std": float(np.std(arr, ddof=1)) if arr.size > 1 else 0.0, "min": float(q[0]), "p05": float(q[1]), "p25": float(q[2]), "median": float(q[3]), "p75": float(q[4]), "p95": float(q[5]), "max": float(q[6]), } def make_summary(rows: list[dict]) -> dict: summary = { "notes": [ "manual_wt is MANUAL.TRAVTIME..WT from data/README.md.", "model_confidence is the neural phase picker's P- or S-group probability at the manual pick sample.", "snr_db uses the same pick-level local window definition as scripts/snr_transfer_experiment.py.", ], "overall": {}, "by_phase_group": {}, "by_phase": {}, "manual_wt_counts_by_phase_group": {}, } for label, subset in [("all", rows)]: summary["overall"][label] = { "model_confidence": describe([r["model_confidence"] for r in subset]), "manual_wt": describe([r["manual_wt"] for r in subset]), "snr_db": describe([r["snr_db"] for r in subset]), } for key in ("phase_group", "phase"): target = summary[f"by_{key}"] grouped: dict[str, list[dict]] = defaultdict(list) for row in rows: grouped[row[key]].append(row) for name, subset in sorted(grouped.items()): target[name] = { "model_confidence": describe([r["model_confidence"] for r in subset]), "manual_wt": describe([r["manual_wt"] for r in subset]), "snr_db": describe([r["snr_db"] for r in subset]), } counts: dict[str, Counter] = defaultdict(Counter) for row in rows: counts[row["phase_group"]][str(row["manual_wt"])] += 1 summary["manual_wt_counts_by_phase_group"] = { group: dict(sorted(counter.items(), key=lambda item: float(item[0]))) for group, counter in sorted(counts.items()) } return summary def write_csv(path: Path, rows: list[dict]) -> None: fieldnames = [ "split", "event", "station", "phase", "phase_group", "index", "distance_km", "manual_wt", "model_confidence", "snr_db", ] with path.open("w", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) def scatter_panel(ax, rows: list[dict], group: str, color: str) -> None: subset = [r for r in rows if r["phase_group"] == group] x = np.asarray([r["model_confidence"] for r in subset], dtype=float) y = np.asarray([r["snr_db"] for r in subset], dtype=float) ax.scatter( x, y, s=9, c=color, alpha=0.28, linewidths=0, label=f"{group} picks (n={len(subset):,})", rasterized=True, ) ax.axhline(0.0, color="#4d4d4d", lw=0.8, ls="--", alpha=0.75) ax.set_xlabel("Neural-network pick confidence") ax.set_ylabel("Pick-level SNR (dB)") ax.set_title(f"{group} picks") ax.grid(True, color="#d9d9d9", lw=0.6, alpha=0.7) ax.legend(frameon=False, loc="best") def plot_scatter(path: Path, rows: list[dict]) -> None: colors = {"P": "#2868a8", "S": "#c44e52"} fig, axes = plt.subplots(2, 1, figsize=(7.2, 8.0), dpi=220, sharex=True) for ax, group in zip(axes, ("P", "S")): scatter_panel(ax, rows, group, colors[group]) axes[-1].set_xlim(-0.02, 1.02) fig.suptitle("Manual Labels Scored by Neural Phase Picker: Confidence vs SNR", y=0.995) fig.tight_layout() fig.savefig(path) plt.close(fig) def plot_single_group(path: Path, rows: list[dict], group: str) -> None: color = {"P": "#2868a8", "S": "#c44e52"}[group] fig, ax = plt.subplots(figsize=(7.2, 5.0), dpi=220) scatter_panel(ax, rows, group, color) ax.set_xlim(-0.02, 1.02) fig.tight_layout() fig.savefig(path) plt.close(fig) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--h5", type=Path, default=DEFAULT_H5) parser.add_argument("--records", type=Path, nargs="+", default=DEFAULT_RECORDS) parser.add_argument("--ckpt", type=Path, default=DEFAULT_CKPT) parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR) parser.add_argument("--sample-size", type=int, default=2000) parser.add_argument("--seed", type=int, default=20260626) parser.add_argument("--length", type=int, default=5120) parser.add_argument("--batch-size", type=int, default=64) parser.add_argument("--device", default="cpu") args = parser.parse_args() args.out_dir.mkdir(parents=True, exist_ok=True) device = torch.device(args.device) candidates = load_records(args.records, args.sample_size, args.seed) sample_note = "all" if args.sample_size is None else f"sampled {len(candidates)}" print(f"loaded {sample_note} unique manual P/S picks from record caches", flush=True) rows = collect_pick_rows(args.h5, candidates, args.ckpt, args.length, args.batch_size, device) csv_path = args.out_dir / "manual_phase_confidence_snr.csv" summary_path = args.out_dir / "manual_phase_confidence_snr_summary.json" png_path = args.out_dir / "manual_phase_confidence_snr_scatter.png" p_png_path = args.out_dir / "manual_phase_confidence_snr_scatter_P.png" s_png_path = args.out_dir / "manual_phase_confidence_snr_scatter_S.png" write_csv(csv_path, rows) summary = make_summary(rows) summary["inputs"] = { "h5": str(args.h5), "records": [str(p) for p in args.records], "ckpt": str(args.ckpt), "sample_size": args.sample_size, "seed": args.seed, "length": args.length, "n_candidate_manual_picks_processed": len(candidates), "n_usable_manual_picks": len(rows), } summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") plot_scatter(png_path, rows) plot_single_group(p_png_path, rows, "P") plot_single_group(s_png_path, rows, "S") print(f"wrote {csv_path}") print(f"wrote {summary_path}") print(f"wrote {png_path}") print(f"wrote {p_png_path}") print(f"wrote {s_png_path}") if __name__ == "__main__": main()