Reading picks, and adding a format¶
specmod.preprocess.with_picks returns a copy of a stream with P and S
arrivals attached:
import specmod.preprocess as pre
stream = pre.with_picks(stream, "event.xml")
The format is detected from the file, not from its name. This page covers what is read out of the box, what to do when your format is not, and how the matching between a pick and a trace works.
What is read out of the box¶
Two readers ship:
Reader |
Formats |
|---|---|
|
everything |
|
Snuffler / Pyrocko marker files |
Measured, by writing each format and reading it back: QuakeML, Nordic and
HypoDD carry their picks through intact. SC3ML/SCML does not — ObsPy
1.5.0 writes the <pick> elements and drops them on read, so SeisComP output
does not work today. NonLinLoc and the bulletins are expected to work but are
not yet confirmed against a real file.
Detection requires exactly one reader to claim a file. If none does, the error lists what was tried; if several do, that is a defect in their sniffing and the error says so. Either way you can force one:
stream = pre.with_picks(stream, "picks.txt", format="obspy_events")
If your format is an event file, register it with ObsPy¶
This is the route to prefer. ObsPy’s own plugin system takes a format
through entry points, and a format registered there is readable by
obspy.read_events — which means SpecMod reads it through the obspy_events
delegate with no SpecMod-side registration at all, and the same parser
serves every other ObsPy-based tool you run.
[project.entry-points."obspy.plugin.event"]
MYFORMAT = "my_package.io"
[project.entry-points."obspy.plugin.event.MYFORMAT"]
isFormat = "my_package.io:_is_myformat"
readFormat = "my_package.io:_read_myformat"
Nothing else is needed. Detection here asks ObsPy’s own isFormat functions,
so your format joins the roster the moment it is installed.
If it is not an event file¶
Picker output, associator output and in-house arrival tables are not catalogues and do not belong in ObsPy’s roster. Register those with SpecMod.
A delimited table¶
For one-row-per-arrival tables. You supply the column mapping, because there is no standard to assume:
import specmod.picks as pk
reader = pk.CSVPickReader(
columns={
"station": "station", # required
"phase": "phase_type", # required
"time": "arrival_time", # required
"network": "net",
"location": "loc",
"channel": "chan",
"weight": "probability",
},
reader_name="phasenet",
)
pk.register_reader(reader)
stream = pre.with_picks(stream, "picks.csv") # detected by its column headings
Four classes, differing only in how a line is split:
Class |
Separator |
Suffixes |
|---|---|---|
|
|
|
|
a tab |
|
|
runs of spaces or tabs |
|
|
whatever you pass as |
whatever you pass as |
DelimitedPickReader is the general case — the other three are it with the
delimiter and the plausible suffixes already set, so pk.TSVPickReader(columns=…)
is all a tab-separated table needs. Use the base directly for anything else:
pk.DelimitedPickReader(
columns={...}, reader_name="piped", delimiter="|", file_suffixes=(".dat",)
)
delimiter is one character, or None for whitespace. The whitespace path is
a separate parse — quoting has no meaning in a space-aligned table, and cells
cannot contain spaces.
One overlap to know about: whitespace-splitting subsumes tab-splitting, so
WhitespacePickReader claims a .tsv too. Registering both against the same
column names makes every tab-separated file ambiguous. Register one, or pass
format=.
Mappable fields are station, phase, time, network, location,
channel, weight, uncertainty, polarity and author. Phase values fold
to P or S on the first letter, with the original kept as raw_phase; rows
that are neither are skipped.
No presets ship for PhaseNet, EQTransformer or SeisBench. Each writes several column layouts across versions, and a preset written from documentation rather than from a real output file is a guess with a name on it. Write the four lines above against the file you actually have.
Mapping the location column matters more than it looks. A column that is
present but blank states an empty location code; a column you do not map at
all states nothing, and the pick will then match any location code at that
station — see matching, below.
Your own reader¶
Anything satisfying the PickReader protocol can be registered:
class MyReader:
name = "my_format"
suffixes = (".arr",)
def can_read(self, source) -> bool: ...
def read(self, source) -> list[PickSet]: ...
can_read must be cheap — read a header, not the file — and must not raise
on a missing, empty, truncated or binary file, or on any other format’s files.
read returns one PickSet per event in the file.
For a reader in an installed package, advertise it instead of calling
register_reader:
[project.entry-points."specmod.pick_readers"]
my_format = "my_package.picks:MyReader"
The entry point names a zero-argument callable returning the reader. Discovery happens on first use of the registry, so an installed plugin costs nothing to a caller who never reads a pick. A plugin that fails to import warns, naming its distribution, and is skipped; it cannot make the built-in formats unreadable, and it cannot take a built-in name.
tests/test_pick_readers.py::TestReaderContract is parameterised over every
registered reader — run it against yours.
How a pick reaches a trace¶
Picks are matched per sensor — NET.STA.LOC — not per channel. An arrival
is one sensor’s observation, and the component it happened to be picked on is
incidental: on a typical file every station has P on HHZ and S on HHN, so
matching on the full SEED id would leave the horizontals with no S at all.
Most formats supply less than a full identity — a bare station code is common. A pick matches a trace when every field it states agrees, so a station-only pick still reaches its sensor. Then:
one match — attached;
no match — the pick is unused, and counted in the report;
several matches — an error naming the candidates.
The last case is a station with two instruments, typically a surface and a borehole one, which differ only by location code and do not see the same arrival. Broadcasting a station-only pick to both is never silently correct, so you have to say what you want:
stream = pre.with_picks(stream, "picks.csv", on_ambiguous="broadcast") # co-located
stream = pre.with_picks(stream, "picks.csv", on_ambiguous="skip") # drop them
Several picks for one arrival¶
Reduced by a policy, rather than by whichever came last in the file:
prefer_reviewed (the default — an analyst’s pick over an automatic one, then
earliest), earliest, highest_weight, or error to refuse to choose.
Several events in one file¶
Bulletins hold many events, and merging their arrivals describes none of them. A multi-event source raises unless you select:
stream = pre.with_picks(stream, "bulletin.ims", event_id="smi:local/12345")
What was actually attached¶
report = []
stream = pre.with_picks(stream, "picks.csv", report=report)
print(report[0].summary())
# 28 attached to 14 sensors, 2 unused, 0 ambiguous, 0 resolved by policy
unused is the one to watch: a large count usually means the pick file and the
waveforms disagree about station naming.