Skip to content

pysmo.tools.project

Declare station/event data to fetch on demand, without storing it on disk.

A PysmoProject holds a list of ProjectEntry objects — station + optional event + optional explicit window — plus a transform callable applied to each freshly downloaded Seismogram. This is the place for whatever data preparation a downstream tool needs — removing the instrument response, detrending, resampling — as well as converting the result into the target type that tool expects (e.g. MiniIccsSeismogram, for ICCS). Results are cached in memory for the life of the object; nothing is ever written to disk by PysmoProject itself. The PysmoProject instance is the reproducible, shareable artefact — pickled, not serialised to a bespoke config format, so transform and fetch_seismogram must be real top-level functions in an importable module rather than lambdas or closures (pickle serialises functions by reference, not by value). A callable attrs class with only picklable fields — see the example below — is the alternative once transform needs its own configuration (e.g. filter corner frequencies): it pickles by value, so it has no such restriction.

Pair PysmoProject with SqliteArchiveFetcher as its fetch_seismogram so a project's entries are only ever fetched once across however many sessions the project is used in (see the second example below) — a different mechanism from ProjectEntry.checksum, which only detects drift on the live-network default rather than avoiding it.

Basic example

This example builds a small project around a single, real station/event pair — IU.ANMO recording the 2010-02-27 Maule, Chile M8.8 earthquake, the same reference event used throughout this project's test suite and in fetch_travel_times's own Examples: block:

>>> import pandas as pd
>>> from attrs import define
>>> from pysmo import MiniEvent, MiniStation, Seismogram
>>> from pysmo.classes import StationXML
>>> from pysmo.functions import clone_to_mini
>>> from pysmo.tools.iccs import ICCS, MiniIccsSeismogram
>>> from pysmo.tools.project import FetchContext, ProjectEntry, PysmoProject
>>> from pysmo.tools.signal import remove_response
>>>
>>> station_anmo = MiniStation(
...     name="ANMO", network="IU", location="00", channel="LHZ",
...     latitude=34.945981, longitude=-106.457133,
... )
>>> event_maule = MiniEvent(
...     latitude=-36.122, longitude=-72.898, depth=22900.0,
...     time=pd.Timestamp("2010-02-27T06:34:11.53Z"),
... )
>>>

This transform removes the instrument response — data preparation ICCS itself assumes has already happened, per its own bandpass_apply docstring — then converts the result into a MiniIccsSeismogram, using the predicted arrival on context as the initial pick (see FetchContext). It's a callable attrs class rather than a plain function specifically so pre_filt is configurable per instance:

>>> @define(kw_only=True)
... class ToMiniIccsSeismogramWithResponseRemoved:
...     pre_filt: tuple[float, float, float, float]
...
...     def __call__(
...         self, seismogram: Seismogram, context: FetchContext
...     ) -> MiniIccsSeismogram:
...         response = StationXML.fetch(
...             station=context.entry.station, time=context.starttime
...         )
...         corrected = remove_response(
...             seismogram, response, pre_filt=self.pre_filt, clone=True
...         )
...         return clone_to_mini(
...             MiniIccsSeismogram, corrected, update={"t0": context.predicted}
...         )
...
>>> to_mini_iccs_seismogram = ToMiniIccsSeismogramWithResponseRemoved(
...     # Teleseismic P on IU.ANMO's LHZ (1 Hz) channel: comfortably above
...     # the instrument's own corner and below the 0.5 Hz Nyquist.
...     pre_filt=(0.01, 0.02, 0.2, 0.3),
... )
>>> project: PysmoProject[MiniIccsSeismogram] = PysmoProject(
...     entries=[ProjectEntry(station=station_anmo, event=event_maule)],
...     transform=to_mini_iccs_seismogram,
... )
>>>

Discovery methods only inspect entries — no network access needed:

>>> len(project.stations)
1
>>> project.events_for(station_anmo)
[MiniEvent(time=Timestamp('2010-02-27 06:34:11.530000+0000', tz='UTC'), latitude=-36.122, longitude=-72.898, depth=22900.0)]
>>>

Fetching a seismogram uses PysmoProject's default fetch_seismogram for the waveform, and transform's own fetch for the instrument response — both download real data from EarthScope's FDSN web services:

>>> one = project.seismogram(station_anmo, event_maule)
>>> isinstance(one, MiniIccsSeismogram)
True
>>> iccs = ICCS(seismograms=project.seismograms_for(event_maule))
>>> len(iccs.seismograms)
1
>>>

Caching downloads

Pairing fetch_seismogram with SqliteArchiveFetcher means a station/window already fetched once is read back locally on a later run, rather than re-fetched — recommended for real analysis work, over the always-fresh default used above.

This only pins the waveform, though. to_mini_iccs_seismogram (reused below unchanged) still fetches StationXML itself on every call, cached or not — an archive-backed fetch_seismogram says nothing about whatever transform independently fetches:

>>> from pysmo.classes import SAC
>>> from pysmo.tools.archive import SqliteArchiveFetcher
>>> from pysmo.tools.web import fetch_sac
>>>
>>> def parse_sac_zip(raw: bytes) -> Seismogram:
...     return SAC.from_zip(raw).seismogram
...
>>> archive = SqliteArchiveFetcher(
...     path="project_cache.sqlite3", fetch_raw=fetch_sac, parse=parse_sac_zip
... )
>>> cached_project: PysmoProject[MiniIccsSeismogram] = PysmoProject(
...     entries=[ProjectEntry(station=station_anmo, event=event_maule)],
...     transform=to_mini_iccs_seismogram,
...     fetch_seismogram=archive,
... )
>>> one = cached_project.seismogram(station_anmo, event_maule)  # waveform miss: fetches, stores
>>> one_again = cached_project.seismogram(station_anmo, event_maule)  # waveform hit; response still fetched
>>> isinstance(one_again, MiniIccsSeismogram)
True
>>>

Classes:

Name Description
FetchContext

Context handed to transform alongside the freshly downloaded seismogram.

ProjectEntry

One station/event selection within a PysmoProject.

PysmoProject

Declares station/event data to fetch on demand and transform into T.

FetchContext

Context handed to transform alongside the freshly downloaded seismogram.

Bundles the originating ProjectEntry with what this specific fetch resolved but that doesn't belong on ProjectEntry itself — recomputed fresh on every fetch, never persisted (unlike entry.checksum, which is deliberately pinned).

Note the deliberate naming overlap with entry.starttime/entry.endtime: those are the entry's possibly-None explicit override (see ProjectEntry), while starttime/endtime here are always-populated and reflect the window that was actually used — identical to the entry's own when an explicit override was given, resolved from predicted otherwise. A transform wanting "the window this fetch actually covered" should read context.starttime/context.endtime, not context.entry.starttime/context.entry.endtime.

Attributes:

Name Type Description
endtime Timestamp

Absolute end of the window actually used for this fetch.

entry ProjectEntry

The entry this seismogram was fetched for.

predicted Timestamp | None

Predicted phase arrival used to derive the window, or None if

starttime Timestamp

Absolute start of the window actually used for this fetch.

Source code in src/pysmo/tools/project/_project.py
@define(kw_only=True, frozen=True)
class FetchContext:
    """Context handed to `transform` alongside the freshly downloaded seismogram.

    Bundles the originating [`ProjectEntry`][pysmo.tools.project.ProjectEntry]
    with what this specific fetch resolved but that doesn't belong on
    `ProjectEntry` itself — recomputed fresh on every fetch, never
    persisted (unlike `entry.checksum`, which is deliberately pinned).

    Note the deliberate naming overlap with `entry.starttime`/`entry.endtime`:
    those are the entry's possibly-`None` *explicit override* (see
    [`ProjectEntry`][pysmo.tools.project.ProjectEntry]), while
    `starttime`/`endtime` here are always-populated and reflect the window
    that was *actually used* — identical to the entry's own when an explicit
    override was given, resolved from `predicted` otherwise. A `transform`
    wanting "the window this fetch actually covered" should read
    `context.starttime`/`context.endtime`, not
    `context.entry.starttime`/`context.entry.endtime`.
    """

    entry: ProjectEntry
    """The entry this seismogram was fetched for."""

    starttime: pd.Timestamp
    """Absolute start of the window actually used for this fetch."""

    endtime: pd.Timestamp
    """Absolute end of the window actually used for this fetch."""

    predicted: pd.Timestamp | None
    """Predicted phase arrival used to derive the window, or `None` if
    `entry.starttime`/`entry.endtime` were used directly."""

endtime instance-attribute

endtime: Timestamp

Absolute end of the window actually used for this fetch.

entry instance-attribute

entry: ProjectEntry

The entry this seismogram was fetched for.

predicted instance-attribute

predicted: Timestamp | None

Predicted phase arrival used to derive the window, or None if entry.starttime/entry.endtime were used directly.

starttime instance-attribute

starttime: Timestamp

Absolute start of the window actually used for this fetch.

ProjectEntry

One station/event selection within a PysmoProject.

Pairs a station with either an event (for a phase-arrival-relative window, resolved at fetch time) or an explicit absolute time window (for event-less or continuous data), or both — an explicit window always takes precedence over one derived from event. See PysmoProject for how the window is actually resolved.

Attributes:

Name Type Description
checksum str | None

Checksum of the fetched seismogram, set on first fetch; None until then.

endtime Timestamp | None

Explicit end of the fetch window (UTC). Overrides event when set together with starttime.

event Event | None

Event used to derive a phase-arrival-relative window, if starttime/endtime are not set.

starttime Timestamp | None

Explicit start of the fetch window (UTC). Overrides event when set together with endtime.

station Station

Station to fetch waveform data for.

Source code in src/pysmo/tools/project/_entry.py
@define(kw_only=True)
class ProjectEntry:
    """One station/event selection within a [`PysmoProject`][pysmo.tools.project.PysmoProject].

    Pairs a station with either an event (for a phase-arrival-relative
    window, resolved at fetch time) or an explicit absolute time window
    (for event-less or continuous data), or both — an explicit window
    always takes precedence over one derived from `event`. See
    [`PysmoProject`][pysmo.tools.project.PysmoProject] for how the window is
    actually resolved.
    """

    station: Station
    """Station to fetch waveform data for."""

    event: Event | None = None
    """Event used to derive a phase-arrival-relative window, if `starttime`/`endtime` are not set."""

    starttime: pd.Timestamp | None = field(
        default=None,
        converter=converters.optional(convert_to_utc_timestamp),
        on_setattr=setters.convert,
    )
    """Explicit start of the fetch window (UTC). Overrides `event` when set together with `endtime`."""

    endtime: pd.Timestamp | None = field(
        default=None,
        converter=converters.optional(convert_to_utc_timestamp),
        on_setattr=setters.convert,
    )
    """Explicit end of the fetch window (UTC). Overrides `event` when set together with `starttime`."""

    checksum: str | None = field(default=None)
    """Checksum of the fetched seismogram, set on first fetch; `None` until then.

    A mismatch on a later fetch means the underlying archive data changed
    since this entry was first fetched — see
    [`PysmoProject.on_checksum_mismatch`][pysmo.tools.project.PysmoProject.on_checksum_mismatch]
    for how that is reported. Deliberately mutated by
    [`PysmoProject`][pysmo.tools.project.PysmoProject] as a side effect of
    fetching, so it is captured the next time the containing project is
    pickled — this is what makes it a durable reproducibility pin rather
    than a one-session-only check.

    Because this field is mutated in place, a `ProjectEntry` shared across
    two different `PysmoProject` instances (e.g. reused deliberately in an
    iterative workflow, or accidentally via a shared `entries` list) has its
    checksum set/checked by *whichever* project fetches it first — the
    entry doesn't belong to one project. Sharing entries across projects is
    fine; sharing them without being aware their checksum state is joint,
    not per-project, is the surprise to avoid.
    """

checksum class-attribute instance-attribute

checksum: str | None = field(default=None)

Checksum of the fetched seismogram, set on first fetch; None until then.

A mismatch on a later fetch means the underlying archive data changed since this entry was first fetched — see PysmoProject.on_checksum_mismatch for how that is reported. Deliberately mutated by PysmoProject as a side effect of fetching, so it is captured the next time the containing project is pickled — this is what makes it a durable reproducibility pin rather than a one-session-only check.

Because this field is mutated in place, a ProjectEntry shared across two different PysmoProject instances (e.g. reused deliberately in an iterative workflow, or accidentally via a shared entries list) has its checksum set/checked by whichever project fetches it first — the entry doesn't belong to one project. Sharing entries across projects is fine; sharing them without being aware their checksum state is joint, not per-project, is the surprise to avoid.

endtime class-attribute instance-attribute

endtime: Timestamp | None = field(
    default=None,
    converter=converters.optional(convert_to_utc_timestamp),
    on_setattr=setters.convert,
)

Explicit end of the fetch window (UTC). Overrides event when set together with starttime.

event class-attribute instance-attribute

event: Event | None = None

Event used to derive a phase-arrival-relative window, if starttime/endtime are not set.

starttime class-attribute instance-attribute

starttime: Timestamp | None = field(
    default=None,
    converter=converters.optional(convert_to_utc_timestamp),
    on_setattr=setters.convert,
)

Explicit start of the fetch window (UTC). Overrides event when set together with endtime.

station instance-attribute

station: Station

Station to fetch waveform data for.

PysmoProject

Declares station/event data to fetch on demand and transform into T.

A PysmoProject holds a flat list of ProjectEntry objects plus the parameters needed to resolve each entry's fetch window and the transform callable that turns a freshly downloaded Seismogram into the caller's target type T. No waveform data is stored on the instance between calls beyond an in-memory cache of already-fetched-and-transformed results.

See the module documentation for a worked example.

Thread-safety

The in-memory fetch cache is safe to touch from multiple threads calling seismogram, seismograms_for, or fetch_all on the same instance concurrently. This does not parallelise fetching itself, though: fetch_seismogram is called outside the lock, so two threads racing the same not-yet-cached entry both still fetch before one result wins and is cached.

Methods:

Name Description
__getstate__

Drop the fetch cache and lock, neither of which can survive pickling.

__setstate__

Restore state without triggering any on_setattr hooks, then create a fresh lock.

clear_cache

Clear the in-memory fetch cache.

events_for

Events available for one station, in first-seen order.

fetch_all

Fetch every entry in the project.

seismogram

Fetch (or return from cache) the result for one station/event combination.

seismograms_for

All seismograms for one event — e.g. ready for ICCS(seismograms=...).

stations_for

Stations available for one event, in first-seen order.

Attributes:

Name Type Description
entries list[ProjectEntry]

Station/event/window selections making up this project.

events list[Event]

Distinct events across all entries, excluding event-less entries.

fetch_seismogram Callable[[Station, Timestamp, Timestamp], Seismogram]

Downloads a seismogram for a station and absolute time window.

on_checksum_mismatch Literal['warn', 'raise', 'ignore']

Behaviour when a fetched seismogram's checksum no longer matches the

phase str

Seismic phase used to derive a window from an entry's event.

post_pick PositiveTimedelta

Offset from the predicted arrival to the window end. Must be positive.

pre_pick NonPositiveTimedelta

Offset from the predicted arrival to the window start. Must be zero or negative.

stations list[Station]

Distinct stations across all entries, in first-seen order.

transform Callable[[Seismogram, FetchContext], T]

Applied to a freshly downloaded seismogram; converts the result into the target type T.

travel_time_backend TravelTimeBackend | None

Optional override for travel-time calculation. See pysmo.tools.web.TravelTimeBackend.

Source code in src/pysmo/tools/project/_project.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
@define(kw_only=True)
class PysmoProject[T]:
    """Declares station/event data to fetch on demand and transform into `T`.

    A `PysmoProject` holds a flat list of
    [`ProjectEntry`][pysmo.tools.project.ProjectEntry] objects plus the
    parameters needed to resolve each entry's fetch window and the
    `transform` callable that turns a freshly downloaded
    [`Seismogram`][pysmo.Seismogram] into the caller's target type `T`. No
    waveform data is stored on the instance between calls beyond an
    in-memory cache of already-fetched-and-transformed results.

    See the [module documentation][pysmo.tools.project] for a worked
    example.

    Note: Thread-safety
        The in-memory fetch cache is safe to touch from multiple threads
        calling [`seismogram`][pysmo.tools.project.PysmoProject.seismogram],
        [`seismograms_for`][pysmo.tools.project.PysmoProject.seismograms_for],
        or [`fetch_all`][pysmo.tools.project.PysmoProject.fetch_all] on the
        same instance concurrently. This does not parallelise fetching
        itself, though: `fetch_seismogram` is called outside the lock, so
        two threads racing the same not-yet-cached entry both still fetch
        before one result wins and is cached.
    """

    entries: list[ProjectEntry] = field(
        factory=list, on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache)
    )
    """Station/event/window selections making up this project."""

    transform: Callable[[Seismogram, FetchContext], T] = field(
        on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache)
    )
    """Applied to a freshly downloaded seismogram; converts the result into the target type `T`.

    Called with the downloaded seismogram and a
    [`FetchContext`][pysmo.tools.project.FetchContext] carrying the
    originating entry and this fetch's resolved window/predicted arrival.
    This is also where ordinary data preparation belongs — detrending,
    resampling, removing the instrument response — not just the final
    conversion to `T`, since it is the one place every fetch already passes
    through. Free to do anything else it needs — including its own
    additional fetches (e.g. instrument response metadata via
    [`StationXML.fetch`][pysmo.classes.StationXML.fetch], demonstrated in
    the [module documentation][pysmo.tools.project]'s own example — this
    design doesn't fetch or know about response data itself, deliberately).
    Must be a top-level function in an importable module — not a lambda or
    closure — if the containing `PysmoProject` is to be pickled; a callable
    `attrs` class with only picklable fields (same example) is the
    alternative once `transform` needs its own configuration.
    """

    fetch_seismogram: Callable[[Station, pd.Timestamp, pd.Timestamp], Seismogram] = (
        field(
            default=_default_fetch_seismogram,
            on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache),
        )
    )
    """Downloads a seismogram for a station and absolute time window.

    Defaults to a private helper wrapping
    [`GeoCsvSeismogram.fetch`][pysmo.classes.GeoCsvSeismogram.fetch] — the
    explicit "always fresh, never cached" choice.

    For any project where reproducibility matters, substitute a
    [`SqliteArchiveFetcher`][pysmo.tools.archive.SqliteArchiveFetcher]
    instance instead — this is the *recommended* value for real analysis
    work, not a power-user option sitting alongside the default on equal
    footing; see its own docstring for why, and how it differs from
    `ProjectEntry.checksum`'s live-fetch drift detection. It only pins the
    waveform, though — see the [module documentation][pysmo.tools.project]'s
    second example for the gotcha this doesn't cover: `transform` making its
    own additional fetches. Any other
    callable of the right shape (e.g. one wrapping
    [`SAC.fetch`][pysmo.classes.SAC.fetch]) also works — this field changes
    the retrieval path without subclassing. Must be picklable by reference
    (a top-level function, or an attrs instance with only picklable
    fields — not a lambda or closure), same constraint as `transform`.
    """

    phase: str = field(
        default="P", on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache)
    )
    """Seismic phase used to derive a window from an entry's `event`."""

    pre_pick: NonPositiveTimedelta = field(
        default=pd.Timedelta(minutes=-2),
        converter=convert_to_timedelta,
        validator=[
            validators.instance_of(pd.Timedelta),
            validators.le(pd.Timedelta(0)),
        ],
        on_setattr=setters.pipe(
            setters.convert, setters.validate, _on_setattr_clear_cache
        ),
    )
    """Offset from the predicted arrival to the window start. Must be zero or negative."""

    post_pick: PositiveTimedelta = field(
        default=pd.Timedelta(minutes=8),
        converter=convert_to_timedelta,
        validator=[
            validators.instance_of(pd.Timedelta),
            validators.gt(pd.Timedelta(0)),
        ],
        on_setattr=setters.pipe(
            setters.convert, setters.validate, _on_setattr_clear_cache
        ),
    )
    """Offset from the predicted arrival to the window end. Must be positive."""

    travel_time_backend: TravelTimeBackend | None = field(
        default=None, on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache)
    )
    """Optional override for travel-time calculation. See [`pysmo.tools.web.TravelTimeBackend`][]."""

    on_checksum_mismatch: Literal["warn", "raise", "ignore"] = field(
        default="warn",
        validator=validators.in_(("warn", "raise", "ignore")),
    )
    """Behaviour when a fetched seismogram's checksum no longer matches the
    one recorded on `entry.checksum` from its first fetch.

    `"warn"` (default) emits a `UserWarning` and still returns the new data;
    `"raise"` raises `ValueError` instead of returning anything, for a
    pipeline that should hard-stop on detected drift; `"ignore"` returns the
    new data with no signal at all. In every case `entry.checksum` keeps the
    value from the *first* fetch — it is never overwritten by a mismatching
    value, so a mismatch is reported (or not) consistently on every
    subsequent fetch, not just the first time it's noticed.

    Deliberately not part of cache invalidation: changing this policy only
    affects how a *future* mismatch is handled, it doesn't change what data
    was fetched or would be re-fetched, so nothing about previously cached
    results becomes stale when it changes.
    """

    _cache: dict[_CacheKey, tuple[str, T]] = field(
        init=False, factory=dict, repr=False, eq=False
    )
    _lock: threading.Lock = field(
        init=False, factory=threading.Lock, repr=False, eq=False
    )
    """Guards reads/writes of `_cache` against concurrent access from more
    than one thread — see the class docstring's thread-safety note."""

    def __getstate__(self) -> dict:
        """Drop the fetch cache and lock, neither of which can survive pickling."""
        state = attrs_getstate(self, {"_cache": {}})
        del state["_lock"]
        return state

    def __setstate__(self, state: dict) -> None:
        """Restore state without triggering any `on_setattr` hooks, then create a fresh lock."""
        attrs_setstate(self, state)
        object.__setattr__(self, "_lock", threading.Lock())

    def clear_cache(self) -> None:
        """Clear the in-memory fetch cache.

        Cleared automatically whenever
        [`entries`][pysmo.tools.project.PysmoProject.entries],
        [`transform`][pysmo.tools.project.PysmoProject.transform],
        [`fetch_seismogram`][pysmo.tools.project.PysmoProject.fetch_seismogram],
        [`phase`][pysmo.tools.project.PysmoProject.phase],
        [`pre_pick`][pysmo.tools.project.PysmoProject.pre_pick],
        [`post_pick`][pysmo.tools.project.PysmoProject.post_pick], or
        [`travel_time_backend`][pysmo.tools.project.PysmoProject.travel_time_backend]
        is *reassigned*.

        Call this manually after any in-place mutation of
        [`entries`][pysmo.tools.project.PysmoProject.entries] (e.g. `append`,
        `remove`, or index assignment), which isn't observable by
        `on_setattr` and therefore doesn't clear the cache automatically.
        """
        self._cache.clear()

    def _resolve_window(
        self, entry: ProjectEntry
    ) -> tuple[pd.Timestamp, pd.Timestamp, pd.Timestamp | None]:
        """Resolve the absolute fetch window and predicted arrival for one entry.

        Returns:
            `(starttime, endtime, predicted_arrival)` — `predicted_arrival`
            is `None` when `entry.starttime`/`entry.endtime` were used
            directly rather than derived from `entry.event`.

        Raises:
            ValueError: If `entry` has neither a usable explicit window nor
                an event to derive one from, or if no `phase` arrival is
                predicted for this station/event geometry.
        """
        if entry.starttime is not None and entry.endtime is not None:
            return entry.starttime, entry.endtime, None
        if entry.event is not None:
            dist = haversine(entry.event, entry.station)
            tt = fetch_travel_times(
                entry.event.depth / 1000.0,
                dist,
                [self.phase],
                travel_time_backend=self.travel_time_backend,
            )
            if self.phase not in tt:
                raise ValueError(
                    f"No {self.phase!r} arrival predicted for "
                    f"{entry.station.network}.{entry.station.name} at this "
                    "distance/depth."
                )
            predicted = entry.event.time + pd.Timedelta(seconds=tt[self.phase])
            return predicted + self.pre_pick, predicted + self.post_pick, predicted
        raise ValueError("ProjectEntry needs either an explicit window or an event.")

    def _fetch(self, entry: ProjectEntry, *, _stacklevel: int = 3) -> T:
        """Fetch, transform, and cache the seismogram for one entry.

        Internal primitive — see
        [`seismogram`][pysmo.tools.project.PysmoProject.seismogram] for the
        public, station/event-based accessor built on top of this.

        Args:
            entry: The station/event/window selection to fetch.

        Returns:
            The transformed result for `entry`, from cache if this exact
            entry has been fetched before.

        Raises:
            ValueError: If `entry` has neither a usable window nor an event
                (via `_resolve_window`); if the underlying fetch raises
                (e.g. no waveform data for the resolved window); or if the
                checksum no longer matches and `on_checksum_mismatch="raise"`.
        """
        event_key: _EventKey = (
            (
                entry.event.latitude,
                entry.event.longitude,
                entry.event.depth,
                entry.event.time,
            )
            if entry.event is not None
            else None
        )
        key: _CacheKey = (
            entry.station.network,
            entry.station.name,
            entry.station.location,
            entry.station.channel,
            event_key,
            entry.starttime,
            entry.endtime,
        )
        with self._lock:
            cached = self._cache.get(key)
        if cached is None:
            # Deliberately outside the lock — see the class docstring's
            # thread-safety note: two threads racing the same not-yet-cached
            # key both fetch here (a stampede) before one result wins.
            starttime, endtime, predicted = self._resolve_window(entry)
            seismogram = self.fetch_seismogram(entry.station, starttime, endtime)
            checksum = _checksum(seismogram)
            context = FetchContext(
                entry=entry,
                starttime=starttime,
                endtime=endtime,
                predicted=predicted,
            )
            cached = (checksum, self.transform(seismogram, context))
            with self._lock:
                cached = self._cache.setdefault(key, cached)

        checksum, result = cached
        if entry.checksum is None:
            entry.checksum = checksum
        elif entry.checksum != checksum and self.on_checksum_mismatch != "ignore":
            message = (
                f"Fetched data for {entry.station.network}.{entry.station.name} "
                "no longer matches the checksum recorded when this entry was "
                "first fetched — the underlying archive may have been revised."
            )
            if self.on_checksum_mismatch == "raise":
                raise ValueError(message)
            # `_stacklevel` is threaded in from the public entry point
            # (`seismogram`/`fetch_all` pass the default; `seismograms_for`
            # passes one level deeper) so the warning always points at the
            # user's own call site, not an intermediate method.
            warnings.warn(message, stacklevel=_stacklevel)
        return result

    @property
    def stations(self) -> list[Station]:
        """Distinct stations across all entries, in first-seen order.

        A plain `@property`, not `@cached_property` — recomputed on each
        access, same as
        [`ICCS.cc_seismograms`][pysmo.tools.iccs.ICCS.cc_seismograms]'s
        precedent for a no-arg derived list view in this codebase. Compares
        with `==` (attrs-generated equality, not identity or hashing —
        `Station` is not hashable).
        """
        seen: list[Station] = []
        for entry in self.entries:
            if entry.station not in seen:
                seen.append(entry.station)
        return seen

    @property
    def events(self) -> list[Event]:
        """Distinct events across all entries, excluding event-less entries.

        In first-seen order; compares with `==`, same caveat as `stations`.
        """
        seen: list[Event] = []
        for entry in self.entries:
            if entry.event is not None and entry.event not in seen:
                seen.append(entry.event)
        return seen

    def events_for(self, station: Station) -> list[Event | None]:
        """Events available for one station, in first-seen order.

        `None` appears in the result if `station` has an event-less entry —
        an event-less selection is a first-class member of this list, not a
        special case to check for separately.
        """
        seen: list[Event | None] = []
        for entry in self.entries:
            if entry.station == station and entry.event not in seen:
                seen.append(entry.event)
        return seen

    def stations_for(self, event: Event | None) -> list[Station]:
        """Stations available for one event, in first-seen order.

        Pass `None` for stations with an event-less entry.
        """
        seen: list[Station] = []
        for entry in self.entries:
            if entry.event == event and entry.station not in seen:
                seen.append(entry.station)
        return seen

    def seismogram(
        self, station: Station, event: Event | None = None, *, _stacklevel: int = 3
    ) -> T:
        """Fetch (or return from cache) the result for one station/event combination.

        Args:
            station: Station to fetch.
            event: Event to fetch for, or `None` for an event-less entry.

        Returns:
            The transformed result for the matching entry.

        Raises:
            KeyError: If no entry matches this station/event combination.
            ValueError: If more than one entry matches — an authoring
                mistake (e.g. the same station/event added twice with
                different explicit windows), surfaced rather than silently
                resolved by picking one.
        """
        matches = [e for e in self.entries if e.station == station and e.event == event]
        if not matches:
            raise KeyError("No entry for this station/event combination.")
        if len(matches) > 1:
            raise ValueError(
                "More than one entry matches this station/event combination."
            )
        return self._fetch(matches[0], _stacklevel=_stacklevel)

    def seismograms_for(self, event: Event) -> list[T]:
        """All seismograms for one event — e.g. ready for `ICCS(seismograms=...)`.

        Built from
        [`stations_for`][pysmo.tools.project.PysmoProject.stations_for] and
        [`seismogram`][pysmo.tools.project.PysmoProject.seismogram], not an
        independent filter over `entries`.

        Typed to require an `Event`, unlike `stations_for`/`events_for`
        (which both treat `None` as first-class) — deliberately: this
        method exists for the event-based bulk-fetch use case (`ICCS`),
        which has no equivalent "all event-less entries" workflow to
        support. `[seismogram(s, None) for s in stations_for(None)]`
        already covers that case directly if it's ever needed.
        """
        return [
            self.seismogram(station, event, _stacklevel=4)
            for station in self.stations_for(event)
        ]

    def fetch_all(self) -> list[T]:
        """Fetch every entry in the project.

        With the default, always-fresh `fetch_seismogram`, this just warms
        `_cache` for the session. With an archive-backed `fetch_seismogram`
        (e.g.
        [`SqliteArchiveFetcher`][pysmo.tools.archive.SqliteArchiveFetcher]),
        this is what actually populates the archive — a single, explicit
        "get everything this project needs into the archive" call, rather
        than relying on incidental use of `seismogram`/`seismograms_for` to
        cover every entry eventually.

        Returns:
            One transformed result per entry, in `entries` order.
        """
        return [self._fetch(entry) for entry in self.entries]

entries class-attribute instance-attribute

entries: list[ProjectEntry] = field(
    factory=list,
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Station/event/window selections making up this project.

events property

events: list[Event]

Distinct events across all entries, excluding event-less entries.

In first-seen order; compares with ==, same caveat as stations.

fetch_seismogram class-attribute instance-attribute

fetch_seismogram: Callable[
    [Station, Timestamp, Timestamp], Seismogram
] = field(
    default=_default_fetch_seismogram,
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Downloads a seismogram for a station and absolute time window.

Defaults to a private helper wrapping GeoCsvSeismogram.fetch — the explicit "always fresh, never cached" choice.

For any project where reproducibility matters, substitute a SqliteArchiveFetcher instance instead — this is the recommended value for real analysis work, not a power-user option sitting alongside the default on equal footing; see its own docstring for why, and how it differs from ProjectEntry.checksum's live-fetch drift detection. It only pins the waveform, though — see the module documentation's second example for the gotcha this doesn't cover: transform making its own additional fetches. Any other callable of the right shape (e.g. one wrapping SAC.fetch) also works — this field changes the retrieval path without subclassing. Must be picklable by reference (a top-level function, or an attrs instance with only picklable fields — not a lambda or closure), same constraint as transform.

on_checksum_mismatch class-attribute instance-attribute

on_checksum_mismatch: Literal["warn", "raise", "ignore"] = (
    field(
        default="warn",
        validator=validators.in_(
            ("warn", "raise", "ignore")
        ),
    )
)

Behaviour when a fetched seismogram's checksum no longer matches the one recorded on entry.checksum from its first fetch.

"warn" (default) emits a UserWarning and still returns the new data; "raise" raises ValueError instead of returning anything, for a pipeline that should hard-stop on detected drift; "ignore" returns the new data with no signal at all. In every case entry.checksum keeps the value from the first fetch — it is never overwritten by a mismatching value, so a mismatch is reported (or not) consistently on every subsequent fetch, not just the first time it's noticed.

Deliberately not part of cache invalidation: changing this policy only affects how a future mismatch is handled, it doesn't change what data was fetched or would be re-fetched, so nothing about previously cached results becomes stale when it changes.

phase class-attribute instance-attribute

phase: str = field(
    default="P",
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Seismic phase used to derive a window from an entry's event.

post_pick class-attribute instance-attribute

post_pick: PositiveTimedelta = field(
    default=pd.Timedelta(minutes=8),
    converter=convert_to_timedelta,
    validator=[
        validators.instance_of(pd.Timedelta),
        validators.gt(pd.Timedelta(0)),
    ],
    on_setattr=setters.pipe(
        setters.convert,
        setters.validate,
        _on_setattr_clear_cache,
    ),
)

Offset from the predicted arrival to the window end. Must be positive.

pre_pick class-attribute instance-attribute

pre_pick: NonPositiveTimedelta = field(
    default=pd.Timedelta(minutes=-2),
    converter=convert_to_timedelta,
    validator=[
        validators.instance_of(pd.Timedelta),
        validators.le(pd.Timedelta(0)),
    ],
    on_setattr=setters.pipe(
        setters.convert,
        setters.validate,
        _on_setattr_clear_cache,
    ),
)

Offset from the predicted arrival to the window start. Must be zero or negative.

stations property

stations: list[Station]

Distinct stations across all entries, in first-seen order.

A plain @property, not @cached_property — recomputed on each access, same as ICCS.cc_seismograms's precedent for a no-arg derived list view in this codebase. Compares with == (attrs-generated equality, not identity or hashing — Station is not hashable).

transform class-attribute instance-attribute

transform: Callable[[Seismogram, FetchContext], T] = (
    field(
        on_setattr=setters.pipe(
            setters.convert, _on_setattr_clear_cache
        )
    )
)

Applied to a freshly downloaded seismogram; converts the result into the target type T.

Called with the downloaded seismogram and a FetchContext carrying the originating entry and this fetch's resolved window/predicted arrival. This is also where ordinary data preparation belongs — detrending, resampling, removing the instrument response — not just the final conversion to T, since it is the one place every fetch already passes through. Free to do anything else it needs — including its own additional fetches (e.g. instrument response metadata via StationXML.fetch, demonstrated in the module documentation's own example — this design doesn't fetch or know about response data itself, deliberately). Must be a top-level function in an importable module — not a lambda or closure — if the containing PysmoProject is to be pickled; a callable attrs class with only picklable fields (same example) is the alternative once transform needs its own configuration.

travel_time_backend class-attribute instance-attribute

travel_time_backend: TravelTimeBackend | None = field(
    default=None,
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Optional override for travel-time calculation. See pysmo.tools.web.TravelTimeBackend.

__getstate__

__getstate__() -> dict

Drop the fetch cache and lock, neither of which can survive pickling.

Source code in src/pysmo/tools/project/_project.py
def __getstate__(self) -> dict:
    """Drop the fetch cache and lock, neither of which can survive pickling."""
    state = attrs_getstate(self, {"_cache": {}})
    del state["_lock"]
    return state

__setstate__

__setstate__(state: dict) -> None

Restore state without triggering any on_setattr hooks, then create a fresh lock.

Source code in src/pysmo/tools/project/_project.py
def __setstate__(self, state: dict) -> None:
    """Restore state without triggering any `on_setattr` hooks, then create a fresh lock."""
    attrs_setstate(self, state)
    object.__setattr__(self, "_lock", threading.Lock())

clear_cache

clear_cache() -> None

Clear the in-memory fetch cache.

Cleared automatically whenever entries, transform, fetch_seismogram, phase, pre_pick, post_pick, or travel_time_backend is reassigned.

Call this manually after any in-place mutation of entries (e.g. append, remove, or index assignment), which isn't observable by on_setattr and therefore doesn't clear the cache automatically.

Source code in src/pysmo/tools/project/_project.py
def clear_cache(self) -> None:
    """Clear the in-memory fetch cache.

    Cleared automatically whenever
    [`entries`][pysmo.tools.project.PysmoProject.entries],
    [`transform`][pysmo.tools.project.PysmoProject.transform],
    [`fetch_seismogram`][pysmo.tools.project.PysmoProject.fetch_seismogram],
    [`phase`][pysmo.tools.project.PysmoProject.phase],
    [`pre_pick`][pysmo.tools.project.PysmoProject.pre_pick],
    [`post_pick`][pysmo.tools.project.PysmoProject.post_pick], or
    [`travel_time_backend`][pysmo.tools.project.PysmoProject.travel_time_backend]
    is *reassigned*.

    Call this manually after any in-place mutation of
    [`entries`][pysmo.tools.project.PysmoProject.entries] (e.g. `append`,
    `remove`, or index assignment), which isn't observable by
    `on_setattr` and therefore doesn't clear the cache automatically.
    """
    self._cache.clear()

events_for

events_for(station: Station) -> list[Event | None]

Events available for one station, in first-seen order.

None appears in the result if station has an event-less entry — an event-less selection is a first-class member of this list, not a special case to check for separately.

Source code in src/pysmo/tools/project/_project.py
def events_for(self, station: Station) -> list[Event | None]:
    """Events available for one station, in first-seen order.

    `None` appears in the result if `station` has an event-less entry —
    an event-less selection is a first-class member of this list, not a
    special case to check for separately.
    """
    seen: list[Event | None] = []
    for entry in self.entries:
        if entry.station == station and entry.event not in seen:
            seen.append(entry.event)
    return seen

fetch_all

fetch_all() -> list[T]

Fetch every entry in the project.

With the default, always-fresh fetch_seismogram, this just warms _cache for the session. With an archive-backed fetch_seismogram (e.g. SqliteArchiveFetcher), this is what actually populates the archive — a single, explicit "get everything this project needs into the archive" call, rather than relying on incidental use of seismogram/seismograms_for to cover every entry eventually.

Returns:

Type Description
list[T]

One transformed result per entry, in entries order.

Source code in src/pysmo/tools/project/_project.py
def fetch_all(self) -> list[T]:
    """Fetch every entry in the project.

    With the default, always-fresh `fetch_seismogram`, this just warms
    `_cache` for the session. With an archive-backed `fetch_seismogram`
    (e.g.
    [`SqliteArchiveFetcher`][pysmo.tools.archive.SqliteArchiveFetcher]),
    this is what actually populates the archive — a single, explicit
    "get everything this project needs into the archive" call, rather
    than relying on incidental use of `seismogram`/`seismograms_for` to
    cover every entry eventually.

    Returns:
        One transformed result per entry, in `entries` order.
    """
    return [self._fetch(entry) for entry in self.entries]

seismogram

seismogram(
    station: Station,
    event: Event | None = None,
    *,
    _stacklevel: int = 3
) -> T

Fetch (or return from cache) the result for one station/event combination.

Parameters:

Name Type Description Default
station Station

Station to fetch.

required
event Event | None

Event to fetch for, or None for an event-less entry.

None

Returns:

Type Description
T

The transformed result for the matching entry.

Raises:

Type Description
KeyError

If no entry matches this station/event combination.

ValueError

If more than one entry matches — an authoring mistake (e.g. the same station/event added twice with different explicit windows), surfaced rather than silently resolved by picking one.

Source code in src/pysmo/tools/project/_project.py
def seismogram(
    self, station: Station, event: Event | None = None, *, _stacklevel: int = 3
) -> T:
    """Fetch (or return from cache) the result for one station/event combination.

    Args:
        station: Station to fetch.
        event: Event to fetch for, or `None` for an event-less entry.

    Returns:
        The transformed result for the matching entry.

    Raises:
        KeyError: If no entry matches this station/event combination.
        ValueError: If more than one entry matches — an authoring
            mistake (e.g. the same station/event added twice with
            different explicit windows), surfaced rather than silently
            resolved by picking one.
    """
    matches = [e for e in self.entries if e.station == station and e.event == event]
    if not matches:
        raise KeyError("No entry for this station/event combination.")
    if len(matches) > 1:
        raise ValueError(
            "More than one entry matches this station/event combination."
        )
    return self._fetch(matches[0], _stacklevel=_stacklevel)

seismograms_for

seismograms_for(event: Event) -> list[T]

All seismograms for one event — e.g. ready for ICCS(seismograms=...).

Built from stations_for and seismogram, not an independent filter over entries.

Typed to require an Event, unlike stations_for/events_for (which both treat None as first-class) — deliberately: this method exists for the event-based bulk-fetch use case (ICCS), which has no equivalent "all event-less entries" workflow to support. [seismogram(s, None) for s in stations_for(None)] already covers that case directly if it's ever needed.

Source code in src/pysmo/tools/project/_project.py
def seismograms_for(self, event: Event) -> list[T]:
    """All seismograms for one event — e.g. ready for `ICCS(seismograms=...)`.

    Built from
    [`stations_for`][pysmo.tools.project.PysmoProject.stations_for] and
    [`seismogram`][pysmo.tools.project.PysmoProject.seismogram], not an
    independent filter over `entries`.

    Typed to require an `Event`, unlike `stations_for`/`events_for`
    (which both treat `None` as first-class) — deliberately: this
    method exists for the event-based bulk-fetch use case (`ICCS`),
    which has no equivalent "all event-less entries" workflow to
    support. `[seismogram(s, None) for s in stations_for(None)]`
    already covers that case directly if it's ever needed.
    """
    return [
        self.seismogram(station, event, _stacklevel=4)
        for station in self.stations_for(event)
    ]

stations_for

stations_for(event: Event | None) -> list[Station]

Stations available for one event, in first-seen order.

Pass None for stations with an event-less entry.

Source code in src/pysmo/tools/project/_project.py
def stations_for(self, event: Event | None) -> list[Station]:
    """Stations available for one event, in first-seen order.

    Pass `None` for stations with an event-less entry.
    """
    seen: list[Station] = []
    for entry in self.entries:
        if entry.event == event and entry.station not in seen:
            seen.append(entry.station)
    return seen