Types
If you are using pysmo, you are hopefully sold on the idea of using pysmo types in your code. This section provides some background on how these types came to be, and what to consider if you want to write some types of your own.
Use patterns
Defining arbitrarily complex types using Protocol classes
is straightforward, and you may be (or perhaps should be!) tempted to use them
everywhere. However, precisely because it is so easy to write types, you may
want to take a moment to contemplate what exactly should go into the type you
are defining. For this it may be worth looking at different use patterns that
exist for custom types.
Note
For simplicity, we will assume classes only contain attributes in the examples shown here. In a real-world situation, classes (and types) may of course contain methods too.
Direct access
We begin by discussing the simplest case: no custom types at all. Consider a
class SomeClass that has attribute names corresponding to all letters of the
alphabet, i.e. a, b, c, ..., z. These data can be used in functions by
passing either the entire class to a function (f1), a subset of attributes
(f2), or all the individual attributes (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)" }
Note that in the above diagram, we hint at the types of function arguments
rather than the names. The intention is to show that f2, for example, is meant
to be used with attributes a and b and should therefore be annotated with
the respective types of those attributes. Corresponding (pseudo) code would look
something like:
@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):
...
- We are now being explicit about the type of
a, so in the diagram above Ta is actuallyfloat. - Same for Tb and all other attributes.
Though the function bodies are not shown, we can safely assume that if they are
half-decently written, they use all declared function parameters. Thus f2 uses
only a fraction of the attributes, while f3 uses all attributes declared in
SomeClass. f2 seems like a reasonable function then; it is simple, it is
clear what data are processed in the function body, and it is decoupled from
SomeClass because the attributes are used directly as function parameters.
f3 is technically also decoupled. However, as all 26 attributes are passed to
the function, there appears to still be a strong link between the two. Moreover,
needing to provide 26 parameters every time f3 is called seems unreasonable;
it makes more sense to write it like f1 and pass it an instance of SomeClass
directly. As for f1, whether or not it is a reasonable function depends on how
many attributes are actually used. If it is a significant enough amount it makes
sense to write the function the way it is. If only e.g. 2 attributes are used,
we might want to write a function that looks more like f2.
Exact match
As writing types is rather easy, it is worth considering always using them.
Doing so immediately makes code more reusable and often more maintainable too.
We can use SomeClass as blueprint for the TSomeClass type, which is then
used to annotate f1. After doing so, we can start using f1 with the
SomeOtherClass class that has the same attributes as SomeClass:
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 }
Here, TSomeClass is not meant to be a "pysmo-like" type. Not much thought went
into it other than "you never know if you'll use this some other way one day".
But you just might...
Subset match
Pysmo types are often intended to group related data together. If we find
ourselves writing a lot of functions that use a and b as input, it is likely
there is a strong connection between those two attributes. They could be
something like the latitude and longitude of an event or station. We can
formally declare this relationship using a new type T1 and use that in the
f2 function instead of a and b. Similarly we define the T2 type here and
use it 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 }
With these types we can seamlessly use f2 with SomeClass as well as
SomeOtherClass, while f4 works with SomeOtherClass. Using T1 and T2 as
input types for two parameters in yet another function f5 looks like this:
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 }
A slightly odd consequence of using these types is that even though an instance
of SomeOtherClass contains all the data needed for f5, it has to be passed
to the function twice (once as T1 and once as T2). Thus you might see
something like this appear in your code:
However, this slight drawback is easily offset by the increased flexibility we
gain from using custom types. For example, it is conceivable that f5 was
originally written for SomeOtherClass instances, but now we suddenly find
ourselves in a situation where the data are spread across two different sources
SomeClass and YetOtherClass(1). Fortunately this isn't an issue thanks to
how types work:
Perhaps you are mixing data sources like files, database queries, web requests, etc.
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?
Discussing the different patterns above provides insight into some of the practical aspects of types. However, there are some more fundamental things to consider when defining types for pysmo. Essentially the idea is to take complex data, and divide them into sensible, smaller pieces (whereby these pieces become the basis for pysmo types). The single most important thing to keep in mind while doing so, is that once defined these types should never change.
A useful strategy to follow is to determine precisely what data functions
actually need before specifying the types (arguably this is how
Protocol is meant to be used in the first place). When
doing so, the functions themselves should be written as general as possible. For
example, a common task is to calculate the distance between an event and a
station. However, that problem can be described more generally as calculating
the distance between two geographic locations. That is exactly why pysmo has the
Location type, which looks like this:
@runtime_checkable
class Location(Protocol):
"""Protocol class to define the `Location` type."""
latitude: float
"""Latitude in degrees."""
longitude: float
"""Longitude in degrees."""
Because these types are meant to be very stable, we can do something that is
often considered bad practice: class inheritance. In pysmo the
Location type is reused in the Station
type via inheritance:
@runtime_checkable
class Station(Location, Protocol):
"""Protocol class to define the `Station` type."""
name: str
"""Station name or identifier.
A 1-5 character identifier for the station recording the data.
"""
network: str
"""Network name or identifier.
A 1-2 character code identifying the network/owner of the data.
"""
location: str
"""Location ID.
A two character code used to uniquely identify different data streams
at a single station.
"""
channel: str
"""Channel code.
A three character combination used to identify:
1. Band and general sample rate.
2. Instrument type.
3. Orientation of the sensor.
"""
elevation: int | float | None
"""Station elevation in metres."""
The result of this is that the Station type gets the
latitude and
longitude attributes from
Location. This means Station classes can
be used as input in functions annotated with Location.
In summary, the strategy for determining types can be summarised as follows:
- Keep types as simple as possible.
- Reuse simple types whenever possible in more complex types.
Tip
If you ever find yourself contemplating a type with attribute names like
station_latitude or station_longitude, you are likely defining a type that
is too specific.
Why does this type exist?
Not every pysmo type came into being for the same reason. It is worth being able to tell the difference, because it changes what you should expect from the type. Looking at existing pysmo types, three distinct origins emerge.
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 analog 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, not because anything outside pysmo needed naming.
_EpochProvenance (src/pysmo/_types/response.py) is the current example: both
SacPZ and StationXML
carry the same six fields — network, station, location, and channel code, plus a
start and (optional) end date for the response epoch — because
write_sacpz needed a name for that overlap to
type-check its input. Nobody has a competing _EpochProvenance design in the
wild; this 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 on its own. _EpochProvenance has no
MiniEpochProvenance because nothing does: every real caller already has a
SacPZ or StationXML
object carrying those six fields as part of something bigger. That is a fact
about how the type is actually used, not a consequence of it being an
internal-refactor type — a future internal-refactor type whose callers do need
a bare, standalone instance would get a Mini* class the same as any other.
The same applies to membership of the internal _BaseProto/_BaseMini
type-alias unions (src/pysmo/__init__.py), which exist to make pysmo's generic
Mini-conversion machinery (proto2mini,
matching_pysmo_types,
clone_to_mini,
copy_from_mini) work. A type is only useful
to that machinery if a Mini* counterpart exists to convert to or from, so
_EpochProvenance — having no Mini* class — has no reason to be in
_BaseProto either. This is not a separate judgement call about importance; it
follows mechanically from the same need-based rule.
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. An
internal-refactor type usually shouldn't be: _EpochProvenance is private for
exactly this reason, following the precedent set by other internal-only types
such as SeismogramEndtimeMixin. Code that needs to reference the composed
result from outside src/pysmo/_types/response.py uses
ResponseWithEpoch instead — the actual
public type built from it.
One more pattern is worth flagging before moving on, because it sits next to
these three without being a fourth origin:
IccsSeismogram, covered in the next
section, 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.
Specialised types
The basic types included in pysmo may become insufficient for the more complex
scenarios in the pysmo.tools modules. Insisting on only using the basic
Seismogram type would require writing functions with lots
of additional input parameters, therefore becoming tedious to write and use.
This is why some of the components in the pysmo package (e.g.
pysmo.tools.iccs) use their own types
(IccsSeismogram) rather than the basic
pysmo types. Crucially, these types inherit from the basic pysmo types, and
therefore can still be used the same way as e.g. a basic
Seismogram.
Tip
These specialised types all have a corresponding mini class.
Thus, if you are working with a class that matches e.g. the
Seismogram type, but not the
IccsSeismogram type, you can create a
MiniIccsSeismogram object using the
clone_to_mini() function by adding the
missing attributes via the update argument.