PhaseNet 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phasenet-0.2.2/.github/workflows/publish.yml +28 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/.gitignore +6 -3
- {phasenet-0.2.0 → phasenet-0.2.2}/PKG-INFO +2 -1
- phasenet-0.2.2/docs/notebooks/lab2a_phasenet_das_inference.ipynb +285 -0
- phasenet-0.2.2/docs/notebooks/lab2b_pick_association.ipynb +195 -0
- phasenet-0.2.2/docs/notebooks/lab2c_semisupervised_training.ipynb +487 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/utils/inference.py +1 -1
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet.egg-info/PKG-INFO +2 -1
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet.egg-info/SOURCES.txt +4 -0
- phasenet-0.2.0/requirements.txt → phasenet-0.2.2/phasenet.egg-info/requires.txt +2 -1
- phasenet-0.2.2/phasenet.egg-info/top_level.txt +4 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/pyproject.toml +5 -1
- phasenet-0.2.0/phasenet.egg-info/requires.txt → phasenet-0.2.2/requirements.txt +1 -0
- phasenet-0.2.0/phasenet.egg-info/top_level.txt +0 -1
- {phasenet-0.2.0 → phasenet-0.2.2}/README.md +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/examples/ceed_training_plots.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/examples/italy/prepare_inventory.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/examples/italy/prepare_mseed_list.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/examples/italy/run_all_years.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/examples/italy/upload_loop.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/examples/italy/upload_phasenet_origin.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/__init__.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/data/__init__.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/data/ceed.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/data/das.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/data/transforms.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/__init__.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/phasenet.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/phasenet_das.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/phasenet_das_plus.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/phasenet_plus.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/phasenet_tf.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/phasenet_tf_plus.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/prompt/__init__.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/prompt/common.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/prompt/mask_decoder.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/prompt/prompt_encoder.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/prompt/transformer.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/unet.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/models/unet2018.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/utils/__init__.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/utils/detect_peaks_cpu.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/utils/postprocess.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet/utils/visualization.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet.egg-info/dependency_links.txt +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/phasenet.egg-info/entry_points.txt +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/predict.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/predict_ceed.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/predict_ceed.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/predict_das.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/predict_das.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/predict_mseed.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/semisupervised_das.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/train_ceed.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/scripts/train_das.sh +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/setup.cfg +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/tests/benchmark_dataloader.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/tests/test_augmentations.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/tests/test_labels.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/train.py +0 -0
- {phasenet-0.2.0 → phasenet-0.2.2}/utils.py +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.10"
|
|
20
|
+
|
|
21
|
+
- name: Install build tools
|
|
22
|
+
run: pip install build
|
|
23
|
+
|
|
24
|
+
- name: Build package
|
|
25
|
+
run: python -m build
|
|
26
|
+
|
|
27
|
+
- name: Publish to PyPI
|
|
28
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phasenet
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A PyTorch implementation of PhaseNet for seismic and DAS phase picking
|
|
5
5
|
Author: Weiqiang Zhu
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,6 +26,7 @@ Requires-Dist: gcsfs
|
|
|
26
26
|
Requires-Dist: datasets
|
|
27
27
|
Requires-Dist: pyarrow
|
|
28
28
|
Requires-Dist: wandb
|
|
29
|
+
Requires-Dist: scikit-learn
|
|
29
30
|
|
|
30
31
|
# PhaseNet-PyTorch
|
|
31
32
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cells": [
|
|
3
|
+
{
|
|
4
|
+
"cell_type": "markdown",
|
|
5
|
+
"metadata": {},
|
|
6
|
+
"source": "[](https://colab.research.google.com/github/AI4EPS/phasenet-pytorch/blob/main/docs/notebooks/lab2a_phasenet_das_inference.ipynb)\n\n# Lab 2a: PhaseNet-DAS Inference\n\nIn this lab, you will learn how to:\n- Load DAS (Distributed Acoustic Sensing) data from the USGS Arcata array\n- Apply PhaseNet for P/S phase picking on DAS data\n- Apply PhaseNet-DAS+, a DAS-specific model with event detection\n- Compare picking quality between a general seismic model and a DAS-specific model\n\n**References:**\n- Zhu, W. & Beroza, G. (2019). PhaseNet: a deep-neural-network-based seismic arrival-time picking method. *GJI*.\n- Zhu, W. et al. (2023). Seismic arrival-time picking on distributed acoustic sensing data using semi-supervised learning. *Nature Communications*, 14, 8192.\n- Zhu, W. et al. (2025). Towards end-to-end earthquake monitoring using a multitask deep learning model. [arXiv:2506.06939](https://arxiv.org/abs/2506.06939)\n- McGuire, J.J. et al. (2025). The GorDAS DAS Experiment Above the Cascadia Locked Zone and Subducted Gorda Slab. *SRL*, 96(4), 2489-2503.\n\n---"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"cell_type": "markdown",
|
|
10
|
+
"metadata": {},
|
|
11
|
+
"source": [
|
|
12
|
+
"## Setup"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"cell_type": "code",
|
|
17
|
+
"execution_count": null,
|
|
18
|
+
"metadata": {},
|
|
19
|
+
"outputs": [],
|
|
20
|
+
"source": "# Install phasenet (only needed once)\n# !pip install phasenet"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"cell_type": "code",
|
|
24
|
+
"execution_count": null,
|
|
25
|
+
"metadata": {},
|
|
26
|
+
"outputs": [],
|
|
27
|
+
"source": "import os\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport h5py\nimport torch\nfrom huggingface_hub import hf_hub_download\n\nimport phasenet.models\nfrom phasenet.utils.inference import predict_2d, detect_picks\nfrom phasenet.utils.postprocess import (\n estimate_origins_from_ps_pairs,\n associate_picks,\n)\n\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nprint(f\"Using device: {DEVICE}\")\n\nDS = 10 # downsample factor for plotting\n\ndef plot_das(ax, arr, nx, nt, dt_s, **kwargs):\n \"\"\"Plot a downsampled DAS 2D array.\"\"\"\n arr = np.asarray(arr)\n ax.imshow(arr[::DS, ::DS], aspect=\"auto\", interpolation=\"bilinear\",\n extent=[0, nt * dt_s, nx, 0], **kwargs)\n\ndef plot_picks(ax, df, dt_s):\n \"\"\"Plot P and S picks.\"\"\"\n for phase, color in [(\"P\", \"red\"), (\"S\", \"blue\")]:\n sub = df[df[\"phase_type\"] == phase]\n ax.scatter(sub[\"phase_index\"] * dt_s, sub[\"channel_index\"].astype(int),\n s=0.5, c=color, alpha=0.3,\n label=f\"{phase} ({len(sub)})\")"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"cell_type": "markdown",
|
|
31
|
+
"metadata": {},
|
|
32
|
+
"source": "## 1. Load DAS Data\n\nWe use DAS data from the USGS Arcata array in Northern California (McGuire et al. 2025), deployed to monitor the 2022 M6.4 Ferndale aftershock sequence. The array records strain rate along a fiber-optic cable with ~2m channel spacing at 250 Hz sampling rate.\n\nWe chose this dataset because:\n1. It is **independent** from any data used in previous PhaseNet/PhaseNet-DAS development — allowing unbiased evaluation\n2. It is a **small, self-contained** dataset (~2,470 events) — ideal for demonstrating how semi-supervised learning can rapidly improve a model on a new DAS array\n\nEach event file is an H5 file containing a 2D array of shape `(nx, nt)` — number of spatial channels by number of time samples."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"cell_type": "code",
|
|
36
|
+
"execution_count": null,
|
|
37
|
+
"metadata": {},
|
|
38
|
+
"outputs": [],
|
|
39
|
+
"source": [
|
|
40
|
+
"HF_REPO = \"AI4EPS/quakeflow_das\"\n",
|
|
41
|
+
"\n",
|
|
42
|
+
"# Download a strong event with clear P and S arrivals\n",
|
|
43
|
+
"EVENT_ID = \"20230106T122215Z\"\n",
|
|
44
|
+
"h5_path = hf_hub_download(\n",
|
|
45
|
+
" HF_REPO, f\"arcata/data/{EVENT_ID}.h5\", repo_type=\"dataset\",\n",
|
|
46
|
+
" local_dir=\"data/quakeflow_das\",\n",
|
|
47
|
+
")\n",
|
|
48
|
+
"\n",
|
|
49
|
+
"with h5py.File(h5_path, \"r\") as fp:\n",
|
|
50
|
+
" data = fp[\"data\"][:]\n",
|
|
51
|
+
" attrs = dict(fp[\"data\"].attrs)\n",
|
|
52
|
+
"\n",
|
|
53
|
+
"dt_s = float(attrs.get(\"dt_s\", 0.008))\n",
|
|
54
|
+
"begin_time = str(attrs.get(\"begin_time\", \"\"))\n",
|
|
55
|
+
"nx, nt = data.shape\n",
|
|
56
|
+
"\n",
|
|
57
|
+
"print(f\"Event: {EVENT_ID}\")\n",
|
|
58
|
+
"print(f\"Shape: (nx={nx}, nt={nt})\")\n",
|
|
59
|
+
"print(f\"Sampling interval: {dt_s} s ({1/dt_s:.0f} Hz)\")\n",
|
|
60
|
+
"print(f\"Duration: {nt * dt_s:.1f} s\")\n",
|
|
61
|
+
"print(f\"Begin time: {begin_time}\")"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"cell_type": "code",
|
|
66
|
+
"execution_count": null,
|
|
67
|
+
"metadata": {},
|
|
68
|
+
"outputs": [],
|
|
69
|
+
"source": "# Plot raw DAS waveform\nfig, ax = plt.subplots(figsize=(12, 8))\nvmax = 2 * np.percentile(np.abs(data), 95)\nplot_das(ax, data, nx, nt, dt_s, cmap=\"seismic\", vmin=-vmax, vmax=vmax)\nax.set_xlabel(\"Time (s)\")\nax.set_ylabel(\"Channel index\")\nax.set_title(f\"Raw DAS waveform — {EVENT_ID}\")\nplt.tight_layout()\nplt.show()"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"cell_type": "markdown",
|
|
73
|
+
"metadata": {},
|
|
74
|
+
"source": [
|
|
75
|
+
"## 2. Run PhaseNet (Seismic Model)\n",
|
|
76
|
+
"\n",
|
|
77
|
+
"PhaseNet (Zhu & Beroza, 2019) is a deep learning model originally designed for 3-component seismic data. It can be applied to single-component DAS data by treating the DAS channel as the vertical (Z) component and zero-padding the other two components.\n",
|
|
78
|
+
"\n",
|
|
79
|
+
"PhaseNet processes each channel independently — it sees `(3, 1, nt)` per channel, treating each DAS channel as a single seismic station."
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"cell_type": "code",
|
|
84
|
+
"execution_count": null,
|
|
85
|
+
"metadata": {},
|
|
86
|
+
"outputs": [],
|
|
87
|
+
"source": [
|
|
88
|
+
"# Prepare input: DAS → 3-component format (E=0, N=0, Z=data)\n",
|
|
89
|
+
"waveform_3ch = np.zeros((3, nx, nt), dtype=np.float32)\n",
|
|
90
|
+
"waveform_3ch[2] = data.astype(np.float32) # Z-component\n",
|
|
91
|
+
"\n",
|
|
92
|
+
"print(f\"Input shape: {waveform_3ch.shape} (3 channels, {nx} stations, {nt} samples)\")"
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"cell_type": "code",
|
|
97
|
+
"execution_count": null,
|
|
98
|
+
"metadata": {},
|
|
99
|
+
"outputs": [],
|
|
100
|
+
"source": [
|
|
101
|
+
"# Load pretrained PhaseNet model\n",
|
|
102
|
+
"import urllib.request\n",
|
|
103
|
+
"\n",
|
|
104
|
+
"MODELS_REPO = \"AI4EPS/models\"\n",
|
|
105
|
+
"\n",
|
|
106
|
+
"def download_model(release, filename):\n",
|
|
107
|
+
" \"\"\"Download a model from GitHub releases (cached locally).\"\"\"\n",
|
|
108
|
+
" cache_dir = os.path.expanduser(f\"~/.cache/phasenet/{release}\")\n",
|
|
109
|
+
" local_path = os.path.join(cache_dir, filename)\n",
|
|
110
|
+
" if not os.path.exists(local_path):\n",
|
|
111
|
+
" os.makedirs(cache_dir, exist_ok=True)\n",
|
|
112
|
+
" url = f\"https://github.com/{MODELS_REPO}/releases/download/{release}/{filename}\"\n",
|
|
113
|
+
" print(f\"Downloading {url}\")\n",
|
|
114
|
+
" urllib.request.urlretrieve(url, local_path)\n",
|
|
115
|
+
" return local_path\n",
|
|
116
|
+
"\n",
|
|
117
|
+
"# Load PhaseNet-2018\n",
|
|
118
|
+
"ckpt_path = download_model(\"PhaseNet-2018\", \"phasenet2018.pth\")\n",
|
|
119
|
+
"ckpt = torch.load(ckpt_path, map_location=\"cpu\", weights_only=False)\n",
|
|
120
|
+
"model_pn = phasenet.models.phasenet.build_model(\n",
|
|
121
|
+
" backbone=\"unet2018\", **ckpt.get(\"model_config\", {})\n",
|
|
122
|
+
")\n",
|
|
123
|
+
"model_pn.load_state_dict(ckpt[\"model\"])\n",
|
|
124
|
+
"model_pn.to(DEVICE).eval()\n",
|
|
125
|
+
"print(\"PhaseNet-2018 loaded\")"
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"cell_type": "code",
|
|
130
|
+
"execution_count": null,
|
|
131
|
+
"metadata": {},
|
|
132
|
+
"outputs": [],
|
|
133
|
+
"source": [
|
|
134
|
+
"# Run inference\n",
|
|
135
|
+
"# PhaseNet uses a sliding window of (256 channels, 3000 samples)\n",
|
|
136
|
+
"NX_PN, NT_PN = 256, 3000\n",
|
|
137
|
+
"MIN_PROB = 0.3\n",
|
|
138
|
+
"\n",
|
|
139
|
+
"with torch.no_grad():\n",
|
|
140
|
+
" scores_pn = predict_2d(\n",
|
|
141
|
+
" model_pn, waveform_3ch, NX_PN, NT_PN,\n",
|
|
142
|
+
" min_t_overlap=0.1, device=DEVICE,\n",
|
|
143
|
+
" taper_nx=False, taper_nt=True, # Independent channels → no nx taper\n",
|
|
144
|
+
" )\n",
|
|
145
|
+
"\n",
|
|
146
|
+
"print(f\"Output scores shape: {scores_pn.shape}\") # (3, nx, nt) — P, S, Noise"
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"cell_type": "code",
|
|
151
|
+
"execution_count": null,
|
|
152
|
+
"metadata": {},
|
|
153
|
+
"outputs": [],
|
|
154
|
+
"source": [
|
|
155
|
+
"# Detect picks from probability scores using peak detection\n",
|
|
156
|
+
"channel_ids = [str(i) for i in range(nx)]\n",
|
|
157
|
+
"\n",
|
|
158
|
+
"picks_pn = detect_picks(\n",
|
|
159
|
+
" scores_pn, channel_ids, EVENT_ID, begin_time,\n",
|
|
160
|
+
" dt_s=dt_s, vmin=MIN_PROB, device=DEVICE,\n",
|
|
161
|
+
")\n",
|
|
162
|
+
"\n",
|
|
163
|
+
"df_pn = pd.DataFrame(picks_pn)\n",
|
|
164
|
+
"n_p = (df_pn[\"phase_type\"] == \"P\").sum()\n",
|
|
165
|
+
"n_s = (df_pn[\"phase_type\"] == \"S\").sum()\n",
|
|
166
|
+
"print(f\"PhaseNet picks: {len(df_pn)} total ({n_p} P, {n_s} S)\")"
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"cell_type": "code",
|
|
171
|
+
"execution_count": null,
|
|
172
|
+
"metadata": {},
|
|
173
|
+
"outputs": [],
|
|
174
|
+
"source": "# Plot picks on waveform\nfig, ax = plt.subplots(figsize=(12, 8))\nplot_das(ax, data, nx, nt, dt_s, cmap=\"seismic\", vmin=-vmax, vmax=vmax)\nplot_picks(ax, df_pn, dt_s)\nax.set_xlabel(\"Time (s)\")\nax.set_ylabel(\"Channel index\")\nax.set_title(f\"PhaseNet picks — {EVENT_ID}\")\nax.legend(loc=\"upper right\", markerscale=10)\nplt.tight_layout()\nplt.show()"
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"cell_type": "markdown",
|
|
178
|
+
"metadata": {},
|
|
179
|
+
"source": "## 3. Run PhaseNet-DAS+\n\nPhaseNet-DAS+ (Zhu et al. 2023, 2025) is specifically designed for DAS data. Unlike PhaseNet which treats each channel independently, PhaseNet-DAS+ uses 2D convolutions across both spatial (channel) and temporal dimensions, learning the spatiotemporal patterns of seismic waves propagating along the fiber.\n\nThe model was trained using a semi-supervised approach: initial labels from PhaseNet picks were iteratively refined by retraining on associated picks.\n\nKey differences from PhaseNet:\n- **Input**: 1 channel (native DAS strain rate) instead of 3\n- **Architecture**: 2D convolutions across (nx, nt) instead of independent 1D per channel\n- **Output**: P/S scores + event detection head (PhaseNet+ multi-task framework)"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"cell_type": "code",
|
|
183
|
+
"execution_count": null,
|
|
184
|
+
"metadata": {},
|
|
185
|
+
"outputs": [],
|
|
186
|
+
"source": [
|
|
187
|
+
"# Prepare input: native DAS format (1 channel)\n",
|
|
188
|
+
"waveform_1ch = data.astype(np.float32)[np.newaxis, :, :] # (1, nx, nt)\n",
|
|
189
|
+
"print(f\"Input shape: {waveform_1ch.shape} (1 channel, {nx} stations, {nt} samples)\")"
|
|
190
|
+
]
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"cell_type": "code",
|
|
194
|
+
"execution_count": null,
|
|
195
|
+
"metadata": {},
|
|
196
|
+
"outputs": [],
|
|
197
|
+
"source": [
|
|
198
|
+
"# Load pretrained PhaseNet-DAS-Plus (Arcata)\n",
|
|
199
|
+
"ckpt_path = download_model(\"PhaseNet-DAS-Plus-Arcata-v1\", \"phasenet_das_plus_arcata_v1.pth\")\n",
|
|
200
|
+
"ckpt = torch.load(ckpt_path, map_location=\"cpu\", weights_only=False)\n",
|
|
201
|
+
"model_das = phasenet.models.phasenet_das_plus.build_model(**ckpt.get(\"model_config\", {}))\n",
|
|
202
|
+
"\n",
|
|
203
|
+
"# Use EMA (Exponential Moving Average) weights for better generalization\n",
|
|
204
|
+
"if \"model_ema_weights\" in ckpt:\n",
|
|
205
|
+
" model_das.load_state_dict(ckpt[\"model_ema_weights\"])\n",
|
|
206
|
+
"else:\n",
|
|
207
|
+
" model_das.load_state_dict(ckpt[\"model\"])\n",
|
|
208
|
+
"\n",
|
|
209
|
+
"model_das.to(DEVICE).eval()\n",
|
|
210
|
+
"print(\"PhaseNet-DAS-Plus loaded\")"
|
|
211
|
+
]
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"cell_type": "code",
|
|
215
|
+
"source": "# Run inference\n# PhaseNet-DAS uses a larger window: (2048 channels, 4096 samples)\nNX_DAS, NT_DAS = 2048, 4096\n\nwith torch.no_grad():\n result = predict_2d(\n model_das, waveform_1ch, NX_DAS, NT_DAS,\n min_t_overlap=0.1, device=DEVICE,\n taper_nx=True, taper_nt=True, # DAS model: taper both axes\n )\n\n# PhaseNet-DAS+ returns (scores, event_detections)\nif isinstance(result, tuple):\n scores_das, event_detections = result\nelse:\n scores_das = result\n event_detections = None\n\nprint(f\"Phase scores shape: {scores_das.shape}\")",
|
|
216
|
+
"metadata": {},
|
|
217
|
+
"execution_count": null,
|
|
218
|
+
"outputs": []
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"cell_type": "code",
|
|
222
|
+
"execution_count": null,
|
|
223
|
+
"metadata": {},
|
|
224
|
+
"outputs": [],
|
|
225
|
+
"source": [
|
|
226
|
+
"# Detect picks\n",
|
|
227
|
+
"picks_das = detect_picks(\n",
|
|
228
|
+
" scores_das, channel_ids, EVENT_ID, begin_time,\n",
|
|
229
|
+
" dt_s=dt_s, vmin=MIN_PROB, device=DEVICE,\n",
|
|
230
|
+
")\n",
|
|
231
|
+
"\n",
|
|
232
|
+
"df_das = pd.DataFrame(picks_das)\n",
|
|
233
|
+
"n_p = (df_das[\"phase_type\"] == \"P\").sum()\n",
|
|
234
|
+
"n_s = (df_das[\"phase_type\"] == \"S\").sum()\n",
|
|
235
|
+
"print(f\"PhaseNet-DAS picks: {len(df_das)} total ({n_p} P, {n_s} S)\")"
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"cell_type": "code",
|
|
240
|
+
"execution_count": null,
|
|
241
|
+
"metadata": {},
|
|
242
|
+
"outputs": [],
|
|
243
|
+
"source": "# Side-by-side comparison\nfig, axes = plt.subplots(1, 2, figsize=(20, 8))\n\nfor ax, df, title in zip(axes, [df_pn, df_das], [\"PhaseNet\", \"PhaseNet-DAS+\"]):\n plot_das(ax, data, nx, nt, dt_s, cmap=\"seismic\", vmin=-vmax, vmax=vmax)\n plot_picks(ax, df, dt_s)\n ax.set_xlabel(\"Time (s)\")\n ax.set_ylabel(\"Channel index\")\n ax.set_title(f\"{title} — {len(df)} picks\")\n ax.legend(loc=\"upper right\", markerscale=10)\n\nplt.tight_layout()\nplt.show()"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"cell_type": "markdown",
|
|
247
|
+
"source": "## 4. Compare at Scale\n\nWe have pre-computed picks for all 2,470 Arcata events using both models. Let's compare on specific examples.",
|
|
248
|
+
"metadata": {}
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"cell_type": "code",
|
|
252
|
+
"metadata": {},
|
|
253
|
+
"source": "# Visualize a weak event: PhaseNet fails, PhaseNet-DAS+ succeeds\nWEAK_EVENT = \"20230119T202515Z\"\n\nh5_path = hf_hub_download(\n HF_REPO, f\"arcata/data/{WEAK_EVENT}.h5\", repo_type=\"dataset\",\n local_dir=\"data/quakeflow_das\",\n)\nwith h5py.File(h5_path, \"r\") as fp:\n weak_data = fp[\"data\"][:]\n\npn_path = hf_hub_download(\n HF_REPO, f\"arcata/phasenet/picks/{WEAK_EVENT}.parquet\",\n repo_type=\"dataset\", local_dir=\"data/quakeflow_das\",\n)\ndas_path = hf_hub_download(\n HF_REPO, f\"arcata/phasenet_das/picks/{WEAK_EVENT}.parquet\",\n repo_type=\"dataset\", local_dir=\"data/quakeflow_das\",\n)\n\npn_weak = pd.read_parquet(pn_path)\ndas_weak = pd.read_parquet(das_path)\n\nfig, axes = plt.subplots(1, 2, figsize=(20, 8))\nvmax_w = 2 * np.percentile(np.abs(weak_data), 95)\nnx_w, nt_w = weak_data.shape\n\nfor ax, df, title in zip(axes, [pn_weak, das_weak], [\"PhaseNet\", \"PhaseNet-DAS+\"]):\n plot_das(ax, weak_data, nx_w, nt_w, dt_s, cmap=\"seismic\", vmin=-vmax_w, vmax=vmax_w)\n\n assoc = df[df[\"origin_id\"] >= 0]\n unassoc = df[df[\"origin_id\"] < 0]\n\n if len(unassoc) > 0:\n ax.scatter(unassoc[\"phase_index\"] * dt_s, unassoc[\"channel_index\"].astype(int),\n s=0.3, c=\"gray\", alpha=0.1, label=f\"Unassociated ({len(unassoc)})\")\n if len(assoc) > 0:\n for phase, color in [(\"P\", \"red\"), (\"S\", \"blue\")]:\n a = assoc[assoc[\"phase_type\"] == phase]\n ax.scatter(a[\"phase_index\"] * dt_s, a[\"channel_index\"].astype(int),\n s=1, c=color, alpha=0.5, label=f\"{phase} assoc ({len(a)})\")\n\n ax.set_xlabel(\"Time (s)\")\n ax.set_ylabel(\"Channel index\")\n ax.set_title(f\"{title} — {WEAK_EVENT}\")\n ax.legend(loc=\"upper right\", markerscale=10)\n\nplt.tight_layout()\nplt.show()",
|
|
254
|
+
"outputs": [],
|
|
255
|
+
"execution_count": null
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"cell_type": "code",
|
|
259
|
+
"execution_count": null,
|
|
260
|
+
"metadata": {},
|
|
261
|
+
"outputs": [],
|
|
262
|
+
"source": "# Noise-only event: PhaseNet hallucinates picks, DAS+ correctly rejects\nNOISE_EVENT = \"20230103T220320Z\"\n\nh5_path = hf_hub_download(\n HF_REPO, f\"arcata/data/{NOISE_EVENT}.h5\", repo_type=\"dataset\",\n local_dir=\"data/quakeflow_das\",\n)\nwith h5py.File(h5_path, \"r\") as fp:\n noise_data = fp[\"data\"][:]\n\npn_noise = pd.read_parquet(hf_hub_download(\n HF_REPO, f\"arcata/phasenet/picks/{NOISE_EVENT}.parquet\",\n repo_type=\"dataset\", local_dir=\"data/quakeflow_das\",\n))\n\nfig, axes = plt.subplots(1, 2, figsize=(20, 8))\nvmax_n = 2 * np.percentile(np.abs(noise_data), 95)\nnx_n, nt_n = noise_data.shape\n\nax = axes[0]\nplot_das(ax, noise_data, nx_n, nt_n, dt_s, cmap=\"seismic\", vmin=-vmax_n, vmax=vmax_n)\nax.scatter(pn_noise[\"phase_index\"] * dt_s, pn_noise[\"channel_index\"].astype(int),\n s=0.3, c=\"orange\", alpha=0.1)\nax.set_title(f\"PhaseNet — {len(pn_noise)} false picks\")\nax.set_xlabel(\"Time (s)\")\nax.set_ylabel(\"Channel index\")\n\nax = axes[1]\nplot_das(ax, noise_data, nx_n, nt_n, dt_s, cmap=\"seismic\", vmin=-vmax_n, vmax=vmax_n)\nax.set_title(\"PhaseNet-DAS+ — 0 picks (correct)\")\nax.set_xlabel(\"Time (s)\")\nax.set_ylabel(\"Channel index\")\n\nplt.tight_layout()\nplt.show()"
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"cell_type": "markdown",
|
|
266
|
+
"execution_count": null,
|
|
267
|
+
"metadata": {},
|
|
268
|
+
"outputs": [],
|
|
269
|
+
"source": "## Summary\n\n| | PhaseNet (seismic) | PhaseNet-DAS+ |\n|---|---|---|\n| **Input** | 3-component (zero-padded DAS) | Native 1-component DAS |\n| **Architecture** | Independent per-channel 1D | Spatiotemporal 2D convolutions |\n| **Strong events** | Detects P/S arrivals | Detects P/S + event heads |\n| **Weak events** | Misses most arrivals | Detects using spatial coherence |\n| **Noise** | Many false picks | Correctly rejects noise |\n\nBrowse all 2,467 prediction figures: [Google Drive](https://drive.google.com/drive/folders/1natqPKvg0q07AxLWFOSEpTEcE1OTzt2h?usp=sharing)\n\n**Next:** [Lab 2b](lab2b_pick_association.ipynb) explores how raw picks are grouped into seismic events using P-S pairing and origin time estimation."
|
|
270
|
+
}
|
|
271
|
+
],
|
|
272
|
+
"metadata": {
|
|
273
|
+
"kernelspec": {
|
|
274
|
+
"display_name": "Python 3",
|
|
275
|
+
"language": "python",
|
|
276
|
+
"name": "python3"
|
|
277
|
+
},
|
|
278
|
+
"language_info": {
|
|
279
|
+
"name": "python",
|
|
280
|
+
"version": "3.10.0"
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"nbformat": 4,
|
|
284
|
+
"nbformat_minor": 4
|
|
285
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cells": [
|
|
3
|
+
{
|
|
4
|
+
"cell_type": "markdown",
|
|
5
|
+
"metadata": {},
|
|
6
|
+
"source": "[](https://colab.research.google.com/github/AI4EPS/phasenet-pytorch/blob/main/docs/notebooks/lab2b_pick_association.ipynb)\n\n# Lab 2b: Phase Association\n\nIn this lab, you will learn how to group raw P and S picks into seismic events:\n- Pair P and S arrivals per DAS channel\n- Estimate earthquake origin times from P-S differential times\n- Use histogram-based clustering to detect events\n- Use RANSAC moveout fitting to improve association quality\n- Tune association parameters interactively\n\n**References:**\n- Zhu, W. et al. (2022). Earthquake phase association using a Bayesian Gaussian mixture model. *JGR: Solid Earth*, 127(5), e2021JB023249.\n- Zhu, W. et al. (2025a). Towards end-to-end earthquake monitoring using a multitask deep learning model. [arXiv:2506.06939](https://arxiv.org/abs/2506.06939)\n- Zhu, W. et al. (2025b). Robust earthquake location using Random Sample Consensus (RANSAC). [arXiv:2502.10933](https://arxiv.org/abs/2502.10933)\n- Song, J. et al. (2026). Scalable earthquake phase association based on Wadati and Voronoi diagrams. SSA Annual Meeting.\n- Zhu, W. et al. (2023). Seismic arrival-time picking on DAS data using semi-supervised learning. *Nature Communications*, 14, 8192.\n\n---"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"cell_type": "markdown",
|
|
10
|
+
"metadata": {},
|
|
11
|
+
"source": [
|
|
12
|
+
"## Setup"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"cell_type": "code",
|
|
17
|
+
"execution_count": null,
|
|
18
|
+
"metadata": {},
|
|
19
|
+
"outputs": [],
|
|
20
|
+
"source": "# !pip install phasenet"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"cell_type": "code",
|
|
24
|
+
"execution_count": null,
|
|
25
|
+
"metadata": {},
|
|
26
|
+
"outputs": [],
|
|
27
|
+
"source": "import numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport h5py\nfrom huggingface_hub import hf_hub_download\n\nfrom phasenet.utils.postprocess import (\n estimate_origins_from_ps_pairs,\n associate_picks,\n)\n\nHF_REPO = \"AI4EPS/quakeflow_das\"\n\nDS = 10 # downsample factor for plotting\n\ndef plot_das(ax, arr, nx, nt, dt_s, **kwargs):\n \"\"\"Plot a downsampled DAS 2D array.\"\"\"\n arr = np.asarray(arr)\n ax.imshow(arr[::DS, ::DS], aspect=\"auto\", interpolation=\"bilinear\",\n extent=[0, nt * dt_s, nx, 0], **kwargs)"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"cell_type": "markdown",
|
|
31
|
+
"metadata": {},
|
|
32
|
+
"source": "## 1. From Picks to Origins\n\nAfter running PhaseNet or PhaseNet-DAS, we have a list of P and S picks — each with a channel index, sample index, score, and phase type. But these are individual detections, not grouped into earthquakes.\n\nThe **association** problem: given thousands of P and S picks scattered across channels and time, which picks belong to the same earthquake? Various methods exist for seismic networks, including Bayesian Gaussian mixture models (GaMMA, Zhu et al. 2022) and Voronoi tessellation (VoRA, Song et al. 2026).\n\nOn a DAS array, the problem is simpler: all channels are on a single fiber, so we can exploit the P-S differential time directly. Following PhaseNet+ (Zhu et al. 2025), we pair P and S picks per channel, estimate origin times, and cluster them using histogram peak detection."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"cell_type": "code",
|
|
36
|
+
"execution_count": null,
|
|
37
|
+
"metadata": {},
|
|
38
|
+
"outputs": [],
|
|
39
|
+
"source": [
|
|
40
|
+
"# Load a multi-event example\n",
|
|
41
|
+
"EVENT_ID = \"20241205T185220Z\"\n",
|
|
42
|
+
"\n",
|
|
43
|
+
"h5_path = hf_hub_download(\n",
|
|
44
|
+
" HF_REPO, f\"arcata/data/{EVENT_ID}.h5\", repo_type=\"dataset\",\n",
|
|
45
|
+
" local_dir=\"data/quakeflow_das\",\n",
|
|
46
|
+
")\n",
|
|
47
|
+
"with h5py.File(h5_path, \"r\") as fp:\n",
|
|
48
|
+
" data = fp[\"data\"][:]\n",
|
|
49
|
+
" attrs = dict(fp[\"data\"].attrs)\n",
|
|
50
|
+
"\n",
|
|
51
|
+
"dt_s = float(attrs.get(\"dt_s\", 0.008))\n",
|
|
52
|
+
"nx, nt = data.shape\n",
|
|
53
|
+
"\n",
|
|
54
|
+
"# Load pre-computed picks from PhaseNet-DAS-Plus\n",
|
|
55
|
+
"das_path = hf_hub_download(\n",
|
|
56
|
+
" HF_REPO, f\"arcata/phasenet_das/picks/{EVENT_ID}.parquet\",\n",
|
|
57
|
+
" repo_type=\"dataset\", local_dir=\"data/quakeflow_das\",\n",
|
|
58
|
+
")\n",
|
|
59
|
+
"df = pd.read_parquet(das_path)\n",
|
|
60
|
+
"\n",
|
|
61
|
+
"print(f\"Event: {EVENT_ID}\")\n",
|
|
62
|
+
"print(f\"Data shape: ({nx}, {nt}), dt={dt_s}s\")\n",
|
|
63
|
+
"print(f\"Picks: {len(df)} total\")\n",
|
|
64
|
+
"print(f\"Columns: {list(df.columns)}\")\n",
|
|
65
|
+
"df.head()"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"cell_type": "code",
|
|
70
|
+
"execution_count": null,
|
|
71
|
+
"metadata": {},
|
|
72
|
+
"outputs": [],
|
|
73
|
+
"source": "# Visualize all picks (before association)\nfig, ax = plt.subplots(figsize=(12, 8))\nvmax = 2 * np.percentile(np.abs(data), 95)\nplot_das(ax, data, nx, nt, dt_s, cmap=\"seismic\", vmin=-vmax, vmax=vmax)\n\np_picks = df[df[\"phase_type\"] == \"P\"]\ns_picks = df[df[\"phase_type\"] == \"S\"]\nax.scatter(p_picks[\"phase_index\"] * dt_s, p_picks[\"channel_index\"].astype(int),\n s=0.5, c=\"red\", alpha=0.3, label=f\"P ({len(p_picks)})\")\nax.scatter(s_picks[\"phase_index\"] * dt_s, s_picks[\"channel_index\"].astype(int),\n s=0.5, c=\"blue\", alpha=0.3, label=f\"S ({len(s_picks)})\")\n\nax.set_xlabel(\"Time (s)\")\nax.set_ylabel(\"Channel index\")\nax.set_title(f\"All picks (unassociated) — {EVENT_ID}\")\nax.legend(loc=\"upper right\", markerscale=10)\nplt.tight_layout()\nplt.show()"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"cell_type": "markdown",
|
|
77
|
+
"metadata": {},
|
|
78
|
+
"source": "## 2. P-S Pairing\n\nThe first step is to pair P and S picks on the same channel. For each P pick, we find the first S pick that follows within a maximum P-S time window. This exploits the physical constraint that S-waves always arrive after P-waves.\n\nFrom each P-S pair, we estimate the earthquake origin time using the Wadati relation:\n\n$$t_0 = \\frac{t_P + t_S}{2} - \\frac{t_S - t_P}{2} \\cdot \\frac{V_P/V_S + 1}{V_P/V_S - 1}$$\n\nwhere $V_P/V_S \\approx 1.73$ is the regional P-to-S velocity ratio."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"cell_type": "code",
|
|
82
|
+
"execution_count": null,
|
|
83
|
+
"metadata": {},
|
|
84
|
+
"outputs": [],
|
|
85
|
+
"source": [
|
|
86
|
+
"# Convert pre-computed picks back to dict format for the pairing function\n",
|
|
87
|
+
"picks = df.to_dict(\"records\")\n",
|
|
88
|
+
"\n",
|
|
89
|
+
"# Pair P and S picks per channel\n",
|
|
90
|
+
"pairs = estimate_origins_from_ps_pairs(\n",
|
|
91
|
+
" picks,\n",
|
|
92
|
+
" dt_s=dt_s,\n",
|
|
93
|
+
" vp_vs=1.73, # regional Vp/Vs ratio\n",
|
|
94
|
+
" min_ps_dt=1.0, # minimum P-S time (seconds) to avoid false pairs\n",
|
|
95
|
+
" max_ps_dt=30.0, # maximum P-S time (seconds)\n",
|
|
96
|
+
")\n",
|
|
97
|
+
"\n",
|
|
98
|
+
"print(f\"Found {len(pairs)} P-S pairs\")\n",
|
|
99
|
+
"# Each pair: (channel_id, p_index, s_index, t0_seconds, weight, ps_center, ps_interval)"
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"cell_type": "code",
|
|
104
|
+
"execution_count": null,
|
|
105
|
+
"metadata": {},
|
|
106
|
+
"outputs": [],
|
|
107
|
+
"source": "# Visualize P-S pairs on waveform\nfig, ax = plt.subplots(figsize=(12, 8))\nplot_das(ax, data, nx, nt, dt_s, cmap=\"seismic\", vmin=-vmax, vmax=vmax)\nfor ch, p_idx, s_idx, t0, w, pc, pi in pairs:\n ch_int = int(ch)\n ax.plot([p_idx * dt_s, s_idx * dt_s], [ch_int, ch_int],\n c=\"green\", alpha=0.1, lw=0.5)\nax.set_xlabel(\"Time (s)\")\nax.set_ylabel(\"Channel index\")\nax.set_title(f\"P-S pairs ({len(pairs)})\")\nplt.tight_layout()\nplt.show()"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"cell_type": "markdown",
|
|
111
|
+
"metadata": {},
|
|
112
|
+
"source": "## 3. Origin Time Estimation\n\nFrom each P-S pair, we estimate when the earthquake occurred (origin time):\n\n$$t_0 = \\frac{t_P + t_S}{2} - \\frac{t_S - t_P}{2} \\cdot \\frac{V_P/V_S + 1}{V_P/V_S - 1}$$\n\nIf multiple P-S pairs from the **same earthquake** all produce similar origin times, we can detect that earthquake by finding **peaks in the histogram of estimated origin times**.\n\nThis is the core idea behind the association algorithm (Zhu et al. 2025; Song et al. 2026): cluster origin time estimates to identify discrete events."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"cell_type": "code",
|
|
116
|
+
"execution_count": null,
|
|
117
|
+
"metadata": {},
|
|
118
|
+
"outputs": [],
|
|
119
|
+
"source": "# Extract origin time estimates from pairs\nt0_estimates = np.array([t0 for _, _, _, t0, _, _, _ in pairs])\nweights = np.array([w for _, _, _, _, w, _, _ in pairs])\n\n# Build weighted histogram of origin times\nbin_size = 0.1 # seconds\nt_max = nt * dt_s\nhist_edges = np.arange(0, t_max + bin_size, bin_size)\nhist_counts, _ = np.histogram(t0_estimates, bins=hist_edges, weights=weights)\nbin_centers = (hist_edges[:-1] + hist_edges[1:]) / 2\n\nfig, ax = plt.subplots(figsize=(14, 4))\nax.bar(bin_centers, hist_counts, width=bin_size, color=\"steelblue\", edgecolor=\"none\")\nax.set_xlabel(\"Estimated origin time (seconds)\")\nax.set_ylabel(\"Weighted count\")\nax.set_title(\"Histogram of estimated origin times\")\nax.set_xlim(0, t_max)\nax.set_yscale(\"log\")\nplt.tight_layout()\nplt.show()"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"cell_type": "code",
|
|
123
|
+
"execution_count": null,
|
|
124
|
+
"metadata": {},
|
|
125
|
+
"outputs": [],
|
|
126
|
+
"source": "# Run full association: pair → histogram → peak detection → link picks to events\nassoc = associate_picks(\n pairs,\n nt=nt,\n dt_s=dt_s,\n bin_size=0.1, # histogram bin width (seconds)\n mph=100, # minimum peak height\n mpd=50, # minimum peak distance (bins)\n origin_window=0.5, # max distance from peak to link a pair (seconds)\n moveout_window=0.5, # RANSAC residual threshold (seconds)\n)\n\nprint(f\"Detected {len(assoc.event_times)} events\")\nprint(f\"Event origin times: {[f'{t:.1f}s' for t in assoc.event_times]}\")\nprint(f\"Associated picks: {len(assoc.pick_to_event)} / {len(picks)}\")"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"cell_type": "code",
|
|
130
|
+
"execution_count": null,
|
|
131
|
+
"metadata": {},
|
|
132
|
+
"outputs": [],
|
|
133
|
+
"source": "# Visualize association results\nfig, ax = plt.subplots(figsize=(12, 8))\nplot_das(ax, data, nx, nt, dt_s, cmap=\"seismic\", vmin=-vmax, vmax=vmax)\n\ncolors = plt.cm.Set1(np.linspace(0, 1, max(len(assoc.event_times), 1)))\n\nunassoc_mask = df.apply(\n lambda r: (r[\"channel_index\"], int(r[\"phase_index\"]), r[\"phase_type\"]) not in assoc.associated_set,\n axis=1,\n)\nunassoc = df[unassoc_mask]\nax.scatter(unassoc[\"phase_index\"] * dt_s, unassoc[\"channel_index\"].astype(int),\n s=0.3, c=\"gray\", alpha=0.1, label=f\"Unassociated ({len(unassoc)})\")\n\nfor eid, t_origin in enumerate(assoc.event_times):\n event_picks = [(ch, idx, ptype) for (ch, idx, ptype), e in assoc.pick_to_event.items() if e == eid]\n if not event_picks:\n continue\n chs = [int(ch) for ch, _, _ in event_picks]\n idxs = [idx * dt_s for _, idx, _ in event_picks]\n ax.scatter(idxs, chs, s=1, c=[colors[eid % len(colors)]], alpha=0.5,\n label=f\"Event {eid} (t={t_origin:.1f}s, {len(event_picks)} picks)\")\n\nax.set_xlabel(\"Time (s)\")\nax.set_ylabel(\"Channel index\")\nax.set_title(f\"Associated picks — {len(assoc.event_times)} events detected\")\nax.legend(loc=\"upper right\", markerscale=10, fontsize=8)\nplt.tight_layout()\nplt.show()"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"cell_type": "markdown",
|
|
137
|
+
"metadata": {},
|
|
138
|
+
"source": "## 4. Event Head Detection\n\nPhaseNet-DAS+ (Zhu et al. 2025a) includes an **event detection head** that directly predicts where earthquakes occur in the spatiotemporal domain. This provides an alternative to purely pick-based association.\n\nThe event head outputs a probability map of event centers — peaks in this map indicate likely earthquake locations. These can be used to:\n1. Provide initial event locations for phase association\n2. Detect events that have too few picks for reliable P-S pairing\n3. Improve association in windows with overlapping events"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"cell_type": "code",
|
|
142
|
+
"execution_count": null,
|
|
143
|
+
"metadata": {},
|
|
144
|
+
"outputs": [],
|
|
145
|
+
"source": [
|
|
146
|
+
"# Let's compare: single-event window vs multi-event window\n",
|
|
147
|
+
"# Strong single event\n",
|
|
148
|
+
"SINGLE_EVENT = \"20230106T122215Z\"\n",
|
|
149
|
+
"single_path = hf_hub_download(\n",
|
|
150
|
+
" HF_REPO, f\"arcata/phasenet_das/picks/{SINGLE_EVENT}.parquet\",\n",
|
|
151
|
+
" repo_type=\"dataset\", local_dir=\"data/quakeflow_das\",\n",
|
|
152
|
+
")\n",
|
|
153
|
+
"df_single = pd.read_parquet(single_path)\n",
|
|
154
|
+
"\n",
|
|
155
|
+
"n_origins_single = df_single[df_single[\"origin_id\"] >= 0][\"origin_id\"].nunique()\n",
|
|
156
|
+
"n_origins_multi = df[df[\"origin_id\"] >= 0][\"origin_id\"].nunique()\n",
|
|
157
|
+
"\n",
|
|
158
|
+
"print(f\"Single-event window ({SINGLE_EVENT}): {n_origins_single} origin(s), \"\n",
|
|
159
|
+
" f\"{(df_single['origin_id'] >= 0).sum()} associated picks\")\n",
|
|
160
|
+
"print(f\"Multi-event window ({EVENT_ID}): {n_origins_multi} origin(s), \"\n",
|
|
161
|
+
" f\"{(df['origin_id'] >= 0).sum()} associated picks\")"
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"cell_type": "markdown",
|
|
166
|
+
"metadata": {},
|
|
167
|
+
"source": "## 5. Tune Parameters\n\nThe association has several key parameters. Let's explore how `mph` (minimum peak height) and `origin_window`/`moveout_window` affect the results:"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"cell_type": "code",
|
|
171
|
+
"execution_count": null,
|
|
172
|
+
"metadata": {},
|
|
173
|
+
"outputs": [],
|
|
174
|
+
"source": "# Compare: mph and origin/moveout window\nconfigs = [\n {\"mph\": 100, \"origin_window\": 0.2, \"moveout_window\": 0.2},\n {\"mph\": 100, \"origin_window\": 0.5, \"moveout_window\": 0.5},\n {\"mph\": 200, \"origin_window\": 0.2, \"moveout_window\": 0.2},\n {\"mph\": 200, \"origin_window\": 0.5, \"moveout_window\": 0.5},\n]\n\nfig, axes = plt.subplots(2, 2, figsize=(18, 12))\n\nfor ax, cfg in zip(axes.flat, configs):\n assoc_test = associate_picks(\n pairs, nt=nt, dt_s=dt_s,\n bin_size=0.1, mph=cfg[\"mph\"], mpd=50,\n origin_window=cfg[\"origin_window\"],\n moveout_window=cfg[\"moveout_window\"],\n )\n n_events = len(assoc_test.event_times)\n n_assoc = len(assoc_test.pick_to_event)\n\n plot_das(ax, data, nx, nt, dt_s, cmap=\"seismic\", vmin=-vmax, vmax=vmax)\n\n colors_t = plt.cm.Set1(np.linspace(0, 1, max(n_events, 1)))\n for eid in range(n_events):\n event_picks = [(ch, idx, pt) for (ch, idx, pt), e in assoc_test.pick_to_event.items() if e == eid]\n if event_picks:\n chs = [int(ch) for ch, _, _ in event_picks]\n idxs = [idx * dt_s for _, idx, _ in event_picks]\n ax.scatter(idxs, chs, s=0.5, c=[colors_t[eid % len(colors_t)]], alpha=0.5)\n\n ax.set_title(f\"mph={cfg['mph']}, window={cfg['origin_window']}s: \"\n f\"{n_events} events, {n_assoc} picks\")\n ax.set_xlabel(\"Time (s)\")\n ax.set_ylabel(\"Channel\")\n\nplt.suptitle(\"Effect of mph and origin/moveout window\", fontsize=14)\nplt.tight_layout()\nplt.show()"
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"cell_type": "markdown",
|
|
178
|
+
"metadata": {},
|
|
179
|
+
"source": "## Summary\n\nThe association pipeline:\n1. **P-S pairing** — match P and S picks on the same channel within a time window\n2. **Origin estimation** — compute origin time from each pair using the Wadati relation\n3. **Histogram clustering** — bin origin time estimates, find peaks → one peak per event\n4. **Pick linking** — assign each pair to the nearest peak within `origin_window`\n5. **RANSAC moveout fitting** — fit the P-S midpoint moveout curve using RANSAC (Zhu et al. 2025b) to reject outlier pairs that don't follow a consistent moveout pattern. The `moveout_window` parameter controls the maximum RANSAC residual.\n\nKey parameters to tune:\n- `mph`: trade-off between detecting weak events vs false detections\n- `origin_window`: how tightly pairs must cluster around a detected origin time\n- `moveout_window`: RANSAC residual threshold for moveout consistency\n\nThis simple histogram-based approach works well for single DAS arrays. For seismic networks with multiple stations, more sophisticated methods are needed: GaMMA (Zhu et al. 2022) uses a Bayesian Gaussian mixture model, while VoRA (Song et al. 2026) uses Voronoi tessellation for scalable association. The origin time estimation from P-S pairs is the fundamental building block shared across all these methods.\n\n**Next:** [Lab 2c](lab2c_semisupervised_training.ipynb) shows how associated picks become training labels for iteratively improving the PhaseNet-DAS model."
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
"metadata": {
|
|
183
|
+
"kernelspec": {
|
|
184
|
+
"display_name": "Python 3",
|
|
185
|
+
"language": "python",
|
|
186
|
+
"name": "python3"
|
|
187
|
+
},
|
|
188
|
+
"language_info": {
|
|
189
|
+
"name": "python",
|
|
190
|
+
"version": "3.10.0"
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"nbformat": 4,
|
|
194
|
+
"nbformat_minor": 4
|
|
195
|
+
}
|