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 (a station, an optional event, and an optional explicit window) plus a seismogram_transform callable applied to each freshly downloaded Seismogram. It defaults to returning the raw trace as a MiniSeismogram; a custom transform 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.

A PysmoProject is a small, reproducible definition rather than a data store, so it is kept as a pickle rather than serialised to a bespoke config format. Its three pluggable callables (window, fetch_seismogram, seismogram_transform) must therefore be real top-level functions in an importable module rather than lambdas or closures (pickle serialises functions by reference, not by value). An optional name labels the project for identification when persisted. A callable that needs its own configuration (e.g. filter corner frequencies) should be a callable attrs class with only picklable fields (see the example below): it pickles by value, and its declared fields are what let resolution_context_digest fingerprint window and seismogram_transform. A plain callable class pickles too, but the digest cannot read one. PhaseWindow, the default window, is one such class.

A pickle is executable code

Unpickling runs arbitrary code, so only ever load a project file you produced yourself or received from someone you trust. The version check on load guards against format drift, not against a hostile file; it runs after unpickling has already executed. To hand a project definition to someone else, share the Python that builds it, not the .pkl.

Build entries with build_entries from already-narrowed lists of events and stations, a filtered cross product. The project is generic over the event and station types it is built from, so project.events / project.stations return those concrete types, not the bare Event / Station protocols.

Pair PysmoProject with FetchCache 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), provided max_bytes is left at its unlimited default; a finite max_bytes evicts old entries, which are then re-fetched on next access. A different mechanism from ProjectEntry.checksum, which only detects drift on the live-network default rather than avoiding it. Wrap seismogram_transform in a TransformCache to cache the transformed result, and whatever the transform fetches itself, on disk too.

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:

>>> import pandas as pd
>>> from attrs import define
>>> from pysmo import Event, MiniEvent, MiniStation, Seismogram, Station
>>> 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 seismogram_transform removes the instrument response, the data preparation ICCS itself assumes has already happened (per its own bandpass_apply docstring), then converts the result into a MiniIccsSeismogram, using context.reference (the predicted arrival) 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[Station, Event]
...     ) -> MiniIccsSeismogram:
...         response = StationXML.fetch(
...             station=context.entry.station, time=context.starttime
...         ).response
...         corrected = clone_to_mini(
...             MiniIccsSeismogram, seismogram, update={"t0": context.reference}
...         )
...         remove_response(corrected, response, pre_filt=self.pre_filt)
...         return corrected
...
>>> 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(
...     entries=[ProjectEntry(station=station_anmo, event=event_maule)],
...     seismogram_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 seismogram_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
>>>

The fetch window

An entry's window comes from one of two places.

The first is the entry itself. If it sets starttime and endtime, that window is used as-is and window is never called. A ProjectEntry with no event is rejected unless it sets an explicit window, so every event-less entry takes this path. An entry that carries an event can also set an explicit window, to override the event-derived one.

>>> pre_maule_noise = PysmoProject(
...     entries=[
...         ProjectEntry(
...             station=station_anmo,
...             starttime="2010-02-27T05:00:00Z",
...             endtime="2010-02-27T06:00:00Z",
...         )
...     ],
... )
>>> pre_maule_noise.events
[]
>>> pre_maule_noise.events_for(station_anmo)
[None]
>>>

The second is the window resolver. It runs when an entry has an event but no explicit window. The default resolver is PhaseWindow. It returns a span around the predicted phase arrival. Its phase, pre_pick, post_pick, and travel_time_backend fields live on the PhaseWindow, not on PysmoProject. The example below sets travel_time_backend to the built-in solver on the ak135 model:

>>> from functools import partial
>>> from pysmo.tools.project import PhaseWindow
>>> from pysmo.tools.traveltime import travel_times
>>>
>>> project_ak135 = PysmoProject(
...     entries=[ProjectEntry(station=station_anmo, event=event_maule)],
...     window=PhaseWindow(travel_time_backend=partial(travel_times, model="ak135")),
... )
>>> arrivals = project_ak135.window.travel_time_backend(
...     depth=event_maule.depth, distance=60.0, phases=["P"]
... )
>>> round(arrivals["P"].total_seconds(), 1)
604.7
>>>

Any WindowResolver can take the place of PhaseWindow.

Caching downloads

Pairing fetch_seismogram with FetchCache 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.

That only pins the waveform, though. to_mini_iccs_seismogram still fetches a StationXML response itself on every call, and re-runs the response removal, cached or not; a cache-backed fetch_seismogram says nothing about whatever seismogram_transform independently does. Wrapping the transform in a TransformCache closes that gap: its output is stored as JSON and reconstructed on a hit, transform and secondary fetches skipped entirely.

>>> from pysmo.classes import SAC
>>> from pysmo.tools.cache import FetchCache
>>> from pysmo.tools.project import TransformCache
>>> from pysmo.tools.web import fetch_sac
>>>
>>> def parse_sac_zip(raw: bytes) -> Seismogram:
...     return SAC.from_zip(raw).seismogram
...
>>> waveform_cache = FetchCache(
...     path="project_cache.sqlite3", fetch_raw=fetch_sac, parse=parse_sac_zip
... )
>>> transform_cache = TransformCache(
...     path="transform_cache.sqlite3", transform=to_mini_iccs_seismogram
... )
>>> cached_project = PysmoProject(
...     entries=[ProjectEntry(station=station_anmo, event=event_maule)],
...     seismogram_transform=transform_cache,
...     fetch_seismogram=waveform_cache,
... )
>>> one = cached_project.seismogram(station_anmo, event_maule)  # miss: fetches, transforms, stores
>>> one_again = cached_project.seismogram(station_anmo, event_maule)  # hit: nothing fetched or transformed
>>> isinstance(one_again, MiniIccsSeismogram)
True
>>>

Project as code

The recommended workflow: fetch broadly once (or load an inventory file), parse each document to a flat list, narrow it with plain comprehensions you can print and check, pair events with stations via build_entries, then hand the result to the one PysmoProject constructor. Everything after the initial fetch is offline.

>>> from pysmo import Event, Station
>>> from pysmo.classes import QuakeML, StationXML, resolve_epochs
>>> from pysmo.tools.azdist import haversine
>>> from pysmo.tools.project import build_entries
>>>
>>> catalogue = b'''<?xml version="1.0"?>
... <q:quakeml xmlns="http://quakeml.org/xmlns/bed/1.2"
...            xmlns:q="http://quakeml.org/xmlns/quakeml/1.2">
...   <eventParameters publicID="smi:example/catalogue">
...     <event publicID="smi:example/maule">
...       <origin publicID="smi:example/o1">
...         <time><value>2010-02-27T06:34:11.53Z</value></time>
...         <latitude><value>-36.122</value></latitude>
...         <longitude><value>-72.898</value></longitude>
...         <depth><value>22900</value></depth>
...       </origin>
...       <magnitude publicID="smi:example/m1"><mag><value>8.8</value></mag></magnitude>
...     </event>
...   </eventParameters>
... </q:quakeml>'''
>>> inventory = b'''<?xml version="1.0"?>
... <FDSNStationXML xmlns="http://www.fdsn.org/xml/station/1">
...   <Network code="IU"><Station code="ANMO" startDate="1989-01-01T00:00:00">
...     <Latitude>34.945981</Latitude><Longitude>-106.457133</Longitude>
...     <Channel code="BHZ" locationCode="00" startDate="2008-06-30T20:00:00"
...              endDate="2011-02-18T19:11:00">
...       <Latitude>34.945981</Latitude><Longitude>-106.457133</Longitude>
...     </Channel>
...   </Station></Network>
... </FDSNStationXML>'''
>>>
>>> events = QuakeML.all_from_bytes(catalogue)
>>> strong = [e for e in events if (e.magnitude or 0) >= 8.0]
>>> bhz = [e for e in StationXML.all_from_bytes(inventory) if e.channel == "BHZ"]
>>> stations = resolve_epochs(bhz, strong[0].time)
>>> len(strong), len(stations)
(1, 1)
>>>
>>> def teleseismic_p(station: Station, event: Event) -> bool:
...     return haversine(event, station) <= 95.0
...
>>> entries = build_entries(stations, strong, teleseismic_p)
>>> project_as_code = PysmoProject(entries=entries)
>>> [type(e).__name__ for e in project_as_code.events]
['QuakeML']
>>> [type(s).__name__ for s in project_as_code.stations]
['StationXML']
>>>

Grow it later without rebuilding, with a plain extend, since the fetch cache is keyed by entry content, not list position:

>>> project_as_code.entries.extend(build_entries(stations, strong, teleseismic_p))
>>> len(project_as_code.entries)
2
>>>

Addressing entries

entry.identity is a stable string derived from the entry's natural key (normalised station code, event hypocentre and origin time, or explicit window), computed without any fetch. Persist it, and later pass it to project.get(identity) to fetch and transform that one entry; get works on a project whose entries have never been fetched.

project.resolution_context_digest is a single digest over the parameters that decide what a fetch returns (window and seismogram_transform). A consumer records it alongside the identities and checks it once per session to detect that the project definition has moved under it. Reassigning fetch_seismogram does not change the digest.

The three are distinct: - entry.identity is computable before any fetch, and is the persistable address. - project.resolution_context_digest catches a definition change before a fetch is issued. - entry.checksum catches a change in the fetched bytes, and only exists after a fetch.

Type Aliases:

Name Description
SeismogramFetcher

Download a seismogram for a station and absolute time window.

SeismogramTransform

Convert a freshly downloaded seismogram into the project's target type.

WindowResolver

Resolve an entry's absolute fetch window.

Classes:

Name Description
FetchContext

Context passed to seismogram_transform with the downloaded seismogram.

PhaseWindow

Resolve a fetch window from an entry's event and a predicted phase arrival.

ProjectEntry

One station/event selection within a PysmoProject.

PysmoProject

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

TransformCache

Caches a seismogram_transform's output, and its secondary fetches, on disk.

UnknownEntryIdentity

Raised when an entry with the requested identity is not found in the project.

WindowResult

The absolute fetch window resolved for one entry.

Functions:

Name Description
build_entries

Build project entries from a filtered cross product of stations and events.

callable_identity

A stable, picklable identity string for a callable.

entry_identity

The entry's stable identity, computed from its natural key without I/O.

entry_identity_components

The normalised natural key of an entry as a nested dict, before hashing.

resolution_context_digest

Digest over the project parameters that determine fetched content.

SeismogramFetcher

SeismogramFetcher = Callable[
    [Station, Timestamp, Timestamp], Seismogram
]

Download a seismogram for a station and absolute time window.

Called with a Station and the resolved starttime/endtime, and returns a Seismogram. "Always fresh" by contract: PysmoProject keeps its own in-memory cache, so a fetcher normally hits the network every time rather than consulting a cache of its own. Swap in a FetchCache for a reproducible on-disk cache instead. Must be picklable by reference.

SeismogramTransform

SeismogramTransform = Callable[
    [Seismogram, FetchContext[TStation, TEvent]],
    TSeismogram,
]

Convert a freshly downloaded seismogram into the project's target type.

Called with the downloaded Seismogram and a FetchContext carrying the originating entry and this fetch's resolved window. The one place ordinary data preparation (response removal, detrending, resampling) belongs, and free to issue its own additional fetches (e.g. instrument response metadata via StationXML.fetch). Must be picklable by reference. Wrap it in a TransformCache to cache its output (and those additional fetches) on disk.

WindowResolver

WindowResolver = Callable[
    [ProjectEntry[TStation, TEvent]], WindowResult
]

Resolve an entry's absolute fetch window.

Called with a single ProjectEntry and returns a WindowResult. Raises ValueError when no window can be resolved (no predicted arrival for the station/event geometry, or a required event missing).

Only ever called for entries without an explicit starttime/endtime pair: PysmoProject resolves that case itself before consulting the resolver, so a custom resolver never has to reimplement it. PhaseWindow is the default. Must be picklable by reference (a top-level function, or an attrs instance with only picklable fields, not a lambda or closure), the same constraint as the other two seams.

PysmoProject caches on entry.identity, which quantises event coordinates (~11 m) and depth (100 m); a resolver that returns materially different windows for entries closer than that shares one cache slot between them. The default PhaseWindow is safe here — the sub-quantum change in a teleseismic arrival time is far below one sample.

FetchContext

Context passed to seismogram_transform with the 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 by the project's window 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[TStation, TEvent]

The entry this seismogram was fetched for.

reference Timestamp | None

Timestamp the window was placed around (a predicted phase arrival for

starttime Timestamp

Absolute start of the window actually used for this fetch.

Source code in src/pysmo/tools/project/_types.py
@define(kw_only=True, frozen=True)
class FetchContext[TStation: Station, TEvent: Event]:
    """Context passed to `seismogram_transform` with the 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 by the project's `window` 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[TStation, TEvent]
    """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."""

    reference: pd.Timestamp | None
    """Timestamp the window was placed around (a predicted phase arrival for
    the default `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[TStation, TEvent]

The entry this seismogram was fetched for.

reference instance-attribute

reference: Timestamp | None

Timestamp the window was placed around (a predicted phase arrival for the default 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.

PhaseWindow

Resolve a fetch window from an entry's event and a predicted phase arrival.

The default WindowResolver for PysmoProject: predicts the phase arrival for the station/event geometry with travel_time_backend, then returns the window [arrival + pre_pick, arrival + post_pick].

Frozen and picklable by value (given a picklable travel_time_backend), so it travels with a pickled PysmoProject.

Methods:

Name Description
__call__

Resolve the window for entry.

Attributes:

Name Type Description
phase str

Seismic phase the window is placed around.

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; zero or negative.

travel_time_backend TravelTimeBackend

Predicts the phase arrival the window is built around.

Source code in src/pysmo/tools/project/_phasewindow.py
@define(kw_only=True, frozen=True)
class PhaseWindow:
    """Resolve a fetch window from an entry's event and a predicted phase arrival.

    The default [`WindowResolver`][pysmo.tools.project.WindowResolver] for
    [`PysmoProject`][pysmo.tools.project.PysmoProject]: predicts the `phase`
    arrival for the station/event geometry with `travel_time_backend`, then
    returns the window `[arrival + pre_pick, arrival + post_pick]`.

    Frozen and picklable by value (given a picklable `travel_time_backend`),
    so it travels with a pickled `PysmoProject`.
    """

    phase: str = field(default="P")
    """Seismic phase the window is placed around.

    With the default `travel_time_backend` this must be one of the phases in
    [`Phase`][pysmo.tools.traveltime.Phase]; any other name raises when a
    window is resolved. A custom backend may accept a wider set.
    """

    pre_pick: NonPositiveTimedelta = field(
        default=pd.Timedelta(minutes=-2),
        converter=to_timedelta,
        validator=[
            validators.instance_of(pd.Timedelta),
            validators.le(pd.Timedelta(0)),
        ],
    )
    """Offset from the predicted arrival to the window start; zero or negative."""

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

    travel_time_backend: TravelTimeBackend = field(default=builtin_backend)
    """Predicts the phase arrival the window is built around.

    Defaults to pysmo's built-in solver,
    [`travel_times`][pysmo.tools.traveltime.travel_times]. Replace it with
    any callable of the same shape
    ([`TravelTimeBackend`][pysmo.tools.traveltime.TravelTimeBackend]) for
    another velocity model, a phase the built-in solver does not cover, or
    arrival times from an external source. Must be picklable: a top-level
    function, a [`functools.partial`][] of one, or an attrs instance with
    only picklable fields; not a lambda or closure.
    """

    def __call__[TS: Station, TE: Event](
        self, entry: ProjectEntry[TS, TE]
    ) -> WindowResult:
        """Resolve the window for `entry`.

        Raises:
            ValueError: If `entry` has no event, or no `phase` arrival is
                predicted for its station/event geometry.
        """
        if entry.event is None:
            raise ValueError(
                "PhaseWindow needs an entry with an event to derive a window."
            )
        distance = haversine(entry.event, entry.station)
        arrivals = self.travel_time_backend(
            depth=entry.event.depth, distance=distance, phases=[self.phase]
        )
        if self.phase not in arrivals:
            raise ValueError(
                f"No {self.phase!r} arrival predicted for "
                + f"{entry.station.network}.{entry.station.name} at this "
                + "distance/depth."
            )
        reference = entry.event.time + arrivals[self.phase]
        return WindowResult(
            starttime=reference + self.pre_pick,
            endtime=reference + self.post_pick,
            reference=reference,
        )

phase class-attribute instance-attribute

phase: str = field(default='P')

Seismic phase the window is placed around.

With the default travel_time_backend this must be one of the phases in Phase; any other name raises when a window is resolved. A custom backend may accept a wider set.

post_pick class-attribute instance-attribute

post_pick: PositiveTimedelta = field(
    default=pd.Timedelta(minutes=8),
    converter=to_timedelta,
    validator=[
        validators.instance_of(pd.Timedelta),
        validators.gt(pd.Timedelta(0)),
    ],
)

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=to_timedelta,
    validator=[
        validators.instance_of(pd.Timedelta),
        validators.le(pd.Timedelta(0)),
    ],
)

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

travel_time_backend class-attribute instance-attribute

travel_time_backend: TravelTimeBackend = field(
    default=builtin_backend
)

Predicts the phase arrival the window is built around.

Defaults to pysmo's built-in solver, travel_times. Replace it with any callable of the same shape (TravelTimeBackend) for another velocity model, a phase the built-in solver does not cover, or arrival times from an external source. Must be picklable: a top-level function, a functools.partial of one, or an attrs instance with only picklable fields; not a lambda or closure.

__call__

__call__(entry: ProjectEntry[TS, TE]) -> WindowResult

Resolve the window for entry.

Raises:

Type Description
ValueError

If entry has no event, or no phase arrival is predicted for its station/event geometry.

Source code in src/pysmo/tools/project/_phasewindow.py
def __call__[TS: Station, TE: Event](
    self, entry: ProjectEntry[TS, TE]
) -> WindowResult:
    """Resolve the window for `entry`.

    Raises:
        ValueError: If `entry` has no event, or no `phase` arrival is
            predicted for its station/event geometry.
    """
    if entry.event is None:
        raise ValueError(
            "PhaseWindow needs an entry with an event to derive a window."
        )
    distance = haversine(entry.event, entry.station)
    arrivals = self.travel_time_backend(
        depth=entry.event.depth, distance=distance, phases=[self.phase]
    )
    if self.phase not in arrivals:
        raise ValueError(
            f"No {self.phase!r} arrival predicted for "
            + f"{entry.station.network}.{entry.station.name} at this "
            + "distance/depth."
        )
    reference = entry.event.time + arrivals[self.phase]
    return WindowResult(
        starttime=reference + self.pre_pick,
        endtime=reference + self.post_pick,
        reference=reference,
    )

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.

At least one of (event, an explicit starttime/endtime pair) is required; a lone starttime or endtime, or a starttime at or after endtime, raises ValueError at construction.

Generic over the station and event types it was built with, so a PysmoProject built from a list of entries keeps those concrete types (e.g. project.events comes back as list[QuakeML], not list[Event]). An event-less entry leaves TEvent at its default, Event. A list mixing event-bearing and event-less entries has no single inferred element type, so annotate it (list[ProjectEntry[MyStation, MyEvent]]) or reach for build_entries, which produces a homogeneous list.

Methods:

Name Description
__attrs_post_init__

Reject a half-specified, reversed, or (event-less) absent window.

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).

event TEvent | None

Event for deriving a phase-arrival-relative window (if no explicit times).

identity str

Stable identity string for this entry, computed without fetching.

identity_components dict[str, Any]

This entry's normalised natural key as a nested dict, before hashing.

starttime Timestamp | None

Explicit start of the fetch window (UTC).

station TStation

Station to fetch waveform data for.

Source code in src/pysmo/tools/project/_entry.py
@define(kw_only=True)
class ProjectEntry[TStation: Station, TEvent: Event = Event]:
    """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`][pysmo.tools.project.PysmoProject] for how the window is
    actually resolved.

    At least one of (`event`, an explicit `starttime`/`endtime` pair) is
    required; a lone `starttime` or `endtime`, or a `starttime` at or after
    `endtime`, raises `ValueError` at construction.

    Generic over the station and event types it was built with, so a
    [`PysmoProject`][pysmo.tools.project.PysmoProject] built from a list of
    entries keeps those concrete types (e.g. `project.events` comes back as
    `list[QuakeML]`, not `list[Event]`). An event-less entry leaves `TEvent`
    at its default, [`Event`][pysmo.Event]. A list mixing event-bearing and
    event-less entries has no single inferred element type, so annotate it
    (`list[ProjectEntry[MyStation, MyEvent]]`) or reach for
    [`build_entries`][pysmo.tools.project.build_entries], which produces a
    homogeneous list.
    """

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

    event: TEvent | None = None
    """Event for deriving a phase-arrival-relative window (if no explicit times)."""

    starttime: pd.Timestamp | None = field(
        default=None,
        converter=converters.optional(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(to_utc_timestamp),
        on_setattr=setters.convert,
    )
    """Explicit end of the fetch window (UTC).

    Overrides `event` when set together with `starttime`.
    """

    _identity_cache: tuple[dict[str, Any], str] | None = field(
        init=False, default=None, eq=False, repr=False
    )
    """Memoised (`identity_components`, `identity`) pair, reused only while the
    components still compare equal — so mutating a nested `station`/`event`
    field invalidates it too, not just reassigning the top-level field."""

    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.
    """

    @property
    def identity_components(self) -> dict[str, Any]:
        """This entry's normalised natural key as a nested dict, before hashing.

        Examples:
            >>> import pandas as pd
            >>> from pysmo import MiniEvent, MiniStation
            >>> from pysmo.tools.project import ProjectEntry
            >>> station = MiniStation(
            ...     name="ANMO",
            ...     network="IU",
            ...     location="00",
            ...     channel="BHZ",
            ...     latitude=34.9459,
            ...     longitude=-106.4571,
            ... )
            >>> event = MiniEvent(
            ...     latitude=-36.122,
            ...     longitude=-72.898,
            ...     depth=22900.0,
            ...     time=pd.Timestamp("2010-02-27T06:34:11.53Z"),
            ... )
            >>> entry = ProjectEntry(station=station, event=event)
            >>> components = entry.identity_components
            >>> components["schema"]
            'v1'
            >>> components["station"]["name"]
            'ANMO'
        """
        return entry_identity_components(self)

    @property
    def identity(self) -> str:
        """Stable identity string for this entry, computed without fetching.

        Derived from the natural key: `'v1:'` and a sha256 hexdigest. Persist it
        and pass it to
        [`PysmoProject.get`][pysmo.tools.project.PysmoProject.get] to retrieve
        the entry's seismogram later.

        Examples:
            >>> import pandas as pd
            >>> from pysmo import MiniEvent, MiniStation
            >>> from pysmo.tools.project import ProjectEntry
            >>> station = MiniStation(
            ...     name="ANMO",
            ...     network="IU",
            ...     location="00",
            ...     channel="BHZ",
            ...     latitude=34.9459,
            ...     longitude=-106.4571,
            ... )
            >>> event = MiniEvent(
            ...     latitude=-36.122,
            ...     longitude=-72.898,
            ...     depth=22900.0,
            ...     time=pd.Timestamp("2010-02-27T06:34:11.53Z"),
            ... )
            >>> entry = ProjectEntry(station=station, event=event)
            >>> entry.identity.startswith("v1:")
            True
            >>> len(entry.identity)
            67
        """
        components = entry_identity_components(self)
        cached = self._identity_cache
        if cached is None or cached[0] != components:
            cached = (components, identity_digest(components))
            object.__setattr__(self, "_identity_cache", cached)
        return cached[1]

    def __attrs_post_init__(self) -> None:
        """Reject a half-specified, reversed, or (event-less) absent window."""
        if (self.starttime is None) != (self.endtime is None):
            raise ValueError(
                "ProjectEntry needs both starttime and endtime, or neither."
            )
        if (
            self.starttime is not None
            and self.endtime is not None
            and self.starttime >= self.endtime
        ):
            raise ValueError("ProjectEntry starttime must be before endtime.")
        if self.event is None and self.starttime is None:
            raise ValueError(
                "ProjectEntry needs an explicit starttime/endtime window or an "
                + "event to derive one from."
            )

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(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: TEvent | None = None

Event for deriving a phase-arrival-relative window (if no explicit times).

identity property

identity: str

Stable identity string for this entry, computed without fetching.

Derived from the natural key: 'v1:' and a sha256 hexdigest. Persist it and pass it to PysmoProject.get to retrieve the entry's seismogram later.

Examples:

>>> import pandas as pd
>>> from pysmo import MiniEvent, MiniStation
>>> from pysmo.tools.project import ProjectEntry
>>> station = MiniStation(
...     name="ANMO",
...     network="IU",
...     location="00",
...     channel="BHZ",
...     latitude=34.9459,
...     longitude=-106.4571,
... )
>>> event = MiniEvent(
...     latitude=-36.122,
...     longitude=-72.898,
...     depth=22900.0,
...     time=pd.Timestamp("2010-02-27T06:34:11.53Z"),
... )
>>> entry = ProjectEntry(station=station, event=event)
>>> entry.identity.startswith("v1:")
True
>>> len(entry.identity)
67

identity_components property

identity_components: dict[str, Any]

This entry's normalised natural key as a nested dict, before hashing.

Examples:

>>> import pandas as pd
>>> from pysmo import MiniEvent, MiniStation
>>> from pysmo.tools.project import ProjectEntry
>>> station = MiniStation(
...     name="ANMO",
...     network="IU",
...     location="00",
...     channel="BHZ",
...     latitude=34.9459,
...     longitude=-106.4571,
... )
>>> event = MiniEvent(
...     latitude=-36.122,
...     longitude=-72.898,
...     depth=22900.0,
...     time=pd.Timestamp("2010-02-27T06:34:11.53Z"),
... )
>>> entry = ProjectEntry(station=station, event=event)
>>> components = entry.identity_components
>>> components["schema"]
'v1'
>>> components["station"]["name"]
'ANMO'

starttime class-attribute instance-attribute

starttime: Timestamp | None = field(
    default=None,
    converter=converters.optional(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: TStation

Station to fetch waveform data for.

__attrs_post_init__

__attrs_post_init__() -> None

Reject a half-specified, reversed, or (event-less) absent window.

Source code in src/pysmo/tools/project/_entry.py
def __attrs_post_init__(self) -> None:
    """Reject a half-specified, reversed, or (event-less) absent window."""
    if (self.starttime is None) != (self.endtime is None):
        raise ValueError(
            "ProjectEntry needs both starttime and endtime, or neither."
        )
    if (
        self.starttime is not None
        and self.endtime is not None
        and self.starttime >= self.endtime
    ):
        raise ValueError("ProjectEntry starttime must be before endtime.")
    if self.event is None and self.starttime is None:
        raise ValueError(
            "ProjectEntry needs an explicit starttime/endtime window or an "
            + "event to derive one from."
        )

PysmoProject

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

A PysmoProject holds a flat list of ProjectEntry objects plus three pluggable callables: window resolves each entry's fetch window, fetch_seismogram downloads the trace, and seismogram_transform turns it into the caller's target type TSeismogram. No waveform data are stored on the instance between calls beyond an in-memory cache of already-fetched-and-transformed results.

An optional name can label the project for identification by tools that hold more than one project. It is free-text and carries no semantics: it is not a key, does not affect cache invalidation, and is not part of resolution_context_digest or any entry identity.

Generic over the station and event types of its entries (matching ProjectEntry's parameter order) and the return type of seismogram_transform, all three inferred at construction: TStation / TEvent from entries (build them with build_entries from a list of, say, StationXML and QuakeML, and project.stations / project.events come back as list[StationXML] / list[QuakeML]), TSeismogram from seismogram_transform's return type, defaulting to MiniSeismogram when the default transform is used.

See the module documentation for a worked example.

Persistence

A PysmoProject travels as a pickle. The in-memory cache and lock are dropped during pickling and reconstructed on unpickling. An optional name is preserved across pickles; unpickling a project serialised without a name defaults it to None.

Unpickling executes code, so load only a project file you produced or trust (see the module documentation). The format-version check on load reports an incompatible pickle; it is not a security boundary and runs after unpickling. There is no cross-version migration: a pickle from an earlier state format must be rebuilt from its source.

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.

Reassigning window (or any other cache-affecting field) on one thread while another is mid-fetch is also safe: the in-flight fetch still returns a result, it just isn't cached (the next call recomputes it with the current parameters).

Methods:

Name Description
__getstate__

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

__setstate__

Restore state without firing on_setattr hooks, then make 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.

get

Fetch (or return from cache) the result for an entry by its identity.

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[TStation, TEvent]]

Station/event/window selections making up this project.

events list[TEvent]

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

fetch_seismogram SeismogramFetcher

Download a seismogram for a station and absolute time window.

name str | None

Optional free-text label for this project.

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

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

resolution_context_digest str

Digest over the project parameters that determine fetched content.

seismogram_transform SeismogramTransform[TStation, TEvent, TSeismogram]

Convert a freshly downloaded seismogram into the target type TSeismogram.

stations list[TStation]

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

window WindowResolver[TStation, TEvent]

Resolve an entry's fetch window when it carries no explicit one.

Source code in src/pysmo/tools/project/_project.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
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
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
@define(kw_only=True)
class PysmoProject[TStation: Station, TEvent: Event, TSeismogram = MiniSeismogram]:
    """Declares station/event data to fetch on demand and transform into `TSeismogram`.

    A `PysmoProject` holds a flat list of
    [`ProjectEntry`][pysmo.tools.project.ProjectEntry] objects plus three
    pluggable callables: `window` resolves each entry's fetch window,
    `fetch_seismogram` downloads the trace, and `seismogram_transform` turns
    it into the caller's target type `TSeismogram`. No waveform data are
    stored on the instance between calls beyond an in-memory cache of
    already-fetched-and-transformed results.

    An optional `name` can label the project for identification by tools
    that hold more than one project. It is free-text and carries no
    semantics: it is not a key, does not affect cache invalidation, and is
    not part of `resolution_context_digest` or any entry identity.

    Generic over the station and event types of its `entries` (matching
    [`ProjectEntry`][pysmo.tools.project.ProjectEntry]'s parameter order)
    and the return type of `seismogram_transform`, all three inferred at
    construction: `TStation` / `TEvent` from `entries` (build them with
    [`build_entries`][pysmo.tools.project.build_entries] from a list of, say,
    `StationXML` and `QuakeML`, and `project.stations` / `project.events`
    come back as `list[StationXML]` / `list[QuakeML]`), `TSeismogram` from
    `seismogram_transform`'s return type, defaulting to
    [`MiniSeismogram`][pysmo.MiniSeismogram] when the default transform is
    used.

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

    Note: Persistence
        A `PysmoProject` travels as a pickle. The in-memory cache and lock
        are dropped during pickling and reconstructed on unpickling. An
        optional `name` is preserved across pickles; unpickling a project
        serialised without a `name` defaults it to `None`.

        Unpickling executes code, so load only a project file you produced or
        trust (see the [module documentation][pysmo.tools.project]). The
        format-version check on load reports an incompatible pickle; it is
        not a security boundary and runs after unpickling. There is no
        cross-version migration: a pickle from an earlier state format must
        be rebuilt from its source.

    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.

        Reassigning `window` (or any other cache-affecting field) on one
        thread while another is mid-fetch is also safe: the in-flight fetch
        still returns a result, it just isn't cached (the next call
        recomputes it with the current parameters).
    """

    # v3: seismogram_checksum's algorithm changed, so v2 checksums no longer
    # compare; there is no migration, so reject rather than mis-compare.
    _FORMAT_VERSION: ClassVar[int] = 3

    name: str | None = field(
        default=None,
        validator=validators.optional(validators.instance_of(str)),
    )
    """Optional free-text label for this project.

    Useful for identification by downstream tools that hold multiple
    projects. This is purely a label, not a key: no uniqueness is enforced,
    renaming does not invalidate the fetch cache, and it is not part of
    [`resolution_context_digest`][pysmo.tools.project.PysmoProject.resolution_context_digest]
    or any entry identity.
    """

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

    Build them with
    [`build_entries`][pysmo.tools.project.build_entries]; grow the project
    later with `project.entries.extend(build_entries(...))` (a plain
    in-place mutation; call
    [`clear_cache`][pysmo.tools.project.PysmoProject.clear_cache] afterwards
    only to free memory, never for correctness, since the fetch cache is
    keyed by entry content).
    """

    seismogram_transform: SeismogramTransform[TStation, TEvent, TSeismogram] = field(
        # The default returns `MiniSeismogram`, which is `TSeismogram`'s own
        # default, but mypy still can't match a concrete return against the
        # bare type parameter in the class body.
        default=_seismogram_to_mini_seismogram,  # type: ignore[assignment]
        on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache),
    )
    """Convert a freshly downloaded seismogram into the target type `TSeismogram`.

    Any [`SeismogramTransform`][pysmo.tools.project.SeismogramTransform] (see
    there for the call contract). Defaults to returning the raw trace as a
    [`MiniSeismogram`][pysmo.MiniSeismogram], with no processing: a custom
    transform is where response removal, detrending and resampling belong,
    and it may issue its own additional fetches, as the
    [module documentation][pysmo.tools.project]'s example does for instrument
    response metadata. A callable `attrs` class with only picklable fields is
    the way to give the transform its own configuration.
    """

    fetch_seismogram: SeismogramFetcher = field(
        default=_default_fetch_seismogram,
        on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache),
    )
    """Download a seismogram for a station and absolute time window.

    Any [`SeismogramFetcher`][pysmo.tools.project.SeismogramFetcher] (see
    there for the call contract). Defaults to a private helper wrapping
    [`MSeed.fetch`][pysmo.classes.MSeed.fetch], the explicit "always fresh,
    never cached" choice.

    For any project where reproducibility matters, substitute a
    [`FetchCache`][pysmo.tools.cache.FetchCache] instance instead. That is
    the *recommended* value for real analysis work, not a power-user option
    on equal footing with the default; see its own docstring for why, and
    how it differs from `ProjectEntry.checksum`'s live-fetch drift
    detection. Leave its `max_bytes` at the default
    (unlimited) for this to hold: a finite `max_bytes` evicts old entries and
    re-fetches them on next access, reintroducing the drift a cache is meant
    to rule out. It only pins the waveform, though: see the
    [module documentation][pysmo.tools.project]'s second example for the
    gotcha it does not cover, `seismogram_transform` making its own
    additional fetches.
    """

    window: WindowResolver[TStation, TEvent] = field(
        default=PhaseWindow(),
        on_setattr=setters.pipe(setters.convert, _on_setattr_clear_cache),
    )
    """Resolve an entry's fetch window when it carries no explicit one.

    Any [`WindowResolver`][pysmo.tools.project.WindowResolver] (see there for
    the call contract). Defaults to
    [`PhaseWindow`][pysmo.tools.project.PhaseWindow], which places the window
    around a predicted phase arrival. An entry with an explicit
    `starttime`/`endtime` bypasses this entirely, so a custom resolver only
    ever handles the event-derived case.
    """

    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, TSeismogram]] = field(
        init=False, factory=dict, repr=False, eq=False
    )
    _cache_generation: int = field(init=False, default=0, repr=False, eq=False)
    """Bumped by every `clear_cache()`. A `_fetch` in progress when the cache
    is cleared (e.g. a parameter reassigned on another thread mid-download)
    sees the mismatch and returns its result without caching it under a
    now-stale key."""
    _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[str, Any]:
        """Drop the fetch cache and lock, neither of which can survive pickling."""
        state = attrs_getstate(self, {"_cache": {}, "_cache_generation": 0})
        del state["_lock"]
        state["_format_version"] = self._FORMAT_VERSION
        state["_pysmo_version"] = __version__
        return state

    def __setstate__(self, state: dict[str, Any]) -> None:
        """Restore state without firing `on_setattr` hooks, then make a fresh lock."""
        # Format-compatibility gate only; pickle.load above has already run
        # any code in the file, so this is not a trust check.
        pickled_format = state.pop("_format_version", 0)
        pickled_pysmo = state.pop("_pysmo_version", "unknown")
        if pickled_format != self._FORMAT_VERSION:
            raise ValueError(
                f"This PysmoProject was pickled by pysmo {pickled_pysmo} in state "
                + f"format v{pickled_format}; this pysmo ({__version__}) uses "
                + f"v{self._FORMAT_VERSION}. Re-create the project."
            )
        state.setdefault("name", None)
        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],
        [`window`][pysmo.tools.project.PysmoProject.window],
        [`seismogram_transform`][pysmo.tools.project.PysmoProject.seismogram_transform],
        or
        [`fetch_seismogram`][pysmo.tools.project.PysmoProject.fetch_seismogram]
        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.
        The same applies to mutating a configuration field *inside* `window`,
        `seismogram_transform`, or `fetch_seismogram` rather than reassigning
        the whole callable; a `frozen=True` attrs callable (as
        [`PhaseWindow`][pysmo.tools.project.PhaseWindow] is) rules that out.
        """
        with self._lock:
            self._cache.clear()
            self._cache_generation += 1

    def _fetch(
        self, entry: ProjectEntry[TStation, TEvent], *, _stacklevel: int = 3
    ) -> TSeismogram:
        """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 an entry with
            the same [`identity`][pysmo.tools.project.ProjectEntry.identity]
            has been fetched before.

        Raises:
            ValueError: If `window` cannot resolve a window for `entry`; 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"`.
        """
        key: _CacheKey = entry.identity
        with self._lock:
            cached = self._cache.get(key)
            generation = self._cache_generation
        if cached is None:
            # Resolve the window only on a miss: an explicit window on the
            # entry wins (it is entry data, not resolution policy), else the
            # `window` resolver derives one. Resolution and the fetch both
            # run outside the lock (see the class docstring's thread-safety
            # note): a concurrent `self.window` reassignment is a tolerated
            # torn read, `generation` (read above) guards the write-back, and
            # two threads racing the same key both fetch before one wins.
            if entry.starttime is not None and entry.endtime is not None:
                window = WindowResult(
                    starttime=entry.starttime, endtime=entry.endtime, reference=None
                )
            else:
                window = self.window(entry)
            seismogram = self.fetch_seismogram(
                entry.station, window.starttime, window.endtime
            )
            # Checksum the raw trace pre-transform: the transform's output can
            # be mutated by downstream consumers (`ICCS` edits `t0`/`t1`/`flip`
            # during a run), which would otherwise read back as false drift.
            checksum = seismogram_checksum(seismogram)
            context = FetchContext(
                entry=entry,
                starttime=window.starttime,
                endtime=window.endtime,
                reference=window.reference,
            )
            fresh = (checksum, self.seismogram_transform(seismogram, context))
            with self._lock:
                if self._cache_generation == generation:
                    cached = self._cache.setdefault(key, fresh)
                else:
                    # A parameter changed (clearing the cache) while this
                    # fetch was in flight: return this result once without
                    # caching it under a now-stale key.
                    cached = fresh

        checksum, result = cached
        with self._lock:
            recorded = entry.checksum
            if recorded is None:
                entry.checksum = checksum
        if (
            recorded is not None
            and recorded != 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 source data changed, or fetch_seismogram "
                + "now yields the same samples in a different dtype."
            )
            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[TStation]:
        """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[TStation] = []
        for entry in self.entries:
            if entry.station not in seen:
                seen.append(entry.station)
        return seen

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

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

    @property
    def resolution_context_digest(self) -> str:
        """Digest over the project parameters that determine fetched content.

        Covers `window` and `seismogram_transform`. Reassigning
        `fetch_seismogram` (e.g. to an on-disk fetch cache) leaves the
        digest unchanged.

        Examples:
            >>> from pysmo.tools.project import PysmoProject
            >>> project = PysmoProject()
            >>> project.resolution_context_digest.startswith("rc2:")
            True
            >>> len(project.resolution_context_digest)
            68
        """
        return resolution_context_digest(self)

    def events_for(self, station: TStation) -> list[TEvent | 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[TEvent | 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: TEvent | None) -> list[TStation]:
        """Stations available for one event, in first-seen order.

        Pass `None` for stations with an event-less entry.
        """
        seen: list[TStation] = []
        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: TStation,
        event: TEvent | None = None,
        *,
        _stacklevel: int = 3,
    ) -> TSeismogram:
        """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 get(self, identity: str, *, _stacklevel: int = 3) -> TSeismogram:
        """Fetch (or return from cache) the result for an entry by its identity.

        Args:
            identity: An [`entry.identity`][pysmo.tools.project.ProjectEntry.identity]
                string. The entry need not have been fetched before.

        Returns:
            The transformed result for the matching entry.

        Raises:
            UnknownEntryIdentity: If no entry matches this identity.
            ValueError: If more than one entry matches: an authoring mistake,
                surfaced rather than silently resolved by picking one.

        Examples:
            >>> import pandas as pd
            >>> from pysmo import MiniEvent, MiniSeismogram, MiniStation, Station
            >>> from pysmo.tools.project import ProjectEntry, PysmoProject
            >>> def fake_fetch(station: Station, t0: pd.Timestamp, t1: pd.Timestamp):
            ...     return MiniSeismogram(
            ...         begin_time=t0, delta=pd.Timedelta(seconds=1), data=[1.0, 2.0]
            ...     )
            >>> station = MiniStation(
            ...     name="ANMO",
            ...     network="IU",
            ...     location="00",
            ...     channel="BHZ",
            ...     latitude=34.9459,
            ...     longitude=-106.4571,
            ... )
            >>> entry = ProjectEntry(
            ...     station=station,
            ...     starttime=pd.Timestamp("2020-01-01T00:00:00Z"),
            ...     endtime=pd.Timestamp("2020-01-01T00:10:00Z"),
            ... )
            >>> project = PysmoProject(entries=[entry], fetch_seismogram=fake_fetch)
            >>> seis = project.get(entry.identity)
            >>> len(seis.data)
            2
        """
        matches = [e for e in self.entries if e.identity == identity]
        if not matches:
            raise UnknownEntryIdentity(identity)
        if len(matches) > 1:
            raise ValueError(f"More than one entry resolves to identity {identity!r}.")
        return self._fetch(matches[0], _stacklevel=_stacklevel)

    def seismograms_for(self, event: TEvent) -> list[TSeismogram]:
        """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[TSeismogram]:
        """Fetch every entry in the project.

        With the default, always-fresh `fetch_seismogram`, this just warms
        `_cache` for the session. With a cache-backed `fetch_seismogram`
        (e.g. [`FetchCache`][pysmo.tools.cache.FetchCache]), this is what
        actually populates the on-disk cache: a single, explicit "get
        everything this project needs onto disk" 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[TStation, TEvent]] = field(
    factory=list,
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Station/event/window selections making up this project.

Build them with build_entries; grow the project later with project.entries.extend(build_entries(...)) (a plain in-place mutation; call clear_cache afterwards only to free memory, never for correctness, since the fetch cache is keyed by entry content).

events property

events: list[TEvent]

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: SeismogramFetcher = field(
    default=_default_fetch_seismogram,
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Download a seismogram for a station and absolute time window.

Any SeismogramFetcher (see there for the call contract). Defaults to a private helper wrapping MSeed.fetch, the explicit "always fresh, never cached" choice.

For any project where reproducibility matters, substitute a FetchCache instance instead. That is the recommended value for real analysis work, not a power-user option on equal footing with the default; see its own docstring for why, and how it differs from ProjectEntry.checksum's live-fetch drift detection. Leave its max_bytes at the default (unlimited) for this to hold: a finite max_bytes evicts old entries and re-fetches them on next access, reintroducing the drift a cache is meant to rule out. It only pins the waveform, though: see the module documentation's second example for the gotcha it does not cover, seismogram_transform making its own additional fetches.

name class-attribute instance-attribute

name: str | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(str)
    ),
)

Optional free-text label for this project.

Useful for identification by downstream tools that hold multiple projects. This is purely a label, not a key: no uniqueness is enforced, renaming does not invalidate the fetch cache, and it is not part of resolution_context_digest or any entry identity.

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.

resolution_context_digest property

resolution_context_digest: str

Digest over the project parameters that determine fetched content.

Covers window and seismogram_transform. Reassigning fetch_seismogram (e.g. to an on-disk fetch cache) leaves the digest unchanged.

Examples:

>>> from pysmo.tools.project import PysmoProject
>>> project = PysmoProject()
>>> project.resolution_context_digest.startswith("rc2:")
True
>>> len(project.resolution_context_digest)
68

seismogram_transform class-attribute instance-attribute

seismogram_transform: SeismogramTransform[
    TStation, TEvent, TSeismogram
] = field(
    default=_seismogram_to_mini_seismogram,
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Convert a freshly downloaded seismogram into the target type TSeismogram.

Any SeismogramTransform (see there for the call contract). Defaults to returning the raw trace as a MiniSeismogram, with no processing: a custom transform is where response removal, detrending and resampling belong, and it may issue its own additional fetches, as the module documentation's example does for instrument response metadata. A callable attrs class with only picklable fields is the way to give the transform its own configuration.

stations property

stations: list[TStation]

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).

window class-attribute instance-attribute

window: WindowResolver[TStation, TEvent] = field(
    default=PhaseWindow(),
    on_setattr=setters.pipe(
        setters.convert, _on_setattr_clear_cache
    ),
)

Resolve an entry's fetch window when it carries no explicit one.

Any WindowResolver (see there for the call contract). Defaults to PhaseWindow, which places the window around a predicted phase arrival. An entry with an explicit starttime/endtime bypasses this entirely, so a custom resolver only ever handles the event-derived case.

__getstate__

__getstate__() -> dict[str, Any]

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[str, Any]:
    """Drop the fetch cache and lock, neither of which can survive pickling."""
    state = attrs_getstate(self, {"_cache": {}, "_cache_generation": 0})
    del state["_lock"]
    state["_format_version"] = self._FORMAT_VERSION
    state["_pysmo_version"] = __version__
    return state

__setstate__

__setstate__(state: dict[str, Any]) -> None

Restore state without firing on_setattr hooks, then make a fresh lock.

Source code in src/pysmo/tools/project/_project.py
def __setstate__(self, state: dict[str, Any]) -> None:
    """Restore state without firing `on_setattr` hooks, then make a fresh lock."""
    # Format-compatibility gate only; pickle.load above has already run
    # any code in the file, so this is not a trust check.
    pickled_format = state.pop("_format_version", 0)
    pickled_pysmo = state.pop("_pysmo_version", "unknown")
    if pickled_format != self._FORMAT_VERSION:
        raise ValueError(
            f"This PysmoProject was pickled by pysmo {pickled_pysmo} in state "
            + f"format v{pickled_format}; this pysmo ({__version__}) uses "
            + f"v{self._FORMAT_VERSION}. Re-create the project."
        )
    state.setdefault("name", None)
    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, window, seismogram_transform, or fetch_seismogram 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. The same applies to mutating a configuration field inside window, seismogram_transform, or fetch_seismogram rather than reassigning the whole callable; a frozen=True attrs callable (as PhaseWindow is) rules that out.

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],
    [`window`][pysmo.tools.project.PysmoProject.window],
    [`seismogram_transform`][pysmo.tools.project.PysmoProject.seismogram_transform],
    or
    [`fetch_seismogram`][pysmo.tools.project.PysmoProject.fetch_seismogram]
    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.
    The same applies to mutating a configuration field *inside* `window`,
    `seismogram_transform`, or `fetch_seismogram` rather than reassigning
    the whole callable; a `frozen=True` attrs callable (as
    [`PhaseWindow`][pysmo.tools.project.PhaseWindow] is) rules that out.
    """
    with self._lock:
        self._cache.clear()
        self._cache_generation += 1

events_for

events_for(station: TStation) -> list[TEvent | 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: TStation) -> list[TEvent | 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[TEvent | 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[TSeismogram]

Fetch every entry in the project.

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

Returns:

Type Description
list[TSeismogram]

One transformed result per entry, in entries order.

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

    With the default, always-fresh `fetch_seismogram`, this just warms
    `_cache` for the session. With a cache-backed `fetch_seismogram`
    (e.g. [`FetchCache`][pysmo.tools.cache.FetchCache]), this is what
    actually populates the on-disk cache: a single, explicit "get
    everything this project needs onto disk" 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]

get

get(identity: str, *, _stacklevel: int = 3) -> TSeismogram

Fetch (or return from cache) the result for an entry by its identity.

Parameters:

Name Type Description Default
identity str

An entry.identity string. The entry need not have been fetched before.

required

Returns:

Type Description
TSeismogram

The transformed result for the matching entry.

Raises:

Type Description
UnknownEntryIdentity

If no entry matches this identity.

ValueError

If more than one entry matches: an authoring mistake, surfaced rather than silently resolved by picking one.

Examples:

>>> import pandas as pd
>>> from pysmo import MiniEvent, MiniSeismogram, MiniStation, Station
>>> from pysmo.tools.project import ProjectEntry, PysmoProject
>>> def fake_fetch(station: Station, t0: pd.Timestamp, t1: pd.Timestamp):
...     return MiniSeismogram(
...         begin_time=t0, delta=pd.Timedelta(seconds=1), data=[1.0, 2.0]
...     )
>>> station = MiniStation(
...     name="ANMO",
...     network="IU",
...     location="00",
...     channel="BHZ",
...     latitude=34.9459,
...     longitude=-106.4571,
... )
>>> entry = ProjectEntry(
...     station=station,
...     starttime=pd.Timestamp("2020-01-01T00:00:00Z"),
...     endtime=pd.Timestamp("2020-01-01T00:10:00Z"),
... )
>>> project = PysmoProject(entries=[entry], fetch_seismogram=fake_fetch)
>>> seis = project.get(entry.identity)
>>> len(seis.data)
2
Source code in src/pysmo/tools/project/_project.py
def get(self, identity: str, *, _stacklevel: int = 3) -> TSeismogram:
    """Fetch (or return from cache) the result for an entry by its identity.

    Args:
        identity: An [`entry.identity`][pysmo.tools.project.ProjectEntry.identity]
            string. The entry need not have been fetched before.

    Returns:
        The transformed result for the matching entry.

    Raises:
        UnknownEntryIdentity: If no entry matches this identity.
        ValueError: If more than one entry matches: an authoring mistake,
            surfaced rather than silently resolved by picking one.

    Examples:
        >>> import pandas as pd
        >>> from pysmo import MiniEvent, MiniSeismogram, MiniStation, Station
        >>> from pysmo.tools.project import ProjectEntry, PysmoProject
        >>> def fake_fetch(station: Station, t0: pd.Timestamp, t1: pd.Timestamp):
        ...     return MiniSeismogram(
        ...         begin_time=t0, delta=pd.Timedelta(seconds=1), data=[1.0, 2.0]
        ...     )
        >>> station = MiniStation(
        ...     name="ANMO",
        ...     network="IU",
        ...     location="00",
        ...     channel="BHZ",
        ...     latitude=34.9459,
        ...     longitude=-106.4571,
        ... )
        >>> entry = ProjectEntry(
        ...     station=station,
        ...     starttime=pd.Timestamp("2020-01-01T00:00:00Z"),
        ...     endtime=pd.Timestamp("2020-01-01T00:10:00Z"),
        ... )
        >>> project = PysmoProject(entries=[entry], fetch_seismogram=fake_fetch)
        >>> seis = project.get(entry.identity)
        >>> len(seis.data)
        2
    """
    matches = [e for e in self.entries if e.identity == identity]
    if not matches:
        raise UnknownEntryIdentity(identity)
    if len(matches) > 1:
        raise ValueError(f"More than one entry resolves to identity {identity!r}.")
    return self._fetch(matches[0], _stacklevel=_stacklevel)

seismogram

seismogram(
    station: TStation,
    event: TEvent | None = None,
    *,
    _stacklevel: int = 3
) -> TSeismogram

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

Parameters:

Name Type Description Default
station TStation

Station to fetch.

required
event TEvent | None

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

None

Returns:

Type Description
TSeismogram

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: TStation,
    event: TEvent | None = None,
    *,
    _stacklevel: int = 3,
) -> TSeismogram:
    """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: TEvent) -> list[TSeismogram]

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: TEvent) -> list[TSeismogram]:
    """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: TEvent | None) -> list[TStation]

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: TEvent | None) -> list[TStation]:
    """Stations available for one event, in first-seen order.

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

TransformCache

Caches a seismogram_transform's output, and its secondary fetches, on disk.

A SeismogramTransform for PysmoProject that wraps another transform. On a miss it runs the wrapped transform and stores its result as a seismogram_to_json document in a BlobCache; on a hit it reconstructs the result from JSON without running the transform at all, which also skips whatever additional fetches (instrument response metadata, say) the transform issues on its own. That second effect is usually the reason to reach for this: a FetchCache pins the waveform but says nothing about a transform's own network calls.

The cache key folds in every part of the fetch the wrapped transform can observe: the entry's identity, the resolved window and its reference time, the wrapped transform's configuration (callable_identity), and a seismogram_checksum of the transform's input seismogram. A change to any of them mints a new key with no explicit purge; an evicted or drifted input drains naturally. Every call hashes the input seismogram to build the key, a hit included.

Only value-object attrs seismograms round-trip: MiniSeismogram, MiniIccsSeismogram, GeoCsvSeismogram, and any user type whose fields are primitives, collections, nested attrs, or the three leaf types the codec handles. A transform returning anything else (a SacSeismogram live view, a type with an unhookable field) raises TypeError at store time, naming clone_to_mini.

Examples:

>>> import pandas as pd
>>> from pathlib import Path
>>> import tempfile
>>> from pysmo import MiniEvent, MiniSeismogram, MiniStation, Seismogram
>>> from pysmo.functions import clone_to_mini
>>> from pysmo.tools.project import FetchContext, ProjectEntry
>>> from pysmo.tools.project import TransformCache
>>>
>>> calls = []
>>> def double(seismogram: Seismogram, context: FetchContext) -> MiniSeismogram:
...     calls.append(1)
...     return clone_to_mini(MiniSeismogram, seismogram)
...
>>> station = MiniStation(
...     name="ANMO", network="IU", location="00", channel="BHZ",
...     latitude=34.9, longitude=-106.5,
... )
>>> event = MiniEvent(
...     latitude=-36.1, longitude=-72.9, depth=22900.0,
...     time=pd.Timestamp("2010-02-27T06:34:11Z"),
... )
>>> entry = ProjectEntry(station=station, event=event)
>>> context = FetchContext(
...     entry=entry,
...     starttime=pd.Timestamp("2010-02-27T06:40:00Z"),
...     endtime=pd.Timestamp("2010-02-27T06:50:00Z"),
...     reference=pd.Timestamp("2010-02-27T06:44:00Z"),
... )
>>> raw = MiniSeismogram(
...     begin_time=pd.Timestamp("2010-02-27T06:40:00Z"),
...     delta=pd.Timedelta(seconds=1), data=[1.0, 2.0, 3.0],
... )
>>>
>>> cache = TransformCache(
...     path=Path(tempfile.mkdtemp()) / "transform.sqlite3", transform=double
... )
>>> first = cache(raw, context)   # miss: runs the wrapped transform
>>> second = cache(raw, context)  # hit: reconstructed from JSON
>>> len(calls)
1
>>> second.data.tolist()
[1.0, 2.0, 3.0]
>>>

Methods:

Name Description
__attrs_post_init__

Build the inner store (which also checks path's parent exists).

__call__

Return the transformed result for this fetch, from cache when possible.

__getstate__

Drop the inner store; it is rebuilt from the plain fields on unpickling.

__setstate__

Restore the plain fields, then rebuild the inner store.

close

Close the inner store's connection, if one is open.

Attributes:

Name Type Description
max_bytes PositiveInt | None

Maximum total size of compressed data stored, in bytes; None for

path Path

Location of the SQLite database file.

transform SeismogramTransform[TStation, TEvent, TSeismogram]

The wrapped transform, run only on a cache miss.

trusted_modules tuple[str, ...]

Top-level packages a cached result's type may be imported from when it

verify bool

Re-decode each freshly stored result and compare it to the transform's

wal bool

Enable WAL mode (local disk only; see

Source code in src/pysmo/tools/project/_transformcache.py
@define(kw_only=True)
class TransformCache[TStation: Station, TEvent: Event, TSeismogram]:
    """Caches a `seismogram_transform`'s output, and its secondary fetches, on disk.

    A [`SeismogramTransform`][pysmo.tools.project.SeismogramTransform] for
    [`PysmoProject`][pysmo.tools.project.PysmoProject] that wraps another
    transform. On a miss it runs the wrapped transform and stores its result
    as a [`seismogram_to_json`][pysmo.functions.seismogram_to_json] document
    in a [`BlobCache`][pysmo.tools.cache.BlobCache]; on a hit it reconstructs
    the result from JSON without running the transform at all, which also
    skips whatever additional fetches (instrument response metadata, say) the
    transform issues on its own. That second effect is usually the reason to
    reach for this: a [`FetchCache`][pysmo.tools.cache.FetchCache] pins the
    waveform but says nothing about a transform's own network calls.

    The cache key folds in every part of the fetch the wrapped transform can
    observe: the entry's identity, the resolved window and its reference
    time, the wrapped transform's configuration
    ([`callable_identity`][pysmo.tools.project.callable_identity]), and a
    [`seismogram_checksum`][pysmo.functions.seismogram_checksum] of the
    transform's input seismogram. A change to any of them mints a new key with
    no explicit purge; an evicted or drifted input drains naturally. Every
    call hashes the input seismogram to build the key, a hit included.

    Only value-object `attrs` seismograms round-trip:
    [`MiniSeismogram`][pysmo.MiniSeismogram],
    [`MiniIccsSeismogram`][pysmo.tools.iccs.MiniIccsSeismogram],
    [`GeoCsvSeismogram`][pysmo.classes.GeoCsvSeismogram], and any user type
    whose fields are primitives, collections, nested `attrs`, or the three
    leaf types the codec handles. A transform returning anything else (a
    `SacSeismogram` live view, a type with an unhookable field) raises
    `TypeError` at store time, naming
    [`clone_to_mini`][pysmo.functions.clone_to_mini].

    Examples:
        ```python
        >>> import pandas as pd
        >>> from pathlib import Path
        >>> import tempfile
        >>> from pysmo import MiniEvent, MiniSeismogram, MiniStation, Seismogram
        >>> from pysmo.functions import clone_to_mini
        >>> from pysmo.tools.project import FetchContext, ProjectEntry
        >>> from pysmo.tools.project import TransformCache
        >>>
        >>> calls = []
        >>> def double(seismogram: Seismogram, context: FetchContext) -> MiniSeismogram:
        ...     calls.append(1)
        ...     return clone_to_mini(MiniSeismogram, seismogram)
        ...
        >>> station = MiniStation(
        ...     name="ANMO", network="IU", location="00", channel="BHZ",
        ...     latitude=34.9, longitude=-106.5,
        ... )
        >>> event = MiniEvent(
        ...     latitude=-36.1, longitude=-72.9, depth=22900.0,
        ...     time=pd.Timestamp("2010-02-27T06:34:11Z"),
        ... )
        >>> entry = ProjectEntry(station=station, event=event)
        >>> context = FetchContext(
        ...     entry=entry,
        ...     starttime=pd.Timestamp("2010-02-27T06:40:00Z"),
        ...     endtime=pd.Timestamp("2010-02-27T06:50:00Z"),
        ...     reference=pd.Timestamp("2010-02-27T06:44:00Z"),
        ... )
        >>> raw = MiniSeismogram(
        ...     begin_time=pd.Timestamp("2010-02-27T06:40:00Z"),
        ...     delta=pd.Timedelta(seconds=1), data=[1.0, 2.0, 3.0],
        ... )
        >>>
        >>> cache = TransformCache(
        ...     path=Path(tempfile.mkdtemp()) / "transform.sqlite3", transform=double
        ... )
        >>> first = cache(raw, context)   # miss: runs the wrapped transform
        >>> second = cache(raw, context)  # hit: reconstructed from JSON
        >>> len(calls)
        1
        >>> second.data.tolist()
        [1.0, 2.0, 3.0]
        >>>
        ```
    """

    path: Path = field(converter=Path, metadata={"identity": False})
    """Location of the SQLite database file.

    The file itself is created on first use; its *parent directory* must
    already exist, checked at construction time. Not part of the wrapper's
    [`callable_identity`][pysmo.tools.project.callable_identity]: moving the
    cache file does not change what a call returns.
    """

    transform: SeismogramTransform[TStation, TEvent, TSeismogram]
    """The wrapped transform, run only on a cache miss.

    Must be picklable by reference (a top-level function or a callable
    `attrs` instance), the same constraint `PysmoProject` places on
    `seismogram_transform` itself.
    """

    wal: bool = field(default=False, metadata={"identity": False})
    """Enable WAL mode (local disk only; see
    [`BlobCache`][pysmo.tools.cache.BlobCache])."""

    max_bytes: PositiveInt | None = field(
        default=None,
        validator=validators.optional(validators.gt(0)),
        metadata={"identity": False},
    )
    """Maximum total size of compressed data stored, in bytes; `None` for
    unlimited. See [`BlobCache.max_bytes`][pysmo.tools.cache.BlobCache]."""

    verify: bool = True
    """Re-decode each freshly stored result and compare it to the transform's
    output, raising `TypeError` on any mismatch.

    Catches a codec that silently loses information on a rich `TSeismogram`
    (e.g. a non-primitive value in `MiniIccsSeismogram.extra`). Leave on
    unless the transform output is known to be a plain
    [`MiniSeismogram`][pysmo.MiniSeismogram]."""

    trusted_modules: tuple[str, ...] = field(
        default=("pysmo",), metadata={"identity": False}
    )
    """Top-level packages a cached result's type may be imported from when it
    is rebuilt on a hit. The pysmo value objects
    ([`MiniSeismogram`][pysmo.MiniSeismogram] and friends) are covered by the
    default; widen it only if the wrapped transform returns a value object
    defined in your own package."""

    _cache: BlobCache = field(init=False, repr=False, eq=False)

    def __attrs_post_init__(self) -> None:
        """Build the inner store (which also checks `path`'s parent exists)."""
        self._cache = self._build_cache()

    def _build_cache(self) -> BlobCache:
        return BlobCache(
            path=self.path,
            encoding_version=_ENCODING_VERSION,
            wal=self.wal,
            max_bytes=self.max_bytes,
        )

    def __getstate__(self) -> dict[str, Any]:
        """Drop the inner store; it is rebuilt from the plain fields on unpickling."""
        state = attrs_getstate(self, {})
        del state["_cache"]
        return state

    def __setstate__(self, state: dict[str, Any]) -> None:
        """Restore the plain fields, then rebuild the inner store."""
        attrs_setstate(self, state)
        self._cache = self._build_cache()

    def close(self) -> None:
        """Close the inner store's connection, if one is open."""
        self._cache.close()

    def __call__(
        self, seismogram: Seismogram, context: FetchContext[TStation, TEvent]
    ) -> TSeismogram:
        """Return the transformed result for this fetch, from cache when possible.

        Args:
            seismogram: The freshly fetched trace, the wrapped transform's input.
            context: The originating entry and this fetch's resolved window.

        Returns:
            The wrapped transform's result: reconstructed from JSON on a hit,
            freshly computed (and then stored) on a miss.
        """
        key = json.dumps(
            [
                context.entry.identity,
                to_utc_timestamp(context.starttime).isoformat(),
                to_utc_timestamp(context.endtime).isoformat(),
                None
                if context.reference is None
                else to_utc_timestamp(context.reference).isoformat(),
                callable_identity(self.transform),
                seismogram_checksum(seismogram),
            ]
        )

        def produce() -> bytes:
            # `TSeismogram` is unbounded; `seismogram_to_json` gates the type
            # at runtime and raises `TypeError` for anything it cannot encode.
            result = cast(Seismogram, self.transform(seismogram, context))
            return seismogram_to_json(result, verify=self.verify)

        blob = self._cache.get(key, produce)
        return cast(
            TSeismogram,
            seismogram_from_json(blob, trusted_modules=self.trusted_modules),
        )

max_bytes class-attribute instance-attribute

max_bytes: PositiveInt | None = field(
    default=None,
    validator=validators.optional(validators.gt(0)),
    metadata={"identity": False},
)

Maximum total size of compressed data stored, in bytes; None for unlimited. See BlobCache.max_bytes.

path class-attribute instance-attribute

path: Path = field(
    converter=Path, metadata={"identity": False}
)

Location of the SQLite database file.

The file itself is created on first use; its parent directory must already exist, checked at construction time. Not part of the wrapper's callable_identity: moving the cache file does not change what a call returns.

transform instance-attribute

transform: SeismogramTransform[
    TStation, TEvent, TSeismogram
]

The wrapped transform, run only on a cache miss.

Must be picklable by reference (a top-level function or a callable attrs instance), the same constraint PysmoProject places on seismogram_transform itself.

trusted_modules class-attribute instance-attribute

trusted_modules: tuple[str, ...] = field(
    default=("pysmo",), metadata={"identity": False}
)

Top-level packages a cached result's type may be imported from when it is rebuilt on a hit. The pysmo value objects (MiniSeismogram and friends) are covered by the default; widen it only if the wrapped transform returns a value object defined in your own package.

verify class-attribute instance-attribute

verify: bool = True

Re-decode each freshly stored result and compare it to the transform's output, raising TypeError on any mismatch.

Catches a codec that silently loses information on a rich TSeismogram (e.g. a non-primitive value in MiniIccsSeismogram.extra). Leave on unless the transform output is known to be a plain MiniSeismogram.

wal class-attribute instance-attribute

wal: bool = field(
    default=False, metadata={"identity": False}
)

Enable WAL mode (local disk only; see BlobCache).

__attrs_post_init__

__attrs_post_init__() -> None

Build the inner store (which also checks path's parent exists).

Source code in src/pysmo/tools/project/_transformcache.py
def __attrs_post_init__(self) -> None:
    """Build the inner store (which also checks `path`'s parent exists)."""
    self._cache = self._build_cache()

__call__

__call__(
    seismogram: Seismogram,
    context: FetchContext[TStation, TEvent],
) -> TSeismogram

Return the transformed result for this fetch, from cache when possible.

Parameters:

Name Type Description Default
seismogram Seismogram

The freshly fetched trace, the wrapped transform's input.

required
context FetchContext[TStation, TEvent]

The originating entry and this fetch's resolved window.

required

Returns:

Type Description
TSeismogram

The wrapped transform's result: reconstructed from JSON on a hit,

TSeismogram

freshly computed (and then stored) on a miss.

Source code in src/pysmo/tools/project/_transformcache.py
def __call__(
    self, seismogram: Seismogram, context: FetchContext[TStation, TEvent]
) -> TSeismogram:
    """Return the transformed result for this fetch, from cache when possible.

    Args:
        seismogram: The freshly fetched trace, the wrapped transform's input.
        context: The originating entry and this fetch's resolved window.

    Returns:
        The wrapped transform's result: reconstructed from JSON on a hit,
        freshly computed (and then stored) on a miss.
    """
    key = json.dumps(
        [
            context.entry.identity,
            to_utc_timestamp(context.starttime).isoformat(),
            to_utc_timestamp(context.endtime).isoformat(),
            None
            if context.reference is None
            else to_utc_timestamp(context.reference).isoformat(),
            callable_identity(self.transform),
            seismogram_checksum(seismogram),
        ]
    )

    def produce() -> bytes:
        # `TSeismogram` is unbounded; `seismogram_to_json` gates the type
        # at runtime and raises `TypeError` for anything it cannot encode.
        result = cast(Seismogram, self.transform(seismogram, context))
        return seismogram_to_json(result, verify=self.verify)

    blob = self._cache.get(key, produce)
    return cast(
        TSeismogram,
        seismogram_from_json(blob, trusted_modules=self.trusted_modules),
    )

__getstate__

__getstate__() -> dict[str, Any]

Drop the inner store; it is rebuilt from the plain fields on unpickling.

Source code in src/pysmo/tools/project/_transformcache.py
def __getstate__(self) -> dict[str, Any]:
    """Drop the inner store; it is rebuilt from the plain fields on unpickling."""
    state = attrs_getstate(self, {})
    del state["_cache"]
    return state

__setstate__

__setstate__(state: dict[str, Any]) -> None

Restore the plain fields, then rebuild the inner store.

Source code in src/pysmo/tools/project/_transformcache.py
def __setstate__(self, state: dict[str, Any]) -> None:
    """Restore the plain fields, then rebuild the inner store."""
    attrs_setstate(self, state)
    self._cache = self._build_cache()

close

close() -> None

Close the inner store's connection, if one is open.

Source code in src/pysmo/tools/project/_transformcache.py
def close(self) -> None:
    """Close the inner store's connection, if one is open."""
    self._cache.close()

UnknownEntryIdentity

Bases: KeyError

Raised when an entry with the requested identity is not found in the project.

Subclasses KeyError, which PysmoProject.seismogram also raises for an unknown station/event pair, so one except KeyError (or except LookupError) catches both lookups.

Source code in src/pysmo/tools/project/_identity.py
class UnknownEntryIdentity(KeyError):
    """Raised when an entry with the requested identity is not found in the project.

    Subclasses `KeyError`, which
    [`PysmoProject.seismogram`][pysmo.tools.project.PysmoProject.seismogram]
    also raises for an unknown station/event pair, so one `except KeyError`
    (or `except LookupError`) catches both lookups.
    """

    def __init__(self, identity: str) -> None:
        super().__init__(f"No entry with identity {identity!r}.")
        self.identity = identity

    def __str__(self) -> str:
        return str(self.args[0])

WindowResult

The absolute fetch window resolved for one entry.

Returned by a WindowResolver. starttime must be strictly before endtime, checked at construction so a buggy resolver fails here rather than at the remote fetch.

Methods:

Name Description
__attrs_post_init__

Reject a reversed or zero-length window.

Attributes:

Name Type Description
endtime Timestamp

Absolute end of the window.

reference Timestamp | None

Timestamp the window was placed around (a predicted phase arrival for

starttime Timestamp

Absolute start of the window.

Source code in src/pysmo/tools/project/_types.py
@define(kw_only=True, frozen=True)
class WindowResult:
    """The absolute fetch window resolved for one entry.

    Returned by a [`WindowResolver`][pysmo.tools.project.WindowResolver].
    `starttime` must be strictly before `endtime`, checked at construction so
    a buggy resolver fails here rather than at the remote fetch.
    """

    starttime: pd.Timestamp
    """Absolute start of the window."""

    endtime: pd.Timestamp
    """Absolute end of the window."""

    reference: pd.Timestamp | None
    """Timestamp the window was placed around (a predicted phase arrival for
    [`PhaseWindow`][pysmo.tools.project.PhaseWindow]), or `None` when the
    window came from an entry's explicit `starttime`/`endtime`."""

    def __attrs_post_init__(self) -> None:
        """Reject a reversed or zero-length window."""
        if self.starttime >= self.endtime:
            raise ValueError(
                f"WindowResult starttime ({self.starttime}) must be before "
                + f"endtime ({self.endtime})."
            )

endtime instance-attribute

endtime: Timestamp

Absolute end of the window.

reference instance-attribute

reference: Timestamp | None

Timestamp the window was placed around (a predicted phase arrival for PhaseWindow), or None when the window came from an entry's explicit starttime/endtime.

starttime instance-attribute

starttime: Timestamp

Absolute start of the window.

__attrs_post_init__

__attrs_post_init__() -> None

Reject a reversed or zero-length window.

Source code in src/pysmo/tools/project/_types.py
def __attrs_post_init__(self) -> None:
    """Reject a reversed or zero-length window."""
    if self.starttime >= self.endtime:
        raise ValueError(
            f"WindowResult starttime ({self.starttime}) must be before "
            + f"endtime ({self.endtime})."
        )

build_entries

build_entries(
    stations: Iterable[TStation],
    events: Iterable[TEvent],
    predicate: (
        Callable[[TStation, TEvent], bool] | None
    ) = None,
) -> list[ProjectEntry[TStation, TEvent]]

Build project entries from a filtered cross product of stations and events.

One ProjectEntry per (station, event) pair for which predicate returns True, or every pair if predicate is None. stations and events are expected to be already narrowed to the working set; this function pairs, it does not narrow or transform.

Parameters:

Name Type Description Default
stations Iterable[TStation]

The stations to pair, already narrowed.

required
events Iterable[TEvent]

The events to pair, already narrowed.

required
predicate Callable[[TStation, TEvent], bool] | None

Optional (station, event) -> bool deciding which pairs become entries. Called eagerly and not stored, so a lambda or closure is fine. The dominant use is a distance cutoff, e.g. lambda s, e: haversine(e, s) <= 95.0.

None

Returns:

Type Description
list[ProjectEntry[TStation, TEvent]]

One ProjectEntry per surviving pair, stations-outer / events-inner.

Source code in src/pysmo/tools/project/_entry.py
def build_entries[TStation: Station, TEvent: Event](
    stations: Iterable[TStation],
    events: Iterable[TEvent],
    predicate: Callable[[TStation, TEvent], bool] | None = None,
) -> list[ProjectEntry[TStation, TEvent]]:
    """Build project entries from a filtered cross product of stations and events.

    One [`ProjectEntry`][pysmo.tools.project.ProjectEntry] per (station,
    event) pair for which `predicate` returns `True`, or every pair if
    `predicate` is `None`. `stations` and `events` are expected to be
    already narrowed to the working set; this function pairs, it does not
    narrow or transform.

    Args:
        stations: The stations to pair, already narrowed.
        events: The events to pair, already narrowed.
        predicate: Optional `(station, event) -> bool` deciding which pairs
            become entries. Called eagerly and not stored, so a lambda or
            closure is fine. The dominant use is a distance cutoff, e.g.
            `lambda s, e: haversine(e, s) <= 95.0`.

    Returns:
        One `ProjectEntry` per surviving pair, stations-outer / events-inner.
    """
    stations = list(stations)
    events = list(events)
    return [
        ProjectEntry(station=station, event=event)
        for station in stations
        for event in events
        if predicate is None or predicate(station, event)
    ]

callable_identity

callable_identity(fn: Any) -> str

A stable, picklable identity string for a callable.

Supports:

  • Top-level functions in importable modules.
  • functools.partial wrapping a supported callable.
  • attrs instances with picklable fields. A field carrying metadata={"identity": False} is left out of the digest, for configuration that does not change what the callable returns (a wrapping cache's storage path, for instance).

Raises TypeError for closures, lambdas, bound methods, classes, and other objects.

Source code in src/pysmo/tools/project/_identity.py
def callable_identity(fn: Any) -> str:
    """A stable, picklable identity string for a callable.

    Supports:

    - Top-level functions in importable modules.
    - `functools.partial` wrapping a supported callable.
    - `attrs` instances with picklable fields. A field carrying
      `metadata={"identity": False}` is left out of the digest, for
      configuration that does not change what the callable returns (a
      wrapping cache's storage path, for instance).

    Raises `TypeError` for closures, lambdas, bound methods, classes, and other
    objects.
    """
    if isinstance(fn, functools.partial):
        func_id = callable_identity(fn.func)
        args_prepared = [_prepare_json_value(arg) for arg in fn.args]
        kw_prepared = {
            k: _prepare_json_value(v) for k, v in (fn.keywords or {}).items()
        }
        payload = _canonical([args_prepared, kw_prepared])
        return f"partial:{func_id}:{payload}"

    if attrs.has(type(fn)):
        fields_payload: dict[str, Any] = {}
        for attribute in attrs.fields(type(fn)):
            if not attribute.init:
                # Identity is the callable's configuration: the arguments it
                # was built from. `init=False` fields (a live connection, a
                # lock, a lazily built helper) are internal state, never
                # config, and some are not serialisable at all.
                continue
            if attribute.metadata.get("identity", True) is False:
                # Explicitly opted out: a field that doesn't change what the
                # callable returns (e.g. a wrapping cache's storage location).
                continue
            value = getattr(fn, attribute.name)
            if attrs.has(type(value)) or (
                callable(value) and not isinstance(value, (str, bytes))
            ):
                # A nested callable field (e.g. a travel-time backend held as
                # a `functools.partial`) that `_prepare_json_value` cannot
                # reduce: digest it recursively instead.
                fields_payload[attribute.name] = callable_identity(value)
            else:
                fields_payload[attribute.name] = _prepare_json_value(value)
        payload = _canonical(fields_payload)
        digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
        return f"attrs:{type(fn).__module__}:{type(fn).__qualname__}:{digest}"

    if callable(fn):
        if isinstance(fn, type):
            raise TypeError(
                f"Callable {fn!r} must be a top-level function, functools.partial, "
                + "or an attrs instance. Classes are not supported."
            )
        if hasattr(fn, "__self__"):
            raise TypeError(
                f"Callable {fn!r} is a bound method. Only top-level functions, "
                + "functools.partial, or attrs instances are supported."
            )
        qualname = getattr(fn, "__qualname__", None)
        module = getattr(fn, "__module__", None)
        if qualname is not None:
            if "<lambda>" in qualname or "<locals>" in qualname:
                raise TypeError(
                    f"Callable {fn!r} must be a top-level function, functools.partial, "
                    + "or an attrs instance. Closures and lambdas are not supported."
                )
            if module is not None:
                return f"func:{module}:{qualname}"

    raise TypeError(
        f"Callable {fn!r} of type {type(fn)} must be a top-level function, "
        + "functools.partial, or an attrs instance."
    )

entry_identity

entry_identity(entry: _IdentityEntry) -> str

The entry's stable identity, computed from its natural key without I/O.

The string is 'v1:' followed by a sha256 hexdigest.

Source code in src/pysmo/tools/project/_identity.py
def entry_identity(entry: _IdentityEntry) -> str:
    """The entry's stable identity, computed from its natural key without I/O.

    The string is `'v1:'` followed by a sha256 hexdigest.
    """
    return identity_digest(entry_identity_components(entry))

entry_identity_components

entry_identity_components(
    entry: _IdentityEntry,
) -> dict[str, Any]

The normalised natural key of an entry as a nested dict, before hashing.

Top-level keys are schema, station, event, and window; event and window are None when absent.

Source code in src/pysmo/tools/project/_identity.py
def entry_identity_components(entry: _IdentityEntry) -> dict[str, Any]:
    """The normalised natural key of an entry as a nested dict, before hashing.

    Top-level keys are `schema`, `station`, `event`, and `window`; `event` and
    `window` are `None` when absent.
    """
    return {
        "schema": _IDENTITY_SCHEMA,
        "station": _normalise_station(entry.station),
        "event": _normalise_event(entry.event),
        "window": _normalise_window(entry),
    }

resolution_context_digest

resolution_context_digest(project: _IdentityProject) -> str

Digest over the project parameters that determine fetched content.

Covers window and seismogram_transform. Reassigning fetch_seismogram (e.g. to an offline archive cache) leaves the digest unchanged.

Source code in src/pysmo/tools/project/_identity.py
def resolution_context_digest(project: _IdentityProject) -> str:
    """Digest over the project parameters that determine fetched content.

    Covers `window` and `seismogram_transform`. Reassigning `fetch_seismogram`
    (e.g. to an offline archive cache) leaves the digest unchanged.
    """
    payload = {
        "schema": "rc2",
        "window": callable_identity(project.window),
        "seismogram_transform": callable_identity(project.seismogram_transform),
    }
    digest = hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest()
    return f"rc2:{digest}"