Types
This section covers how the pysmo types came to be, and what to weigh when defining new types.
Use patterns
Defining a type with a Protocol class is easy, which makes
it tempting to define one for everything. That same ease is a reason to stop and
think about what actually belongs in a type. The use patterns below help.
Attributes only, for simplicity
The examples here assume classes contain only attributes. Real classes and types may also contain methods.
Direct access
Start with the simplest case: no custom types at all. Take a class SomeClass
with one attribute for each letter of the alphabet: a, b, c, ..., z. Its
data can reach a function in three ways: the whole instance (f1), a subset of
attributes (f2), or every attribute individually (f3):
flowchart TD
C1@{ shape: das, label: "**SomeClass**
a, b, c, ..., z" }
C1 ---> F1@{ shape: rounded, label: "*f1*(SomeClass)" }
C1 ---> F2@{ shape: rounded, label: "*f2*(Ta,Tb)" }
C1 ---> F3@{ shape: rounded, label: "*f3*(Ta,Tb,Tc, ..., Tz)" }
The diagram labels the function arguments by type, not by name. It shows that
f2, for example, is meant for attributes a and b, so it is annotated with
their types. In (pseudo) code:
@dataclass
class SomeClass:
a: float #(1)!
b: float #(2)!
c: str
...
z: datetime
def f1(some_class: SomeClass):
...
def f2(a: float, b: float):
...
def f3(a: float, b: float, c: str, ..., z: datetime):
...
- The type of
ais now explicit, soTain the diagram above isfloat. - Likewise for
Tband the rest.
Assume each function body uses all of its declared parameters. f2 then uses
only a few of the attributes; f3 uses all of them. f2 looks reasonable: it
is simple, the data it works on are clear from the signature, and it is
decoupled from SomeClass because it takes the attributes directly. f3 is
decoupled in the same technical sense, but passing all 26 attributes keeps a
strong link to SomeClass, and supplying 26 arguments on every call is
impractical. It would be better written like f1, taking a SomeClass
instance. Whether f1 itself is reasonable depends on how many attributes it
uses: many, and taking the whole instance is fine; only two or three, and
something like f2 is better.
Exact match
Because types are easy to write, one option is to use them everywhere. It
makes code more reusable, and often more maintainable. Using SomeClass as the
blueprint for a TSomeClass type, and annotating f1 with it, lets f1 also
accept SomeOtherClass, which has the same attributes:
flowchart TD
C1@{ shape: das, label: "**SomeClass**
a, b, c, ..., z" }
C2@{ shape: das, label: "**SomeOtherClass**
a, b, c, ..., z, A, B, C, ..., Z" }
T1@{ shape: stadium, label: "**TSomeClass**
Ta, Tb, Tc, ..., Tz" }
C1 ---> T1
C2 ---> T1
T1 e1@--- F1@{ shape: rounded, label: "*f1*(TSomeClass)" }
e1@{ animate: true }
TSomeClass is not a "pysmo-like" type. It was written on the reasoning that
the flexibility might be useful one day. That is rarely a good enough reason:
mirroring a whole class gives a type with no clear concept behind it. The next
pattern is usually the better choice.
Subset match
Pysmo types usually group related data. Writing many functions that take a and
b together is a sign those two attributes are connected: they might be the
latitude and longitude of an event or station. A new type T1 can name that
relationship and replace a and b in f2. A T2 type does the same for
f4:
flowchart TD
C1@{ shape: das, label: "**SomeClass**
a, b, c, ..., z" }
C2@{ shape: das, label: "**SomeOtherClass**
a, b, c, ..., z, A, B, C, ..., Z" }
T1@{ shape: stadium, label: "**T1**
Ta, Tb" }
T2@{ shape: stadium, label: "**T2**
TA, TB" }
C1 ---> T1
C2 ---> T1
C2 ---> T2
T1 e1@--- F2@{ shape: rounded, label: "*f2*(T1)" }
T2 e2@--- F4@{ shape: rounded, label: "*f4*(T2)" }
e1@{ animate: true }
e2@{ animate: true }
f2 now works with both SomeClass and SomeOtherClass; f4 works with
SomeOtherClass. A third function f5 can take T1 and T2 as two separate
parameters:
flowchart TD
C2@{ shape: das, label: "**SomeOtherClass**
a, b, c, ..., z, A, B, C, ..., Z" }
T1@{ shape: stadium, label: "**T1**
Ta, Tb" }
T2@{ shape: stadium, label: "**T2**
TA, TB" }
C2 ---> T1
C2 ---> T2
T1 e1@--- F5@{ shape: rounded, label: "*f5*(T1, T2)" }
T2 e2@--- F5
e1@{ animate: true }
e2@{ animate: true }
One odd consequence: a SomeOtherClass instance holds everything f5 needs,
yet it has to be passed twice, once as T1 and once as T2:
The extra flexibility is worth it. Suppose f5 was written for
SomeOtherClass, but the data later end up split across two sources,
SomeClass and YetOtherClass(1). The types absorb this with no change to
f5:
Perhaps mixing data sources such as files, database queries, and web requests.
flowchart TD
C1@{ shape: das, label: "**SomeClass**
a, b, c, ..., z" }
C3@{ shape: das, label: "**YetOtherClass**
A, B, C, ..., Z" }
T1@{ shape: stadium, label: "**T1**
Tp, Ty" }
T2@{ shape: stadium, label: "**T2**
TP, TY" }
C1 ---> T1
C3 ---> T2
T1 e1@--- F5@{ shape: rounded, label: "*f5*(T1, T2)" }
T2 e2@--- F5
e1@{ animate: true }
e2@{ animate: true }
What should become a type?
The patterns above show the shapes a type can take. Deciding which to define is a separate question, and it comes with one hard rule: once defined, a type should never change. That rules out a top-down guess at what a "seismogram" or a "station" contains. Instead, split the data into small pieces along the lines the functions actually use.
So the order matters: work out what the functions need first, then define the
types. (This is arguably how Protocol is meant to be used.) Write the
functions as generally as possible while doing so. Calculating the distance
between an event and a station, for example, is really the distance between two
geographic locations. That is why pysmo has the Location
type:
class Location(Protocol):
"""Protocol class to define the `Location` type.
A geographic point, given as latitude and longitude in degrees.
"""
latitude: float
"""Latitude in degrees."""
longitude: float
"""Longitude in degrees."""
Because these types are meant to be stable, they can use something often
considered bad practice: class inheritance. The Station type
inherits from two smaller types, Location and
StationCode:
class StationCode(Protocol):
"""Protocol class to define the `StationCode` type.
Network/station/location/channel (NSLC) identity for a data stream,
independent of geographic location; the subset of `Station` a format
like miniSEED can provide without coordinates.
"""
name: str
"""Station name or identifier.
A 1- to 5-character identifier for the station recording the data.
"""
network: str
"""Network name or identifier.
A 1- or 2-character code identifying the network that owns the data.
"""
location: str
"""Location ID.
A 2-character code used to identify different data streams at a single
station.
"""
channel: str
"""Channel code.
A 3-character combination that identifies:
1. Band and general sample rate.
2. Instrument type.
3. Orientation of the sensor.
"""
class Station(Location, StationCode, Protocol):
"""Protocol class to define the `Station` type.
A seismic station: NSLC identity from
[`StationCode`][pysmo.StationCode], position from
[`Location`][pysmo.Location], and an optional elevation.
"""
elevation: int | float | None
"""Station elevation in metres."""
Station then has latitude and
longitude from Location, and name, network,
location, and channel from StationCode. A Station class can therefore be
passed to any function annotated with Location or StationCode.
In short:
- Keep types as simple as possible.
- Reuse simple types when building more complex ones.
Avoid overly specific attribute names
Attribute names like station_latitude or station_longitude are a sign the
type is too specific.
Why does this type exist?
That recipe is not the only route to a type, and the set pysmo ships did not all follow it. Reading pysmo's own types is easier with that in mind: three distinct origins emerge, and which one a type has changes what to expect from it.
The first is a convergence type: Seismogram is the
clearest example, and is discussed at length in the
"The pysmo solution" section of the
motivation page. Here, the problem is that the real world already has too many
competing representations of the same concept: every tool has its own waveform
class. The type's entire purpose is to capture what these representations have
in common, so pysmo can operate on any of them without privileging one or
forcing a conversion. Third-party code is expected to implement types like this
directly; that is the "bring your own class" idea pysmo is built around.
The second is a converged-concept type: Response and its
related types (ResponseStage,
StagedResponse). Here the origin is different. There
is no proliferation of competing in-memory response designs to reconcile. Poles,
zeros, and sensitivity are already the standard, settled way this domain
represents an analogue instrument response. The type names an already-agreed
mathematical object rather than resolving external disagreement. In practice,
hardly anyone hand-implements a new Response-conformant class from scratch the
way they might for Seismogram; responses come from a small, closed set of
known sources (SAC PZ, StationXML, RESP), and pysmo already ships readers for
those. This type is still physically meaningful in the same way Seismogram is,
just extended differently in practice.
The third is an internal-refactor type: it exists purely to let a pysmo
function type-check, or to name a slice of behaviour pysmo's own code shares,
not because anything outside pysmo needed naming.
StationCode is an example: the network/station/
location/channel identity common to Station and to format
records like StationXML that carry NSLC codes
without necessarily carrying coordinates. Nobody has a competing StationCode
design in the wild; extracting it is about avoiding duplication in pysmo's own
code, not a response to anything external.
The Location-in-Station inheritance described above is the
same "reuse simple types" instinct, applied one step earlier: instead of
factoring out duplication after the fact, Location was identified as reusable
from the start.
Mini classes and root export are separate questions
It is tempting to assume that an internal-refactor type, being the least
"important" of the three, should also be the one that never gets a
Mini class or never appears in the pysmo root namespace.
Neither follows. Both are separate, need-based questions that happen to
correlate with origin without being defined by it.
A Mini* class exists only when some real code path needs to construct a bare
instance of exactly that type's fields. An internal-refactor type whose callers
never need a bare, standalone instance gets no Mini* class, and that is a fact
about how it is used rather than about its origin.
Mini-conversion machinery
Membership of the internal _BaseProto/_BaseMini type-alias unions
(src/pysmo/__init__.py), which drive pysmo's generic Mini-conversion machinery
(proto2mini,
matching_pysmo_types,
clone_to_mini,
copy_from_mini), only makes sense once a
Mini* counterpart exists to convert to or from.
Whether a type is exported at the pysmo root namespace (importable as
from pysmo import X, and therefore listed on the
API reference) is a third, again independent, question. A
type that exists only to type-check one internal call site usually stays
private, following the precedent set by other internal-only helpers such as
SeismogramEndtimeMixin.
One more pattern is worth flagging, because it sits next to these three without
being a fourth origin: IccsSeismogram
extends Seismogram with extra fields for one algorithm's bookkeeping. It is
internally motivated in the same way an internal-refactor type is, but shaped
the opposite way: adding fields for one tool's needs rather than factoring out
fields shared by several concrete classes. It is not a fourth origin, just a
tool-scoped extension of an existing one, and the
specialised types section returns to it.
Specialised types
The basic types can fall short of the more complex scenarios in the
pysmo.tools modules. Sticking to the basic
Seismogram type there would mean functions with many extra
parameters, tedious to write and to call.
IccsSeismogram, used throughout
pysmo.tools.iccs, is one such type. As the section above
notes, it extends Seismogram rather than replacing it, so an
IccsSeismogram still works anywhere a plain Seismogram is expected.
Mini classes for specialised types
Each specialised type has a corresponding mini class. A class
that matches Seismogram but not IccsSeismogram can be turned into a
MiniIccsSeismogram with
clone_to_mini(), supplying the missing
attributes through the update argument.