Skip to content

External classes

The tutorial and the section on mini classes show how straightforward it is to write a bespoke class for use with pysmo. Sometimes an existing class is already in use, though, perhaps because part of the processing happens in another framework. This chapter covers that case.

Does a class work with pysmo?

Pysmo types are typically simple, so an individual type usually has far fewer attributes than any third-party class. The first step is deciding which types the class needs to satisfy. Some will work as-is, some need extra work (a different attribute name or data format), and some will never work, because the data are not in the class at all.

Interface only, not implementation

Pysmo types define the interface, not the implementation. If the external class behaves differently from what a type expects (something as simple as different units, for instance), the mismatch will not show up as a type error.

Yes

If the external class has the same attributes (name and type) as a given pysmo type, it works as-is. To check, pass an instance to a function annotated with the pysmo type and run mypy, or watch the editor: a matching class is accepted, and a near-miss is flagged at the attribute that does not line up.

>>> from pysmo import Location
>>>
>>> def describe(location: Location) -> str:
...     return f"{location.latitude}, {location.longitude}"
...
>>> describe(my_external_object)  # mypy accepts this call if the class matches
'41.9, -87.6'
>>>

This is most likely with simpler types like Location, which only requires latitude and longitude attributes of type float.

There is deliberately no need to gate this with a runtime check such as isinstance against the protocol (pysmo's protocols are not runtime_checkable). Conformance is something the type checker proves, so the workflow is to act on what mypy or the editor reports, before the code runs. Runtime checks are still useful for a different question, covered in When a runtime check is genuinely needed.

Yes, with a tiny bit of work

The most common reason a class does not match a pysmo type is that the attribute names differ. A class might store a station latitude in an attribute called stla instead of latitude. A thin subclass can map the existing attributes to the expected names using Python properties:

class MyExtendedClass(ExternalClass):
    @property
    def latitude(self) -> float:
        return self.stla

    @latitude.setter
    def latitude(self, value: float) -> None:
        self.stla = value

    @property
    def longitude(self) -> float:
        return self.stlo

    @longitude.setter
    def longitude(self, value: float) -> None:
        self.stlo = value

This pattern is lightweight: the subclass inherits everything from the original class and only adds the property aliases needed for pysmo compatibility. Changing an aliased attribute also changes the original, and vice versa. The SAC API documentation has a concrete example.

Yes, with a bit more work

Sometimes simple property aliases are not enough. This happens when:

  • The same class needs to match the same type more than once. A class that stores both station and event coordinates cannot alias both to latitude and longitude; the names would clash.
  • The data format differs. The external class might store a time as a float (seconds since some reference), while pysmo expects a datetime object.
  • An attribute is optional in the external class but required by the pysmo type. The property getter then needs validation logic.

The approach here is a helper class: a small class that holds a reference to the parent object and provides pysmo-compatible attribute access through properties:

class StationLocation:
    def __init__(self, parent: ExternalClass) -> None:
        self._parent = parent

    @property
    def latitude(self) -> float:
        if self._parent.stla is None:
            raise ValueError("Station latitude is not set")
        return self._parent.stla

    @latitude.setter
    def latitude(self, value: float) -> None:
        self._parent.stla = value

    # longitude property omitted for brevity...
class EventLocation:
    def __init__(self, parent: ExternalClass) -> None:
        self._parent = parent

    @property
    def latitude(self) -> float:
        if self._parent.evla is None:
            raise ValueError("Event latitude is not set")
        return self._parent.evla

    @latitude.setter
    def latitude(self, value: float) -> None:
        self._parent.evla = value

    # longitude property omitted for brevity...

Both StationLocation and EventLocation match the Location type, and there is no name clash because each helper class has its own namespace. Because they reference the parent object, a change made through a helper class is reflected in the parent, and vice versa.

The helper classes can then be added as attributes on a new class that inherits from the external one:

class MyExtendedClass(ExternalClass):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.station_location = StationLocation(parent=self)
        self.event_location = EventLocation(parent=self)

Inheriting from ExternalClass is not required. Composition, holding the external object as an attribute rather than subclassing it, works just as well:

class MyExtendedClass:
    def __init__(self, *args, **kwargs) -> None:
        self.external = ExternalClass(*args, **kwargs)
        self.station_location = StationLocation(parent=self.external)
        self.event_location = EventLocation(parent=self.external)

Composition is the better choice when ExternalClass has a large surface that should not be exposed wholesale on MyExtendedClass. The SAC example below does exactly this.

No

Some data are simply not in the class. An ObsPy Trace, for example, carries no station coordinates, so no adapter can make it satisfy Station. An aliasing property or helper class can rename and convert what is there; it cannot invent what is missing. In that case the type is the wrong fit for that class, and the missing data have to come from somewhere else.

Examples

SAC

The pysmo package itself uses this pattern for the SAC class. The underlying SacIO class manages file I/O and exposes every SAC header field under its original name (stla, evla, b, and so on). Those names do not match pysmo types, and station location, event location, and seismogram data all coexist in one object. Exposing all ~99 raw headers next to the curated attributes would invite name clashes, and worse, silent errors where a raw header and a curated attribute share a name but carry incompatible types and meaning (for example a raw time offset in seconds against a pandas.Timestamp in UTC).

SAC solves this with the composition variant above: it holds a SacIO instance (SAC.native) rather than inheriting from it, and adds helper-class attributes (station/event/ seismogram/ timestamps) that read from and write to it. Only a small, deliberately curated surface (file I/O) is forwarded onto SAC directly; seismogram data and sampling interval are reached via SAC.seismogram, and the raw header names stay reachable only via SAC.native.<name>, for anyone who specifically wants them, rather than sitting on SAC itself under the same names as the curated attributes. With that in place, pysmo functions work directly on the nested objects:

>>> from pysmo import Event, Seismogram, Station
>>> from pysmo.classes import SAC
>>>
>>> def begin_year(seismogram: Seismogram) -> int:
...     return seismogram.begin_time.year
...
>>> def station_id(station: Station) -> str:
...     return f"{station.network}.{station.name}"
...
>>> def origin_year(event: Event) -> int:
...     return event.time.year
...
>>> sac = SAC.from_file("example.sac")
>>> begin_year(sac.seismogram), station_id(sac.station), origin_year(sac.event)
(2010, 'IU.ANMO', 2010)
>>>

For more details, see the SAC API documentation.

ObsPy's Trace

The SAC example above is pysmo's own code adapting pysmo's own SacIO class: useful for seeing the pattern in use, but not literally an external class. A more realistic case for many seismologists is waveform data already held as an ObsPy Trace object, to be run through pysmo's functions and tools without converting the whole workflow.

Trace.stats stores starttime as ObsPy's own UTCDateTime type (not pandas.Timestamp) and delta as a plain float in seconds (not pandas.Timedelta), so this needs the "attribute name and format differ" treatment from above, not the free out-of-the-box case: the adapter needs properties that convert between the two, not simple aliases. (end_time is not read from Trace.stats.endtime at all; it is derived from begin_time and delta instead, the same way Seismogram's end_time is meant to be computed.)

trace_seismogram.py
import numpy as np
import pandas as pd
from obspy import Trace, UTCDateTime


class TraceSeismogram:
    def __init__(self, parent: Trace) -> None:
        self._parent = parent

    @property
    def begin_time(self) -> pd.Timestamp:
        return pd.Timestamp(self._parent.stats.starttime.ns, unit="ns", tz="UTC")

    @begin_time.setter
    def begin_time(self, value: pd.Timestamp) -> None:
        self._parent.stats.starttime = UTCDateTime(ns=value.value)

    @property
    def delta(self) -> pd.Timedelta:
        return pd.Timedelta(seconds=self._parent.stats.delta)

    @delta.setter
    def delta(self, value: pd.Timedelta) -> None:
        self._parent.stats.delta = value.total_seconds()

    @property
    def data(self) -> np.ndarray:
        return self._parent.data

    @data.setter
    def data(self, value: np.ndarray) -> None:
        self._parent.data = value

    @property
    def end_time(self) -> pd.Timestamp:
        if len(self.data) == 0:
            return self.begin_time
        return self.begin_time + self.delta * (len(self.data) - 1)
>>> import numpy as np
>>> from obspy import Trace
>>> from pysmo.functions import detrend
>>> trace = Trace(
...     data=np.array([1.0, 2.0, 3.0, 2.0, 1.0]),
...     header={
...         "network": "XX",
...         "station": "TEST",
...         "location": "",
...         "channel": "HHZ",
...         "starttime": "2024-01-01T00:00:00",
...         "delta": 0.01,
...     },
... )
>>> trace_seis = TraceSeismogram(trace)
>>> detrend(trace_seis)  # detrend takes a Seismogram; mypy accepts trace_seis
>>> trace.data
array([...])
>>>

With the setters in place, every pysmo.functions and pysmo.tools call that takes a Seismogram works on trace_seis directly, including the ones that mutate in place, like detrend above. Note what this adapter does not attempt: Trace.stats has no latitude/longitude (that lives in ObsPy's separate Inventory hierarchy, not on Trace itself), so TraceSeismogram only ever satisfies Seismogram, never Station. Supply station/event metadata separately, the same way GeoCsvSeismogram.fetch() does.

Method vs function

Notice detrend is available in two different shapes above: ObsPy exposes it as a method on Trace (trace.detrend(...)), while pysmo exposes the same operation as a standalone function that takes a Seismogram (detrend(seismogram)). That difference isn't incidental. A method bound to Trace only ever works on a Trace (or a subclass of it). A function written against the Seismogram protocol works on anything that satisfies it: TraceSeismogram here, SacSeismogram, a mini class, or any bespoke class written for the purpose. That's why the exact same detrend call works unchanged on the SAC example above and on trace_seis here, with no dispatch logic anywhere to make it happen. Targeting the protocol rather than one specific class is what makes every function in pysmo.functions/pysmo.tools reusable this way, and it isn't limited to pysmo's own functions: any function written against Seismogram gets the same property for free.

When a runtime check is genuinely needed

The adapter patterns above are all about conformance: does this class speak a pysmo type? That is a question for the type checker, not for isinstance. One related task needs a different tool. At runtime, a collection holds a mix of objects and two shapes need different treatment. Some station objects might carry coordinates (satisfying Station) while others are NSLC-only (satisfying StationCode but not Station, as a seismogram read straight from miniSEED is), and the code needs to branch on which it has.

Write that branch as a TypeIs guard. It narrows the type for the type checker in both branches, and names exactly what is being checked:

>>> from typing import TypeIs
>>> from pysmo import MiniStation, MiniStationCode, Station, StationCode
>>>
>>> def has_location(sta: StationCode) -> TypeIs[Station]:
...     return hasattr(sta, "latitude") and hasattr(sta, "longitude")
...
>>> stations: list[StationCode] = [
...     MiniStation(
...         name="ANMO", network="IU", location="00", channel="BHZ",
...         latitude=34.95, longitude=-106.46,
...     ),
...     MiniStationCode(name="COLA", network="IU", location="00", channel="BHZ"),
... ]
>>> [sta.name for sta in stations if has_location(sta)]
['ANMO']
>>>

This is for runtime dispatch over mixed data, not for checking whether a class conforms to a protocol. That stays a type-checker job.

Beyond adapting existing classes

The chapter so far is about making an external class usable with pysmo's existing functions, in Python. That is useful, but it undersells what a pysmo type actually is.

This chapter has already built that twice, in two different shapes, without naming it. The helper-class approach builds a narrow view as a separate object that reads from, and writes back to, a richer parent. SacSeismogram is a real example: SAC carries the full complexity of a SAC file's header, SacSeismogram exposes only what Seismogram needs. The thin-subclass approach does not even need a separate object: a single bespoke class can carry its own extra attributes directly, alongside the ones a protocol requires, and still be handed to any function expecting that protocol unchanged. Either way, the "rich" and "narrow" views are not two objects with data copied between them; they are two readings of the same one.

This is a general pattern, not something specific to these two examples. The same object can be two different things at once, depending on what is looking at it. To the code that owns it, a seismogram can carry arbitrarily rich, problem-specific state: processing history, quality flags, picks, provenance, whatever the task needs. To a function written against Seismogram, that same object is just begin_time, data, delta, nothing more. Both are true at once, of the same instance: the type does not strip anything away, it only describes which part of the object a given piece of code has committed to relying on.

The narrow view is the more interesting half of that pair. A type like Seismogram is not really a Python feature; it is a minimal specification of what a seismogram fundamentally needs to be, arrived at by asking what processing functions actually require rather than what any one class happens to expose (see What should become a type?). Protocol is simply the notation this specification is written in today. The definition itself (begin time, data, sampling interval, nothing more) would translate just as directly into a struct in C or Fortran, a database schema, or a paragraph of prose. It does not depend on Python, or on pysmo, to remain true.

That distinction matters over a project's lifetime. Libraries and file formats change (sometimes for good reasons, sometimes because tooling fashions shift), and code written directly against one specific class inherits that churn. Code written against a well-considered, minimal interface mostly does not: only the adaptation layer at the boundary needs to change, not the logic behind it. The same holds on a smaller scale. Moving a slow step like mccc from Python to a compiled language for speed is a good example. The hard part is never translating syntax; it is working out what the minimal data model needs to be. If that work is already done, captured as a small, deliberate interface rather than smeared across whatever attributes a particular class happened to expose, the move means reimplementing the interface and its adapters in the new language's idiom, not re-deriving the whole data model from scratch.

External change is not the only unpredictable thing. How complex the class supporting a particular problem will need to become is rarely clear in advance. A bespoke class does not have to mean a dataclass with a few extra attributes; that is the simplest case, not the only one. What starts that way can, as a project's real requirements surface, turn into something far more involved. AIMBAT (an arrival-time picking package built on pysmo) shows both ends of that range. Its first version had nowhere else to put processing state, so it stored things like filter parameters directly in SAC's generic user6/user7/kuser1/kuser2 header fields, which have no defined meaning of their own and were repurposed because nothing better was available. (It is also why old AIMBAT-processed SAC files can carry oddly-populated header fields that are confusing to decode later.) Current AIMBAT solves the same problem properly: its seismogram objects are backed by a SQL database, with begin_time and delta stored as ordinary table columns and data fetched from a separate table only when needed. That split pays off concretely: changing a pick or a sampling interval is a small database update, not a read of the whole waveform file. With a SAC file read directly, no such distinction is available: updating one value means touching the entire seismogram. None of this can be planned for up front, and it does not need to be. The protocol boundary does not ask the rich side to stay simple, or to stay anything in particular, only that begin_time, data, and delta keep meaning what they always meant. The processing logic on the other side of that boundary never has to change, no matter how unrecognisable the rich side becomes.

Pysmo's types are one attempt at doing this work for seismology. The same exercise is worth doing for whatever is specific to a given project.