| |
| """Build the phase-balanced event-geometry figure for the GRL manuscript.""" |
|
|
| from __future__ import annotations |
|
|
| import csv |
| import importlib.util |
| import json |
| import math |
| from collections import Counter, defaultdict |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from statistics import median |
|
|
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import numpy as np |
|
|
|
|
| OVERLEAF_ROOT = Path(__file__).resolve().parents[1] |
| PROJECT_ROOT = OVERLEAF_ROOT.parent |
| PHASE_BALANCED_ROOT = PROJECT_ROOT / "odata" / "phase_balanced_20190706_20211113" |
| PICK_EVAL_SCRIPT = PROJECT_ROOT / "odata" / "evaluate_pick_recall_no_nms.py" |
| LABEL_JSON = PROJECT_ROOT / "data" / "datax" / "data" / "label" / "annotations_for_continuous_hdf5.json" |
| WAVEFORM_DB = PROJECT_ROOT / "data" / "datax" / "data" / "index" / "waveform_index.sqlite" |
| FIG_DIR = OVERLEAF_ROOT / "figures" |
| FIG_PDF = FIG_DIR / "fig_event_geometry_distribution_polished.pdf" |
| FIG_PNG = FIG_DIR / "fig_event_geometry_distribution_polished.png" |
| DATA_CSV = FIG_DIR / "fig_event_geometry_distribution_polished_data.csv" |
| SUMMARY_JSON = FIG_DIR / "fig_event_geometry_distribution_polished_summary.json" |
|
|
| DAYS = {"20190706", "20211113"} |
| TP_TOL_S = 1.5 |
| SEARCH_WINDOW_S = 5.0 |
|
|
| CONDITIONS = [ |
| { |
| "slug": "moderate", |
| "label": "Moderate phase-balanced", |
| "threshold": "P>=5/S>=3.73 dB", |
| "plot_threshold": r"P$\geq$5/S$\geq$3.73 dB", |
| "snr": "p5_sbal", |
| "confidence": "conf_p5_sbal", |
| }, |
| { |
| "slug": "strict", |
| "label": "Strict phase-balanced", |
| "threshold": "P>=10/S>=6.01 dB", |
| "plot_threshold": r"P$\geq$10/S$\geq$6.01 dB", |
| "snr": "p10_sbal", |
| "confidence": "conf_p10_sbal", |
| }, |
| ] |
|
|
| COLORS = { |
| "snr_only": "#F58518", |
| "confidence_only": "#4C78A8", |
| "snr": "#F58518", |
| "confidence": "#4C78A8", |
| } |
|
|
|
|
| def load_pick_eval_module(): |
| spec = importlib.util.spec_from_file_location("pick_eval_no_nms", PICK_EVAL_SCRIPT) |
| if spec is None or spec.loader is None: |
| raise RuntimeError(f"Cannot load {PICK_EVAL_SCRIPT}") |
| module = importlib.util.module_from_spec(spec) |
| spec.loader.exec_module(module) |
| return module |
|
|
|
|
| def iter_jsonl(path: Path): |
| with path.open("r", encoding="utf-8", errors="replace") as handle: |
| for line in handle: |
| if line.strip(): |
| yield json.loads(line) |
|
|
|
|
| def load_event_summary(condition: str) -> dict: |
| path = PHASE_BALANCED_ROOT / "eval_events" / condition / "event_match_summary.json" |
| return json.loads(path.read_text(encoding="utf-8")) |
|
|
|
|
| def load_true_positive_event_ids(condition: str) -> set[str]: |
| path = PHASE_BALANCED_ROOT / "eval_events" / condition / "event_matches.jsonl" |
| ids: set[str] = set() |
| for row in iter_jsonl(path): |
| if row.get("record_type") == "event_true_positive": |
| ids.add(str(row["ref_event_id"])) |
| return ids |
|
|
|
|
| def load_labels(eval_mod) -> list[dict]: |
| coverage = eval_mod.Coverage(WAVEFORM_DB) |
| labels = [] |
| for label in eval_mod.iter_label_picks(LABEL_JSON, None, None, coverage): |
| day = datetime.fromtimestamp(float(label["time"]), tz=timezone.utc).strftime("%Y%m%d") |
| if day in DAYS: |
| labels.append(label) |
| return labels |
|
|
|
|
| def load_auto_pick_index(eval_mod, condition: str): |
| indexed = defaultdict(list) |
| starts: dict[tuple[str, str], list[float]] = {} |
| auto_counts = Counter() |
| pick_dir = PHASE_BALANCED_ROOT / "hourly" / condition |
| files = sorted(pick_dir.glob("*.phase.jsonl")) |
| if not files: |
| raise FileNotFoundError(f"No phase files found in {pick_dir}") |
| for path in files: |
| sub_indexed, _sub_starts, sub_counts = eval_mod.load_auto_picks(path, None, None) |
| for key, values in sub_indexed.items(): |
| indexed[key].extend(values) |
| auto_counts.update(sub_counts) |
| for key, values in indexed.items(): |
| values.sort(key=lambda item: item["time"]) |
| starts[key] = [item["time"] for item in values] |
| return indexed, starts, auto_counts |
|
|
|
|
| def event_support_counts(eval_mod, labels: list[dict], condition: str) -> tuple[dict[str, dict], Counter]: |
| indexed, starts, auto_counts = load_auto_pick_index(eval_mod, condition) |
| counts = defaultdict(lambda: {"P": 0, "S": 0, "stations": set(), "S_stations": set()}) |
| for label in labels: |
| hit = eval_mod.nearest( |
| indexed, |
| starts, |
| label["station_id"], |
| label["phase"], |
| label["time"], |
| SEARCH_WINDOW_S, |
| ) |
| if hit is None or abs(float(hit["residual_s"])) > TP_TOL_S: |
| continue |
| event_id = str(label["event_id"]) |
| phase = str(label["phase"]) |
| station_id = str(label["station_id"]) |
| counts[event_id][phase] += 1 |
| counts[event_id]["stations"].add(station_id) |
| if phase == "S": |
| counts[event_id]["S_stations"].add(station_id) |
|
|
| packed = {} |
| for event_id, row in counts.items(): |
| packed[event_id] = { |
| "P": int(row["P"]), |
| "S": int(row["S"]), |
| "stations": len(row["stations"]), |
| "S_stations": len(row["S_stations"]), |
| } |
| return packed, auto_counts |
|
|
|
|
| def safe_metric(counts: dict[str, dict], event_id: str, metric: str) -> int: |
| return int(counts.get(event_id, {}).get(metric, 0)) |
|
|
|
|
| def event_rows_for_condition(condition: dict, support: dict[str, dict[str, dict]]) -> tuple[list[dict], dict]: |
| snr = condition["snr"] |
| confidence = condition["confidence"] |
| snr_tp = load_true_positive_event_ids(snr) |
| conf_tp = load_true_positive_event_ids(confidence) |
| snr_summary = load_event_summary(snr) |
| conf_summary = load_event_summary(confidence) |
|
|
| rows = [] |
| class_defs = [ |
| ("snr_only", "SNR-only", sorted(snr_tp - conf_tp), snr, confidence), |
| ("confidence_only", "Confidence-only", sorted(conf_tp - snr_tp), confidence, snr), |
| ] |
| for class_slug, class_label, event_ids, recovered_cond, missed_cond in class_defs: |
| for event_id in event_ids: |
| for metric in ("P", "S", "stations", "S_stations"): |
| recovered = safe_metric(support[recovered_cond], event_id, metric) |
| missed = safe_metric(support[missed_cond], event_id, metric) |
| rows.append( |
| { |
| "condition_slug": condition["slug"], |
| "condition_label": condition["label"], |
| "threshold": condition["threshold"], |
| "class_slug": class_slug, |
| "class_label": class_label, |
| "event_id": event_id, |
| "metric": metric, |
| "recovered_support": recovered, |
| "missed_support": missed, |
| "difference": recovered - missed, |
| } |
| ) |
|
|
| summary = { |
| "condition_slug": condition["slug"], |
| "condition_label": condition["label"], |
| "threshold": condition["threshold"], |
| "plot_threshold": condition["plot_threshold"], |
| "snr_condition": snr, |
| "confidence_condition": confidence, |
| "snr_event_metrics": snr_summary["metrics"], |
| "confidence_event_metrics": conf_summary["metrics"], |
| "snr_event_counts": snr_summary["counts"], |
| "confidence_event_counts": conf_summary["counts"], |
| "both_tp": len(snr_tp & conf_tp), |
| "snr_only_tp": len(snr_tp - conf_tp), |
| "confidence_only_tp": len(conf_tp - snr_tp), |
| } |
| return rows, summary |
|
|
|
|
| def describe_differences(rows: list[dict]) -> dict: |
| out = {} |
| for condition in CONDITIONS: |
| cond_rows = [r for r in rows if r["condition_slug"] == condition["slug"]] |
| out[condition["slug"]] = {} |
| for class_slug in ("snr_only", "confidence_only"): |
| out[condition["slug"]][class_slug] = {} |
| for metric in ("P", "S", "stations", "S_stations"): |
| values = [int(r["difference"]) for r in cond_rows if r["class_slug"] == class_slug and r["metric"] == metric] |
| if not values: |
| continue |
| out[condition["slug"]][class_slug][metric] = { |
| "n_events": len(values), |
| "median_recovered_minus_missed": median(values), |
| "positive_fraction": sum(v > 0 for v in values) / len(values), |
| "zero_fraction": sum(v == 0 for v in values) / len(values), |
| "negative_fraction": sum(v < 0 for v in values) / len(values), |
| "min": min(values), |
| "max": max(values), |
| } |
| return out |
|
|
|
|
| def write_outputs(rows: list[dict], summary: dict) -> None: |
| fieldnames = [ |
| "condition_slug", |
| "condition_label", |
| "threshold", |
| "class_slug", |
| "class_label", |
| "event_id", |
| "metric", |
| "recovered_support", |
| "missed_support", |
| "difference", |
| ] |
| with DATA_CSV.open("w", encoding="utf-8", newline="") as handle: |
| writer = csv.DictWriter(handle, fieldnames=fieldnames) |
| writer.writeheader() |
| writer.writerows(rows) |
| SUMMARY_JSON.write_text(json.dumps(summary, indent=2), encoding="utf-8") |
|
|
|
|
| def style_axis(ax): |
| ax.spines[["top", "right"]].set_visible(False) |
| ax.grid(axis="y", color="#e8e8e8", linewidth=0.8) |
| ax.set_axisbelow(True) |
| ax.tick_params(labelsize=7) |
|
|
|
|
| def panel_label(ax, label: str): |
| ax.text( |
| 0.02, |
| 0.96, |
| label, |
| transform=ax.transAxes, |
| ha="left", |
| va="top", |
| fontsize=9, |
| fontweight="bold", |
| bbox={"facecolor": "white", "edgecolor": "none", "alpha": 0.85, "pad": 1.0}, |
| ) |
|
|
|
|
| def plot_metric_bars(ax, condition_summary: dict, panel: str): |
| metrics = ["precision", "recall"] |
| x = np.arange(len(metrics)) |
| width = 0.35 |
| snr_values = [condition_summary["snr_event_metrics"][m] for m in metrics] |
| conf_values = [condition_summary["confidence_event_metrics"][m] for m in metrics] |
| ax.bar(x - width / 2, snr_values, width, label="Phase-balanced SNR", color=COLORS["snr"], edgecolor="black", linewidth=0.3) |
| ax.bar(x + width / 2, conf_values, width, label="Top phase probability", color=COLORS["confidence"], edgecolor="black", linewidth=0.3) |
| ax.set_xticks(x, ["Precision", "Recall"]) |
| ax.set_ylim(0, 0.9) |
| ax.set_ylabel("Event metric", fontsize=8) |
| ax.set_title(condition_summary["plot_threshold"], fontsize=8) |
| style_axis(ax) |
| panel_label(ax, panel) |
| for xpos, value in zip(x - width / 2, snr_values): |
| ax.text(xpos, value + 0.025, f"{value:.3f}", ha="center", va="bottom", fontsize=6, rotation=90) |
| for xpos, value in zip(x + width / 2, conf_values): |
| ax.text(xpos, value + 0.025, f"{value:.3f}", ha="center", va="bottom", fontsize=6, rotation=90) |
|
|
|
|
| def plot_difference_hist( |
| ax, |
| rows: list[dict], |
| condition_slug: str, |
| metric: str, |
| panel: str, |
| title: str, |
| show_legend: bool = False, |
| ): |
| cond_rows = [r for r in rows if r["condition_slug"] == condition_slug and r["metric"] == metric] |
| values_by_class = { |
| class_slug: [int(r["difference"]) for r in cond_rows if r["class_slug"] == class_slug] |
| for class_slug in ("snr_only", "confidence_only") |
| } |
| all_values = [v for values in values_by_class.values() for v in values] |
| xmin = min(-8, min(all_values) if all_values else -1) |
| xmax = max(16, max(all_values) if all_values else 1) |
| xs = np.arange(xmin, xmax + 1) |
| width = 0.38 |
| offsets = {"snr_only": -width / 2, "confidence_only": width / 2} |
| labels = {"snr_only": "SNR-only events", "confidence_only": "Confidence-only events"} |
| for class_slug in ("snr_only", "confidence_only"): |
| values = values_by_class[class_slug] |
| counts = Counter(values) |
| denom = len(values) or 1 |
| heights = np.array([counts.get(int(x), 0) / denom for x in xs], dtype=float) |
| ax.bar( |
| xs + offsets[class_slug], |
| heights, |
| width, |
| label=labels[class_slug], |
| color=COLORS[class_slug], |
| alpha=0.86, |
| edgecolor="black", |
| linewidth=0.2, |
| ) |
| ax.axvline(0, color="#333333", linewidth=0.8) |
| ax.set_xlim(xmin - 0.8, xmax + 0.8) |
| ax.set_ylim(0, None) |
| ax.set_xlabel("Recovered stream minus missed stream", fontsize=8) |
| ylabel = "Fraction of discordant events" |
| ax.set_ylabel(ylabel, fontsize=8) |
| ax.set_title(title, fontsize=8) |
| style_axis(ax) |
| panel_label(ax, panel) |
| text_lines = [] |
| for class_slug in ("snr_only", "confidence_only"): |
| values = values_by_class[class_slug] |
| if not values: |
| continue |
| med = median(values) |
| pos = 100.0 * sum(v > 0 for v in values) / len(values) |
| text_lines.append(f"{labels[class_slug].replace(' events', '')}: n={len(values)}, med={med:g}, >0={pos:.0f}%") |
| ax.text( |
| 0.98, |
| 0.95, |
| "\n".join(text_lines), |
| transform=ax.transAxes, |
| ha="right", |
| va="top", |
| fontsize=6.2, |
| bbox={"facecolor": "white", "edgecolor": "#bbbbbb", "alpha": 0.88, "pad": 2.0}, |
| ) |
| if show_legend: |
| ax.legend(frameon=False, fontsize=7, loc="upper left", bbox_to_anchor=(0.02, 0.82)) |
|
|
|
|
| def make_figure(rows: list[dict], summaries: dict) -> None: |
| plt.rcParams.update( |
| { |
| "font.family": "DejaVu Sans", |
| "font.size": 8, |
| "axes.labelsize": 8, |
| "xtick.labelsize": 7, |
| "ytick.labelsize": 7, |
| "legend.fontsize": 7, |
| "pdf.fonttype": 42, |
| "ps.fonttype": 42, |
| } |
| ) |
| fig, axes = plt.subplots( |
| 2, |
| 2, |
| figsize=(7.2, 4.95), |
| dpi=300, |
| constrained_layout=True, |
| ) |
| moderate = summaries["moderate"] |
| strict = summaries["strict"] |
| moderate_pr = ( |
| f"{moderate['plot_threshold']}, matched S arrivals\n" |
| f"P/R: SNR {moderate['snr_event_metrics']['precision']:.3f}/{moderate['snr_event_metrics']['recall']:.3f}; " |
| f"prob. {moderate['confidence_event_metrics']['precision']:.3f}/{moderate['confidence_event_metrics']['recall']:.3f}" |
| ) |
| strict_pr = ( |
| f"{strict['plot_threshold']}, matched S arrivals\n" |
| f"P/R: SNR {strict['snr_event_metrics']['precision']:.3f}/{strict['snr_event_metrics']['recall']:.3f}; " |
| f"prob. {strict['confidence_event_metrics']['precision']:.3f}/{strict['confidence_event_metrics']['recall']:.3f}" |
| ) |
| plot_difference_hist(axes[0, 0], rows, "moderate", "S", "A", moderate_pr, show_legend=True) |
| plot_difference_hist(axes[0, 1], rows, "strict", "S", "B", strict_pr, show_legend=True) |
| plot_difference_hist(axes[1, 0], rows, "moderate", "S_stations", "C", "Moderate budget, S-contributing stations") |
| plot_difference_hist(axes[1, 1], rows, "strict", "S_stations", "D", "Strict budget, S-contributing stations") |
| FIG_DIR.mkdir(parents=True, exist_ok=True) |
| fig.savefig(FIG_PDF, bbox_inches="tight") |
| fig.savefig(FIG_PNG, dpi=300, bbox_inches="tight", facecolor="white") |
| plt.close(fig) |
|
|
|
|
| def main() -> None: |
| eval_mod = load_pick_eval_module() |
| labels = load_labels(eval_mod) |
| needed_conditions = sorted({cond["snr"] for cond in CONDITIONS} | {cond["confidence"] for cond in CONDITIONS}) |
| support = {} |
| auto_counts = {} |
| for condition in needed_conditions: |
| support[condition], auto_counts[condition] = event_support_counts(eval_mod, labels, condition) |
|
|
| rows = [] |
| summaries = {} |
| for condition in CONDITIONS: |
| condition_rows, condition_summary = event_rows_for_condition(condition, support) |
| rows.extend(condition_rows) |
| summaries[condition["slug"]] = condition_summary |
|
|
| full_summary = { |
| "source_root": str(PHASE_BALANCED_ROOT), |
| "days": sorted(DAYS), |
| "tp_tolerance_s": TP_TOL_S, |
| "search_window_s": SEARCH_WINDOW_S, |
| "event_matching": "20 km epicentral distance / 3 s origin time", |
| "auto_pick_counts_by_condition": {key: dict(value) for key, value in auto_counts.items()}, |
| "conditions": summaries, |
| "difference_summary": describe_differences(rows), |
| } |
| write_outputs(rows, full_summary) |
| make_figure(rows, summaries) |
| print(json.dumps(full_summary, indent=2)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|