PhaseNet 0.2.4__tar.gz → 0.2.6.dev0__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.
Files changed (68) hide show
  1. phasenet-0.2.6.dev0/PKG-INFO +375 -0
  2. phasenet-0.2.6.dev0/README.md +345 -0
  3. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/data/__init__.py +2 -2
  4. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/data/ceed.py +240 -197
  5. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/data/das.py +100 -327
  6. phasenet-0.2.6.dev0/phasenet/data/preprocess.py +207 -0
  7. phasenet-0.2.6.dev0/phasenet/data/transforms.py +831 -0
  8. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/phasenet.py +34 -0
  9. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/unet.py +14 -1
  10. phasenet-0.2.6.dev0/phasenet/normalize.py +195 -0
  11. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/utils/detect_peaks_cpu.py +0 -1
  12. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/utils/inference.py +69 -43
  13. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/utils/visualization.py +144 -33
  14. phasenet-0.2.6.dev0/phasenet.egg-info/PKG-INFO +375 -0
  15. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet.egg-info/SOURCES.txt +4 -22
  16. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/pyproject.toml +2 -2
  17. phasenet-0.2.6.dev0/tests/test_augmentations.py +105 -0
  18. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/tests/test_labels.py +79 -0
  19. phasenet-0.2.6.dev0/tests/test_normalize.py +245 -0
  20. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/train.py +118 -21
  21. phasenet-0.2.4/.github/workflows/publish.yml +0 -28
  22. phasenet-0.2.4/.gitignore +0 -15
  23. phasenet-0.2.4/PKG-INFO +0 -163
  24. phasenet-0.2.4/README.md +0 -133
  25. phasenet-0.2.4/docs/notebooks/lab2a_phasenet_das_inference.ipynb +0 -285
  26. phasenet-0.2.4/docs/notebooks/lab2b_pick_association.ipynb +0 -195
  27. phasenet-0.2.4/docs/notebooks/lab2c_semisupervised_training.ipynb +0 -487
  28. phasenet-0.2.4/examples/ceed_training_plots.py +0 -181
  29. phasenet-0.2.4/examples/italy/prepare_inventory.py +0 -233
  30. phasenet-0.2.4/examples/italy/prepare_mseed_list.py +0 -118
  31. phasenet-0.2.4/examples/italy/run_all_years.sh +0 -64
  32. phasenet-0.2.4/examples/italy/upload_loop.sh +0 -12
  33. phasenet-0.2.4/examples/italy/upload_phasenet_origin.sh +0 -47
  34. phasenet-0.2.4/phasenet/data/transforms.py +0 -776
  35. phasenet-0.2.4/phasenet.egg-info/PKG-INFO +0 -163
  36. phasenet-0.2.4/requirements.txt +0 -16
  37. phasenet-0.2.4/scripts/predict_ceed.py +0 -489
  38. phasenet-0.2.4/scripts/predict_ceed.sh +0 -41
  39. phasenet-0.2.4/scripts/predict_das.py +0 -607
  40. phasenet-0.2.4/scripts/predict_das.sh +0 -55
  41. phasenet-0.2.4/scripts/predict_mseed.py +0 -487
  42. phasenet-0.2.4/scripts/semisupervised_das.sh +0 -221
  43. phasenet-0.2.4/scripts/train_ceed.sh +0 -21
  44. phasenet-0.2.4/scripts/train_das.sh +0 -57
  45. phasenet-0.2.4/tests/benchmark_dataloader.py +0 -373
  46. phasenet-0.2.4/tests/test_augmentations.py +0 -234
  47. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/__init__.py +0 -0
  48. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/__init__.py +0 -0
  49. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/phasenet_das.py +0 -0
  50. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/phasenet_das_plus.py +0 -0
  51. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/phasenet_plus.py +0 -0
  52. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/phasenet_tf.py +0 -0
  53. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/phasenet_tf_plus.py +0 -0
  54. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/prompt/__init__.py +0 -0
  55. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/prompt/common.py +0 -0
  56. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/prompt/mask_decoder.py +0 -0
  57. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/prompt/prompt_encoder.py +0 -0
  58. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/prompt/transformer.py +0 -0
  59. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/models/unet2018.py +0 -0
  60. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/utils/__init__.py +0 -0
  61. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet/utils/postprocess.py +0 -0
  62. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet.egg-info/dependency_links.txt +0 -0
  63. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet.egg-info/entry_points.txt +0 -0
  64. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet.egg-info/requires.txt +0 -0
  65. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/phasenet.egg-info/top_level.txt +0 -0
  66. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/predict.py +0 -0
  67. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/setup.cfg +0 -0
  68. {phasenet-0.2.4 → phasenet-0.2.6.dev0}/utils.py +0 -0
@@ -0,0 +1,375 @@
1
+ Metadata-Version: 2.4
2
+ Name: phasenet
3
+ Version: 0.2.6.dev0
4
+ Summary: A PyTorch implementation of PhaseNet for seismic and DAS phase picking
5
+ Author: Weiqiang Zhu
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AI4EPS/phasenet-pytorch
8
+ Project-URL: Repository, https://github.com/AI4EPS/phasenet-pytorch
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Scientific/Engineering
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: torch
15
+ Requires-Dist: torchvision
16
+ Requires-Dist: einops
17
+ Requires-Dist: numpy
18
+ Requires-Dist: scipy
19
+ Requires-Dist: h5py
20
+ Requires-Dist: matplotlib
21
+ Requires-Dist: pandas
22
+ Requires-Dist: tqdm
23
+ Requires-Dist: fsspec
24
+ Requires-Dist: obspy
25
+ Requires-Dist: gcsfs
26
+ Requires-Dist: datasets
27
+ Requires-Dist: pyarrow
28
+ Requires-Dist: wandb
29
+ Requires-Dist: scikit-learn
30
+
31
+ # PhaseNet-PyTorch
32
+
33
+ PyTorch implementation of PhaseNet for seismic and DAS phase picking, event detection, and polarity classification.
34
+ Python 3.10 or later.
35
+
36
+ ## Models
37
+
38
+ | Model | Features | Data Type |
39
+ |-------|----------|-----------|
40
+ | `phasenet` | Phase (P/S) picking | Seismic 3-component |
41
+ | `phasenet_tf` | Phase picking + STFT spectrogram | Seismic 3-component |
42
+ | `phasenet_plus` | Phase + polarity + event detection | Seismic 3-component |
43
+ | `phasenet_tf_plus` | Phase + polarity + event detection + STFT | Seismic 3-component |
44
+ | `phasenet_das` | Phase picking | DAS single-channel |
45
+ | `phasenet_das_plus` | Phase + event detection | DAS single-channel |
46
+
47
+ The `_tf` variants add a Short-Time Fourier Transform (STFT) branch that extracts frequency features alongside the temporal waveform, improving performance on noisy data.
48
+
49
+ The original PhaseNet-2018 weights, converted from TensorFlow, load through `load_phasenet2018_model` (and as `--model phasenet` in `predict_das.py`); every other model is trained here with `train.py`.
50
+
51
+ ## Prediction
52
+
53
+ ### Continuous mseed (seismic network)
54
+
55
+ ```bash
56
+ # PhaseNet-2018 weights (the default), run as TF PhaseNet ran them: a 1 Hz highpass, accelerometers picked on velocity
57
+ python scripts/predict_mseed.py --data-list mseed_list.txt --inventory stations.xml --result-dir results/picks \
58
+ --highpass 1.0 --integrate-acceleration
59
+
60
+ # a retrained model: its checkpoint brings its contract (normalization, windows); filtering is your choice
61
+ python scripts/predict_mseed.py --data-list mseed_list.txt --inventory stations.xml --result-dir results/picks \
62
+ --checkpoint output/train_ceed/checkpoint.pth
63
+ ```
64
+
65
+ `--data-list` has one station-instrument per line, its component files separated by `|`. `--inventory` is one
66
+ StationXML file for every station or a directory, whose `*.xml` files (flat or nested) are matched to each
67
+ station by `NET.STA`, then `STA`, then `NET_network`; it serves the `phase_amplitude` column and the channel
68
+ orientation, picking runs on counts, and a station without a match is still picked from its channel names. `--sampling-rate`
69
+ (100) is the working rate: the input is resampled to it and pick indices become times with it. `--workers`
70
+ (8) sets the reader processes; the reader, not the model, is the bottleneck.
71
+
72
+ Output: one CSV per station-instrument with `station_id, begin_time, phase_index, phase_time, phase_score, phase_type[, phase_amplitude]`.
73
+ This is the picker the QuakeFlow pipeline runs (`scripts/run_phasenet_pytorch.py` there).
74
+
75
+ ### CEED (Seismic) Prediction
76
+
77
+ ```bash
78
+ # Demo: process a few events with plots
79
+ python scripts/predict_ceed.py --n-events 5
80
+
81
+ # Process all days for a year (saves parquets to results/ceed/)
82
+ python scripts/predict_ceed.py --all --year 2025 --output-dir results/ceed
83
+ ```
84
+
85
+ Output: one parquet per day file in `results/ceed/{region}/`, with columns:
86
+ `event_id, station_id, waveform_index, origin_id, origin_index, origin_time, phase_index, phase_time, phase_score, phase_type, phase_polarity`
87
+
88
+ ### DAS Prediction
89
+
90
+ ```bash
91
+ # Predict with base PhaseNet on a HuggingFace subset
92
+ python scripts/predict_das.py --subset arcata --plot
93
+
94
+ # Predict with a trained DAS model on local data
95
+ python scripts/predict_das.py \
96
+ --data-dir data/quakeflow_das/arcata/data \
97
+ --model-type phasenet_das_plus \
98
+ --checkpoint output/train_das_arcata/checkpoint.pth \
99
+ --output-dir results/das/train_das_arcata/arcata \
100
+ --no-ema --plot
101
+
102
+ # Predict from a file list
103
+ python scripts/predict_das.py \
104
+ --file-list file_list.txt \
105
+ --model-type phasenet_das_plus \
106
+ --checkpoint output/model.pth \
107
+ --plot
108
+
109
+ # Multi-GPU prediction
110
+ bash scripts/predict_das.sh phasenet_das_plus arcata 8 output/model.pth
111
+ ```
112
+
113
+ Output: one parquet per event in `results/das/{model_name}/{subset}/`, with columns:
114
+ `event_id, channel_index, origin_id, origin_index, origin_time, phase_index, phase_time, phase_score, phase_type, dt_s, ps_center, ps_interval`
115
+
116
+ By default, picks are associated using P-S pairing. Use `--use-event-head` to associate via the model's event detection head instead.
117
+
118
+ ## Training
119
+
120
+ ### CEED (Seismic) Training
121
+
122
+ ```bash
123
+ python train.py \
124
+ --model phasenet_plus \
125
+ --dataset-type ceed \
126
+ --label-path results/ceed \
127
+ --nx 16 \
128
+ --max-iters 100000 \
129
+ --batch-size 8 \
130
+ --workers 4 \
131
+ --lr 3e-4 \
132
+ --eval-interval 5000 \
133
+ --output-dir output/train_ceed
134
+ ```
135
+
136
+ The input contract is set here and saved in the checkpoint: `--norm-kind trailing --norm-window 1024 --norm-scope station --norm-stat std` are the defaults (see Design). Filtering is an augmentation, not a setting.
137
+
138
+ ### DAS Training
139
+
140
+ ```bash
141
+ # Using the training script
142
+ bash scripts/train_das.sh 0 arcata v26
143
+
144
+ # Or directly
145
+ python train.py \
146
+ --model phasenet_das_plus \
147
+ --dataset-type das \
148
+ --data-path data/quakeflow_das/arcata/data \
149
+ --label-path results/das/phasenet/arcata/picks \
150
+ --label-list results/das/phasenet/arcata/labels.txt \
151
+ --nx 2048 --nt 4096 \
152
+ --num-patch 16 \
153
+ --max-iters 50000 \
154
+ --batch-size 2 --workers 8 \
155
+ --lr 1e-4 --weight-decay 0.01 \
156
+ --model-ema --model-ema-decay 0.999 \
157
+ --eval-interval 1000 --save-interval 1000 \
158
+ --output-dir output/train_das_arcata_v26
159
+ ```
160
+
161
+ ### Key Training Options
162
+
163
+ | Option | Description | Default |
164
+ |--------|-------------|---------|
165
+ | `--norm-kind` | where the statistics come from: `window` (the whole input), `trailing` (before each sample), `centred` (around it) | trailing |
166
+ | `--norm-window N` | normalization window, samples (`trailing`, `centred`) | 1024 |
167
+ | `--norm-scope` | what shares one scale: `station`, `component`, `global` | station |
168
+ | `--norm-stat` | `std` or `l1` | std |
169
+ | `--num-patch N` | Random crops per DAS sample (amortizes IO) | 2 |
170
+ | `--model-ema` | Enable exponential moving average | off |
171
+ | `--gradient-accumulation-steps N` | Accumulate gradients for larger effective batch | 1 |
172
+ | `--clip-grad-norm V` | Gradient clipping | 1.0 |
173
+ | `--compile` | Enable torch.compile | off |
174
+ | `--resume --checkpoint PATH` | Resume from checkpoint | - |
175
+ | `--reset-lr` | Reset LR schedule when resuming | off |
176
+
177
+ ## Semi-supervised Training
178
+
179
+ Iterative self-training pipeline for DAS: predict → train on predictions → predict with new model → repeat.
180
+
181
+ ```bash
182
+ # Start from PhaseNet (train DAS model from scratch)
183
+ bash scripts/semisupervised_das.sh arcata 5 0
184
+
185
+ # Start from a pretrained DAS model
186
+ bash scripts/semisupervised_das.sh arcata 5 0 phasenet_das output/train_das_v26/checkpoint.pth
187
+ ```
188
+
189
+ Arguments: `[subset] [num_iterations] [gpu] [start_from] [checkpoint]`
190
+
191
+ - **From phasenet**: iteration 0 predicts with PhaseNet, iteration 1 trains from scratch (10k steps, warmup), iterations 2+ continue (1k steps, no warmup)
192
+ - **From phasenet_das**: iteration 0 predicts with pretrained DAS model, iterations 1+ continue (1k steps, no warmup)
193
+
194
+ Results are saved to `results/semisupervised_das/` and checkpoints to `output/semisupervised_das_v{N}/`.
195
+
196
+ ## Design: one input contract per model
197
+
198
+ Three models share one code path; what differs between them is written down in each model's input
199
+ contract, not in the scripts that run them.
200
+
201
+ | | PhaseNet-2018 (TF port) | PhaseNet (retrain) | PhaseNet-DAS (retrain) |
202
+ |---|---|---|---|
203
+ | weights | converted TF checkpoint, no config of its own | `train.py` | `train.py` |
204
+ | backbone | `unet2018`, convolutions only | `unet2018` or `unet` | `unet` (attention over time and space) |
205
+ | contract | pinned in code, `PHASENET_2018_INPUT` | `model_config["input"]`, written by `train.py` | same |
206
+ | filtering | none of its own; the QuakeFlow pipeline applies a 1 Hz highpass and picks accelerometers on velocity, as TF did | trained with a random highpass (augmentation); at inference the caller's `--highpass` | trained with random highpass and common-mode removal (augmentations); at inference common-mode removal is on (`--no-common-mode`), the highpass off (`--highpass`) |
207
+ | normalization | TF `normalize_long` (`tf_interp`, 3000 samples) | trailing 1024, per station, std | trailing 1024, per channel (a channel is a one-component station) |
208
+ | inference cut | whole trace | whole trace for `unet2018`, else windows of `--nt`, half overlap, overlap-discard | windows of `--nt` x `--nx`, half overlap, overlap-discard |
209
+ | trained on | TF, 30 s windows | CEED event windows (`--dataset-type ceed`) | DAS HDF5 patches (`--dataset-type das`) |
210
+ | run on | continuous mseed (`scripts/predict_mseed.py`) | continuous mseed or CEED days (`scripts/predict_ceed.py`) | DAS HDF5 events (`scripts/predict_das.py`) |
211
+
212
+ ### The contract
213
+
214
+ `InputSpec` (`phasenet/data/preprocess.py`) is saved in a checkpoint's `model_config["input"]` and
215
+ rebuilt by every loader; a checkpoint without one is refused. Its fields:
216
+
217
+ - `normalize`: a `NormalizeSpec` (`phasenet/normalize.py`): `kind` (`window`: the classic per-window
218
+ statistics; `trailing`: the samples before each sample, causal, the default for the retrains;
219
+ `centred`: the samples around each sample; `tf_interp`: the 2018 port's TF `normalize_long`),
220
+ `scope` (`station`: one scale shared by a station's components, which keeps their relative
221
+ amplitudes; `component`; `global`), `stat` (`std` or `l1`), `window` in samples, `compress`
222
+ (`None` or `log1p`).
223
+ - `window_nt`: the longest input, in samples, the backbone takes at once. `None` means any length,
224
+ which is true of a convolution-only backbone, so inference runs on the whole trace.
225
+ - `window_nx`: stations per patch, `None` for all at once. A model that couples channels (DAS) sets
226
+ it and gets a taper along that axis.
227
+
228
+ The model knows samples, not a rate. The caller owns the working rate (`predict_mseed.py
229
+ --sampling-rate`, 100 Hz by default): it resamples the input to it, applies any Hz corner at it, and
230
+ turns pick indices into times with it. The same weights therefore apply to a network at 100 Hz or a
231
+ borehole at 500 Hz. Filtering is not in the contract: training applies a highpass (and, for DAS,
232
+ common-mode removal) at random as an augmentation, so the model works with or without it and the
233
+ choice belongs to whoever runs it.
234
+
235
+ ### Preprocessing, shared with the dataset
236
+
237
+ Three functions live in `phasenet/data/preprocess.py` with identical copies in
238
+ `QuakeFlow/datasets/common.py`, asserted identical by `datasets/tests/test_preprocess.py`, so the
239
+ training data and the picker's input are prepared by the same code, in the same order: merge,
240
+ resample, orient. `merge_stream` joins a station's
241
+ fragments: overlaps resolved, every contiguous piece demeaned on its own, gaps filled with zeros that
242
+ continue each piece at its own level. `orient_from_inventory` recovers Z-up, N and E from
243
+ each channel's inventory azimuth and dip, whatever the channel is named, passing the usual E/N/Z
244
+ stations through untouched and recording `z_sign` where a vertical had to be negated; the picker
245
+ applies it when it has an inventory. `resample_trace` brings a trace to the working rate: polyphase
246
+ interpolation upward (an FIR lowpass at the old Nyquist, flat over the recorded band and free of edge
247
+ artifacts; obspy's FFT resampling puts a spike on the first sample, and its default Hann window is a
248
+ lowpass that removed 10-20 Hz from 40 Hz stations), a causal 4-pole Butterworth at 0.4 x the target
249
+ rate then decimation downward (causal, so nothing precedes an onset). On the demo the polyphase and
250
+ the FFT kernel give the same analyst-pick recall on every instrument class and differ in 0.06 % of the
251
+ picks. `QuakeFlow/datasets/common.py` holds an identical copy and
252
+ `datasets/tests/test_preprocess.py` asserts the two agree sample by sample.
253
+
254
+ `highpass` is the detrend, when one is wanted: TF PhaseNet's spline detrend gave the same picks at six
255
+ times the cost and follows the long-period motion of large earthquakes; the highpass is causal and
256
+ streams. `remove_common_mode` subtracts the median over DAS channels. Both are augmentations in
257
+ training (`RandomHighpass`, 0.5-2 Hz half the time; `RandomCommonMode`, half the time) and options
258
+ at inference: for DAS the common-mode removal is on by default (cheap), the highpass off (costly).
259
+
260
+ ### What miniSEED data does, and what happens to it
261
+
262
+ A station-day is rarely three clean channels of one continuous record. Every case below is handled by
263
+ the same code on both sides (`merge_stream`, `orient_from_inventory`, `resample_trace`, the
264
+ normalization), so a training record and a picker input that came from the same kind of file look the
265
+ same to the model. The picker reports a station-day it cannot read as an error and moves on; the
266
+ dataset skips the station.
267
+
268
+ | the data | what is done | where |
269
+ |---|---|---|
270
+ | Overlapping fragments (re-sent or duplicated records) | obspy merge method 1: the later fragment wins, sample for sample, no blending. Duplicates agree exactly and leave no mark. | `merge_stream` |
271
+ | Timing tears (gaps up to 0.1 s between records, or at the join of two day files whose sample grids differ by a fraction of a sample) | bridged by linear interpolation: a tear is not missing data, and a zero there would be a spike | `merge_stream` |
272
+ | Gaps longer than 0.1 s (outages) | every contiguous piece is demeaned on its own, then the gap is filled with zeros, which continue each piece at its own level. The zeros are data from then on: the normalization does not infer gaps from values, a window straddling a gap is scaled by a std the zeros lower, and a filter applied afterwards rings a little into the gap. Nothing is tapered: real samples are never altered. | `merge_stream`; the normalization |
273
+ | A fragment with a wrong timestamp (months away) | fragments farther than two days from the station-day's median start are dropped before the merge | picker |
274
+ | Fragments of one channel at different sampling rates | obspy cannot merge them: the station-day fails with an error (picker) or the station is skipped (dataset) | `merge_stream` |
275
+ | A channel recorded below or above the working rate (BH at 40 Hz, borehole at 500 Hz) | resampled to the working rate, the same kernel on both sides | `resample_trace` |
276
+ | A missing channel (one or two of three) | the present ones are placed by name, the missing component is zeros. A component without variation has scale 0, read as 1, so it stays zero; with `station` scope the live components come out sqrt(3/live) larger, the 2018 model's `tf_interp` applies TF's 3/live gain | picker, dataset cut; the normalization |
277
+ | A dead channel (present, flat) | the same as missing: the dataset leaves a flat component zero and unlisted, the normalization gives it scale 0 | dataset cut; the normalization |
278
+ | Channels named 1/2/3 or X/Y/Z, or mislabeled E/N/Z (Z with dip +90, horizontals swapped or rotated) | with an inventory, Z-up, N and E are recovered from each channel's azimuth and dip whatever its name; `z_sign` records a negated vertical so polarity labels follow. Without an inventory the picker places channels by a name table | `orient_from_inventory`; picker |
279
+ | Nearly parallel channel vectors (a metadata error: two channels with the same azimuth) | no rotation can separate them, so the group is kept as named instead of averaging or amplifying the channels | `orient_from_inventory` |
280
+ | A single channel | at most one component is recovered (Z, or a horizontal on its axis); an oblique single horizontal gives nothing | `orient_from_inventory` |
281
+ | Several location codes or bands for one station | one station-day per location and instrument in the picker's file list; the dataset keeps the catalog's location and one band per record | picker, dataset cut |
282
+ | A record shorter than the model's window, or not a multiple of 16 samples | zero padding at the end, the zeros being data as above | `RandomCrop`, `predict_2d` |
283
+
284
+ Consistency is asserted, not assumed: `datasets/tests/test_preprocess.py` requires the three
285
+ functions to have identical source in both copies and `resample_trace` to give identical samples.
286
+
287
+ ### Normalization
288
+
289
+ Training normalizes exactly as inference does, so the two cannot differ. For the local kinds
290
+ (`trailing`, `centred`) inference normalizes the whole record and cuts it afterwards; training
291
+ normalizes the whole loaded waveform and the random crop is a view of the result. For the
292
+ `window` kind the statistics are defined on the window, so each inference patch is normalized on
293
+ its own and the training crop comes before the normalization. `tests/test_normalize.py` asserts
294
+ that a training crop equals what `predict_2d` computes, for every kind.
295
+
296
+ `trailing`, the default for the retrains, scales every sample by the statistics of the `window`
297
+ samples ending at it (the first window's for the first window). It is causal by design: a centred
298
+ window lets a large event inflate the scale for half a window *before* it happens, so a small
299
+ event shortly before a larger one loses its amplitude; with a trailing window it keeps it
300
+ (measured on the Ridgecrest demo: 90 % of its isolated amplitude against 28 % under the centred TF
301
+ window). The statistics come from cumulative sums over every sample; a zero is a sample like any
302
+ other, and missing data is recognised by its variation, not its value: a window, station or record
303
+ with none has scale 0, read as 1, so a dead component comes out as zeros. The same `Normalizer` runs on a training waveform, a
304
+ whole day, or a stream with carried state. The model never normalizes in `forward`: the data
305
+ transforms and `predict_2d` call `model.normalizer`.
306
+
307
+ In the training pipelines, augmentations that shape amplitude or spectrum (highpass, bandpass,
308
+ common-mode removal, event stacking) run before the normalization; calibrated noise, flips,
309
+ masking and the median filter run after it, on normalized data, which is what the model meets at
310
+ inference.
311
+
312
+ The 2018 port keeps TF's `normalize_long` as the `tf_interp` branch: a centred window measured
313
+ every `window // 2` samples and interpolated, with TF's 3/live gain for stations missing
314
+ components. Those weights were validated against TF with exactly this; an exact centred window is
315
+ not equivalent (6.8 % of the demo picks change), so it is not a shortcut.
316
+
317
+ ### Inference
318
+
319
+ `predict_2d(model, waveform, device)` is the one entry point. It normalizes the whole `(nch, nx,
320
+ nt)` input, then cuts by the contract: the whole trace as one window when `window_nt` is `None`
321
+ (patches only add seams, and the trace is padded to a multiple of 16 rather than the U-Net's
322
+ full 1024, since a wall of zeros inside the receptive field erases picks at the day boundary),
323
+ otherwise windows of `window_nt` overlapping by half, each sample taken from the window whose centre
324
+ is nearest (overlap-discard, so nothing is predicted within a quarter window of an edge), and along
325
+ stations `window_nx` patches assigned the same way when set. The three predict scripts differ only in how
326
+ they read data.
327
+
328
+ ### Training
329
+
330
+ `train.py` builds the contract from `--norm-kind`, `--norm-window`, `--norm-scope`, `--norm-stat`,
331
+ `--norm-compress`, `--nt` and `--nx`, gives it to the data transforms and writes it into the
332
+ checkpoint. The default transforms run, in order: `RandomHighpass` (and `RandomCommonMode` for DAS),
333
+ a whole-sample `Normalize` that only equalizes samples for the augmentations,
334
+ event stacking, `InputNormalize` (before `RandomCrop` for the local kinds, after it for `window`),
335
+ then noise, flips and masking.
336
+
337
+ ### Verification
338
+
339
+ Ten percent of CEED training samples are event-free: the 40.96 s of real noise before each record's
340
+ first P, through the same pipeline. `python tests/test_normalize.py` pins the contract: the `tf_interp` branch against a numpy transcription
341
+ of TF's `normalize_long`, the window, trailing and centred kinds against a sample-by-sample brute
342
+ force for every scope and statistic, the training pipelines against `predict_2d`, streamed chunks
343
+ against the whole trace, gaps and dead components, and the spec's round trip through `model_config`. On the
344
+ QuakeFlow Ridgecrest demo (45 station-days) the port reproduces TF PhaseNet: identical picks on the
345
+ 100 Hz stations, analyst-pick recall 0.861 for both, the 40 Hz stations differing only by the
346
+ resampling kernel. `scripts/plot_normalization.py` shows what each candidate normalization does to
347
+ a station-day.
348
+
349
+ ### Open issues
350
+
351
+ - **Station grouping.** With `--nx` above 1 the CEED loader builds one sample per event from its `nx`
352
+ nearest stations, and the `unet` backbone attends across them. `scripts/predict_mseed.py` picks one
353
+ station-day at a time, so it hands such a model a single station, a situation it was never trained on
354
+ and in which its cross-station attention has nothing to attend to. `scripts/predict_ceed.py` builds the
355
+ same event arrays as training and is not affected. Until resolved: train the model that picks
356
+ continuous data with `--nx 1`, and `predict_mseed.py` should refuse a checkpoint whose `window_nx`
357
+ is greater than 1 instead of feeding it one station. The full fix is a multi-station reader that
358
+ assembles concurrent station-days into a `(3, nx, nt)` array with a spatial station order used in
359
+ training as well (the training sort by distance to the event does not exist at inference), plus
360
+ chunked inference along time to bound memory.
361
+ - **Crop edges.** Training crops are 4,096 samples while the `unet2018` receptive field is 3,331, so
362
+ most training samples sit within half a receptive field of a zero-padded crop edge, which a whole
363
+ day never shows the model. Longer crops (the record has 12,288 samples) or a loss mask near edges.
364
+ - **Trace-start rule in training.** A crop that overlaps the first 1,024 samples of its record is
365
+ scaled by the first window's statistics; inference meets that once a day. Start crops at or after
366
+ the normalization window.
367
+ - **DAS archives.** The training loader differentiates OptaSense continuous files to strain rate;
368
+ the event files used for training and prediction are stored as strain rate already. A model run on
369
+ continuous archives needs that step in the shared preprocessing.
370
+ - **Gap edges.** A gap is zeros after each piece's own demean (see the miniSEED table), and nothing
371
+ is done about picks at its edges. What the model does there is a training question: a gap
372
+ augmentation for CEED (DAS has `Masking`) and a pick filter near gap edges.
373
+ - **Shared preprocessing is a copy.** `QuakeFlow/datasets/common.py` holds identical copies of
374
+ `merge_stream`, `orient_from_inventory` and `resample_trace`, kept equal by a test. One source,
375
+ importable without torch (move `InputSpec` out of `preprocess.py`), would make the guarantee structural.