Skip to content

pysmo.lib

Internal utilities, validators, defaults, and I/O used by pysmo.

Modules:

Name Description
defaults

Defaults for pysmo functions/classes.

io

Low-level I/O classes for reading and writing seismological data.

mini_utils

Mini utils.

validators

Validators and converters for pysmo classes using attrs.

defaults

Defaults for pysmo functions/classes.

io

Low-level I/O classes for reading and writing seismological data.

Classes in this module handle file format details but do not implement pysmo protocol types directly. The parse_* functions here return uninterpreted raw records meant to be wrapped by a pysmo.classes type before use, and should generally not be used directly for that reason. The write_* functions are the exception: they accept any object satisfying the relevant pysmo protocol directly (not just a pysmo.classes type) and are intended to be used directly, either standalone or via the thin .write() wrapper each supporting class provides.

Classes:

Name Description
GeoCsvDataset

A single uninterpreted dataset from a GeoCSV text body.

ResponseWithEpoch

Protocol class to define the ResponseWithEpoch type.

SacIO

Access SAC files in Python.

Functions:

Name Description
extract_geocsv_timeseries

Interpret a GeoCSV dataset as a waveform segment.

http_get

Perform an HTTP GET request with retries on server errors.

merge_geocsv_timeseries

Merge contiguous waveform segments into a single segment.

parse_geocsv

Split a GeoCSV text body into a list of datasets.

parse_sacpz

Split SAC PZ text into a list of uninterpreted records.

parse_stationxml

Parse response metadata from a StationXML document.

write_geocsv

Write one or more Seismogram objects to a GeoCSV 2.0 file.

write_sacpz

Write one or more Response objects to a SAC PZ file.

GeoCsvDataset dataclass

A single uninterpreted dataset from a GeoCSV text body.

Attributes:

Name Type Description
column_names list[str]

Field names from the header line.

delimiter str

Field delimiter for this dataset (defaults to a comma).

headers dict[str, str]

Keyword comment values, keyed by lowercased keyword.

rows list[list[str]]

Data lines split on the dataset delimiter, values stripped of

Source code in src/pysmo/lib/io/_geocsv.py
@dataclass
class GeoCsvDataset:
    """A single uninterpreted dataset from a GeoCSV text body."""

    headers: dict[str, str] = field(default_factory=dict)
    """Keyword comment values, keyed by lowercased keyword."""

    column_names: list[str] = field(default_factory=list)
    """Field names from the header line."""

    rows: list[list[str]] = field(default_factory=list)
    """Data lines split on the dataset delimiter, values stripped of
    surrounding whitespace."""

    @cached_property
    def delimiter(self) -> str:
        """Field delimiter for this dataset (defaults to a comma)."""
        delimiter = self.headers.get("delimiter", ",")
        delimiter = _DELIMITER_ESCAPES.get(delimiter, delimiter)
        if len(delimiter) != 1:
            raise ValueError(
                f"GeoCSV delimiter must be a single character, got {delimiter!r}."
            )
        return delimiter

column_names class-attribute instance-attribute

column_names: list[str] = field(default_factory=list)

Field names from the header line.

delimiter cached property

delimiter: str

Field delimiter for this dataset (defaults to a comma).

headers class-attribute instance-attribute

headers: dict[str, str] = field(default_factory=dict)

Keyword comment values, keyed by lowercased keyword.

rows class-attribute instance-attribute

rows: list[list[str]] = field(default_factory=list)

Data lines split on the dataset delimiter, values stripped of surrounding whitespace.

ResponseWithEpoch

Bases: Response, _EpochProvenance, Protocol

Protocol class to define the ResponseWithEpoch type.

A Response with _EpochProvenance (channel identity plus a validity window) — what write_sacpz requires. Any object satisfying both protocols (e.g. SacPZ or StationXML) already satisfies this one structurally; there is usually no need to reference it directly unless type-annotating a variable meant to hold whatever write_sacpz accepts.

Attributes:

Name Type Description
channel str

Channel code.

end_date Timestamp | None

End of the epoch this response applies to, or None if still open.

input_units str

Physical units produced by removing this response (e.g. "M/S", "M/S**2").

location str

Location code.

network str

Network code.

overall_sensitivity NonZeroNumber

Scale factor combined with poles/zeros to reconstruct the full,

poles list[complex]

Response poles, in radians/second (SAC PZ / LAPLACE (RADIANS/SECOND) convention).

reference_sensitivity NonZeroNumber | None

Total system sensitivity (counts per physical unit) at the response's

start_date Timestamp

Start of the epoch this response applies to.

station str

Station code.

zeros list[complex]

Response zeros, in radians/second.

Source code in src/pysmo/lib/io/_sacpz.py
@runtime_checkable
class ResponseWithEpoch(Response, _EpochProvenance, Protocol):
    """Protocol class to define the `ResponseWithEpoch` type.

    A [`Response`][pysmo.Response] with `_EpochProvenance` (channel identity plus a
    validity window) — what [`write_sacpz`][pysmo.lib.io.write_sacpz] requires. Any
    object satisfying both protocols (e.g. [`SacPZ`][pysmo.classes.SacPZ] or
    [`StationXML`][pysmo.classes.StationXML]) already satisfies this one structurally;
    there is usually no need to reference it directly unless type-annotating a variable
    meant to hold whatever `write_sacpz` accepts.
    """

channel instance-attribute

channel: str

Channel code.

end_date instance-attribute

end_date: Timestamp | None

End of the epoch this response applies to, or None if still open.

input_units instance-attribute

input_units: str

Physical units produced by removing this response (e.g. "M/S", "M/S**2").

Informational only: not validated against a fixed set of units, and not read by remove_response or integrate/ differentiate — callers are responsible for interpreting it themselves.

location instance-attribute

location: str

Location code.

network instance-attribute

network: str

Network code.

overall_sensitivity instance-attribute

overall_sensitivity: NonZeroNumber

Scale factor combined with poles/zeros to reconstruct the full, frequency-dependent transfer function H(f).

Equivalent to CONSTANT in a SAC PZ file (A0 * sensitivity, the analog stage's normalisation factor times the reference-frequency sensitivity), or FDSN StationXML's NormalizationFactor * InstrumentSensitivity. This is not the instrument's plain flat-band gain — see reference_sensitivity for that — so dividing raw data by overall_sensitivity directly (rather than combining it with poles/zeros, or using reference_sensitivity instead) mis-scales the result by the A0 factor, often several orders of magnitude.

Negative values are permitted (but not zero): a negative CONSTANT/ NormalizationFactor is how a reversed-polarity channel is recorded in the wild, not an error.

poles instance-attribute

poles: list[complex]

Response poles, in radians/second (SAC PZ / LAPLACE (RADIANS/SECOND) convention).

reference_sensitivity instance-attribute

reference_sensitivity: NonZeroNumber | None

Total system sensitivity (counts per physical unit) at the response's own reference/normalisation frequency, with no A0 normalisation folded in.

Equivalent to SAC PZ's SENSITIVITY header value, or FDSN StationXML's InstrumentSensitivity/Value. This — not overall_sensitivity, which has A0 folded in — is the correct divisor for a flat, zero-phase approximation of the response (e.g. remove_response's sensitivity-only path). None if unavailable (e.g. a SAC PZ file without a SENSITIVITY header): callers needing it should raise rather than silently substituting overall_sensitivity. As with overall_sensitivity, a negative value indicates reversed polarity rather than an error; zero is not permitted.

start_date instance-attribute

start_date: Timestamp

Start of the epoch this response applies to.

station instance-attribute

station: str

Station code.

zeros instance-attribute

zeros: list[complex]

Response zeros, in radians/second.

SacIO

Bases: SacIOBase

Access SAC files in Python.

The SacIO class reads and writes data and header values to and from a SAC file. Instances of SacIO provide attributes named identically to header names in the SAC file format. Additional attributes may be set, but are not written to a SAC file (because there is no space reserved for them there). Class attributes with corresponding header fields in a SAC file (for example the begin time b) are checked for a valid format before being saved in the SacIO instance.

Tip

This class should typically never be used directly. Instead use the SAC class, which wraps a SacIO instance (reachable as SAC.native) and exposes it through pysmo types.

Examples:

Create a new instance from a file and print seismogram data:

>>> from pysmo.lib.io import SacIO
>>> sac = SacIO.from_file("example.sac")
>>> data = sac.data
>>> data
array([-47201., -47361., -47511., ..., -82144., -71072., -59960.],
      shape=(57465,))
>>>

Read the sampling rate:

>>> delta = sac.delta
>>> delta
0.05000000074505806
>>>

Change the sampling rate:

>>> newdelta = 0.05
>>> sac.delta = newdelta
>>> sac.delta
0.05
>>>

Methods:

Name Description
change_ref_time

Re-point the reference time to a different time header.

from_buffer

Create a new SAC instance from a SAC data buffer.

from_file

Create a new SAC instance from a SAC file.

raw

Temporarily relax cross-field header restrictions on this instance.

read

Read data and headers from a SAC file into an existing SAC instance.

read_buffer

Read data and headers from a SAC byte buffer into an existing SAC instance.

write

Writes data and header values to a SAC file.

Attributes:

Name Type Description
a float | None

First arrival time (seconds relative to reference time).

az int | float

Event to station azimuth (degrees).

b float

Beginning value of the independent variable.

baz int | float

Station to event azimuth (degrees).

cmpaz float | None

Component azimuth (degrees clockwise from north).

cmpinc float | None

Component incident angle (degrees from upward vertical; SEED/MINISEED uses dip: degrees from horizontal down).

data ndarray

Seismogram data.

delta float

Increment between evenly spaced samples (nominal value).

depmax int | float | None

Maximum value of dependent variable.

depmen int | float | None

Mean value of dependent variable.

depmin int | float | None

Minimum value of dependent variable.

dist int | float

Station to event distance (km).

e int | float

Ending value of the independent variable.

evdp float | None

Event depth below surface (kilometres -- previously metres).

evel float | None

Event elevation (metres).

evla float | None

Event latitude (degrees, north positive).

evlo float | None

Event longitude (degrees, east positive).

f float | None

Fini or end of event time (seconds relative to reference time).

gcarc int | float

Station to event great circle arc length (degrees).

ibody str | None

Body / Spheroid definition used in Distance Calculations.

idep str

Type of dependent variable.

ievreg str | None

Event geographic region.

ievtyp str

Type of event.

iftype str

Type of file.

iinst str | None

Type of recording instrument.

imagsrc str | None

Source of magnitude information.

imagtyp str | None

Magnitude type.

iqual str | None

Quality of data.

istreg str | None

Station geographic region.

isynth str | None

Synthetic data flag.

iztype str

Reference time equivalence. Read-only; changed via SacIO.change_ref_time.

ka str | None

First arrival time identification.

kcmpnm str | None

Channel name. SEED volumes use three character names, and the third is the component/orientation. For horizontals, the current trend is to use 1 and 2 instead of N and E.

kdatrd str | None

Date data was read onto computer.

kevnm str | None

Event name.

kf str | None

Fini identification.

khole str | None

Nuclear: hole identifier; Other: location identifier (LOCID).

kinst str | None

Generic name of recording instrument.

knetwk str | None

Name of seismic network.

ko str | None

Event origin time identification.

kstnm str | None

Station name.

kt0 str | None

User defined time pick identification.

kt1 str | None

User defined time pick identification.

kt2 str | None

User defined time pick identification.

kt3 str | None

User defined time pick identification.

kt4 str | None

User defined time pick identification.

kt5 str | None

User defined time pick identification.

kt6 str | None

User defined time pick identification.

kt7 str | None

User defined time pick identification.

kt8 str | None

User defined time pick identification.

kt9 str | None

User defined time pick identification.

kuser0 str | None

User defined variable storage area.

kuser1 str | None

User defined variable storage area.

kuser2 str | None

User defined variable storage area.

kzdate str | None

ISO 8601 format of GMT reference date.

kztime str | None

Alphanumeric form of GMT reference time.

lcalda Literal[True]

TRUE if DIST, AZ, BAZ, and GCARC are to be calculated from station and event coordinates.

leven bool

TRUE if data is evenly spaced.

lovrok bool | None

TRUE if it is okay to overwrite this file on disk.

lpspol bool | None

TRUE if station components have a positive polarity (left-hand rule).

mag float | None

Event magnitude.

nevid int | None

Event ID (CSS 3.0).

norid int | None

Origin ID (CSS 3.0).

npts int

Number of points per data component.

nvhdr int

Header version number.

nwfid int | None

Waveform ID (CSS 3.0).

nxsize int | None

Spectral Length (Spectral files only).

nysize int | None

Spectral Width (Spectral files only).

nzhour int | None

GMT hour.

nzjday int | None

GMT julian day.

nzmin int | None

GMT minute.

nzmsec int | None

GMT millisecond.

nzsec int | None

GMT second.

nzyear int | None

GMT year corresponding to reference (zero) time in file.

o float | None

Event origin time (seconds relative to reference time).

odelta float | None

Observed increment if different from nominal value.

ref_datetime datetime | None

GMT reference time and date, as a Python datetime object.

resp0 float | None

Instrument response parameter 0 (not currently used).

resp1 float | None

Instrument response parameter 1 (not currently used).

resp2 float | None

Instrument response parameter 2 (not currently used).

resp3 float | None

Instrument response parameter 3 (not currently used).

resp4 float | None

Instrument response parameter 4 (not currently used).

resp5 float | None

Instrument response parameter 5 (not currently used).

resp6 float | None

Instrument response parameter 6 (not currently used).

resp7 float | None

Instrument response parameter 7 (not currently used).

resp8 float | None

Instrument response parameter 8 (not currently used).

resp9 float | None

Instrument response parameter 9 (not currently used).

stdp float | None

Station depth below surface (metres).

stel float | None

Station elevation above sea level (metres).

stla float | None

Station latitude (degrees, north positive).

stlo float | None

Station longitude (degrees, east positive).

t0 float | None

User defined time pick or marker 0 (seconds relative to reference time).

t1 float | None

User defined time pick or marker 1 (seconds relative to reference time).

t2 float | None

User defined time pick or marker 2 (seconds relative to reference time).

t3 float | None

User defined time pick or marker 3 (seconds relative to reference time).

t4 float | None

User defined time pick or marker 4 (seconds relative to reference time).

t5 float | None

User defined time pick or marker 5 (seconds relative to reference time).

t6 float | None

User defined time pick or marker 6 (seconds relative to reference time).

t7 float | None

User defined time pick or marker 7 (seconds relative to reference time).

t8 float | None

User defined time pick or marker 8 (seconds relative to reference time).

t9 float | None

User defined time pick or marker 9 (seconds relative to reference time).

user0 float | None

User defined variable storage area.

user1 float | None

User defined variable storage area.

user2 float | None

User defined variable storage area.

user3 float | None

User defined variable storage area.

user4 float | None

User defined variable storage area.

user5 float | None

User defined variable storage area.

user6 float | None

User defined variable storage area.

user7 float | None

User defined variable storage area.

user8 float | None

User defined variable storage area.

user9 float | None

User defined variable storage area.

xmaximum int | float | None

Maximum value of X (Spectral files only).

xminimum int | float | None

Minimum value of X (Spectral files only).

ymaximum int | float | None

Maximum value of Y (Spectral files only).

yminimum int | float | None

Minimum value of Y (Spectral files only).

Source code in src/pysmo/lib/io/_sacio/sacio.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 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
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
@define(kw_only=True)
class SacIO(SacIOBase):
    """Access SAC files in Python.

    The `SacIO` class reads and writes data and header values to and from a
    SAC file. Instances of `SacIO` provide attributes named identically to
    header names in the SAC file format. Additional attributes may be set, but
    are not written to a SAC file (because there is no space reserved for them
    there). Class attributes with corresponding header fields in a SAC file
    (for example the begin time [`b`][pysmo.lib.io.SacIO.b]) are checked for a
    valid format before being saved in the `SacIO` instance.

    Tip:
        This class should typically never be used directly. Instead use the
        [`SAC`][pysmo.classes.SAC] class, which wraps a `SacIO` instance
        (reachable as [`SAC.native`][pysmo.classes.SAC.native]) and exposes
        it through pysmo types.

    Examples:
        Create a new instance from a file and print seismogram data:

        ```python
        >>> from pysmo.lib.io import SacIO
        >>> sac = SacIO.from_file("example.sac")
        >>> data = sac.data
        >>> data
        array([-47201., -47361., -47511., ..., -82144., -71072., -59960.],
              shape=(57465,))
        >>>
        ```

        Read the sampling rate:

        ```python
        >>> delta = sac.delta
        >>> delta
        0.05000000074505806
        >>>
        ```

        Change the sampling rate:

        ```python
        >>> newdelta = 0.05
        >>> sac.delta = newdelta
        >>> sac.delta
        0.05
        >>>
        ```
    """

    @contextmanager
    def raw(self) -> Iterator[None]:
        """Temporarily relax cross-field header restrictions on this instance.

        Some headers are restricted based on the value of another header,
        rather than by type or range alone. Writing a value that violates
        such a restriction normally raises `RuntimeError`; within this
        context, that check is skipped, so the write goes through.

        For example, [`iztype`][pysmo.lib.io.SacIO.iztype] names one time
        header (`b`, `o`, `a`, `t0`, etc.) as the zero-time reference, and
        that header is normally pinned at `0`.
        [`change_ref_time`][pysmo.lib.io.SacIO.change_ref_time] and
        [`read_buffer`][pysmo.lib.io.SacIO.read_buffer] use this context
        manager internally to move or replace the zero-time header before
        the restriction holds again.

        Note:
            Only cross-field restrictions like this are relaxed, and only
            on this instance. Other checks (type, enum membership,
            numeric bounds, string length) still apply.

        Examples:
            ```python
            >>> from pysmo.lib.io import SacIO
            >>> sac = SacIO(o=0.0, iztype="o")
            >>> with sac.raw():
            ...     sac.o = 12.0
            ...
            >>> sac.o
            12.0
            >>>
            ```
        """
        self._raw_mode = True
        try:
            yield
        finally:
            self._raw_mode = False

    @property
    def depmin(self) -> int | float | None:
        """Minimum value of dependent variable."""
        if self.npts == 0:
            return None
        return np.min(self.data).item()

    @property
    def depmax(self) -> int | float | None:
        """Maximum value of dependent variable."""
        if self.npts == 0:
            return None
        return np.max(self.data).item()

    @property
    def depmen(self) -> int | float | None:
        """Mean value of dependent variable."""
        if self.npts == 0:
            return None
        return np.mean(self.data).item()

    @property
    def e(self) -> int | float:
        """Ending value of the independent variable."""
        if self.npts == 0:
            return self.b
        return self.b + (self.npts - 1) * self.delta

    @property
    def dist(self) -> int | float:
        """Station to event distance (km)."""
        if (
            self.stla is not None
            and self.stlo is not None
            and self.evla is not None
            and self.evlo is not None
        ):
            station_location = MiniLocation(latitude=self.stla, longitude=self.stlo)
            event_location = MiniLocation(latitude=self.evla, longitude=self.evlo)
            return (
                distance(location_1=station_location, location_2=event_location) / 1000
            )
        raise TypeError("One or more coordinates are None.")

    @property
    def az(self) -> int | float:
        """Event to station azimuth (degrees)."""
        if (
            self.stla is not None
            and self.stlo is not None
            and self.evla is not None
            and self.evlo is not None
        ):
            station_location = MiniLocation(latitude=self.stla, longitude=self.stlo)
            event_location = MiniLocation(latitude=self.evla, longitude=self.evlo)
            return azimuth(location_1=station_location, location_2=event_location)
        raise TypeError("One or more coordinates are None.")

    @property
    def baz(self) -> int | float:
        """Station to event azimuth (degrees)."""
        if (
            self.stla is not None
            and self.stlo is not None
            and self.evla is not None
            and self.evlo is not None
        ):
            station_location = MiniLocation(latitude=self.stla, longitude=self.stlo)
            event_location = MiniLocation(latitude=self.evla, longitude=self.evlo)
            return backazimuth(location_1=station_location, location_2=event_location)
        raise TypeError("One or more coordinates are None.")

    @property
    def gcarc(self) -> int | float:
        """Station to event great circle arc length (degrees)."""
        if (
            self.stla is not None
            and self.stlo is not None
            and self.evla is not None
            and self.evlo is not None
        ):
            lat1, lon1 = np.deg2rad(self.stla), np.deg2rad(self.stlo)
            lat2, lon2 = np.deg2rad(self.evla), np.deg2rad(self.evlo)
            return np.rad2deg(
                np.arccos(
                    np.sin(lat1) * np.sin(lat2)
                    + np.cos(lat1) * np.cos(lat2) * np.cos(np.abs(lon1 - lon2))
                )
            )
        raise TypeError("One or more coordinates are None.")

    @property
    def xminimum(self) -> int | float | None:
        """Minimum value of X (Spectral files only)."""
        if self.nxsize == 0 or not self.nxsize:
            return None
        return np.min(self.x).item()

    @property
    def xmaximum(self) -> int | float | None:
        """Maximum value of X (Spectral files only)."""
        if self.nxsize == 0 or not self.nxsize:
            return None
        return np.max(self.x).item()

    @property
    def yminimum(self) -> int | float | None:
        """Minimum value of Y (Spectral files only)."""
        if self.nysize == 0 or not self.nysize:
            return None
        return np.min(self.y).item()

    @property
    def ymaximum(self) -> int | float | None:
        """Maximum value of Y (Spectral files only)."""
        if self.nysize == 0 or not self.nysize:
            return None
        return np.max(self.y).item()

    @property
    def npts(self) -> int:
        """Number of points per data component."""
        return np.size(self.data)

    @property
    def nxsize(self) -> int | None:
        """Spectral Length (Spectral files only)."""
        if np.size(self.x) == 0:
            return None
        return np.size(self.x)

    @property
    def nysize(self) -> int | None:
        """Spectral Width (Spectral files only)."""
        if np.size(self.y) == 0:
            return None
        return np.size(self.y)

    @property
    def lcalda(self) -> Literal[True]:
        """TRUE if DIST, AZ, BAZ, and GCARC are to be calculated from station and event coordinates.

        Note:
            Above fields are all read only properties in this class, so
            they are always calculated.
        """
        return True

    @property
    def ref_datetime(self) -> datetime | None:
        """GMT reference time and date, as a Python `datetime` object."""
        if (
            self.nzyear is None
            or self.nzjday is None
            or self.nzhour is None
            or self.nzmin is None
            or self.nzsec is None
            or self.nzmsec is None
        ):
            return None
        return datetime(
            year=self.nzyear,
            month=1,
            day=1,
            hour=self.nzhour,
            minute=self.nzmin,
            second=self.nzsec,
            microsecond=self.nzmsec * 1000,
            tzinfo=timezone.utc,
        ) + timedelta(days=self.nzjday - 1)

    @ref_datetime.setter
    def ref_datetime(self, value: datetime) -> None:
        timedelta_for_rounding = timedelta(microseconds=500)
        value += timedelta_for_rounding
        self.nzyear = value.year
        self.nzjday = value.timetuple().tm_yday
        self.nzhour = value.hour
        self.nzmin = value.minute
        self.nzsec = value.second
        self.nzmsec = int(value.microsecond / 1000)

    @property
    def kzdate(self) -> str | None:
        """ISO 8601 format of GMT reference date."""
        if self.ref_datetime is None:
            return None
        return self.ref_datetime.date().isoformat()

    @property
    def kztime(self) -> str | None:
        """Alphanumeric form of GMT reference time."""
        if self.ref_datetime is None:
            return None
        return self.ref_datetime.time().isoformat(timespec="milliseconds")

    def read(self, filename: str | PathLike) -> None:
        """Read data and headers from a SAC file into an existing SAC instance.

        Args:
            filename: Name of the sac file to read.
        """

        filename = Path(filename).resolve()

        self.read_buffer(filename.read_bytes())

    def write(self, filename: str | PathLike) -> None:
        """Writes data and header values to a SAC file.

        Args:
            filename: Name of the sacfile to write to.
        """
        with open(filename, "wb") as file_handle:
            # loop over all valid header fields and write them to the file
            for header, header_metadata in SAC_HEADERS.items():
                header_type = header_metadata.type
                header_format = header_metadata.format
                start = header_metadata.start
                header_undefined = HEADER_TYPES[header_type].undefined

                value = None
                try:
                    if hasattr(self, header):
                        value = getattr(self, header)
                except TypeError:
                    value = None

                # convert enumerated header to integer if it is not None
                if header_type == "i" and value is not None:
                    value = SAC_ENUMS_DICT[header][value]

                # set None to -12345
                if value is None:
                    value = header_undefined

                # Encode strings to bytes
                if isinstance(value, str):
                    value = value.encode()

                # write to file
                file_handle.seek(start)
                file_handle.write(struct.pack(header_format, value))

            # write data (if npts > 0)
            data_1_start = 632
            data_1_end = data_1_start + self.npts * 4
            file_handle.truncate(data_1_start)
            if self.npts > 0:
                file_handle.seek(data_1_start)
                for x in self.data:
                    file_handle.write(struct.pack("f", x))

            if self.nvhdr == 7:
                for footer, footer_metadata in SAC_FOOTERS.items():
                    undefined = -12345.0
                    start = footer_metadata.start + data_1_end
                    value = None
                    try:
                        if hasattr(self, footer):
                            value = getattr(self, footer)
                    except AttributeError:
                        value = None

                    # set None to -12345
                    if value is None:
                        value = undefined

                    # write to file
                    file_handle.seek(start)
                    file_handle.write(struct.pack("d", value))

    @classmethod
    def from_file(cls, filename: str | PathLike) -> Self:
        """Create a new SAC instance from a SAC file.

        Args:
            filename: Name of the SAC file to read.

        Returns:
            A new SacIO instance.
        """
        newinstance = cls()
        newinstance.read(filename)
        return newinstance

    @classmethod
    def from_buffer(cls, buffer: bytes) -> Self:
        """Create a new SAC instance from a SAC data buffer.

        Args:
            buffer: Buffer containing SAC file content.

        Returns:
            A new SacIO instance.
        """
        newinstance = cls()
        newinstance.read_buffer(buffer)
        return newinstance

    def read_buffer(self, buffer: bytes) -> None:
        """Read data and headers from a SAC byte buffer into an existing SAC instance.

        Args:
            buffer: Buffer containing SAC file content.
        """

        if len(buffer) < 632:
            raise EOFError()

        # Guess the file endianness first using the unused12 header field.
        # It is located at position 276 and its value should be -12345.0.
        # Try reading with little endianness
        if struct.unpack("<f", buffer[276:280])[-1] == -12345.0:
            file_byteorder = "<"
        # otherwise assume big endianness.
        else:
            file_byteorder = ">"

        # Reusing an existing instance (SAC.read/read_buffer's documented
        # reload path) must not leave it in a mix of old and new file
        # state. Suspend the zero-time guard for the whole import below:
        # otherwise a header could still carry this instance's *previous*
        # iztype-pinned value while that old iztype hasn't been overwritten
        # yet, and setting it to the new file's value would incorrectly
        # raise. iztype itself goes through object.__setattr__ throughout,
        # since it is frozen (see change_ref_time) independently of this
        # guard.
        with self.raw():
            # Reset optional headers to their defaults first, so a header
            # this file doesn't define ends up unset rather than keeping a
            # stale value from a previously loaded file.
            for header, header_metadata in SAC_HEADERS.items():
                if header_metadata.required:
                    continue
                default = getattr(SacIODefaults, header, None)
                if header == "iztype":
                    object.__setattr__(self, header, default)
                    continue
                try:
                    setattr(self, header, default)
                except AttributeError as e:
                    if "object has no setter" in str(e):
                        pass

            # Loop over all header fields and store them in the SAC object under their
            # respective private names.
            npts = 0
            for header, header_metadata in SAC_HEADERS.items():
                header_type = header_metadata.type
                header_required = header_metadata.required
                header_undefined = HEADER_TYPES[header_type].undefined
                start = header_metadata.start
                length = header_metadata.length
                end = start + length
                if end >= len(buffer):
                    continue
                content = buffer[start:end]
                value = struct.unpack(file_byteorder + header_metadata.format, content)[
                    0
                ]
                if isinstance(value, bytes):
                    # strip spaces and "\x00" chars
                    value = value.decode().rstrip(" \x00")

                # npts is read only property in this class, but is needed for reading data
                if header == "npts":
                    npts = int(value)

                # raise error if header is undefined AND required
                if value == header_undefined and header_required:
                    raise RuntimeError(
                        f"Required {header=} is undefined - invalid SAC file!"
                    )

                # skip if undefined (value == -12345...) and not required
                if value == header_undefined and not header_required:
                    continue

                # convert enumerated header to string and format others
                if header_type == "i":
                    value = SAC_ENUMS_DICT[header](value).name

                # iztype is frozen after construction (see change_ref_time), but
                # reading a file must still be able to set it from raw data.
                if header == "iztype":
                    object.__setattr__(self, header, value)
                    continue

                # SAC file has headers fields which are read only attributes in this
                # class. We skip them with this try/except.
                # TODO: This is a bit crude, should maybe be a bit more specific.
                try:
                    setattr(self, header, value)
                except AttributeError as e:
                    if "object has no setter" in str(e):
                        pass

            # Only accept IFTYPE = ITIME SAC files. Other IFTYPE use two data blocks,
            # which is something we don't support for now.
            if self.iftype.lower() != "time":
                raise NotImplementedError(
                    f"Reading SAC files with IFTYPE=(I){self.iftype.upper()} is not supported."  # noqa: E501
                )

            # Read first data block
            start = 632
            length = npts * 4
            data_end = start + length
            self.data = np.array([])
            if length > 0:
                data_end = start + length
                data_format = file_byteorder + str(npts) + "f"
                if data_end > len(buffer):
                    raise EOFError()
                content = buffer[start:data_end]
                data = struct.unpack(data_format, content)
                self.data = np.array(data)

            if self.nvhdr == 7:
                for footer, footer_metadata in SAC_FOOTERS.items():
                    undefined = -12345.0
                    length = 8
                    start = footer_metadata.start + data_end
                    end = start + length

                    if end > len(buffer):
                        raise EOFError()
                    content = buffer[start:end]

                    value = struct.unpack(file_byteorder + "d", content)[0]

                    # skip if undefined (value == -12345...)
                    if value == undefined:
                        continue

                    # SAC file has headers fields which are read only attributes in this
                    # class. We skip them with this try/except.
                    # TODO: This is a bit crude, should maybe be a bit more specific.
                    try:
                        setattr(self, footer, value)
                    except AttributeError as e:
                        if "object has no setter" in str(e):
                            pass

    def change_ref_time(self, header: str) -> None:
        """Re-point the reference time to a different time header.

        `header`'s absolute time becomes the new reference time and
        [`SacIO.iztype`][pysmo.lib.io.SacIO.iztype] is updated to match.
        [`SacIO.ref_datetime`][pysmo.lib.io.SacIO.ref_datetime] and every
        other time header are shifted by the exact same amount, so the
        absolute (UTC) time each of them represents is unchanged.

        Note:
            [`SacIO.ref_datetime`][pysmo.lib.io.SacIO.ref_datetime] only has
            millisecond precision, so the shift actually applied is rounded
            to the nearest millisecond. `header` therefore ends up within
            half a millisecond of `0`, rather than exactly `0`, whenever its
            old value was not already millisecond-aligned.

        Args:
            header: Name of the time header to make the new zero-time
                reference (e.g. `"b"`, `"o"`, `"a"`, `"t0"`, ..., `"t9"`).

        Raises:
            ValueError: If `header` cannot be used as a zero-time
                reference, if [`SacIO.ref_datetime`][pysmo.lib.io.SacIO.ref_datetime]
                is not set, or if `header`'s current value is `None`.
        """
        if header not in _IZTYPE_TARGET_HEADERS:
            raise ValueError(
                f"{header=} cannot be used as a zero-time reference "
                f"(must be one of {sorted(_IZTYPE_TARGET_HEADERS)})."
            )
        old_ref = self.ref_datetime
        if old_ref is None:
            raise ValueError(
                "Unable to change reference time: SacIO.ref_datetime is not set."
            )
        dtime = getattr(self, header)
        if dtime is None:
            raise ValueError(f"Unable to use '{header}' as a reference: it is not set.")

        # ref_datetime only has millisecond precision, so read back the
        # rounded shift it actually applied and use that for the headers.
        # This keeps every header's absolute time exactly consistent with
        # the new reference, at the cost of 'header' landing within half a
        # millisecond of 0 rather than exactly on it.
        self.ref_datetime = old_ref + timedelta(seconds=dtime)
        new_ref = self.ref_datetime
        assert new_ref is not None
        actual_dtime = (new_ref - old_ref).total_seconds()

        with self.raw():
            for time_header in SAC_TIME_HEADERS:
                try:
                    setattr(
                        self, time_header, getattr(self, time_header) - actual_dtime
                    )
                except AttributeError as e:
                    if "object has no setter" in str(e):
                        continue
                except TypeError as e:
                    if "unsupported operand type(s) for" in str(e):
                        continue

        object.__setattr__(self, "iztype", header)

a class-attribute instance-attribute

a: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

First arrival time (seconds relative to reference time).

az property

az: int | float

Event to station azimuth (degrees).

b class-attribute instance-attribute

b: float = field(
    default=SacIODefaults.b,
    converter=float,
    validator=validators.and_(
        validators.instance_of(float), _validate_with_iztype
    ),
)

Beginning value of the independent variable.

baz property

baz: int | float

Station to event azimuth (degrees).

cmpaz class-attribute instance-attribute

cmpaz: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Component azimuth (degrees clockwise from north).

cmpinc class-attribute instance-attribute

cmpinc: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Component incident angle (degrees from upward vertical; SEED/MINISEED uses dip: degrees from horizontal down).

data class-attribute instance-attribute

data: ndarray = field(
    factory=lambda: np.array([]),
    converter=convert_to_ndarray,
    validator=validators.instance_of(np.ndarray),
    on_setattr=setters.pipe(
        setters.convert, setters.validate
    ),
)

Seismogram data.

delta class-attribute instance-attribute

delta: float = field(
    default=SacIODefaults.delta,
    converter=float,
    validator=validators.instance_of(float),
)

Increment between evenly spaced samples (nominal value).

depmax property

depmax: int | float | None

Maximum value of dependent variable.

depmen property

depmen: int | float | None

Mean value of dependent variable.

depmin property

depmin: int | float | None

Minimum value of dependent variable.

dist property

dist: int | float

Station to event distance (km).

e property

e: int | float

Ending value of the independent variable.

evdp class-attribute instance-attribute

evdp: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Event depth below surface (kilometres -- previously metres).

evel class-attribute instance-attribute

evel: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Event elevation (metres).

evla class-attribute instance-attribute

evla: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            validators.ge(-90),
            validators.le(90),
        )
    ),
)

Event latitude (degrees, north positive).

evlo class-attribute instance-attribute

evlo: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            validators.ge(-180),
            validators.le(180),
        )
    ),
)

Event longitude (degrees, east positive).

f class-attribute instance-attribute

f: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

Fini or end of event time (seconds relative to reference time).

gcarc property

gcarc: int | float

Station to event great circle arc length (degrees).

ibody class-attribute instance-attribute

ibody: str | None = field(
    default=None,
    validator=validators.optional(_validate_sacenum),
)

Body / Spheroid definition used in Distance Calculations.

idep class-attribute instance-attribute

idep: str = field(
    default=SacIODefaults.idep, validator=_validate_sacenum
)

Type of dependent variable.

ievreg class-attribute instance-attribute

ievreg: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(4),
        )
    ),
)

Event geographic region.

ievtyp class-attribute instance-attribute

ievtyp: str = field(
    default=SacIODefaults.ievtyp,
    validator=_validate_sacenum,
)

Type of event.

iftype class-attribute instance-attribute

iftype: str = field(
    default=SacIODefaults.iftype,
    validator=_validate_sacenum,
)

Type of file.

iinst class-attribute instance-attribute

iinst: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(4),
        )
    ),
)

Type of recording instrument.

imagsrc class-attribute instance-attribute

imagsrc: str | None = field(
    default=None,
    validator=validators.optional(_validate_sacenum),
)

Source of magnitude information.

imagtyp class-attribute instance-attribute

imagtyp: str | None = field(
    default=None,
    validator=validators.optional(_validate_sacenum),
)

Magnitude type.

iqual class-attribute instance-attribute

iqual: str | None = field(
    default=None,
    validator=validators.optional(_validate_sacenum),
)

Quality of data.

istreg class-attribute instance-attribute

istreg: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(4),
        )
    ),
)

Station geographic region.

isynth class-attribute instance-attribute

isynth: str | None = field(
    default=None,
    validator=validators.optional(_validate_sacenum),
)

Synthetic data flag.

iztype class-attribute instance-attribute

iztype: str = field(
    default=SacIODefaults.iztype,
    validator=_validate_sacenum,
    on_setattr=setters.frozen,
)

Reference time equivalence. Read-only; changed via SacIO.change_ref_time.

ka class-attribute instance-attribute

ka: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

First arrival time identification.

kcmpnm class-attribute instance-attribute

kcmpnm: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Channel name. SEED volumes use three character names, and the third is the component/orientation. For horizontals, the current trend is to use 1 and 2 instead of N and E.

kdatrd class-attribute instance-attribute

kdatrd: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Date data was read onto computer.

kevnm class-attribute instance-attribute

kevnm: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(16),
        )
    ),
)

Event name.

kf class-attribute instance-attribute

kf: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Fini identification.

khole class-attribute instance-attribute

khole: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Nuclear: hole identifier; Other: location identifier (LOCID).

kinst class-attribute instance-attribute

kinst: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Generic name of recording instrument.

knetwk class-attribute instance-attribute

knetwk: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Name of seismic network.

ko class-attribute instance-attribute

ko: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Event origin time identification.

kstnm class-attribute instance-attribute

kstnm: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

Station name.

kt0 class-attribute instance-attribute

kt0: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt1 class-attribute instance-attribute

kt1: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt2 class-attribute instance-attribute

kt2: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt3 class-attribute instance-attribute

kt3: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt4 class-attribute instance-attribute

kt4: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt5 class-attribute instance-attribute

kt5: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt6 class-attribute instance-attribute

kt6: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt7 class-attribute instance-attribute

kt7: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt8 class-attribute instance-attribute

kt8: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kt9 class-attribute instance-attribute

kt9: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined time pick identification.

kuser0 class-attribute instance-attribute

kuser0: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined variable storage area.

kuser1 class-attribute instance-attribute

kuser1: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined variable storage area.

kuser2 class-attribute instance-attribute

kuser2: str | None = field(
    default=None,
    validator=validators.optional(
        validators.and_(
            validators.instance_of(str),
            validators.max_len(8),
        )
    ),
)

User defined variable storage area.

kzdate property

kzdate: str | None

ISO 8601 format of GMT reference date.

kztime property

kztime: str | None

Alphanumeric form of GMT reference time.

lcalda property

lcalda: Literal[True]

TRUE if DIST, AZ, BAZ, and GCARC are to be calculated from station and event coordinates.

Note

Above fields are all read only properties in this class, so they are always calculated.

leven class-attribute instance-attribute

leven: bool = field(
    default=SacIODefaults.leven,
    validator=validators.instance_of(bool),
)

TRUE if data is evenly spaced.

lovrok class-attribute instance-attribute

lovrok: bool | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(bool)
    ),
)

TRUE if it is okay to overwrite this file on disk.

lpspol class-attribute instance-attribute

lpspol: bool | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(bool)
    ),
)

TRUE if station components have a positive polarity (left-hand rule).

mag class-attribute instance-attribute

mag: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Event magnitude.

nevid class-attribute instance-attribute

nevid: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

Event ID (CSS 3.0).

norid class-attribute instance-attribute

norid: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

Origin ID (CSS 3.0).

npts property

npts: int

Number of points per data component.

nvhdr class-attribute instance-attribute

nvhdr: int = field(
    default=SacIODefaults.nvhdr,
    validator=validators.instance_of(int),
)

Header version number.

nwfid class-attribute instance-attribute

nwfid: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

Waveform ID (CSS 3.0).

nxsize property

nxsize: int | None

Spectral Length (Spectral files only).

nysize property

nysize: int | None

Spectral Width (Spectral files only).

nzhour class-attribute instance-attribute

nzhour: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

GMT hour.

nzjday class-attribute instance-attribute

nzjday: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

GMT julian day.

nzmin class-attribute instance-attribute

nzmin: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

GMT minute.

nzmsec class-attribute instance-attribute

nzmsec: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

GMT millisecond.

nzsec class-attribute instance-attribute

nzsec: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

GMT second.

nzyear class-attribute instance-attribute

nzyear: int | None = field(
    default=None,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

GMT year corresponding to reference (zero) time in file.

o class-attribute instance-attribute

o: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

Event origin time (seconds relative to reference time).

odelta class-attribute instance-attribute

odelta: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Observed increment if different from nominal value.

ref_datetime property writable

ref_datetime: datetime | None

GMT reference time and date, as a Python datetime object.

resp0 class-attribute instance-attribute

resp0: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 0 (not currently used).

resp1 class-attribute instance-attribute

resp1: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 1 (not currently used).

resp2 class-attribute instance-attribute

resp2: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 2 (not currently used).

resp3 class-attribute instance-attribute

resp3: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 3 (not currently used).

resp4 class-attribute instance-attribute

resp4: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 4 (not currently used).

resp5 class-attribute instance-attribute

resp5: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 5 (not currently used).

resp6 class-attribute instance-attribute

resp6: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 6 (not currently used).

resp7 class-attribute instance-attribute

resp7: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 7 (not currently used).

resp8 class-attribute instance-attribute

resp8: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 8 (not currently used).

resp9 class-attribute instance-attribute

resp9: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 9 (not currently used).

stdp class-attribute instance-attribute

stdp: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Station depth below surface (metres).

stel class-attribute instance-attribute

stel: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Station elevation above sea level (metres).

stla class-attribute instance-attribute

stla: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            validators.ge(-90),
            validators.le(90),
        )
    ),
)

Station latitude (degrees, north positive).

stlo class-attribute instance-attribute

stlo: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            validators.ge(-180),
            validators.le(180),
        )
    ),
)

Station longitude (degrees, east positive).

t0 class-attribute instance-attribute

t0: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 0 (seconds relative to reference time).

t1 class-attribute instance-attribute

t1: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 1 (seconds relative to reference time).

t2 class-attribute instance-attribute

t2: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 2 (seconds relative to reference time).

t3 class-attribute instance-attribute

t3: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 3 (seconds relative to reference time).

t4 class-attribute instance-attribute

t4: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 4 (seconds relative to reference time).

t5 class-attribute instance-attribute

t5: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 5 (seconds relative to reference time).

t6 class-attribute instance-attribute

t6: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 6 (seconds relative to reference time).

t7 class-attribute instance-attribute

t7: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 7 (seconds relative to reference time).

t8 class-attribute instance-attribute

t8: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 8 (seconds relative to reference time).

t9 class-attribute instance-attribute

t9: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.and_(
            validators.instance_of(float),
            _validate_with_iztype,
        )
    ),
)

User defined time pick or marker 9 (seconds relative to reference time).

user0 class-attribute instance-attribute

user0: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user1 class-attribute instance-attribute

user1: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user2 class-attribute instance-attribute

user2: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user3 class-attribute instance-attribute

user3: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user4 class-attribute instance-attribute

user4: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user5 class-attribute instance-attribute

user5: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user6 class-attribute instance-attribute

user6: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user7 class-attribute instance-attribute

user7: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user8 class-attribute instance-attribute

user8: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user9 class-attribute instance-attribute

user9: float | None = field(
    default=None,
    converter=converters.optional(float),
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

xmaximum property

xmaximum: int | float | None

Maximum value of X (Spectral files only).

xminimum property

xminimum: int | float | None

Minimum value of X (Spectral files only).

ymaximum property

ymaximum: int | float | None

Maximum value of Y (Spectral files only).

yminimum property

yminimum: int | float | None

Minimum value of Y (Spectral files only).

change_ref_time

change_ref_time(header: str) -> None

Re-point the reference time to a different time header.

header's absolute time becomes the new reference time and SacIO.iztype is updated to match. SacIO.ref_datetime and every other time header are shifted by the exact same amount, so the absolute (UTC) time each of them represents is unchanged.

Note

SacIO.ref_datetime only has millisecond precision, so the shift actually applied is rounded to the nearest millisecond. header therefore ends up within half a millisecond of 0, rather than exactly 0, whenever its old value was not already millisecond-aligned.

Parameters:

Name Type Description Default
header str

Name of the time header to make the new zero-time reference (e.g. "b", "o", "a", "t0", ..., "t9").

required

Raises:

Type Description
ValueError

If header cannot be used as a zero-time reference, if SacIO.ref_datetime is not set, or if header's current value is None.

Source code in src/pysmo/lib/io/_sacio/sacio.py
def change_ref_time(self, header: str) -> None:
    """Re-point the reference time to a different time header.

    `header`'s absolute time becomes the new reference time and
    [`SacIO.iztype`][pysmo.lib.io.SacIO.iztype] is updated to match.
    [`SacIO.ref_datetime`][pysmo.lib.io.SacIO.ref_datetime] and every
    other time header are shifted by the exact same amount, so the
    absolute (UTC) time each of them represents is unchanged.

    Note:
        [`SacIO.ref_datetime`][pysmo.lib.io.SacIO.ref_datetime] only has
        millisecond precision, so the shift actually applied is rounded
        to the nearest millisecond. `header` therefore ends up within
        half a millisecond of `0`, rather than exactly `0`, whenever its
        old value was not already millisecond-aligned.

    Args:
        header: Name of the time header to make the new zero-time
            reference (e.g. `"b"`, `"o"`, `"a"`, `"t0"`, ..., `"t9"`).

    Raises:
        ValueError: If `header` cannot be used as a zero-time
            reference, if [`SacIO.ref_datetime`][pysmo.lib.io.SacIO.ref_datetime]
            is not set, or if `header`'s current value is `None`.
    """
    if header not in _IZTYPE_TARGET_HEADERS:
        raise ValueError(
            f"{header=} cannot be used as a zero-time reference "
            f"(must be one of {sorted(_IZTYPE_TARGET_HEADERS)})."
        )
    old_ref = self.ref_datetime
    if old_ref is None:
        raise ValueError(
            "Unable to change reference time: SacIO.ref_datetime is not set."
        )
    dtime = getattr(self, header)
    if dtime is None:
        raise ValueError(f"Unable to use '{header}' as a reference: it is not set.")

    # ref_datetime only has millisecond precision, so read back the
    # rounded shift it actually applied and use that for the headers.
    # This keeps every header's absolute time exactly consistent with
    # the new reference, at the cost of 'header' landing within half a
    # millisecond of 0 rather than exactly on it.
    self.ref_datetime = old_ref + timedelta(seconds=dtime)
    new_ref = self.ref_datetime
    assert new_ref is not None
    actual_dtime = (new_ref - old_ref).total_seconds()

    with self.raw():
        for time_header in SAC_TIME_HEADERS:
            try:
                setattr(
                    self, time_header, getattr(self, time_header) - actual_dtime
                )
            except AttributeError as e:
                if "object has no setter" in str(e):
                    continue
            except TypeError as e:
                if "unsupported operand type(s) for" in str(e):
                    continue

    object.__setattr__(self, "iztype", header)

from_buffer classmethod

from_buffer(buffer: bytes) -> Self

Create a new SAC instance from a SAC data buffer.

Parameters:

Name Type Description Default
buffer bytes

Buffer containing SAC file content.

required

Returns:

Type Description
Self

A new SacIO instance.

Source code in src/pysmo/lib/io/_sacio/sacio.py
@classmethod
def from_buffer(cls, buffer: bytes) -> Self:
    """Create a new SAC instance from a SAC data buffer.

    Args:
        buffer: Buffer containing SAC file content.

    Returns:
        A new SacIO instance.
    """
    newinstance = cls()
    newinstance.read_buffer(buffer)
    return newinstance

from_file classmethod

from_file(filename: str | PathLike) -> Self

Create a new SAC instance from a SAC file.

Parameters:

Name Type Description Default
filename str | PathLike

Name of the SAC file to read.

required

Returns:

Type Description
Self

A new SacIO instance.

Source code in src/pysmo/lib/io/_sacio/sacio.py
@classmethod
def from_file(cls, filename: str | PathLike) -> Self:
    """Create a new SAC instance from a SAC file.

    Args:
        filename: Name of the SAC file to read.

    Returns:
        A new SacIO instance.
    """
    newinstance = cls()
    newinstance.read(filename)
    return newinstance

raw

raw() -> Iterator[None]

Temporarily relax cross-field header restrictions on this instance.

Some headers are restricted based on the value of another header, rather than by type or range alone. Writing a value that violates such a restriction normally raises RuntimeError; within this context, that check is skipped, so the write goes through.

For example, iztype names one time header (b, o, a, t0, etc.) as the zero-time reference, and that header is normally pinned at 0. change_ref_time and read_buffer use this context manager internally to move or replace the zero-time header before the restriction holds again.

Note

Only cross-field restrictions like this are relaxed, and only on this instance. Other checks (type, enum membership, numeric bounds, string length) still apply.

Examples:

>>> from pysmo.lib.io import SacIO
>>> sac = SacIO(o=0.0, iztype="o")
>>> with sac.raw():
...     sac.o = 12.0
...
>>> sac.o
12.0
>>>
Source code in src/pysmo/lib/io/_sacio/sacio.py
@contextmanager
def raw(self) -> Iterator[None]:
    """Temporarily relax cross-field header restrictions on this instance.

    Some headers are restricted based on the value of another header,
    rather than by type or range alone. Writing a value that violates
    such a restriction normally raises `RuntimeError`; within this
    context, that check is skipped, so the write goes through.

    For example, [`iztype`][pysmo.lib.io.SacIO.iztype] names one time
    header (`b`, `o`, `a`, `t0`, etc.) as the zero-time reference, and
    that header is normally pinned at `0`.
    [`change_ref_time`][pysmo.lib.io.SacIO.change_ref_time] and
    [`read_buffer`][pysmo.lib.io.SacIO.read_buffer] use this context
    manager internally to move or replace the zero-time header before
    the restriction holds again.

    Note:
        Only cross-field restrictions like this are relaxed, and only
        on this instance. Other checks (type, enum membership,
        numeric bounds, string length) still apply.

    Examples:
        ```python
        >>> from pysmo.lib.io import SacIO
        >>> sac = SacIO(o=0.0, iztype="o")
        >>> with sac.raw():
        ...     sac.o = 12.0
        ...
        >>> sac.o
        12.0
        >>>
        ```
    """
    self._raw_mode = True
    try:
        yield
    finally:
        self._raw_mode = False

read

read(filename: str | PathLike) -> None

Read data and headers from a SAC file into an existing SAC instance.

Parameters:

Name Type Description Default
filename str | PathLike

Name of the sac file to read.

required
Source code in src/pysmo/lib/io/_sacio/sacio.py
def read(self, filename: str | PathLike) -> None:
    """Read data and headers from a SAC file into an existing SAC instance.

    Args:
        filename: Name of the sac file to read.
    """

    filename = Path(filename).resolve()

    self.read_buffer(filename.read_bytes())

read_buffer

read_buffer(buffer: bytes) -> None

Read data and headers from a SAC byte buffer into an existing SAC instance.

Parameters:

Name Type Description Default
buffer bytes

Buffer containing SAC file content.

required
Source code in src/pysmo/lib/io/_sacio/sacio.py
def read_buffer(self, buffer: bytes) -> None:
    """Read data and headers from a SAC byte buffer into an existing SAC instance.

    Args:
        buffer: Buffer containing SAC file content.
    """

    if len(buffer) < 632:
        raise EOFError()

    # Guess the file endianness first using the unused12 header field.
    # It is located at position 276 and its value should be -12345.0.
    # Try reading with little endianness
    if struct.unpack("<f", buffer[276:280])[-1] == -12345.0:
        file_byteorder = "<"
    # otherwise assume big endianness.
    else:
        file_byteorder = ">"

    # Reusing an existing instance (SAC.read/read_buffer's documented
    # reload path) must not leave it in a mix of old and new file
    # state. Suspend the zero-time guard for the whole import below:
    # otherwise a header could still carry this instance's *previous*
    # iztype-pinned value while that old iztype hasn't been overwritten
    # yet, and setting it to the new file's value would incorrectly
    # raise. iztype itself goes through object.__setattr__ throughout,
    # since it is frozen (see change_ref_time) independently of this
    # guard.
    with self.raw():
        # Reset optional headers to their defaults first, so a header
        # this file doesn't define ends up unset rather than keeping a
        # stale value from a previously loaded file.
        for header, header_metadata in SAC_HEADERS.items():
            if header_metadata.required:
                continue
            default = getattr(SacIODefaults, header, None)
            if header == "iztype":
                object.__setattr__(self, header, default)
                continue
            try:
                setattr(self, header, default)
            except AttributeError as e:
                if "object has no setter" in str(e):
                    pass

        # Loop over all header fields and store them in the SAC object under their
        # respective private names.
        npts = 0
        for header, header_metadata in SAC_HEADERS.items():
            header_type = header_metadata.type
            header_required = header_metadata.required
            header_undefined = HEADER_TYPES[header_type].undefined
            start = header_metadata.start
            length = header_metadata.length
            end = start + length
            if end >= len(buffer):
                continue
            content = buffer[start:end]
            value = struct.unpack(file_byteorder + header_metadata.format, content)[
                0
            ]
            if isinstance(value, bytes):
                # strip spaces and "\x00" chars
                value = value.decode().rstrip(" \x00")

            # npts is read only property in this class, but is needed for reading data
            if header == "npts":
                npts = int(value)

            # raise error if header is undefined AND required
            if value == header_undefined and header_required:
                raise RuntimeError(
                    f"Required {header=} is undefined - invalid SAC file!"
                )

            # skip if undefined (value == -12345...) and not required
            if value == header_undefined and not header_required:
                continue

            # convert enumerated header to string and format others
            if header_type == "i":
                value = SAC_ENUMS_DICT[header](value).name

            # iztype is frozen after construction (see change_ref_time), but
            # reading a file must still be able to set it from raw data.
            if header == "iztype":
                object.__setattr__(self, header, value)
                continue

            # SAC file has headers fields which are read only attributes in this
            # class. We skip them with this try/except.
            # TODO: This is a bit crude, should maybe be a bit more specific.
            try:
                setattr(self, header, value)
            except AttributeError as e:
                if "object has no setter" in str(e):
                    pass

        # Only accept IFTYPE = ITIME SAC files. Other IFTYPE use two data blocks,
        # which is something we don't support for now.
        if self.iftype.lower() != "time":
            raise NotImplementedError(
                f"Reading SAC files with IFTYPE=(I){self.iftype.upper()} is not supported."  # noqa: E501
            )

        # Read first data block
        start = 632
        length = npts * 4
        data_end = start + length
        self.data = np.array([])
        if length > 0:
            data_end = start + length
            data_format = file_byteorder + str(npts) + "f"
            if data_end > len(buffer):
                raise EOFError()
            content = buffer[start:data_end]
            data = struct.unpack(data_format, content)
            self.data = np.array(data)

        if self.nvhdr == 7:
            for footer, footer_metadata in SAC_FOOTERS.items():
                undefined = -12345.0
                length = 8
                start = footer_metadata.start + data_end
                end = start + length

                if end > len(buffer):
                    raise EOFError()
                content = buffer[start:end]

                value = struct.unpack(file_byteorder + "d", content)[0]

                # skip if undefined (value == -12345...)
                if value == undefined:
                    continue

                # SAC file has headers fields which are read only attributes in this
                # class. We skip them with this try/except.
                # TODO: This is a bit crude, should maybe be a bit more specific.
                try:
                    setattr(self, footer, value)
                except AttributeError as e:
                    if "object has no setter" in str(e):
                        pass

write

write(filename: str | PathLike) -> None

Writes data and header values to a SAC file.

Parameters:

Name Type Description Default
filename str | PathLike

Name of the sacfile to write to.

required
Source code in src/pysmo/lib/io/_sacio/sacio.py
def write(self, filename: str | PathLike) -> None:
    """Writes data and header values to a SAC file.

    Args:
        filename: Name of the sacfile to write to.
    """
    with open(filename, "wb") as file_handle:
        # loop over all valid header fields and write them to the file
        for header, header_metadata in SAC_HEADERS.items():
            header_type = header_metadata.type
            header_format = header_metadata.format
            start = header_metadata.start
            header_undefined = HEADER_TYPES[header_type].undefined

            value = None
            try:
                if hasattr(self, header):
                    value = getattr(self, header)
            except TypeError:
                value = None

            # convert enumerated header to integer if it is not None
            if header_type == "i" and value is not None:
                value = SAC_ENUMS_DICT[header][value]

            # set None to -12345
            if value is None:
                value = header_undefined

            # Encode strings to bytes
            if isinstance(value, str):
                value = value.encode()

            # write to file
            file_handle.seek(start)
            file_handle.write(struct.pack(header_format, value))

        # write data (if npts > 0)
        data_1_start = 632
        data_1_end = data_1_start + self.npts * 4
        file_handle.truncate(data_1_start)
        if self.npts > 0:
            file_handle.seek(data_1_start)
            for x in self.data:
                file_handle.write(struct.pack("f", x))

        if self.nvhdr == 7:
            for footer, footer_metadata in SAC_FOOTERS.items():
                undefined = -12345.0
                start = footer_metadata.start + data_1_end
                value = None
                try:
                    if hasattr(self, footer):
                        value = getattr(self, footer)
                except AttributeError:
                    value = None

                # set None to -12345
                if value is None:
                    value = undefined

                # write to file
                file_handle.seek(start)
                file_handle.write(struct.pack("d", value))

extract_geocsv_timeseries

extract_geocsv_timeseries(
    dataset: GeoCsvDataset,
) -> _TimeseriesSegment

Interpret a GeoCSV dataset as a waveform segment.

Uses the timeseries extension keywords emitted by the EarthScope FDSN dataselect service (SID, start_time, sample_rate_hz, sample_count). The sample column is located via the field_type keyword; the dataset must declare a numeric field_type.

Parameters:

Name Type Description Default
dataset GeoCsvDataset

Uninterpreted GeoCSV dataset.

required

Returns:

Type Description
_TimeseriesSegment

The dataset as a waveform segment.

Raises:

Type Description
ValueError

If a required timeseries header is missing, no numeric field_type column is found, or the number of data rows does not match the declared sample_count (e.g. a truncated response).

Source code in src/pysmo/lib/io/_geocsv.py
def extract_geocsv_timeseries(dataset: GeoCsvDataset) -> _TimeseriesSegment:
    """Interpret a GeoCSV dataset as a waveform segment.

    Uses the timeseries extension keywords emitted by the EarthScope FDSN
    dataselect service (`SID`, `start_time`, `sample_rate_hz`,
    `sample_count`). The sample column is located via the `field_type`
    keyword; the dataset must declare a numeric `field_type`.

    Args:
        dataset: Uninterpreted GeoCSV dataset.

    Returns:
        The dataset as a waveform segment.

    Raises:
        ValueError: If a required timeseries header is missing, no numeric
            `field_type` column is found, or the number of data rows does
            not match the declared `sample_count` (e.g. a truncated
            response).
    """
    headers = dataset.headers
    try:
        _ts = pd.Timestamp(headers["start_time"])
        start_time = _ts if _ts.tzinfo is not None else _ts.tz_localize("UTC")
        sample_rate_hz = float(headers["sample_rate_hz"])
        sample_count = int(headers["sample_count"])
    except KeyError as error:
        raise ValueError(
            f"GeoCSV dataset is missing required timeseries header {error}."
        ) from error

    if dataset.rows:
        sample_column = _find_sample_column(dataset)
        try:
            data = np.array(
                [row[sample_column] for row in dataset.rows], dtype=np.float64
            )
        except IndexError as error:
            raise ValueError(
                f"GeoCSV dataset row has fewer than {sample_column + 1} fields; "
                "cannot locate the sample column declared by 'field_type'."
            ) from error
    else:
        data = np.array([], dtype=np.float64)

    if len(data) != sample_count:
        raise ValueError(
            f"GeoCSV dataset declares sample_count {sample_count} "
            f"but contains {len(data)} data rows."
        )

    return _TimeseriesSegment(
        start_time=start_time,
        sample_rate_hz=sample_rate_hz,
        sample_count=sample_count,
        sid=headers.get("sid", ""),
        data=data,
    )

http_get

http_get(
    url: str,
    fields: dict[str, Any],
    *,
    timeout_seconds: int | float,
    request_retries: int,
    retry_delay_seconds: int | float,
    redirect: bool = True
) -> bytes

Perform an HTTP GET request with retries on server errors.

Requests returning HTTP 500 are retried up to request_retries times, sleeping retry_delay_seconds between attempts. Any other HTTP error status raises immediately.

Parameters:

Name Type Description Default
url str

URL to request.

required
fields dict[str, Any]

Query parameters to send with the request.

required
timeout_seconds int | float

Timeout in seconds for each request attempt.

required
request_retries int

Maximum number of request attempts (must be at least 1).

required
retry_delay_seconds int | float

Delay in seconds between request attempts.

required
redirect bool

Whether to automatically follow HTTP redirects.

True

Returns:

Type Description
bytes

The response body.

Raises:

Type Description
ValueError

If request_retries is less than 1.

ResponseError

If the server returns an HTTP error status.

Source code in src/pysmo/lib/io/_http.py
def http_get(
    url: str,
    fields: dict[str, Any],
    *,
    timeout_seconds: int | float,
    request_retries: int,
    retry_delay_seconds: int | float,
    redirect: bool = True,
) -> bytes:
    """Perform an HTTP GET request with retries on server errors.

    Requests returning HTTP 500 are retried up to `request_retries` times,
    sleeping `retry_delay_seconds` between attempts. Any other HTTP error
    status raises immediately.

    Args:
        url: URL to request.
        fields: Query parameters to send with the request.
        timeout_seconds: Timeout in seconds for each request attempt.
        request_retries: Maximum number of request attempts (must be at least 1).
        retry_delay_seconds: Delay in seconds between request attempts.
        redirect: Whether to automatically follow HTTP redirects.

    Returns:
        The response body.

    Raises:
        ValueError: If `request_retries` is less than 1.
        urllib3.exceptions.ResponseError: If the server returns an HTTP
            error status.
    """
    if request_retries < 1:
        raise ValueError("request_retries must be at least 1.")
    for attempt in range(request_retries):
        response = _pool.request(
            "GET",
            url,
            fields=fields,
            timeout=timeout_seconds,
            redirect=redirect,
        )
        if response.status == 500 and attempt < request_retries - 1:
            time.sleep(retry_delay_seconds)
            continue
        if response.status >= 400:
            raise urllib3.exceptions.ResponseError(f"HTTP {response.status}")
        break
    return response.data

merge_geocsv_timeseries

merge_geocsv_timeseries(
    segments: list[_TimeseriesSegment],
    *,
    gap_tolerance_factor: NonNegativeNumber = 0.5,
    auto_delta: bool = False
) -> _TimeseriesSegment

Merge contiguous waveform segments into a single segment.

Zero-sample segments are discarded before merging; the remaining segments must share a channel (SID) and sample rate. The merge itself — chronological ordering, gap/overlap tolerance, and overlap verification — is delegated to merge; see its docstring for details.

Parameters:

Name Type Description Default
segments list[_TimeseriesSegment]

Waveform segments to merge, in any order.

required
gap_tolerance_factor NonNegativeNumber

Maximum allowed boundary timestamp jitter between consecutive segments, as a fraction of the sampling interval. Passed through to merge.

0.5
auto_delta bool

Estimate a common sampling interval with estimate_delta instead of requiring segments to share the exact same sample rate — useful when reported sample rates only disagree by measurement or floating-point noise. Passed through to merge.

False

Returns:

Type Description
_TimeseriesSegment

A single segment covering all input segments.

Raises:

Type Description
ValueError

If no non-empty segments remain, the segments belong to different channels, the sample rates differ and auto_delta is False, or the underlying merge fails (see merge).

Source code in src/pysmo/lib/io/_geocsv.py
def merge_geocsv_timeseries(
    segments: list[_TimeseriesSegment],
    *,
    gap_tolerance_factor: NonNegativeNumber = 0.5,
    auto_delta: bool = False,
) -> _TimeseriesSegment:
    """Merge contiguous waveform segments into a single segment.

    Zero-sample segments are discarded before merging; the remaining
    segments must share a channel (SID) and sample rate. The merge itself —
    chronological ordering, gap/overlap tolerance, and overlap verification —
    is delegated to
    [`merge`][pysmo.functions.merge]; see its
    docstring for details.

    Args:
        segments: Waveform segments to merge, in any order.
        gap_tolerance_factor: Maximum allowed boundary timestamp jitter
            between consecutive segments, as a fraction of the sampling
            interval. Passed through to
            [`merge`][pysmo.functions.merge].
        auto_delta: Estimate a common sampling interval with
            [`estimate_delta`][pysmo.functions.estimate_delta] instead of
            requiring segments to share the exact same sample rate — useful
            when reported sample rates only disagree by measurement or
            floating-point noise. Passed through to
            [`merge`][pysmo.functions.merge].

    Returns:
        A single segment covering all input segments.

    Raises:
        ValueError: If no non-empty segments remain, the segments belong
            to different channels, the sample rates differ and `auto_delta`
            is `False`, or the underlying merge fails (see
            [`merge`][pysmo.functions.merge]).
    """
    if gap_tolerance_factor < 0:
        raise ValueError("gap_tolerance_factor must be non-negative.")

    segments = [segment for segment in segments if segment.sample_count > 0]
    if not segments:
        raise ValueError("No non-empty timeseries segments to merge.")

    sids = {segment.sid for segment in segments}
    if len(sids) > 1:
        raise ValueError(
            f"Cannot merge segments from different channels: {sorted(sids)}."
        )

    if len(segments) == 1:
        return segments[0]

    if not auto_delta:
        sample_rates = {segment.sample_rate_hz for segment in segments}
        if len(sample_rates) > 1:
            raise ValueError(
                f"Cannot merge segments with different sample rates: "
                f"{sorted(sample_rates)} Hz."
            )

    reference = segments[0]
    mini_seismograms = tuple(
        MiniSeismogram(
            begin_time=segment.start_time,
            delta=pd.Timedelta(seconds=1.0 / segment.sample_rate_hz),
            data=segment.data,
        )
        for segment in segments
    )
    # merge's `delta`/`auto_delta` overloads require a literal
    # `auto_delta`, which a plain `bool` variable can't satisfy; branching
    # here lets each call site narrow to the right overload.
    if auto_delta:
        merged = merge(
            mini_seismograms,
            auto_delta=True,
            gap_tolerance_factor=gap_tolerance_factor,
            clone=True,
        )
    else:
        merged = merge(
            mini_seismograms,
            gap_tolerance_factor=gap_tolerance_factor,
            clone=True,
        )
    return _TimeseriesSegment(
        start_time=merged.begin_time,
        # `.value` (integer nanoseconds) rather than `.total_seconds()`:
        # the latter loses sub-microsecond precision, which matters here
        # since `merged.delta` may be an auto_delta-estimated value only
        # a few nanoseconds off from a round number.
        sample_rate_hz=1_000_000_000 / merged.delta.value,
        sample_count=len(merged.data),
        sid=reference.sid,
        data=merged.data,
    )

parse_geocsv

parse_geocsv(text: str) -> list[GeoCsvDataset]

Split a GeoCSV text body into a list of datasets.

A new dataset starts at every dataset: keyword line. Keyword lines are recognised with the whitespace flexibility the specification allows (e.g. #dataset:GeoCSV 2.0 is equivalent to # dataset: GeoCSV 2.0). Comment lines without a keyword are ignored. The first non-comment line of each dataset is taken as the column header line; all further non-comment lines are data rows, split on the dataset delimiter.

Parameters:

Name Type Description Default
text str

GeoCSV text body.

required

Returns:

Type Description
list[GeoCsvDataset]

List of uninterpreted datasets in order of appearance.

Source code in src/pysmo/lib/io/_geocsv.py
def parse_geocsv(text: str) -> list[GeoCsvDataset]:
    """Split a GeoCSV text body into a list of datasets.

    A new dataset starts at every `dataset:` keyword line. Keyword lines
    are recognised with the whitespace flexibility the specification
    allows (e.g. `#dataset:GeoCSV 2.0` is equivalent to
    `# dataset: GeoCSV 2.0`). Comment lines without a keyword are
    ignored. The first non-comment line of each dataset is taken as the
    column header line; all further non-comment lines are data rows,
    split on the dataset delimiter.

    Args:
        text: GeoCSV text body.

    Returns:
        List of uninterpreted datasets in order of appearance.
    """
    datasets: list[GeoCsvDataset] = []
    current: GeoCsvDataset | None = None

    for line in text.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        if match := _KEYWORD_PATTERN.match(line):
            keyword, value = match.group(1).lower(), match.group(2)
            if keyword == "dataset" or current is None:
                current = GeoCsvDataset()
                datasets.append(current)
            current.headers[keyword] = value
            continue
        if stripped.startswith("#"):
            continue
        if current is None:
            current = GeoCsvDataset()
            datasets.append(current)
        values = _parse_fields(line, current.delimiter)
        if current.column_names:
            current.rows.append(values)
        else:
            current.column_names = values

    return datasets

parse_sacpz

parse_sacpz(text: str) -> list[_RawSacPzResponse]

Split SAC PZ text into a list of uninterpreted records.

A text body may contain several concatenated records (the EarthScope SACPZ web service returns one per channel epoch when a query is not pinned to a single epoch); this function returns all of them, in order of appearance.

Parameters:

Name Type Description Default
text str

SAC PZ text body, containing one or more records.

required

Returns:

Type Description
list[_RawSacPzResponse]

List of uninterpreted SAC PZ records in order of appearance.

Raises:

Type Description
ValueError

If a record is missing a required header field, or the ZEROS/POLES/CONSTANT blocks are missing or malformed.

Examples:

>>> from pysmo.lib.io._sacpz import parse_sacpz
>>> text = '''\
... * NETWORK   (KNETWK): IU
... * STATION    (KSTNM): ANMO
... * LOCATION   (KHOLE): 00
... * CHANNEL   (KCMPNM): BHZ
... * START             : 2018-07-09T20:45:00
... * END               :
... * INPUT UNIT        : M
... ZEROS 2
... \t+0.000000e+00\t+0.000000e+00
... \t+0.000000e+00\t+0.000000e+00
... POLES 1
... \t-1.000000e-02\t+0.000000e+00
... CONSTANT 1.0e+09
... '''
>>> records = parse_sacpz(text)
>>> len(records)
1
>>> records[0].network, records[0].station
('IU', 'ANMO')
>>> records[0].end_date is None
True
>>>
Source code in src/pysmo/lib/io/_sacpz.py
def parse_sacpz(text: str) -> list[_RawSacPzResponse]:
    r"""Split SAC PZ text into a list of uninterpreted records.

    A text body may contain several concatenated records (the EarthScope
    SACPZ web service returns one per channel epoch when a query is not
    pinned to a single epoch); this function returns all of them, in order
    of appearance.

    Args:
        text: SAC PZ text body, containing one or more records.

    Returns:
        List of uninterpreted SAC PZ records in order of appearance.

    Raises:
        ValueError: If a record is missing a required header field, or the
            `ZEROS`/`POLES`/`CONSTANT` blocks are missing or malformed.

    Examples:
        ```python
        >>> from pysmo.lib.io._sacpz import parse_sacpz
        >>> text = '''\
        ... * NETWORK   (KNETWK): IU
        ... * STATION    (KSTNM): ANMO
        ... * LOCATION   (KHOLE): 00
        ... * CHANNEL   (KCMPNM): BHZ
        ... * START             : 2018-07-09T20:45:00
        ... * END               :
        ... * INPUT UNIT        : M
        ... ZEROS 2
        ... \t+0.000000e+00\t+0.000000e+00
        ... \t+0.000000e+00\t+0.000000e+00
        ... POLES 1
        ... \t-1.000000e-02\t+0.000000e+00
        ... CONSTANT 1.0e+09
        ... '''
        >>> records = parse_sacpz(text)
        >>> len(records)
        1
        >>> records[0].network, records[0].station
        ('IU', 'ANMO')
        >>> records[0].end_date is None
        True
        >>>
        ```
    """
    lines = text.splitlines()
    records: list[_RawSacPzResponse] = []
    index = 0
    n = len(lines)

    while index < n:
        if not lines[index].strip():
            index += 1
            continue
        if not lines[index].strip().startswith("*"):
            raise ValueError(
                f"Expected a comment header line at line {index + 1}, found "
                f"{lines[index]!r}."
            )

        headers, index = _parse_headers(lines, index)
        missing = [key for key in _REQUIRED_HEADERS if key not in headers]
        if missing:
            raise ValueError(f"SAC PZ record is missing required header(s): {missing}.")

        zeros, index = _parse_complex_block(lines, index, "ZEROS")
        poles, index = _parse_complex_block(lines, index, "POLES")

        constant_line = lines[index].strip() if index < n else ""
        if not constant_line.startswith("CONSTANT"):
            raise ValueError(
                f"Expected 'CONSTANT' at line {index + 1}, found {constant_line!r}."
            )
        overall_sensitivity = _parse_float(constant_line.split()[1])
        index += 1

        end_date_text = headers.get("END", "")
        sensitivity_text = headers.get("SENSITIVITY", "")
        records.append(
            _RawSacPzResponse(
                network=headers["NETWORK"],
                station=headers["STATION"],
                location=headers["LOCATION"],
                channel=headers["CHANNEL"],
                start_date=convert_to_utc_timestamp(headers["START"]),
                end_date=(
                    convert_to_utc_timestamp(end_date_text) if end_date_text else None
                ),
                poles=poles,
                zeros=zeros,
                overall_sensitivity=overall_sensitivity,
                reference_sensitivity=(
                    _parse_float(sensitivity_text.split()[0])
                    if sensitivity_text
                    else None
                ),
                input_units=headers["INPUT UNIT"],
            )
        )

    return records

parse_stationxml

parse_stationxml(xml: bytes) -> list[_RawResponse]

Parse response metadata from a StationXML document.

Returns one entry per <Channel> epoch found — the FDSN station web service does not default to a single "current" epoch, so a query covering a channel's full instrument history returns several. Epoch selection (matching a specific time, or finding the currently-open one) is left to the caller.

Parameters:

Name Type Description Default
xml bytes

Raw StationXML document bytes (as returned by the FDSN station web service with level=response).

required

Returns:

Type Description
list[_RawResponse]

One uninterpreted response per <Channel> epoch found, in document

list[_RawResponse]

order.

Raises:

Type Description
ValueError

If xml contains a <!DOCTYPE declaration (rejected unconditionally, since xml.etree.ElementTree has no way to disable DTD-defined entity expansion and this input may come from an untrusted network response), a <Channel> has no <Response> element, a required sub-element is missing, or a <Stage> uses an unrecognised or unsupported (e.g. digital PolesZeros) encoding.

Examples:

>>> from pysmo.lib.io._stationxml import parse_stationxml
>>> xml = b'''<?xml version="1.0"?>
... <FDSNStationXML xmlns="http://www.fdsn.org/xml/station/1">
...   <Network code="IU">
...     <Station code="ANMO">
...       <Channel code="BHZ" locationCode="00"
...                startDate="2018-07-09T20:45:00.0000">
...         <Response>
...           <InstrumentSensitivity>
...             <Value>1.98475E9</Value>
...             <Frequency>0.02</Frequency>
...             <InputUnits><Name>m/s</Name></InputUnits>
...             <OutputUnits><Name>counts</Name></OutputUnits>
...           </InstrumentSensitivity>
...           <Stage number="1">
...             <PolesZeros>
...               <InputUnits><Name>m/s</Name></InputUnits>
...               <OutputUnits><Name>V</Name></OutputUnits>
...               <PzTransferFunctionType>LAPLACE (RADIANS/SECOND)</PzTransferFunctionType>
...               <NormalizationFactor>5.03773E14</NormalizationFactor>
...               <NormalizationFrequency>0.02</NormalizationFrequency>
...               <Zero number="0"><Real>0.0</Real><Imaginary>0.0</Imaginary></Zero>
...               <Pole number="0"><Real>-0.037</Real><Imaginary>0.037</Imaginary></Pole>
...             </PolesZeros>
...             <Decimation>
...               <InputSampleRate>40.0</InputSampleRate>
...               <Factor>1</Factor>
...             </Decimation>
...             <StageGain><Value>1183.0</Value><Frequency>0.02</Frequency></StageGain>
...           </Stage>
...         </Response>
...       </Channel>
...     </Station>
...   </Network>
... </FDSNStationXML>'''
>>> responses = parse_stationxml(xml)
>>> len(responses)
1
>>> responses[0].network, responses[0].station, responses[0].channel
('IU', 'ANMO', 'BHZ')
>>> responses[0].sensitivity_input_units
'm/s'
>>> responses[0].digital_stages
[]
>>>
Source code in src/pysmo/lib/io/_stationxml.py
def parse_stationxml(xml: bytes) -> list[_RawResponse]:
    """Parse response metadata from a StationXML document.

    Returns one entry per `<Channel>` epoch found — the FDSN station web
    service does not default to a single "current" epoch, so a query
    covering a channel's full instrument history returns several. Epoch
    *selection* (matching a specific time, or finding the currently-open
    one) is left to the caller.

    Args:
        xml: Raw StationXML document bytes (as returned by the FDSN station
            web service with `level=response`).

    Returns:
        One uninterpreted response per `<Channel>` epoch found, in document
        order.

    Raises:
        ValueError: If `xml` contains a `<!DOCTYPE` declaration (rejected
            unconditionally, since `xml.etree.ElementTree` has no way to
            disable DTD-defined entity expansion and this input may come
            from an untrusted network response), a `<Channel>` has no
            `<Response>` element, a required sub-element is missing, or a
            `<Stage>` uses an unrecognised or unsupported (e.g. digital
            `PolesZeros`) encoding.

    Examples:
        ```python
        >>> from pysmo.lib.io._stationxml import parse_stationxml
        >>> xml = b'''<?xml version="1.0"?>
        ... <FDSNStationXML xmlns="http://www.fdsn.org/xml/station/1">
        ...   <Network code="IU">
        ...     <Station code="ANMO">
        ...       <Channel code="BHZ" locationCode="00"
        ...                startDate="2018-07-09T20:45:00.0000">
        ...         <Response>
        ...           <InstrumentSensitivity>
        ...             <Value>1.98475E9</Value>
        ...             <Frequency>0.02</Frequency>
        ...             <InputUnits><Name>m/s</Name></InputUnits>
        ...             <OutputUnits><Name>counts</Name></OutputUnits>
        ...           </InstrumentSensitivity>
        ...           <Stage number="1">
        ...             <PolesZeros>
        ...               <InputUnits><Name>m/s</Name></InputUnits>
        ...               <OutputUnits><Name>V</Name></OutputUnits>
        ...               <PzTransferFunctionType>LAPLACE (RADIANS/SECOND)</PzTransferFunctionType>
        ...               <NormalizationFactor>5.03773E14</NormalizationFactor>
        ...               <NormalizationFrequency>0.02</NormalizationFrequency>
        ...               <Zero number="0"><Real>0.0</Real><Imaginary>0.0</Imaginary></Zero>
        ...               <Pole number="0"><Real>-0.037</Real><Imaginary>0.037</Imaginary></Pole>
        ...             </PolesZeros>
        ...             <Decimation>
        ...               <InputSampleRate>40.0</InputSampleRate>
        ...               <Factor>1</Factor>
        ...             </Decimation>
        ...             <StageGain><Value>1183.0</Value><Frequency>0.02</Frequency></StageGain>
        ...           </Stage>
        ...         </Response>
        ...       </Channel>
        ...     </Station>
        ...   </Network>
        ... </FDSNStationXML>'''
        >>> responses = parse_stationxml(xml)
        >>> len(responses)
        1
        >>> responses[0].network, responses[0].station, responses[0].channel
        ('IU', 'ANMO', 'BHZ')
        >>> responses[0].sensitivity_input_units
        'm/s'
        >>> responses[0].digital_stages
        []
        >>>
        ```
    """
    if b"<!DOCTYPE" in xml:
        raise ValueError(
            "Refusing to parse StationXML containing a <!DOCTYPE declaration "
            "(possible entity-expansion payload)."
        )
    root = ET.fromstring(xml)
    results: list[_RawResponse] = []

    for network_elem in root.findall("fdsn:Network", _NS):
        network_code = network_elem.get("code", "")
        for station_elem in network_elem.findall("fdsn:Station", _NS):
            station_code = station_elem.get("code", "")
            for channel in station_elem.findall("fdsn:Channel", _NS):
                response = channel.find("fdsn:Response", _NS)
                if response is None:
                    raise ValueError(
                        f"Channel {channel.get('code')!r} has no <Response> "
                        "element; ensure the StationXML query used "
                        "level=response."
                    )
                (
                    poles,
                    zeros,
                    normalization_factor,
                    sensitivity_value,
                    sensitivity_input_units,
                    digital_stages,
                ) = _parse_response(response)

                start_date = _parse_timestamp(channel.get("startDate"))
                if start_date is None:
                    raise ValueError(
                        f"Channel {channel.get('code')!r} has no startDate attribute."
                    )

                results.append(
                    _RawResponse(
                        network=network_code,
                        station=station_code,
                        location=channel.get("locationCode", ""),
                        channel=channel.get("code", ""),
                        start_date=start_date,
                        end_date=_parse_timestamp(channel.get("endDate")),
                        poles=poles,
                        zeros=zeros,
                        normalization_factor=normalization_factor,
                        sensitivity_value=sensitivity_value,
                        sensitivity_input_units=sensitivity_input_units,
                        digital_stages=digital_stages,
                    )
                )

    return results

write_geocsv

write_geocsv(
    seismograms: Seismogram | Sequence[Seismogram],
    path: str | PathLike,
) -> None

Write one or more Seismogram objects to a GeoCSV 2.0 file.

Each object is serialised as a self-contained GeoCSV 2.0 timeseries dataset block (# dataset: GeoCSV 2.0 header, keyword metadata, column header line, one row per sample). Multiple objects produce a multi-dataset file that is readable by parse_geocsv.

Parameters:

Name Type Description Default
seismograms Seismogram | Sequence[Seismogram]

A single Seismogram or a non-empty sequence of them.

required
path str | PathLike

Destination file path. Written in UTF-8 text mode; existing content is overwritten.

required

Raises:

Type Description
ValueError

If seismograms is an empty sequence.

OSError

If the file cannot be written.

Note

Dataset blocks are separated by a single blank line. The sample_rate_hz header value is derived from 1_000_000_000 / delta.value (integer nanoseconds, to preserve sub-microsecond precision). Both the # start_time: header and every Time column value are pd.Timestamp.isoformat() calls (begin_time and begin_time + n * delta respectively), which preserve full precision (including nanoseconds). Sample values are written as integer or float depending on whether the data is integral, so genuinely non-integral data (e.g. a detrended or filtered seismogram) is never silently truncated. A sid attribute is used if present (e.g. on a GeoCsvSeismogram), but is not required by the Seismogram protocol itself, so the # SID: header line is simply omitted for objects that don't have one. # field_unit: UTC, Counts is always written as-is — neither Seismogram nor GeoCsvSeismogram carries a units concept, so this label may not describe the data's actual physical units (e.g. after response removal); parse_geocsv/ extract_geocsv_timeseries never read it back, so this doesn't affect round-tripping, only external readers. sid is written verbatim, with no escaping: a value containing a newline would produce a file this module's own parse_geocsv cannot read back correctly (a comma is fine, since header lines are matched by regex, not CSV-split). Not a concern for real FDSN source identifiers, which never contain a newline.

Examples:

>>> import pandas as pd
>>> import numpy as np
>>> from pysmo import MiniSeismogram
>>> from pysmo.lib.io import write_geocsv
>>> now = pd.Timestamp.now("UTC")
>>> delta = pd.Timedelta(seconds=0.1)
>>> seg1 = MiniSeismogram(begin_time=now, delta=delta, data=np.arange(5.0))
>>> seg2 = MiniSeismogram(begin_time=now, delta=delta, data=np.arange(5.0))
>>> write_geocsv(seg1, "out.geocsv")
>>> write_geocsv([seg1, seg2], "multi.geocsv")
>>>
Source code in src/pysmo/lib/io/_geocsv.py
def write_geocsv(
    seismograms: Seismogram | Sequence[Seismogram],
    path: str | PathLike,
) -> None:
    """Write one or more Seismogram objects to a GeoCSV 2.0 file.

    Each object is serialised as a self-contained GeoCSV 2.0 timeseries
    dataset block (`# dataset: GeoCSV 2.0` header, keyword metadata,
    column header line, one row per sample). Multiple objects produce a
    multi-dataset file that is readable by
    [`parse_geocsv`][pysmo.lib.io.parse_geocsv].

    Args:
        seismograms: A single [`Seismogram`][pysmo.Seismogram] or a
            non-empty sequence of them.
        path: Destination file path. Written in UTF-8 text mode;
            existing content is overwritten.

    Raises:
        ValueError: If *seismograms* is an empty sequence.
        OSError: If the file cannot be written.

    Note:
        Dataset blocks are separated by a single blank line. The
        `sample_rate_hz` header value is derived from
        `1_000_000_000 / delta.value` (integer nanoseconds, to preserve
        sub-microsecond precision). Both the `# start_time:` header and
        every `Time` column value are `pd.Timestamp.isoformat()` calls
        (`begin_time` and `begin_time + n * delta` respectively), which
        preserve full precision (including nanoseconds). Sample values are
        written as `integer` or `float` depending on whether the data is
        integral, so genuinely non-integral data (e.g. a detrended or
        filtered seismogram) is never silently truncated. A `sid` attribute
        is used if present (e.g. on a
        [`GeoCsvSeismogram`][pysmo.classes.GeoCsvSeismogram]), but is not
        required by the [`Seismogram`][pysmo.Seismogram] protocol itself,
        so the `# SID:` header line is simply omitted for objects that
        don't have one. `# field_unit: UTC, Counts` is always written as-is
        — neither `Seismogram` nor `GeoCsvSeismogram` carries a units
        concept, so this label may not describe the data's actual physical
        units (e.g. after response removal); `parse_geocsv`/
        `extract_geocsv_timeseries` never read it back, so this doesn't
        affect round-tripping, only external readers. `sid` is written
        verbatim, with no escaping: a value containing a newline would
        produce a file this module's own `parse_geocsv` cannot read back
        correctly (a comma is fine, since header lines are matched by
        regex, not CSV-split). Not a concern for real FDSN source
        identifiers, which never contain a newline.

    Examples:
        ```python
        >>> import pandas as pd
        >>> import numpy as np
        >>> from pysmo import MiniSeismogram
        >>> from pysmo.lib.io import write_geocsv
        >>> now = pd.Timestamp.now("UTC")
        >>> delta = pd.Timedelta(seconds=0.1)
        >>> seg1 = MiniSeismogram(begin_time=now, delta=delta, data=np.arange(5.0))
        >>> seg2 = MiniSeismogram(begin_time=now, delta=delta, data=np.arange(5.0))
        >>> write_geocsv(seg1, "out.geocsv")
        >>> write_geocsv([seg1, seg2], "multi.geocsv")
        >>>
        ```
    """
    items = seismograms if isinstance(seismograms, Sequence) else [seismograms]
    if not items:
        raise ValueError("seismograms must not be an empty sequence.")

    blocks = [_geocsv_block(seismogram) for seismogram in items]

    with open(path, "w", encoding="utf-8") as f:
        f.write("\n\n".join(blocks))
        f.write("\n")

write_sacpz

write_sacpz(
    responses: (
        ResponseWithEpoch | Sequence[ResponseWithEpoch]
    ),
    path: str | PathLike,
) -> None

Write one or more Response objects to a SAC PZ file.

Parameters:

Name Type Description Default
responses ResponseWithEpoch | Sequence[ResponseWithEpoch]

A single object satisfying ResponseWithEpoch (e.g. SacPZ or StationXML), or a non-empty sequence of them.

required
path str | PathLike

Destination file path; written in UTF-8 text mode.

required

Raises:

Type Description
ValueError

If responses is an empty sequence.

OSError

If the file cannot be written.

Note

Records are separated by a single blank line. Poles, zeros, and CONSTANT are written at 6 decimal digits (.6e), matching the EarthScope SACPZ web service's own output convention — a real SAC PZ file never carries more precision than this, so writing a SacPZ instance (which was itself parsed from .6e-formatted text) back out loses nothing. Writing a higher-precision source instead — e.g. a StationXML instance, whose XML <Real>/<Imaginary> elements are not limited to 6 decimals — does round to this format's conventional precision; that is expected when converting into SAC PZ, not a bug to work around here. The * SENSITIVITY header line is omitted when reference_sensitivity is None. network/station/location/ channel/input_units are written verbatim, with no escaping: a value containing a newline would produce a file this module's own parse_sacpz cannot read back correctly (a colon is fine, since _HEADER_PATTERN's value group captures the rest of the line). Not a concern for real FDSN network/station/location/channel codes or SEED unit strings, which never contain a newline.

Examples:

>>> from pathlib import Path
>>> from pysmo.classes import SacPZ
>>> from pysmo.lib.io import write_sacpz
>>> text = Path("SACPZ.IU.ANMO.00.BHZ").read_text()
>>> response = SacPZ.from_text(text)
>>> write_sacpz(response, "out.pz")
>>> write_sacpz([response, response], "multi.pz")
>>>
Source code in src/pysmo/lib/io/_sacpz.py
def write_sacpz(
    responses: ResponseWithEpoch | Sequence[ResponseWithEpoch],
    path: str | PathLike,
) -> None:
    """Write one or more Response objects to a SAC PZ file.

    Args:
        responses: A single object satisfying
            [`ResponseWithEpoch`][pysmo.lib.io.ResponseWithEpoch] (e.g.
            [`SacPZ`][pysmo.classes.SacPZ] or
            [`StationXML`][pysmo.classes.StationXML]), or a non-empty
            sequence of them.
        path: Destination file path; written in UTF-8 text mode.

    Raises:
        ValueError: If *responses* is an empty sequence.
        OSError: If the file cannot be written.

    Note:
        Records are separated by a single blank line. Poles, zeros, and
        `CONSTANT` are written at 6 decimal digits (`.6e`), matching the
        EarthScope SACPZ web service's own output convention — a real SAC
        PZ file never carries more precision than this, so writing a
        [`SacPZ`][pysmo.classes.SacPZ] instance (which was itself parsed
        from `.6e`-formatted text) back out loses nothing. Writing a
        higher-precision source instead — e.g. a
        [`StationXML`][pysmo.classes.StationXML] instance, whose XML
        `<Real>`/`<Imaginary>` elements are not limited to 6 decimals —
        does round to this format's conventional precision; that is
        expected when converting into SAC PZ, not a bug to work around
        here. The `* SENSITIVITY` header line is omitted when
        `reference_sensitivity` is `None`. `network`/`station`/`location`/
        `channel`/`input_units` are written verbatim, with no escaping: a
        value containing a newline would produce a file this module's own
        `parse_sacpz` cannot read back correctly (a colon is fine, since
        `_HEADER_PATTERN`'s value group captures the rest of the line).
        Not a concern for real FDSN network/station/location/channel codes
        or SEED unit strings, which never contain a newline.

    Examples:
        ```python
        >>> from pathlib import Path
        >>> from pysmo.classes import SacPZ
        >>> from pysmo.lib.io import write_sacpz
        >>> text = Path("SACPZ.IU.ANMO.00.BHZ").read_text()
        >>> response = SacPZ.from_text(text)
        >>> write_sacpz(response, "out.pz")
        >>> write_sacpz([response, response], "multi.pz")
        >>>
        ```
    """
    items = responses if isinstance(responses, Sequence) else [responses]
    if not items:
        raise ValueError("responses must not be an empty sequence.")

    blocks = [_sacpz_block(response) for response in items]

    with open(path, "w", encoding="utf-8") as f:
        f.write("\n\n".join(blocks))
        f.write("\n")

mini_utils

Mini utils.

Functions:

Name Description
matching_pysmo_types

Returns pysmo types that objects may be an instance of.

proto2mini

Returns valid Mini classes that implement the given pysmo Protocol.

_AnyMini

_AnyMini = _BaseMini | _ToolsMini

Type alias for any pysmo Mini class.

_AnyProto

_AnyProto = _BaseProto | _ToolsProto

Type alias for any pysmo Protocol class.

matching_pysmo_types

matching_pysmo_types(
    obj: object,
) -> tuple[type[_AnyProto], ...]

Returns pysmo types that objects may be an instance of.

Parameters:

Name Type Description Default
obj object

The object (or class) to check.

required

Returns:

Type Description
tuple[type[_AnyProto], ...]

Pysmo types that obj is an instance of.

Examples:

Pysmo types matching instances of MiniLocationWithDepth or the class itself:

>>> from pysmo.lib.mini_utils import matching_pysmo_types
>>> from pysmo import MiniLocationWithDepth
>>>
>>> mini = MiniLocationWithDepth(latitude=12, longitude=34, depth=56)
>>> matching_pysmo_types(mini)
(<class 'pysmo.Location'>, <class 'pysmo.LocationWithDepth'>)
>>>
>>> matching_pysmo_types(MiniLocationWithDepth)
(<class 'pysmo.Location'>, <class 'pysmo.LocationWithDepth'>)
>>>
Source code in src/pysmo/lib/mini_utils.py
def matching_pysmo_types(obj: object) -> tuple[type[_AnyProto], ...]:
    """Returns pysmo types that objects may be an instance of.

    Args:
        obj: The object (or class) to check.

    Returns:
        Pysmo types that `obj` is an instance of.

    Examples:
        Pysmo types matching instances of
        [`MiniLocationWithDepth`][pysmo.MiniLocationWithDepth] or the class
        itself:

        ```python
        >>> from pysmo.lib.mini_utils import matching_pysmo_types
        >>> from pysmo import MiniLocationWithDepth
        >>>
        >>> mini = MiniLocationWithDepth(latitude=12, longitude=34, depth=56)
        >>> matching_pysmo_types(mini)
        (<class 'pysmo.Location'>, <class 'pysmo.LocationWithDepth'>)
        >>>
        >>> matching_pysmo_types(MiniLocationWithDepth)
        (<class 'pysmo.Location'>, <class 'pysmo.LocationWithDepth'>)
        >>>
        ```
    """

    matches: list[type[_AnyProto]] = []

    possible_protos = _get_flattened_types(_AnyProto)

    for proto in possible_protos:
        if _safe_check(obj, proto):
            matches.append(cast(type[_AnyProto], proto))

    return tuple(matches)

proto2mini

proto2mini(
    proto: type[_AnyProto],
) -> tuple[type[_AnyMini], ...]

Returns valid Mini classes that implement the given pysmo Protocol.

This function resolves the input protocol (handling modern type aliases and unions) and filters the available 'Mini' classes to find those that structurally implement it.

Parameters:

Name Type Description Default
proto type[_AnyProto]

A pysmo type (e.g., Location, Event) or a type alias pointing to one.

required

Returns:

Type Description
type[_AnyMini]

A tuple of concrete Mini classes (e.g., MiniLocation, MiniEvent)

...

that satisfy the interface defined by proto.

Examples:

Get all Mini classes that implement the Location protocol:

>>> from pysmo.lib.mini_utils import proto2mini
>>> from pysmo import Location, Event
>>> proto2mini(Location)
(<class 'pysmo.MiniStation'>, <class 'pysmo.MiniEvent'>, <class 'pysmo.MiniLocation'>, <class 'pysmo.MiniLocationWithDepth'>)
>>>

Works with Type Aliases and Unions (if the input is a union, it returns Minis matching any of the protocols in that union):

>>> type MyProto = Location | Event
>>> proto2mini(MyProto)
(<class 'pysmo.MiniStation'>, <class 'pysmo.MiniEvent'>, <class 'pysmo.MiniLocation'>, <class 'pysmo.MiniLocationWithDepth'>)
>>>
Source code in src/pysmo/lib/mini_utils.py
def proto2mini(proto: type[_AnyProto]) -> tuple[type[_AnyMini], ...]:
    """Returns valid Mini classes that implement the given pysmo Protocol.

    This function resolves the input protocol (handling modern type aliases and
    unions) and filters the available 'Mini' classes to find those that
    structurally implement it.

    Args:
        proto: A pysmo type (e.g., `Location`, `Event`) or a type alias
            pointing to one.

    Returns:
        A tuple of concrete Mini classes (e.g., `MiniLocation`, `MiniEvent`)
        that satisfy the interface defined by `proto`.

    Examples:
        Get all Mini classes that implement the `Location` protocol:

        ```python
        >>> from pysmo.lib.mini_utils import proto2mini
        >>> from pysmo import Location, Event
        >>> proto2mini(Location)
        (<class 'pysmo.MiniStation'>, <class 'pysmo.MiniEvent'>, <class 'pysmo.MiniLocation'>, <class 'pysmo.MiniLocationWithDepth'>)
        >>>
        ```

        Works with Type Aliases and Unions (if the input is a union, it returns
        Minis matching *any* of the protocols in that union):

        ```python
        >>> type MyProto = Location | Event
        >>> proto2mini(MyProto)
        (<class 'pysmo.MiniStation'>, <class 'pysmo.MiniEvent'>, <class 'pysmo.MiniLocation'>, <class 'pysmo.MiniLocationWithDepth'>)
        >>>
        ```
    """

    target_protos = _get_flattened_types(proto)
    possible_minis = _get_flattened_types(_AnyMini)

    seen: set[type[_AnyMini]] = set()
    result: list[type[_AnyMini]] = []
    for mini in possible_minis:
        mini_types = matching_pysmo_types(mini)
        if any(tp in mini_types for tp in target_protos) and mini not in seen:
            seen.add(mini)
            result.append(mini)
    return tuple(result)

validators

Validators and converters for pysmo classes using attrs.

Functions:

Name Description
convert_to_ndarray

Convert a value to a ndarray object.

convert_to_timedelta

Convert a value to a Timedelta object.

convert_to_utc_timestamp

Convert a value to a Timestamp object with tzinfo=timezone.utc set.

validate_nonzero

Ensure value is not exactly zero. Either sign is otherwise permitted.

convert_to_ndarray

convert_to_ndarray(
    value: ndarray | list | tuple,
) -> ndarray

Convert a value to a ndarray object.

Source code in src/pysmo/lib/validators.py
def convert_to_ndarray(value: np.ndarray | list | tuple) -> np.ndarray:
    """Convert a value to a [`ndarray`][numpy.ndarray] object."""
    return np.asanyarray(value)

convert_to_timedelta

convert_to_timedelta(
    value: Timedelta | timedelta | float | int | str,
) -> Timedelta

Convert a value to a Timedelta object.

If the value is a float or int, it is assumed to be in seconds.

Source code in src/pysmo/lib/validators.py
def convert_to_timedelta(
    value: pd.Timedelta | timedelta | float | int | str,
) -> pd.Timedelta:
    """Convert a value to a [`Timedelta`][pandas.Timedelta] object.

    If the value is a float or int, it is assumed to be in seconds.
    """
    if isinstance(value, (float, int)):
        return pd.Timedelta(value, unit="s")
    return pd.Timedelta(value)

convert_to_utc_timestamp

convert_to_utc_timestamp(
    value: Timestamp | datetime | str,
) -> Timestamp

Convert a value to a Timestamp object with tzinfo=timezone.utc set.

Source code in src/pysmo/lib/validators.py
def convert_to_utc_timestamp(value: pd.Timestamp | datetime | str) -> pd.Timestamp:
    """Convert a value to a [`Timestamp`][pandas.Timestamp] object with `#!py tzinfo=timezone.utc` set."""
    if value is None:
        raise TypeError("Value is None.")

    ts = pd.Timestamp(value)

    if ts.tz is None:
        return ts.tz_localize(timezone.utc)

    return ts.tz_convert(timezone.utc)

validate_nonzero

validate_nonzero(
    instance: object, attribute: Attribute, value: float
) -> None

Ensure value is not exactly zero. Either sign is otherwise permitted.

Source code in src/pysmo/lib/validators.py
def validate_nonzero(instance: object, attribute: Attribute, value: float) -> None:
    """Ensure `value` is not exactly zero. Either sign is otherwise permitted."""
    if value == 0:
        raise ValueError(f"{attribute.name} must not be zero.")