"""
Testing utilities for ObsPlus.
"""
from __future__ import annotations
import os
from collections import Counter
from contextlib import contextmanager
from os.path import join
from pathlib import Path
from typing import ClassVar
import numpy as np
import obspy
import pandas as pd
from obsplus.bank.core import _Bank
from obsplus.constants import NSLC, utc_able_type
from obsplus.utils.bank import _natify_paths
from obsplus.utils.misc import iter_files
from obsplus.utils.time import make_time_chunks, to_utc
[docs]
@contextmanager
def instrument_methods(obj):
"""
Temporarily instrument an object's methods.
This allows the calls to each of the objects methods to be counted.
"""
old_methods = {}
counter = Counter()
setattr(obj, "_counter", counter)
for attr in dir(obj):
# skip dunders
if attr.startswith("__"):
continue
method = getattr(obj, attr, None)
# skip anything that isnt callable
if not callable(method):
continue
# append old method to old_methods and create new method
old_methods[attr] = method
def func(*args, __method_name=attr, **kwargs):
counter.update({__method_name: 1})
return old_methods[__method_name](*args, **kwargs)
setattr(obj, attr, func)
# yield monkey patched object
yield obj
# reset methods
for attr, method in old_methods.items():
setattr(obj, attr, method)
# delete counter
delattr(obj, "_counter")
[docs]
class ArchiveDirectory:
"""class for creating a simple archive"""
def __init__(
self,
path,
starttime=None,
endtime=None,
sampling_rate=1,
duration=3600,
overlap=0,
gaps=None,
seed_ids=("TA.M17A..VHZ", "TA.BOB..VHZ"),
):
self.path = path
if not os.path.exists(path):
os.makedirs(path)
self.starttime = starttime
self.endtime = endtime
self.sampling_rate = sampling_rate
self.duration = duration
self.overlap = overlap
self.seed_ids = seed_ids
self.gaps = gaps
[docs]
def create_stream(
self,
starttime: utc_able_type,
endtime: utc_able_type,
seed_ids: list[str] | None = None,
sampling_rate: float | int | None = None,
) -> obspy.Stream:
"""Create a waveforms from random data"""
t1 = to_utc(starttime)
t2 = to_utc(endtime)
sr = sampling_rate or self.sampling_rate
ar_len = int((t2.timestamp - t1.timestamp) * sr)
st = obspy.Stream()
rand_gen = np.random.default_rng(seed=20)
for seed in seed_ids or self.seed_ids:
net, sta, loc, cha = seed.split(".")
meta = {
"sampling_rate": sr,
"starttime": t1,
"network": net,
"station": sta,
"location": loc,
"channel": cha,
}
data = rand_gen.standard_normal(ar_len)
tr = obspy.Trace(data=data, header=meta)
st.append(tr)
return st
[docs]
def get_gap_stream(self, t1, t2, gaps):
"""Return streams with gaps in it"""
assert len(gaps) == 1
gap = gaps.iloc[0]
ts1, ts2 = t1.timestamp, t2.timestamp
# if gap covers time completely
if gap.start <= ts1 and gap.end >= ts2:
raise ValueError("gapped out")
# if gap is contained by time frame
elif gap.start > ts1 and gap.end < ts2:
st1 = self.create_stream(ts1, gap.start)
st2 = self.create_stream(gap.end, ts2)
return st1 + st2
# if gap only effects endtime
elif ts1 < gap.start < ts2 <= gap.end:
return self.create_stream(ts1, gap.start)
# if gap only effects starttime
elif gap.start <= ts1 < gap.end < ts2:
return self.create_stream(gap.end, ts2)
else: # should not reach here
raise ValueError("something went wrong!") # pragma: no cover
[docs]
def create_directory(self):
"""Create the directory with gaps in it"""
# get a dataframe of the gaps
if self.gaps is not None:
df = pd.DataFrame(self.gaps, columns=["start", "end"])
df["start"] = df["start"].apply(lambda x: x.timestamp)
df["end"] = df["end"].apply(lambda x: x.timestamp)
else:
df = pd.DataFrame(columns=["start", "end"])
assert self.starttime and self.endtime, "needs defined times"
for t1, t2 in make_time_chunks(
self.starttime, self.endtime, self.duration, self.overlap
):
# figure out of this time lies in a gap
gap = df[~((df.start >= t2) | (df.end <= t1))]
if not gap.empty:
try:
st = self.get_gap_stream(t1, t2, gap)
except ValueError:
continue
else:
st = self.create_stream(t1, t2)
finame = str(t1).split(".")[0].replace(":", "-") + ".mseed"
path = join(self.path, finame)
st.write(path, "mseed")
[docs]
def create_directory_from_bulk_args(self, bulk_args):
"""Create a directory from bulk waveform arguments"""
# ensure directory exists
path = Path(self.path)
path.mkdir(exist_ok=True, parents=True)
for net, sta, loc, chan, t1, t2 in bulk_args:
nslc = ".".join([net, sta, loc, chan])
st = self.create_stream(t1, t2, (nslc,))
time_name = str(t1).split(".")[0].replace(":", "-") + ".mseed"
save_name = path / f"{net}_{sta}_{time_name}"
st.write(str(save_name), "mseed")
[docs]
def assert_streams_almost_equal(
st1: obspy.Stream,
st2: obspy.Stream,
basic_stats: bool = True,
atol: float = 1e-05,
rtol: float = 1e-08,
equal_nan: bool = True,
allow_off_by_one: bool = False,
) -> None:
"""
Assert that two streams are almost equal else raise helpful exceptions.
Parameters
----------
st1
The first stream
st2
The second stream
basic_stats
If True, only compare basic stats of the streams including:
network, station, location, channel, starttime, endtime
atol
The absolute tolerance parameter
rtol
The relative tolerance parameter
equal_nan
If True evaluate NaNs as equal
allow_off_by_one
If True, allow the arrays and time alignments to be off by one sample.
Notes
-----
See numpy.allclose for description of atol and rtol paramter.
Raises
------
AssertionError if streams are not about equal.
"""
kwargs = dict(
basic_stats=basic_stats,
atol=atol,
rtol=rtol,
equal_nan=equal_nan,
allow_off_by_one=allow_off_by_one,
)
_StreamEqualTester(**kwargs)(st1, st2)
class _StreamEqualTester:
"""
Simple class for testing if streams are (almost) equal.
This class is not intended to be used directly, instead use
:func:`obsplus.utils.testing.assert_streams_almost_equal`.
"""
skeys: ClassVar = [*list(NSLC), "sampling_rate", "starttime", "endtime"]
def __init__(
self,
basic_stats: bool = True,
atol: float = 1e-05,
rtol: float = 1e-08,
equal_nan: bool = True,
allow_off_by_one: bool = False,
):
self.basic_stats = basic_stats
self.atol = atol
self.rtol = rtol
self.equal_nan = equal_nan
self.allow_off_by_one = allow_off_by_one
def _assert_stats_equal(self, tr1, tr2):
"""Assert that the stats dicts are almost equal."""
skeys, basic_stats = self.skeys, self.basic_stats
sta1 = {x: tr1.stats[x] for x in skeys} if basic_stats else tr1.stats
sta2 = {x: tr2.stats[x] for x in skeys} if basic_stats else tr2.stats
if not sta1 == sta2:
stats_equal = False
# see if the start and end times are within one sample rate
if self.allow_off_by_one:
times = ("starttime", "endtime")
sta1_new = {i: v for i, v in sta1.items() if i not in times}
sta2_new = {i: v for i, v in sta2.items() if i not in times}
if sta1["sampling_rate"] == sta2["sampling_rate"]:
sr = sta1["sampling_rate"]
t1_diff = abs(sta1["starttime"] - sta2["starttime"])
t2_diff = abs(sta1["endtime"] - sta2["starttime"])
if t1_diff < sr and t2_diff < sr and sta1_new == sta2_new:
stats_equal = True
if not stats_equal:
msg = f"Stats are not the same for the traces: \n{sta1}\n{sta2}"
assert 0, msg
def _assert_arrays_almost_equal(self, tr1, tr2):
"""Assert that the data arrays of the traces are almost equal."""
ars = sorted([tr1.data, tr2.data], key=lambda x: len(x))
len1, len2 = len(ars[0]), len(ars[1])
len_diff = len2 - len1
kwargs = dict(atol=self.atol, rtol=self.rtol, equal_nan=self.equal_nan)
# check for off by one error
if len_diff != 0: # if they aren't equal in len
close = False
else:
close = np.allclose(ars[0], ars[1], **kwargs)
if not close and self.allow_off_by_one:
# If the arrays are within 2 samples of each other in length
if abs(len(ars[0]) - len(ars[1])) <= 2:
sub_ar = ars[0][1:-1]
# slide the smaller array over the larger, return true if found
for i in range(len(ars[1]) - len(ars[0]) + 3):
if np.allclose(sub_ar, ars[1][i : i + len(sub_ar)], **kwargs):
close = True
break
assert close, "Data of traces are not nearly equal"
def __call__(self, st1, st2):
"""
Assert that two streams are almost equal else raise AssertionError.
Parameters
----------
st1
The first stream
st2
The second stream
"""
st1.sort(), st2.sort()
if len(st1) != len(st2):
assert 0, "streams do not have the same number of traces"
# iterate each trace and raise if stats and arrays are not almost equal.
for tr1, tr2 in zip(st1, st2):
self._assert_stats_equal(tr1, tr2)
self._assert_arrays_almost_equal(tr1, tr2)
[docs]
def check_index_paths(bank: _Bank):
"""
Make sure the paths in a bank's index can be resolved correctly
Parameters
----------
bank:
A Bank (either WaveBank or EventBank) to verify
"""
bank_path = bank.bank_path
index = bank.read_index()
index_paths = _natify_paths(index["path"])
file_paths = set([bank.bank_path / pth for pth in index_paths])
for file_path in iter_files(str(bank_path), ext="mseed"):
# go up two levels to match path reference
file_path = Path(file_path)
assert file_path in file_paths