Skip to content

pysmo.lib

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

Modules:

Name Description
defaults

Default values for pysmo's functions and classes.

io

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

mini_utils

Match objects and protocols to pysmo's Mini classes.

validators

Validators and converters for pysmo's attrs classes.

defaults

Default values for pysmo's functions and 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. write_geocsv and write_mseed 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 GeoCsvSeismogram.write / MSeed.write.

Classes:

Name Description
GeoCsvDataset

A single uninterpreted dataset from a GeoCSV text body.

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, retrying transient failures.

merge_geocsv_timeseries

Merge contiguous waveform segments into a single segment.

parse_geocsv

Split a GeoCSV text body into a list of datasets.

parse_quakeml

Parse the event hypocentre and origin time from a QuakeML 1.2 document.

parse_sacpz

Split SAC PZ text into a list of uninterpreted records.

parse_stationxml

Parse station identity, coordinates and response from a StationXML document.

write_geocsv

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

write_mseed

Write one or more waveform segments to a miniSEED 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.

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.

For advanced use cases only

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

Write data and headers 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[floating]

Seismogram data.

data2 NDArray[floating]

Second data section (real/imaginary, amplitude/phase, or independent

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 computed from station/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.

nsnpts int | None

Number of points of original time series file (Stored NPTS). Only meaningful when NVHDR=7 (this word is unused/internal otherwise).

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

sb float | None

Begin value of the original time series file (Stored B).

sdelta float | None

Time increment in the original time series file (Stored DELTA).

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
 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
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
@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: For advanced use cases only
        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
        >>>
        ```
    """

    sb: float | None = field(
        default=None,
        converter=converters.optional(float),
        validator=validators.optional(validators.instance_of(float)),
    )
    """Begin value of the original time series file (Stored B).

    Note: Only present in v7 spectral files
        Only present in v7 files, and only meaningful for spectral
        (IRLIM/IAMPH) files produced by a forward Fourier transform. Has no
        corresponding header field of its own; it exists only in the v7
        footer, unlike headers such as [`b`][pysmo.lib.io.SacIO.b] that also
        have a single-precision header slot.
    """

    sdelta: float | None = field(
        default=None,
        converter=converters.optional(float),
        validator=validators.optional(validators.instance_of(float)),
    )
    """Time increment in the original time series file (Stored DELTA).

    Note: Only present in v7 spectral files
        Only present in v7 files, and only meaningful for spectral
        (IRLIM/IAMPH) files produced by a forward Fourier transform. Has no
        corresponding header field of its own; it exists only in the v7
        footer, unlike headers such as [`delta`][pysmo.lib.io.SacIO.delta]
        that also have a single-precision header slot.
    """

    @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 are relaxed
            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)
            cos_angle = np.sin(lat1) * np.sin(lat2) + np.cos(lat1) * np.cos(
                lat2
            ) * np.cos(np.abs(lon1 - lon2))
            # Clamp: rounding can push this just past ±1, making arccos NaN.
            return np.rad2deg(np.arccos(np.clip(cos_angle, -1.0, 1.0)))
        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 computed from station/event coordinates.

        Note: Always calculated, never stored
            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.

        See [`datetime`][datetime.datetime].
        """
        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=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[str]) -> 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[str]) -> None:
        """Write data and headers to a SAC file.

        The file is written via a temporary file and an atomic replace, so a
        failure mid-write cannot destroy an existing valid file. When the target
        already exists its mode and ownership are carried over to the new file;
        a symlink target is resolved so the file it points to is replaced, not
        the link itself.

        Args:
            filename: Name of the sacfile to write to.
        """
        target = Path(os.path.realpath(filename))
        try:
            existing = target.stat()
        except FileNotFoundError:
            existing = None
        fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".sac.tmp")
        try:
            with open(fd, "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()
                        # struct.pack silently truncates a "Ns" field to N bytes
                        # rather than raising. header_metadata.length validators
                        # (e.g. max_len) only bound the *character* count, so a
                        # string using multi-byte UTF-8 characters can still
                        # overflow the header's byte width and get corrupted on
                        # write without warning.
                        if len(value) > header_metadata.length:
                            raise ValueError(
                                f"{header!r} value {value.decode()!r} is "
                                + f"{len(value)} bytes when encoded, exceeding the "
                                + f"{header_metadata.length}-byte SAC header limit."
                            )

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

                has_second_block = self.iftype.lower() in ("rlim", "amph") or (
                    not self.leven
                )

                # 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)
                    file_handle.write(np.asarray(self.data, dtype=np.float32).tobytes())

                data_end = data_1_end
                if has_second_block:
                    if len(self.data2) != self.npts:
                        raise ValueError(
                            f"data2 must have the same length as data ({self.npts=}), "
                            + f"got {len(self.data2)}."
                        )
                    data_2_end = data_1_end + self.npts * 4
                    if self.npts > 0:
                        file_handle.seek(data_1_end)
                        file_handle.write(
                            np.asarray(self.data2, dtype=np.float32).tobytes()
                        )
                    data_end = data_2_end

                if self.nvhdr == 7:
                    for footer, footer_metadata in SAC_FOOTERS.items():
                        undefined = -12345.0
                        start = footer_metadata.start + data_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))
            if existing is not None:
                shutil.copymode(target, tmp_name)
                if hasattr(os, "chown"):
                    try:
                        os.chown(tmp_name, existing.st_uid, existing.st_gid)
                    except OSError:
                        # Unprivileged and not the owner: keep our own uid/gid.
                        pass
            else:
                os.chmod(tmp_name, 0o666 & ~_current_umask())
            os.replace(tmp_name, target)
        except BaseException:
            Path(tmp_name).unlink(missing_ok=True)
            raise

    @classmethod
    def from_file(cls, filename: str | PathLike[str]) -> 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.
        # DELTA has no meaningful nominal value for unevenly-spaced files,
        # and real SAC writes it as undefined for them - so its "required"
        # header flag only actually holds when LEVEN is True. Peek at
        # LEVEN's raw byte before the reset/parse loops below need it,
        # since delta (word 0) is read before leven (word 105) in file
        # order.
        leven_header = SAC_HEADERS["leven"]
        leven_end = leven_header.start + leven_header.length
        file_leven = (
            struct.unpack(
                file_byteorder + leven_header.format,
                buffer[leven_header.start : leven_end],
            )[0]
            if leven_end <= len(buffer)
            else True
        )

        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():
                required = header_metadata.required and not (
                    header == "delta" and not file_leven
                )
                if required:
                    continue
                default = getattr(SacIODefaults, header, None)
                if header == "iztype":
                    object.__setattr__(self, header, default)
                    continue
                if header not in _READ_ONLY_HEADERS:
                    setattr(self, header, default)

            # sb/sdelta have no header slot of their own (only a v7-footer
            # one), so the SAC_HEADERS reset loop above never touches them.
            # Reset explicitly to avoid a previous file's footer values
            # leaking through a reused instance.
            self.sb = None
            self.sdelta = None

            # 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 and not (
                    header == "delta" and not file_leven
                )
                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)

                # Logical headers have no "undefined" state distinct from
                # False, so the checks below don't apply to type "l".
                is_undefined = header_type != "l" and value == header_undefined

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

                # skip if undefined (value == -12345...) and not required
                if is_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 (computed rather than stored). Skip them.
                if header not in _READ_ONLY_HEADERS:
                    setattr(self, header, value)

            # Spectral files (IRLIM/IAMPH) and unevenly-spaced data (LEVEN =
            # False, which IXY implies) carry a second NPTS-length data
            # section straight after the first.
            has_second_block = self.iftype.lower() in ("rlim", "amph") or (
                not self.leven
            )

            # Read first data block
            start = 632
            length = npts * 4
            data_end = start + length
            self.data = np.array([])
            self.data2 = 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 has_second_block:
                    block2_start = data_end
                    block2_end = block2_start + length
                    if block2_end > len(buffer):
                        raise EOFError
                    content = buffer[block2_start:block2_end]
                    data2 = struct.unpack(data_format, content)
                    self.data2 = np.array(data2)
                    data_end = block2_end

            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 (computed rather than
                    # stored). Skip them.
                    if footer not in _READ_ONLY_HEADERS:
                        setattr(self, footer, value)

    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: Rounded to millisecond precision
            [`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:
                if time_header in _READ_ONLY_HEADERS:
                    continue
                current = getattr(self, time_header)
                if current is None:
                    continue
                setattr(self, time_header, current - actual_dtime)

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

a class-attribute instance-attribute

a: float | None = field(
    default=None,
    converter=_convert_a,
    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=_convert_b,
    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=_convert_cmpaz,
    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=_convert_cmpinc,
    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[floating] = 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.

data2 class-attribute instance-attribute

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

Second data section (real/imaginary, amplitude/phase, or independent variable, depending on IFTYPE). Empty for evenly-spaced ITIME files, which have only one data section.

delta class-attribute instance-attribute

delta: float = field(
    default=SacIODefaults.delta,
    converter=_convert_delta,
    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=_convert_evdp,
    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=_convert_evel,
    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=_convert_f,
    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,
    converter=_convert_ievreg,
    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,
    converter=_convert_iinst,
    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,
    converter=_convert_istreg,
    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,
    converter=_convert_ka,
    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,
    converter=_convert_kcmpnm,
    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,
    converter=_convert_kdatrd,
    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,
    converter=_convert_kevnm,
    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,
    converter=_convert_kf,
    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,
    converter=_convert_khole,
    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,
    converter=_convert_kinst,
    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,
    converter=_convert_knetwk,
    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,
    converter=_convert_ko,
    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,
    converter=_convert_kstnm,
    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,
    converter=_convert_kt0,
    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,
    converter=_convert_kt1,
    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,
    converter=_convert_kt2,
    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,
    converter=_convert_kt3,
    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,
    converter=_convert_kt4,
    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,
    converter=_convert_kt5,
    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,
    converter=_convert_kt6,
    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,
    converter=_convert_kt7,
    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,
    converter=_convert_kt8,
    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,
    converter=_convert_kt9,
    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,
    converter=_convert_kuser0,
    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,
    converter=_convert_kuser1,
    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,
    converter=_convert_kuser2,
    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 computed from station/event coordinates.

Always calculated, never stored

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=_convert_mag,
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Event magnitude.

nevid class-attribute instance-attribute

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

Event ID (CSS 3.0).

norid class-attribute instance-attribute

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

Origin ID (CSS 3.0).

npts property

npts: int

Number of points per data component.

nsnpts class-attribute instance-attribute

nsnpts: int | None = field(
    default=None,
    converter=_convert_nsnpts,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

Number of points of original time series file (Stored NPTS). Only meaningful when NVHDR=7 (this word is unused/internal otherwise).

nvhdr class-attribute instance-attribute

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

Header version number.

nwfid class-attribute instance-attribute

nwfid: int | None = field(
    default=None,
    converter=_convert_nwfid,
    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,
    converter=_convert_nzhour,
    validator=validators.optional(
        validators.instance_of(int)
    ),
)

GMT hour.

nzjday class-attribute instance-attribute

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

GMT julian day.

nzmin class-attribute instance-attribute

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

GMT minute.

nzmsec class-attribute instance-attribute

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

GMT millisecond.

nzsec class-attribute instance-attribute

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

GMT second.

nzyear class-attribute instance-attribute

nzyear: int | None = field(
    default=None,
    converter=_convert_nzyear,
    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=_convert_o,
    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=_convert_odelta,
    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.

See datetime.

resp0 class-attribute instance-attribute

resp0: float | None = field(
    default=None,
    converter=_convert_resp0,
    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=_convert_resp1,
    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=_convert_resp2,
    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=_convert_resp3,
    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=_convert_resp4,
    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=_convert_resp5,
    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=_convert_resp6,
    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=_convert_resp7,
    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=_convert_resp8,
    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=_convert_resp9,
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

Instrument response parameter 9 (not currently used).

sb class-attribute instance-attribute

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

Begin value of the original time series file (Stored B).

Only present in v7 spectral files

Only present in v7 files, and only meaningful for spectral (IRLIM/IAMPH) files produced by a forward Fourier transform. Has no corresponding header field of its own; it exists only in the v7 footer, unlike headers such as b that also have a single-precision header slot.

sdelta class-attribute instance-attribute

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

Time increment in the original time series file (Stored DELTA).

Only present in v7 spectral files

Only present in v7 files, and only meaningful for spectral (IRLIM/IAMPH) files produced by a forward Fourier transform. Has no corresponding header field of its own; it exists only in the v7 footer, unlike headers such as delta that also have a single-precision header slot.

stdp class-attribute instance-attribute

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

Station depth below surface (metres).

stel class-attribute instance-attribute

stel: float | None = field(
    default=None,
    converter=_convert_stel,
    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=_convert_t0,
    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=_convert_t1,
    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=_convert_t2,
    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=_convert_t3,
    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=_convert_t4,
    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=_convert_t5,
    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=_convert_t6,
    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=_convert_t7,
    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=_convert_t8,
    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=_convert_t9,
    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=_convert_user0,
    validator=validators.optional(
        validators.instance_of(float)
    ),
)

User defined variable storage area.

user1 class-attribute instance-attribute

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

User defined variable storage area.

user2 class-attribute instance-attribute

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

User defined variable storage area.

user3 class-attribute instance-attribute

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

User defined variable storage area.

user4 class-attribute instance-attribute

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

User defined variable storage area.

user5 class-attribute instance-attribute

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

User defined variable storage area.

user6 class-attribute instance-attribute

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

User defined variable storage area.

user7 class-attribute instance-attribute

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

User defined variable storage area.

user8 class-attribute instance-attribute

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

User defined variable storage area.

user9 class-attribute instance-attribute

user9: float | None = field(
    default=None,
    converter=_convert_user9,
    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.

Rounded to millisecond precision

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: Rounded to millisecond precision
        [`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:
            if time_header in _READ_ONLY_HEADERS:
                continue
            current = getattr(self, time_header)
            if current is None:
                continue
            setattr(self, time_header, current - actual_dtime)

    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[str]) -> Self

Create a new SAC instance from a SAC file.

Parameters:

Name Type Description Default
filename str | PathLike[str]

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[str]) -> 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.

Only cross-field restrictions are relaxed

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 are relaxed
        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[str]) -> None

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

Parameters:

Name Type Description Default
filename str | PathLike[str]

Name of the sac file to read.

required
Source code in src/pysmo/lib/io/_sacio/sacio.py
def read(self, filename: str | PathLike[str]) -> 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.
    # DELTA has no meaningful nominal value for unevenly-spaced files,
    # and real SAC writes it as undefined for them - so its "required"
    # header flag only actually holds when LEVEN is True. Peek at
    # LEVEN's raw byte before the reset/parse loops below need it,
    # since delta (word 0) is read before leven (word 105) in file
    # order.
    leven_header = SAC_HEADERS["leven"]
    leven_end = leven_header.start + leven_header.length
    file_leven = (
        struct.unpack(
            file_byteorder + leven_header.format,
            buffer[leven_header.start : leven_end],
        )[0]
        if leven_end <= len(buffer)
        else True
    )

    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():
            required = header_metadata.required and not (
                header == "delta" and not file_leven
            )
            if required:
                continue
            default = getattr(SacIODefaults, header, None)
            if header == "iztype":
                object.__setattr__(self, header, default)
                continue
            if header not in _READ_ONLY_HEADERS:
                setattr(self, header, default)

        # sb/sdelta have no header slot of their own (only a v7-footer
        # one), so the SAC_HEADERS reset loop above never touches them.
        # Reset explicitly to avoid a previous file's footer values
        # leaking through a reused instance.
        self.sb = None
        self.sdelta = None

        # 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 and not (
                header == "delta" and not file_leven
            )
            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)

            # Logical headers have no "undefined" state distinct from
            # False, so the checks below don't apply to type "l".
            is_undefined = header_type != "l" and value == header_undefined

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

            # skip if undefined (value == -12345...) and not required
            if is_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 (computed rather than stored). Skip them.
            if header not in _READ_ONLY_HEADERS:
                setattr(self, header, value)

        # Spectral files (IRLIM/IAMPH) and unevenly-spaced data (LEVEN =
        # False, which IXY implies) carry a second NPTS-length data
        # section straight after the first.
        has_second_block = self.iftype.lower() in ("rlim", "amph") or (
            not self.leven
        )

        # Read first data block
        start = 632
        length = npts * 4
        data_end = start + length
        self.data = np.array([])
        self.data2 = 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 has_second_block:
                block2_start = data_end
                block2_end = block2_start + length
                if block2_end > len(buffer):
                    raise EOFError
                content = buffer[block2_start:block2_end]
                data2 = struct.unpack(data_format, content)
                self.data2 = np.array(data2)
                data_end = block2_end

        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 (computed rather than
                # stored). Skip them.
                if footer not in _READ_ONLY_HEADERS:
                    setattr(self, footer, value)

write

write(filename: str | PathLike[str]) -> None

Write data and headers to a SAC file.

The file is written via a temporary file and an atomic replace, so a failure mid-write cannot destroy an existing valid file. When the target already exists its mode and ownership are carried over to the new file; a symlink target is resolved so the file it points to is replaced, not the link itself.

Parameters:

Name Type Description Default
filename str | PathLike[str]

Name of the sacfile to write to.

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

    The file is written via a temporary file and an atomic replace, so a
    failure mid-write cannot destroy an existing valid file. When the target
    already exists its mode and ownership are carried over to the new file;
    a symlink target is resolved so the file it points to is replaced, not
    the link itself.

    Args:
        filename: Name of the sacfile to write to.
    """
    target = Path(os.path.realpath(filename))
    try:
        existing = target.stat()
    except FileNotFoundError:
        existing = None
    fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".sac.tmp")
    try:
        with open(fd, "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()
                    # struct.pack silently truncates a "Ns" field to N bytes
                    # rather than raising. header_metadata.length validators
                    # (e.g. max_len) only bound the *character* count, so a
                    # string using multi-byte UTF-8 characters can still
                    # overflow the header's byte width and get corrupted on
                    # write without warning.
                    if len(value) > header_metadata.length:
                        raise ValueError(
                            f"{header!r} value {value.decode()!r} is "
                            + f"{len(value)} bytes when encoded, exceeding the "
                            + f"{header_metadata.length}-byte SAC header limit."
                        )

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

            has_second_block = self.iftype.lower() in ("rlim", "amph") or (
                not self.leven
            )

            # 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)
                file_handle.write(np.asarray(self.data, dtype=np.float32).tobytes())

            data_end = data_1_end
            if has_second_block:
                if len(self.data2) != self.npts:
                    raise ValueError(
                        f"data2 must have the same length as data ({self.npts=}), "
                        + f"got {len(self.data2)}."
                    )
                data_2_end = data_1_end + self.npts * 4
                if self.npts > 0:
                    file_handle.seek(data_1_end)
                    file_handle.write(
                        np.asarray(self.data2, dtype=np.float32).tobytes()
                    )
                data_end = data_2_end

            if self.nvhdr == 7:
                for footer, footer_metadata in SAC_FOOTERS.items():
                    undefined = -12345.0
                    start = footer_metadata.start + data_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))
        if existing is not None:
            shutil.copymode(target, tmp_name)
            if hasattr(os, "chown"):
                try:
                    os.chown(tmp_name, existing.st_uid, existing.st_gid)
                except OSError:
                    # Unprivileged and not the owner: keep our own uid/gid.
                    pass
        else:
            os.chmod(tmp_name, 0o666 & ~_current_umask())
        os.replace(tmp_name, target)
    except BaseException:
        Path(tmp_name).unlink(missing_ok=True)
        raise

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 or unparseable, no numeric field_type column is found, a sample value is non-numeric, 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 or
            unparseable, no numeric `field_type` column is found, a sample
            value is non-numeric, 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
    except ValueError as error:
        raise ValueError(
            f"GeoCSV dataset has an unparseable 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
        except ValueError as error:
            raise ValueError(
                "GeoCSV dataset has a non-numeric value in the sample column: "
                + f"{error}."
            ) 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" is the lowercased GeoCSV `SID` keyword, not the pysmo attribute name.
        sourceid=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, retrying transient failures.

Connection failures, timeouts, and responses with a transient status (429, 500, 502, 503, or 504) are retried up to request_retries times. The first retry is immediate; retry n thereafter waits retry_delay_seconds * 2 ** (n - 1) seconds plus a random jitter of up to retry_delay_seconds, capped at 120 seconds total. A Retry-After header on a 429 or 503 response replaces that wait, clamped to 6 hours. 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

Base delay for the backoff schedule, and the width of the random jitter added to each wait.

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.

HTTPError

If a connection or timeout failure persists after all retries.

ResponseError

If the server returns a non-retryable HTTP error status, or a transient one that persists after all retries.

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, retrying transient failures.

    Connection failures, timeouts, and responses with a transient status
    (429, 500, 502, 503, or 504) are retried up to `request_retries` times.
    The first retry is immediate; retry `n` thereafter waits
    `retry_delay_seconds * 2 ** (n - 1)` seconds plus a random jitter of up
    to `retry_delay_seconds`, capped at 120 seconds total. A `Retry-After`
    header on a 429 or 503 response replaces that wait, clamped to 6 hours.
    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: Base delay for the backoff schedule, and the
            width of the random jitter added to each wait.
        redirect: Whether to automatically follow HTTP redirects.

    Returns:
        The response body.

    Raises:
        ValueError: If `request_retries` is less than 1.
        urllib3.exceptions.HTTPError: If a connection or timeout failure
            persists after all retries.
        urllib3.exceptions.ResponseError: If the server returns a
            non-retryable HTTP error status, or a transient one that
            persists after all retries.
    """
    if request_retries < 1:
        raise ValueError("request_retries must be at least 1.")

    # raise_on_status=False: hand the exhausted response back so the status
    # check below raises a consistent ResponseError for any error status.
    retries = Retry(
        total=request_retries - 1,
        status_forcelist=_RETRYABLE_STATUSES,
        backoff_factor=retry_delay_seconds,
        backoff_max=_MAX_BACKOFF_SECONDS,
        backoff_jitter=retry_delay_seconds,
        respect_retry_after_header=True,
        raise_on_status=False,
    )
    response = _pool.request(
        "GET",
        url,
        fields=fields,
        timeout=timeout_seconds,
        redirect=redirect,
        retries=retries,
    )
    if response.status >= 400:
        raise urllib3.exceptions.ResponseError(f"HTTP {response.status}")
    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.")

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

    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(
                "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,
            replace=True,
        )
    else:
        merged = merge(
            mini_seismograms,
            gap_tolerance_factor=gap_tolerance_factor,
            replace=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),
        sourceid=reference.sourceid,
        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)
            elif keyword in current.headers:
                warnings.warn(
                    f"Duplicate {keyword!r} keyword line in GeoCSV dataset; "
                    + f"{current.headers[keyword]!r} is replaced by {value!r}.",
                    UserWarning,
                    stacklevel=2,
                )
            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_quakeml

parse_quakeml(
    xml: bytes, *, strict: bool = True
) -> list[_RawEvent]

Parse the event hypocentre and origin time from a QuakeML 1.2 document.

Returns one entry per <event> found. Only the preferred origin's time/latitude/longitude/depth, and (where present) the preferred magnitude, event type and first description, are read.

Parameters:

Name Type Description Default
xml bytes

Raw QuakeML 1.2 document bytes (as returned by any fdsnws-event service).

required
strict bool

If True (default), a single unrepresentable event fails the whole document. If False, unrepresentable events are skipped and a UserWarning reports how many; useful for a broad catalogue query where a few malformed origins should not discard the rest.

True

Returns:

Type Description
list[_RawEvent]

One uninterpreted event per representable <event> found, in

list[_RawEvent]

document order.

Raises:

Type Description
ValueError

If xml is not well-formed XML, its root is not a QuakeML <quakeml> element, or it has no <eventParameters>. When strict is True, also if any event has no publicID, no resolvable preferred origin, a preferred origin missing <time>/<latitude>/<longitude>/<depth>, an ambiguous preferred origin/magnitude, or an unparseable numeric or timestamp value; the message names the first such event and the count.

Examples:

>>> from pysmo.lib.io._quakeml import parse_quakeml
>>> xml = b'''<?xml version="1.0"?>
... <q:quakeml xmlns="http://quakeml.org/xmlns/bed/1.2"
...            xmlns:q="http://quakeml.org/xmlns/quakeml/1.2">
...   <eventParameters publicID="smi:example/catalogue">
...     <event publicID="smi:example/event/1">
...       <description><text>Example</text></description>
...       <origin publicID="smi:example/origin/1">
...         <time><value>2010-02-27T06:34:11.53Z</value></time>
...         <latitude><value>-36.122</value></latitude>
...         <longitude><value>-72.898</value></longitude>
...         <depth><value>22900</value></depth>
...       </origin>
...       <magnitude publicID="smi:example/magnitude/1">
...         <mag><value>8.8</value></mag>
...         <type>Mw</type>
...       </magnitude>
...       <type>earthquake</type>
...     </event>
...   </eventParameters>
... </q:quakeml>'''
>>> events = parse_quakeml(xml)
>>> len(events)
1
>>> events[0].latitude, events[0].depth, events[0].magnitude
(-36.122, 22900.0, 8.8)
>>>
Source code in src/pysmo/lib/io/_quakeml.py
def parse_quakeml(xml: bytes, *, strict: bool = True) -> list[_RawEvent]:
    """Parse the event hypocentre and origin time from a QuakeML 1.2 document.

    Returns one entry per `<event>` found. Only the preferred origin's
    time/latitude/longitude/depth, and (where present) the preferred
    magnitude, event type and first description, are read.

    Args:
        xml: Raw QuakeML 1.2 document bytes (as returned by any
            `fdsnws-event` service).
        strict: If `True` (default), a single unrepresentable event fails
            the whole document. If `False`, unrepresentable events are
            skipped and a `UserWarning` reports how many; useful for a
            broad catalogue query where a few malformed origins should not
            discard the rest.

    Returns:
        One uninterpreted event per representable `<event>` found, in
        document order.

    Raises:
        ValueError: If `xml` is not well-formed XML, its root is not a
            QuakeML `<quakeml>` element, or it has no `<eventParameters>`.
            When `strict` is `True`, also if any event has no `publicID`, no
            resolvable preferred origin, a preferred origin missing
            `<time>`/`<latitude>`/`<longitude>`/`<depth>`, an ambiguous
            preferred origin/magnitude, or an unparseable numeric or
            timestamp value; the message names the first such event and the
            count.

    Examples:
        ```python
        >>> from pysmo.lib.io._quakeml import parse_quakeml
        >>> xml = b'''<?xml version="1.0"?>
        ... <q:quakeml xmlns="http://quakeml.org/xmlns/bed/1.2"
        ...            xmlns:q="http://quakeml.org/xmlns/quakeml/1.2">
        ...   <eventParameters publicID="smi:example/catalogue">
        ...     <event publicID="smi:example/event/1">
        ...       <description><text>Example</text></description>
        ...       <origin publicID="smi:example/origin/1">
        ...         <time><value>2010-02-27T06:34:11.53Z</value></time>
        ...         <latitude><value>-36.122</value></latitude>
        ...         <longitude><value>-72.898</value></longitude>
        ...         <depth><value>22900</value></depth>
        ...       </origin>
        ...       <magnitude publicID="smi:example/magnitude/1">
        ...         <mag><value>8.8</value></mag>
        ...         <type>Mw</type>
        ...       </magnitude>
        ...       <type>earthquake</type>
        ...     </event>
        ...   </eventParameters>
        ... </q:quakeml>'''
        >>> events = parse_quakeml(xml)
        >>> len(events)
        1
        >>> events[0].latitude, events[0].depth, events[0].magnitude
        (-36.122, 22900.0, 8.8)
        >>>
        ```
    """
    try:
        root = ET.fromstring(xml)
    except ET.ParseError as exc:
        raise ValueError(f"Not well-formed XML: {exc}") from exc
    root_uri = _namespace_uri(root.tag)
    if _local_name(root.tag) != "quakeml" or (
        root_uri is not None and root_uri not in _QUAKEML_NS
    ):
        raise ValueError(
            f"Not a QuakeML document: root element is <{_local_name(root.tag)}>."
        )

    event_parameters = _find_child(root, "eventParameters")
    if event_parameters is None:
        raise ValueError("QuakeML document has no <eventParameters> element.")

    results: list[_RawEvent] = []
    errors: list[str] = []
    for position, event in enumerate(_iter_children(event_parameters, "event")):
        try:
            results.append(_parse_event(event, position))
        except ValueError as exc:
            errors.append(str(exc))

    if errors:
        message = errors[0]
        if len(errors) > 1:
            message += f" ({len(errors)} events in the document could not be parsed)"
        if strict:
            raise ValueError(message)
        warnings.warn(
            f"Skipped {len(errors)} unrepresentable event(s); first: {message}",
            UserWarning,
            stacklevel=2,
        )

    return results

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 (EarthScope's fdsnws-station 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 (EarthScope's
    fdsnws-station 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}."
            )
        constant_tokens = constant_line.split()
        if len(constant_tokens) < 2:
            raise ValueError(
                f"'CONSTANT' at line {index + 1} is missing its value: "
                + f"{constant_line!r}."
            )
        overall_sensitivity = _parse_float(constant_tokens[1])
        index += 1

        end_date_text = headers.get("END", "")
        sensitivity_text = headers.get("SENSITIVITY", "")
        sensitivity_tokens = sensitivity_text.split()
        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_tokens[0]) if sensitivity_tokens else None
                ),
                input_units=headers["INPUT UNIT"],
            )
        )

    return records

parse_stationxml

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

Parse station identity, coordinates and response from a StationXML document.

Walks the document once and returns one entry per <Channel> epoch (or per <Station> for a channel-less level=station document), in document order; the FDSN station web service does not default to a single "current" epoch, so a query covering a channel's full history returns several. Epoch selection is left to the caller. <Response> is read when present (level=response) and left as None otherwise; channel-level coordinates take precedence over station-level ones.

Parameters:

Name Type Description Default
xml bytes

Raw StationXML document bytes (any level that carries coordinates: channel, station or response).

required

Returns:

Type Description
list[_RawStationEpoch]

One uninterpreted epoch per <Channel> (or <Station>) found, in

list[_RawStationEpoch]

document order.

Raises:

Type Description
ValueError

If xml is not well-formed XML, a <Network>, <Station> or <Channel> element has no code attribute, a <Channel> (or channel-less <Station>) has no startDate, an epoch has neither channel-level nor station-level latitude/longitude, or a <Response> that is present uses an unrecognised or unsupported 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">
...       <Latitude>34.9</Latitude><Longitude>-106.5</Longitude>
...       <Channel code="BHZ" locationCode="00"
...                startDate="2018-07-09T20:45:00.0000">
...         <Latitude>34.95</Latitude><Longitude>-106.46</Longitude>
...         <Elevation>1632.7</Elevation>
...       </Channel>
...     </Station>
...   </Network>
... </FDSNStationXML>'''
>>> epochs = parse_stationxml(xml)
>>> len(epochs)
1
>>> epochs[0].station, epochs[0].channel, epochs[0].latitude
('ANMO', 'BHZ', 34.95)
>>> epochs[0].response is None
True
>>>
Source code in src/pysmo/lib/io/_stationxml.py
def parse_stationxml(xml: bytes) -> list[_RawStationEpoch]:
    """Parse station identity, coordinates and response from a StationXML document.

    Walks the document once and returns one entry per `<Channel>` epoch (or
    per `<Station>` for a channel-less `level=station` document), in document
    order; the FDSN station web service does not default to a single
    "current" epoch, so a query covering a channel's full history returns
    several. Epoch *selection* is left to the caller. `<Response>` is read
    when present (`level=response`) and left as `None` otherwise;
    channel-level coordinates take precedence over station-level ones.

    Args:
        xml: Raw StationXML document bytes (any `level` that carries
            coordinates: `channel`, `station` or `response`).

    Returns:
        One uninterpreted epoch per `<Channel>` (or `<Station>`) found, in
        document order.

    Raises:
        ValueError: If `xml` is not well-formed XML, a `<Network>`,
            `<Station>` or `<Channel>` element has no `code` attribute, a
            `<Channel>` (or channel-less `<Station>`) has no `startDate`, an
            epoch has neither channel-level nor station-level
            latitude/longitude, or a `<Response>` that is present uses an
            unrecognised or unsupported 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">
        ...       <Latitude>34.9</Latitude><Longitude>-106.5</Longitude>
        ...       <Channel code="BHZ" locationCode="00"
        ...                startDate="2018-07-09T20:45:00.0000">
        ...         <Latitude>34.95</Latitude><Longitude>-106.46</Longitude>
        ...         <Elevation>1632.7</Elevation>
        ...       </Channel>
        ...     </Station>
        ...   </Network>
        ... </FDSNStationXML>'''
        >>> epochs = parse_stationxml(xml)
        >>> len(epochs)
        1
        >>> epochs[0].station, epochs[0].channel, epochs[0].latitude
        ('ANMO', 'BHZ', 34.95)
        >>> epochs[0].response is None
        True
        >>>
        ```
    """
    try:
        root = ET.fromstring(xml)
    except ET.ParseError as exc:
        raise ValueError(f"Not well-formed XML: {exc}") from exc

    results: list[_RawStationEpoch] = []
    for network_elem in root.findall("fdsn:Network", _NS):
        network_code = network_elem.get("code")
        if network_code is None:
            raise ValueError("<Network> element has no code attribute.")
        for station_elem in network_elem.findall("fdsn:Station", _NS):
            station_code = station_elem.get("code")
            if station_code is None:
                raise ValueError(
                    f"<Station> element in network {network_code!r} has no "
                    + "code attribute."
                )
            station_coords = (
                _optional_child_float(station_elem, "Latitude"),
                _optional_child_float(station_elem, "Longitude"),
                _optional_child_float(station_elem, "Elevation"),
            )

            channels = station_elem.findall("fdsn:Channel", _NS)
            elements: list[tuple[ET.Element, str, str]] = (
                [(station_elem, "", "")]
                if not channels
                else [
                    (channel, channel.get("code", ""), channel.get("locationCode", ""))
                    for channel in channels
                ]
            )

            for elem, channel_code, location_code in elements:
                if channels and not channel_code:
                    raise ValueError(
                        "<Channel> element in station "
                        + f"{network_code}.{station_code} has no code attribute."
                    )
                start_date = _parse_timestamp(elem.get("startDate"))
                if start_date is None:
                    what = f"Channel {channel_code!r}" if channels else "Station"
                    raise ValueError(
                        f"{what} in station {network_code}.{station_code} has "
                        + "no startDate attribute."
                    )
                latitude, longitude, elevation = _epoch_coords(elem, station_coords)
                if latitude is None or longitude is None:
                    raise ValueError(
                        f"{network_code}.{station_code}."
                        + f"{channel_code or '(station)'} has no latitude/longitude."
                    )
                response_elem = elem.find("fdsn:Response", _NS)
                results.append(
                    _RawStationEpoch(
                        network=network_code,
                        station=station_code,
                        location=location_code,
                        channel=channel_code,
                        start_date=start_date,
                        end_date=_parse_timestamp(elem.get("endDate")),
                        latitude=latitude,
                        longitude=longitude,
                        elevation=elevation,
                        response=(
                            None
                            if response_elem is None
                            else _parse_response(response_elem)
                        ),
                    )
                )

    return results

write_geocsv

write_geocsv(
    seismograms: Seismogram | Sequence[Seismogram],
    path: str | PathLike[str],
) -> 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[str]

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.

Formatting and precision conventions

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 are integral, so genuinely non-integral data (e.g. a detrended or filtered seismogram) are never silently truncated. A sourceid 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, and 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. sourceid 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[str],
) -> 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: Formatting and precision conventions
        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 are
        integral, so genuinely non-integral data (e.g. a detrended or
        filtered seismogram) are never silently truncated. A `sourceid`
        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, and 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. `sourceid` 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 = as_sequence(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_mseed

write_mseed(
    segments: Sequence[tuple[StationCode, Seismogram]],
    path: str | PathLike[str],
    *,
    sample_type: Literal["i", "f", "d"] | None = None
) -> None

Write one or more waveform segments to a miniSEED file.

Each (identity, seismogram) pair becomes one trace segment. Pairs that resolve to the same FDSN Source Identifier are grouped into one trace with several segments, the natural representation of a channel with a data gap.

Every segment is written with publication (quality) version 1, regardless of any version an MSeed identity carries. Because MSeed always decodes samples to float64, a file read with MSeed and written back defaults to the uncompressed "d" encoding even if the source used STEIM integer compression; pass sample_type="i" to compress integer-valued data.

Parameters:

Name Type Description Default
segments Sequence[tuple[StationCode, Seismogram]]

A non-empty sequence of (identity, seismogram) pairs. identity is any StationCode (e.g. a MiniStationCode, an MSeed, or a full Station; extra coordinate fields are ignored); it supplies the network, station, location and channel codes. seismogram is any Seismogram.

required
path str | PathLike[str]

Destination file path. Any existing content is overwritten.

required
sample_type Literal['i', 'f', 'd'] | None

miniSEED sample encoding: "i" (32-bit integer, STEIM2), "f" (32-bit float) or "d" (64-bit float). The default None picks it from each seismogram's data.dtype (int32 → "i", float32 → "f", float64 → "d"; any other dtype raises TypeError). An explicit value is applied to every segment as given, and a UserWarning is emitted per segment whose data do not already match, since pymseed silently truncates or downcasts in that case.

None

Raises:

Type Description
TypeError

If sample_type is None and a seismogram's data array has a dtype other than int32, float32 or float64.

ValueError

If segments is empty.

MiniSEEDError

If the data cannot be encoded or written.

Examples:

>>> import numpy as np
>>> import pandas as pd
>>> from pysmo import MiniSeismogram, MiniStationCode
>>> from pysmo.lib.io import write_mseed
>>> identity = MiniStationCode(
...     name="ANMO", network="IU", location="00", channel="BHZ"
... )
>>> seismogram = MiniSeismogram(
...     begin_time=pd.Timestamp("2010-02-27T06:30:00Z"),
...     delta=pd.Timedelta(seconds=0.05),
...     data=np.arange(100.0),
... )
>>> write_mseed([(identity, seismogram)], "out.mseed")
>>>
Source code in src/pysmo/lib/io/_mseed.py
def write_mseed(
    segments: Sequence[tuple[StationCode, Seismogram]],
    path: str | PathLike[str],
    *,
    sample_type: Literal["i", "f", "d"] | None = None,
) -> None:
    """Write one or more waveform segments to a miniSEED file.

    Each `(identity, seismogram)` pair becomes one trace segment. Pairs
    that resolve to the same FDSN Source Identifier are grouped into one
    trace with several segments, the natural representation of a channel
    with a data gap.

    Every segment is written with publication (quality) version 1,
    regardless of any version an `MSeed` identity carries. Because
    [`MSeed`][pysmo.classes.MSeed] always decodes samples to `float64`, a
    file read with `MSeed` and written back defaults to the uncompressed
    `"d"` encoding even if the source used STEIM integer compression; pass
    `sample_type="i"` to compress integer-valued data.

    Args:
        segments: A non-empty sequence of `(identity, seismogram)` pairs.
            *identity* is any [`StationCode`][pysmo.StationCode] (e.g. a
            [`MiniStationCode`][pysmo.MiniStationCode], an
            [`MSeed`][pysmo.classes.MSeed], or a full
            [`Station`][pysmo.Station]; extra coordinate fields are
            ignored); it supplies the network, station, location and
            channel codes. *seismogram* is any
            [`Seismogram`][pysmo.Seismogram].
        path: Destination file path. Any existing content is overwritten.
        sample_type: miniSEED sample encoding: `"i"` (32-bit integer,
            STEIM2), `"f"` (32-bit float) or `"d"` (64-bit float). The
            default `None` picks it from each seismogram's `data.dtype`
            (`int32` → `"i"`, `float32` → `"f"`, `float64` → `"d"`; any
            other dtype raises `TypeError`). An explicit value is applied
            to every segment as given, and a `UserWarning` is emitted per
            segment whose data do not already match, since `pymseed`
            silently truncates or downcasts in that case.

    Raises:
        TypeError: If *sample_type* is `None` and a seismogram's data array
            has a dtype other than `int32`, `float32` or `float64`.
        ValueError: If *segments* is empty.
        pymseed.MiniSEEDError: If the data cannot be encoded or written.

    Examples:
        ```python
        >>> import numpy as np
        >>> import pandas as pd
        >>> from pysmo import MiniSeismogram, MiniStationCode
        >>> from pysmo.lib.io import write_mseed
        >>> identity = MiniStationCode(
        ...     name="ANMO", network="IU", location="00", channel="BHZ"
        ... )
        >>> seismogram = MiniSeismogram(
        ...     begin_time=pd.Timestamp("2010-02-27T06:30:00Z"),
        ...     delta=pd.Timedelta(seconds=0.05),
        ...     data=np.arange(100.0),
        ... )
        >>> write_mseed([(identity, seismogram)], "out.mseed")
        >>>
        ```
    """
    if not segments:
        raise ValueError("segments must not be an empty sequence.")

    tracelist = MS3TraceList()
    encodings: set[DataEncoding] = set()

    for identity, seismogram in segments:
        # pymseed reads a raw C buffer; materialise any non-contiguous view.
        data = np.ascontiguousarray(seismogram.data)
        resolved_type = sample_type or _sample_type_for(data)
        if (
            sample_type is not None
            and _DTYPE_TO_SAMPLE_TYPE.get(data.dtype.type) != sample_type
        ):
            warn(
                f"Writing {data.dtype} data as sample_type {sample_type!r}; "
                + "pymseed will convert each sample, which may lose precision.",
                UserWarning,
                stacklevel=2,
            )
        encodings.add(_SAMPLE_TYPE_TO_ENCODING[resolved_type])
        sourceid = pymseed.nslc2sourceid(
            identity.network,
            identity.name,
            identity.location,
            identity.channel,
        )
        tracelist.add_data(
            sourceid=sourceid,
            data_samples=data,
            sample_type=resolved_type,
            sample_rate=1_000_000_000 / seismogram.delta.value,
            starttime=seismogram.begin_time.value,
            publication_version=1,
        )

    if len(encodings) > 1:
        raise ValueError(
            "All segments in one miniSEED file must share a sample encoding; "
            + "pass a single explicit sample_type, or write segments of "
            + "differing dtype to separate files."
        )

    tracelist.to_file(path, overwrite=True, encoding=encodings.pop())

mini_utils

Match objects and protocols to pysmo's Mini classes.

Functions:

Name Description
matching_pysmo_types

Return the pysmo types an object may be an instance of.

proto2mini

Return the Mini classes that implement a 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], ...]

Return the pysmo types an object 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], ...]:
    """Return the pysmo types an object 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 _structural_match(obj, proto):
            matches.append(cast(type[_AnyProto], proto))

    return tuple(matches)

proto2mini

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

Return the Mini classes that implement a 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], ...]:
    """Return the Mini classes that implement a 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's attrs classes.

Functions:

Name Description
convert_to_complex_list

Convert an iterable of numbers to a list of complex values.

convert_to_longitude

Convert a value to a float longitude in degrees, folding -180 onto +180.

convert_to_ndarray

Convert a value to a floating-point ndarray.

convert_to_timedelta

Convert a value to a Timedelta.

convert_to_utc_timestamp

Convert a value to a UTC Timestamp (tzinfo=timezone.utc).

validate_nonzero

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

convert_to_complex_list

convert_to_complex_list(
    value: list[complex],
) -> list[complex]

Convert an iterable of numbers to a list of complex values.

See complex.

Source code in src/pysmo/lib/validators.py
def convert_to_complex_list(value: list[complex]) -> list[complex]:
    """Convert an iterable of numbers to a list of `complex` values.

    See [`complex`][].
    """
    return [complex(item) for item in value]

convert_to_longitude

convert_to_longitude(value: float | str) -> float

Convert a value to a float longitude in degrees, folding -180 onto +180.

-180 and +180 name the same meridian; +180 is kept as the single canonical value so two spellings of the antimeridian cannot compare unequal. Values genuinely outside [-180, 180] are returned unchanged for a downstream validator to reject.

Source code in src/pysmo/lib/validators.py
def convert_to_longitude(value: float | str) -> float:
    """Convert a value to a `float` longitude in degrees, folding -180 onto +180.

    -180 and +180 name the same meridian; +180 is kept as the single
    canonical value so two spellings of the antimeridian cannot compare
    unequal. Values genuinely outside `[-180, 180]` are returned unchanged
    for a downstream validator to reject.
    """
    longitude = float(value)
    return 180.0 if longitude == -180.0 else longitude

convert_to_ndarray

convert_to_ndarray(
    value: NDArray[Any] | list[Any] | tuple[Any, ...],
) -> NDArray[floating]

Convert a value to a floating-point ndarray.

Non-floating input (e.g. an integer list) is cast to float64; floating input keeps its own precision. np.asarray, not np.asanyarray, so an ndarray subclass (masked array, np.matrix) does not leak through.

Source code in src/pysmo/lib/validators.py
def convert_to_ndarray(
    value: npt.NDArray[Any] | list[Any] | tuple[Any, ...],
) -> npt.NDArray[np.floating]:
    """Convert a value to a floating-point `ndarray`.

    Non-floating input (e.g. an integer list) is cast to `float64`; floating
    input keeps its own precision. `np.asarray`, not `np.asanyarray`, so an
    [`ndarray`][numpy.ndarray] subclass (masked array, `np.matrix`) does not
    leak through.
    """
    array = np.asarray(value)
    if np.issubdtype(array.dtype, np.floating):
        return array
    return array.astype(np.float64)

convert_to_timedelta

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

Convert a value to a Timedelta.

A float or int is assumed to be in seconds. See Timedelta.

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

    A float or int is assumed to be in seconds.
    See [`Timedelta`][pandas.Timedelta].
    """
    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 UTC Timestamp (tzinfo=timezone.utc).

See Timestamp.

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 UTC `Timestamp` (`#!py tzinfo=timezone.utc`).

    See [`Timestamp`][pandas.Timestamp].
    """
    if value is None:
        raise TypeError("Value is None.")

    ts = pd.Timestamp(value)

    if pd.isna(ts):
        raise ValueError(f"{value!r} is not a valid timestamp.")

    if ts.tz is None:
        return ts.tz_localize(UTC)

    return ts.tz_convert(UTC)

validate_nonzero

validate_nonzero(
    instance: object, attribute: Attribute[T], value: T
) -> None

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

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