#!/usr/bin/env python3 """Export exact training-key manifests for the manuscript experiments. The manifests contain dataset keys, labels, SNR thresholds, and random seeds needed to reconstruct the training pools from the public datasets. They do not contain raw waveform or dispersion arrays. """ from __future__ import annotations import argparse import csv import gzip import hashlib import json import math import sys from pathlib import Path from typing import Any import numpy as np ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from scripts.disp_snr_transfer_experiment import matched_threshold_subsets from scripts.reproduce_paper_stats import PHASE_TO_GROUP, PhasePick, Record from scripts.snr_transfer_phase_balanced_experiment import ( PHASE_COMPOSITION_ORDER, collect_phase_snr, filter_records_by_any_thresholds, filter_records_by_complete_thresholds, filter_records_by_phase_thresholds, matched_records_by_phase_composition, matched_records, phase_composition_counts, phase_counts, phase_snr_key, record_key, ) def read_json(path: Path) -> Any: opener = gzip.open if path.suffix == ".gz" else open with opener(path, "rt", encoding="utf-8") as handle: return json.load(handle) def write_json(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) opener = gzip.open if path.suffix == ".gz" else open with opener(path, "wt", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) def write_jsonl_gz(path: Path, rows: list[dict[str, Any]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with gzip.open(path, "wt", encoding="utf-8") as handle: for row in rows: handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") def sha256_text(values: list[str]) -> str: h = hashlib.sha256() for value in values: h.update(value.encode("utf-8")) h.update(b"\n") return h.hexdigest() def record_from_dict(row: dict[str, Any]) -> Record: phases = tuple( PhasePick(phase=str(p["phase"]), index=int(p["index"]), source=str(p.get("source", ""))) for p in row.get("phases", []) ) return Record( event=str(row["event"]), station=str(row["station"]), length=int(row["length"]), delta=float(row["delta"]), distance_km=float(row["distance_km"]), phases=phases, ) def record_to_manifest_row(record: Record, *, seed: int, subset_slug: str) -> dict[str, Any]: phases = [ {"phase": pick.phase, "index": pick.index, "source": pick.source} for pick in record.phases ] phase_groups = [PHASE_TO_GROUP[pick.phase] for pick in record.phases] return { "seed": seed, "subset_slug": subset_slug, "record_key": record_key(record), "event": record.event, "station": record.station, "length": record.length, "delta": record.delta, "distance_km": record.distance_km, "phase_count": len(record.phases), "phase_groups": "".join(phase_groups), "phases": phases, } def export_phase(args: argparse.Namespace) -> None: raw = read_json(args.phase_records_json) records = [record_from_dict(row) for row in raw["records"]] phase_snr = {str(k): float(v) for k, v in read_json(args.phase_snr_json).items()} by_group = collect_phase_snr(records, phase_snr) if args.phase_s_threshold_mode == "same-as-p": s5 = 5.0 s10 = 10.0 p5_label = "P/S>=5 dB record-any" p10_label = "P/S>=10 dB record-any" else: # This branch is preserved for auditability, but the manuscript archive # uses same-as-P thresholds for the training-manifest export. from scripts.snr_transfer_phase_balanced_experiment import choose_s_threshold target_p5 = sum(v >= 5.0 for v in by_group["P"]) target_p10 = sum(v >= 10.0 for v in by_group["P"]) s5, kept_s5 = choose_s_threshold(by_group["S"], target_p5) s10, kept_s10 = choose_s_threshold(by_group["S"], target_p10) p5_label = f"P>=5 dB, S>={s5:.2f} dB phase-balanced" p10_label = f"P>=10 dB, S>={s10:.2f} dB phase-balanced" subset_defs = [ ("full", "Full", None, None), ("p5_s_bal", p5_label, 5.0, s5), ("p10_s_bal", p10_label, 10.0, s10), ] filter_fn = { "phase-label": filter_records_by_phase_thresholds, "record-complete": filter_records_by_complete_thresholds, "record-any": filter_records_by_any_thresholds, }[args.phase_filter_mode] candidate_records = { slug: filter_fn(records, phase_snr, p_thr, s_thr) for slug, _label, p_thr, s_thr in subset_defs } candidate_phase_composition = { slug: phase_composition_counts(pool) for slug, pool in candidate_records.items() } candidate_phase_counts = {slug: phase_counts(pool) for slug, pool in candidate_records.items()} if args.phase_match_mode == "phase-composition": matched_composition = { key: min(counts[key] for counts in candidate_phase_composition.values()) for key in PHASE_COMPOSITION_ORDER } train_pools_by_seed = {} for seed in args.seeds: train_pools_by_seed[seed] = { slug: matched_records_by_phase_composition(candidate_records[slug], matched_composition, seed + idx * 8191) for idx, (slug, _label, _p, _s) in enumerate(subset_defs) } else: matched_count = min(len(pool) for pool in candidate_records.values()) matched_composition = None train_pools_by_seed = {} for seed in args.seeds: train_pools_by_seed[seed] = { slug: matched_records(candidate_records[slug], matched_count, seed + idx * 8191) for idx, (slug, _label, _p, _s) in enumerate(subset_defs) } out_dir = args.out_dir / "phase_picker" out_dir.mkdir(parents=True, exist_ok=True) summary_rows: list[dict[str, Any]] = [] for seed, pools in train_pools_by_seed.items(): for slug, records_selected in pools.items(): rows = [record_to_manifest_row(record, seed=seed, subset_slug=slug) for record in records_selected] out_path = out_dir / f"seed{seed}_{slug}_train_records.jsonl.gz" write_jsonl_gz(out_path, rows) keys = [row["record_key"] for row in rows] phase_counts_selected = phase_counts(records_selected) comp_counts_selected = phase_composition_counts(records_selected) summary_rows.append( { "task": "phase_picker", "seed": seed, "subset_slug": slug, "manifest": str(out_path.relative_to(args.out_dir)), "records": len(rows), "sha256_record_keys": sha256_text(keys), "P_labels": phase_counts_selected["P"], "S_labels": phase_counts_selected["S"], "P_only_records": comp_counts_selected["P_only"], "PS_records": comp_counts_selected["PS"], "S_only_records": comp_counts_selected["S_only"], "none_records": comp_counts_selected["none"], } ) with (out_dir / "phase_training_manifest_summary.csv").open("w", newline="", encoding="utf-8") as handle: fieldnames = [ "task", "seed", "subset_slug", "manifest", "records", "sha256_record_keys", "P_labels", "S_labels", "P_only_records", "PS_records", "S_only_records", "none_records", ] writer = csv.DictWriter(handle, fieldnames=fieldnames) writer.writeheader() writer.writerows(summary_rows) config = { "task": "phase_picker", "public_dataset": "CREDIT-X1local", "source_records": str(args.phase_records_json), "source_phase_snr": str(args.phase_snr_json), "seeds": args.seeds, "filter_mode": args.phase_filter_mode, "s_threshold_mode": args.phase_s_threshold_mode, "match_mode": args.phase_match_mode, "subset_definitions": [ {"slug": slug, "label": label, "p_threshold_db": p_thr, "s_threshold_db": s_thr} for slug, label, p_thr, s_thr in subset_defs ], "candidate_records": {slug: len(pool) for slug, pool in candidate_records.items()}, "candidate_phase_counts": candidate_phase_counts, "candidate_phase_composition_counts": candidate_phase_composition, "matched_phase_composition": matched_composition, "snr_summary_db": { group: { "count": len(values), "median": float(np.median(values)) if values else math.nan, "p25": float(np.percentile(values, 25)) if values else math.nan, "p75": float(np.percentile(values, 75)) if values else math.nan, } for group, values in by_group.items() }, } write_json(out_dir / "phase_training_manifest_config.json", config) print(f"Wrote phase training manifests to {out_dir}") def export_dispersion(args: argparse.Namespace) -> None: rows = read_json(args.dispersion_snr_json) out_dir = args.out_dir / "dispersion" out_dir.mkdir(parents=True, exist_ok=True) summary_rows: list[dict[str, Any]] = [] config = { "task": "dispersion", "public_dataset": "SeisDispFusion-NCF", "source_snr_cache": str(args.dispersion_snr_json), "seeds": args.seeds, "subsets": {}, } for seed in args.seeds: subsets, thresholds = matched_threshold_subsets(rows, seed) config["subsets"][str(seed)] = {"thresholds": thresholds} for slug, keys in subsets.items(): out_path = out_dir / f"seed{seed}_{slug}_train_keys.txt" out_path.write_text("\n".join(keys) + "\n", encoding="utf-8") snrs = [float(rows[key]["snr_db"]) for key in keys] summary_rows.append( { "task": "dispersion", "seed": seed, "subset_slug": slug, "manifest": str(out_path.relative_to(args.out_dir)), "records": len(keys), "sha256_keys": sha256_text(keys), "snr_min": min(snrs), "snr_median": float(np.median(snrs)), "snr_max": max(snrs), "q1_threshold": thresholds["q1"], "q2_threshold": thresholds["q2"], } ) with (out_dir / "dispersion_training_manifest_summary.csv").open("w", newline="", encoding="utf-8") as handle: fieldnames = [ "task", "seed", "subset_slug", "manifest", "records", "sha256_keys", "snr_min", "snr_median", "snr_max", "q1_threshold", "q2_threshold", ] writer = csv.DictWriter(handle, fieldnames=fieldnames) writer.writeheader() writer.writerows(summary_rows) write_json(out_dir / "dispersion_training_manifest_config.json", config) print(f"Wrote dispersion training manifests to {out_dir}") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out-dir", type=Path, default=Path("training_manifests")) parser.add_argument("--seeds", nargs="+", type=int, default=[20260609, 20260610, 20260611]) parser.add_argument("--phase-records-json", type=Path, default=None) parser.add_argument("--phase-snr-json", type=Path, default=None) parser.add_argument("--phase-filter-mode", choices=["phase-label", "record-complete", "record-any"], default="record-any") parser.add_argument("--phase-s-threshold-mode", choices=["balanced", "same-as-p"], default="same-as-p") parser.add_argument("--phase-match-mode", choices=["record-count", "phase-composition"], default="phase-composition") parser.add_argument("--dispersion-snr-json", type=Path, default=None) args = parser.parse_args() args.out_dir.mkdir(parents=True, exist_ok=True) if args.phase_records_json and args.phase_snr_json: export_phase(args) if args.dispersion_snr_json: export_dispersion(args) if not ((args.phase_records_json and args.phase_snr_json) or args.dispersion_snr_json): raise SystemExit("No manifests requested. Provide phase and/or dispersion source cache paths.") if __name__ == "__main__": main()