pyStrata 0.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pystrata/__init__.py +38 -0
- pystrata/data/published_curves.toml +1565 -0
- pystrata/motion.py +367 -0
- pystrata/output.py +832 -0
- pystrata/propagation.py +690 -0
- pystrata/site.py +1422 -0
- pystrata/tools.py +299 -0
- pystrata/variation.py +929 -0
- pystrata-0.5.3.dist-info/METADATA +112 -0
- pystrata-0.5.3.dist-info/RECORD +13 -0
- pystrata-0.5.3.dist-info/WHEEL +4 -0
- pystrata-0.5.3.dist-info/licenses/AUTHORS.md +9 -0
- pystrata-0.5.3.dist-info/licenses/LICENSE +21 -0
pystrata/output.py
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# The MIT License (MIT)
|
|
3
|
+
#
|
|
4
|
+
# Copyright (c) 2016-2021 Albert Kottke
|
|
5
|
+
#
|
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
# furnished to do so, subject to the following conditions:
|
|
12
|
+
#
|
|
13
|
+
# The above copyright notice and this permission notice shall be included in
|
|
14
|
+
# all copies or substantial portions of the Software.
|
|
15
|
+
#
|
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
# SOFTWARE.
|
|
23
|
+
import collections
|
|
24
|
+
|
|
25
|
+
import matplotlib.pyplot as plt
|
|
26
|
+
import numpy as np
|
|
27
|
+
import scipy.integrate
|
|
28
|
+
from matplotlib.colors import LogNorm
|
|
29
|
+
from matplotlib.colors import TwoSlopeNorm
|
|
30
|
+
from scipy.interpolate import interp1d
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import pandas as pd
|
|
34
|
+
except ImportError:
|
|
35
|
+
pd = None
|
|
36
|
+
|
|
37
|
+
import pykooh
|
|
38
|
+
|
|
39
|
+
from .motion import TimeSeriesMotion, WaveField, GRAVITY
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def plot_amplification_evolv(
|
|
43
|
+
calc,
|
|
44
|
+
metric="accel_tf",
|
|
45
|
+
depths=None,
|
|
46
|
+
freqs=None,
|
|
47
|
+
normalized=False,
|
|
48
|
+
wave_field_out="within",
|
|
49
|
+
diverging_cmap=True,
|
|
50
|
+
include_vs_profile=False,
|
|
51
|
+
ax=None,
|
|
52
|
+
**kwds,
|
|
53
|
+
):
|
|
54
|
+
# Default plotting kwds. Combine both set of plot keywords and prefer the provided
|
|
55
|
+
kwds = {
|
|
56
|
+
"cmap": "RdBu" if diverging_cmap else "magma_r",
|
|
57
|
+
"shading": "gouraud",
|
|
58
|
+
"norm": TwoSlopeNorm(vmin=0, vcenter=1) if diverging_cmap else LogNorm(),
|
|
59
|
+
} | kwds
|
|
60
|
+
|
|
61
|
+
if freqs is None:
|
|
62
|
+
freqs = np.logspace(-1, 2, num=301)
|
|
63
|
+
|
|
64
|
+
osc_damping = 0.05 if "osc_damping" not in kwds else kwds["osc_damping"]
|
|
65
|
+
|
|
66
|
+
ln_freqs = np.log(freqs)
|
|
67
|
+
ln_freqs_mot = np.log(calc.motion.freqs)
|
|
68
|
+
|
|
69
|
+
def get_amp(metric, depth):
|
|
70
|
+
loc_output = calc.profile.location(wave_field_out, depth=depth)
|
|
71
|
+
if metric == "accel_tf":
|
|
72
|
+
y = np.abs(calc.calc_accel_tf(calc.loc_input, loc_output))
|
|
73
|
+
# Interpolate the specific frequencies
|
|
74
|
+
y = np.interp(ln_freqs, ln_freqs_mot, y)
|
|
75
|
+
elif metric == "site_amp":
|
|
76
|
+
if get_amp.in_ars is None:
|
|
77
|
+
get_amp.in_ars = calc.motion.calc_osc_accels(freqs, osc_damping)
|
|
78
|
+
|
|
79
|
+
out_ars = calc.motion.calc_osc_accels(
|
|
80
|
+
freqs, osc_damping, calc.calc_accel_tf(calc.loc_input, loc_output)
|
|
81
|
+
)
|
|
82
|
+
y = out_ars / get_amp.in_ars
|
|
83
|
+
else:
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
return y
|
|
87
|
+
|
|
88
|
+
# Initialize static variable
|
|
89
|
+
get_amp.in_ars = None
|
|
90
|
+
|
|
91
|
+
if depths is None:
|
|
92
|
+
depths = np.linspace(0, calc.profile[-1].depth)
|
|
93
|
+
|
|
94
|
+
if ax is None:
|
|
95
|
+
fig, ax = plt.subplots()
|
|
96
|
+
|
|
97
|
+
amps = np.array([get_amp(metric, d) for d in depths])
|
|
98
|
+
if normalized:
|
|
99
|
+
amps /= amps[-1, :]
|
|
100
|
+
|
|
101
|
+
cf = ax.pcolormesh(freqs, depths, amps, **kwds)
|
|
102
|
+
|
|
103
|
+
cb = plt.colorbar(cf, ax=ax)
|
|
104
|
+
cb.set_label("|TF|" if metric == "accel_tf" else "Site Ampl.")
|
|
105
|
+
|
|
106
|
+
ax.set(
|
|
107
|
+
xlabel="Frequency (Hz)",
|
|
108
|
+
xscale="log",
|
|
109
|
+
ylabel="Depth (m)",
|
|
110
|
+
yscale="linear",
|
|
111
|
+
ylim=(depths[0], 0),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return ax
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class OutputCollection(collections.abc.Collection):
|
|
118
|
+
def __init__(self, outputs):
|
|
119
|
+
super().__init__()
|
|
120
|
+
self.outputs = outputs
|
|
121
|
+
|
|
122
|
+
def __iter__(self):
|
|
123
|
+
return iter(self.outputs)
|
|
124
|
+
|
|
125
|
+
def __contains__(self, value):
|
|
126
|
+
return value in self.outputs
|
|
127
|
+
|
|
128
|
+
def __len__(self):
|
|
129
|
+
return len(self.outputs)
|
|
130
|
+
|
|
131
|
+
def __getitem__(self, key):
|
|
132
|
+
return self.outputs[key]
|
|
133
|
+
|
|
134
|
+
def __call__(self, calc, name=None):
|
|
135
|
+
# Save results
|
|
136
|
+
for o in self:
|
|
137
|
+
o(calc, name=name)
|
|
138
|
+
|
|
139
|
+
def reset(self):
|
|
140
|
+
for o in self:
|
|
141
|
+
o.reset()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def append_arrays(many, single):
|
|
145
|
+
"""Append an array to another padding with NaNs for constant length.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
many : array_like of rank (j, k)
|
|
150
|
+
values appended to a copy of this array. This may be a 1-D or 2-D
|
|
151
|
+
array.
|
|
152
|
+
single : array_like of rank l
|
|
153
|
+
values to append. This should be a 1-D array.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
append : :class:`numpy.ndarray`
|
|
158
|
+
2-D array with rank (j + 1, max(k, l)) with missing values padded
|
|
159
|
+
with :class:`numpy.nan`
|
|
160
|
+
"""
|
|
161
|
+
assert np.ndim(single) == 1
|
|
162
|
+
|
|
163
|
+
# Check if the values need to be padded to for equal length
|
|
164
|
+
diff = single.shape[0] - many.shape[0]
|
|
165
|
+
if diff < 0:
|
|
166
|
+
single = np.pad(single, (0, -diff), "constant", constant_values=np.nan)
|
|
167
|
+
elif diff > 0:
|
|
168
|
+
# Need different padding based on if many is 1d or 2d.
|
|
169
|
+
padding = ((0, diff), (0, 0)) if len(many.shape) > 1 else (0, diff)
|
|
170
|
+
many = np.pad(many, padding, "constant", constant_values=np.nan)
|
|
171
|
+
else:
|
|
172
|
+
# No padding needed
|
|
173
|
+
pass
|
|
174
|
+
return np.c_[many, single]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Output(object):
|
|
178
|
+
_const_ref = False
|
|
179
|
+
|
|
180
|
+
xscale = "log"
|
|
181
|
+
yscale = "log"
|
|
182
|
+
drawstyle = "default"
|
|
183
|
+
|
|
184
|
+
def __init__(self, refs=None):
|
|
185
|
+
self._refs = np.asarray([] if refs is None else refs)
|
|
186
|
+
self._values = None
|
|
187
|
+
self._names = []
|
|
188
|
+
|
|
189
|
+
def __call__(self, calc, name=None):
|
|
190
|
+
if name is None:
|
|
191
|
+
if self.values is None:
|
|
192
|
+
i = 1
|
|
193
|
+
elif len(self.values.shape) == 1:
|
|
194
|
+
i = 2
|
|
195
|
+
else:
|
|
196
|
+
i = self.values.shape[1] + 1
|
|
197
|
+
name = "r%d" % i
|
|
198
|
+
self._names.append(name)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def refs(self):
|
|
202
|
+
return self._refs
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def values(self):
|
|
206
|
+
return self._values
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def names(self):
|
|
210
|
+
return self._names
|
|
211
|
+
|
|
212
|
+
def reset(self):
|
|
213
|
+
self._values = None
|
|
214
|
+
self._names = []
|
|
215
|
+
if not self._const_ref:
|
|
216
|
+
self._refs = np.array([])
|
|
217
|
+
|
|
218
|
+
def iter_results(self):
|
|
219
|
+
shared_ref = len(self.refs.shape) == 1
|
|
220
|
+
for i, name in enumerate(self.names):
|
|
221
|
+
refs = self.refs if shared_ref else self.refs[:, i]
|
|
222
|
+
values = self.values if len(self.values.shape) == 1 else self.values[:, i]
|
|
223
|
+
yield name, refs, values
|
|
224
|
+
|
|
225
|
+
def _add_refs(self, refs):
|
|
226
|
+
refs = np.asarray(refs)
|
|
227
|
+
if len(self._refs) == 0:
|
|
228
|
+
self._refs = np.array(refs)
|
|
229
|
+
else:
|
|
230
|
+
self._refs = append_arrays(self._refs, refs)
|
|
231
|
+
|
|
232
|
+
def _add_values(self, values):
|
|
233
|
+
values = np.asarray(values)
|
|
234
|
+
if self._values is None:
|
|
235
|
+
self._values = values
|
|
236
|
+
else:
|
|
237
|
+
self._values = append_arrays(self._values, values)
|
|
238
|
+
|
|
239
|
+
def calc_stats(self, as_dataframe=False):
|
|
240
|
+
ln_values = np.log(self.values)
|
|
241
|
+
median = np.exp(np.nanmean(ln_values, axis=1))
|
|
242
|
+
ln_std = np.nanstd(ln_values, axis=1)
|
|
243
|
+
|
|
244
|
+
stats = {"ref": self.refs, "median": median, "ln_std": ln_std}
|
|
245
|
+
if as_dataframe and pd:
|
|
246
|
+
stats = pd.DataFrame(stats).set_index("ref")
|
|
247
|
+
stats.index.name = self.ref_name
|
|
248
|
+
|
|
249
|
+
return stats
|
|
250
|
+
|
|
251
|
+
def to_dataframe(self):
|
|
252
|
+
if not pd:
|
|
253
|
+
raise RuntimeError("Install `pandas` library.")
|
|
254
|
+
|
|
255
|
+
if isinstance(self.names[0], tuple):
|
|
256
|
+
columns = pd.MultiIndex.from_tuples(self.names)
|
|
257
|
+
else:
|
|
258
|
+
columns = self.names
|
|
259
|
+
|
|
260
|
+
df = pd.DataFrame(self.values, index=self.refs, columns=columns)
|
|
261
|
+
|
|
262
|
+
return df
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _get_xy(refs, values):
|
|
266
|
+
return refs, values
|
|
267
|
+
|
|
268
|
+
def plot(self, ax=None, style="indiv"):
|
|
269
|
+
assert style in ["stats", "indiv"]
|
|
270
|
+
|
|
271
|
+
if ax is None:
|
|
272
|
+
fig, ax = plt.subplots()
|
|
273
|
+
|
|
274
|
+
if style == "stats" and len(self.values.shape) > 1 and self.values.shape[1] < 3:
|
|
275
|
+
raise RuntimeError("Unable to plot stats for less than 3 values.")
|
|
276
|
+
|
|
277
|
+
if style == "stats":
|
|
278
|
+
kwds = {"color": "C0", "alpha": 0.6, "lw": 0.8, "drawstyle": self.drawstyle}
|
|
279
|
+
elif style == "indiv":
|
|
280
|
+
kwds = {"lw": 1.0, "drawstyle": self.drawstyle}
|
|
281
|
+
else:
|
|
282
|
+
raise NotImplementedError("Valid options are: stats, indiv.")
|
|
283
|
+
|
|
284
|
+
# Add the data
|
|
285
|
+
x, y = self._get_xy(self.refs, self.values)
|
|
286
|
+
lines = ax.plot(x, y, **kwds)
|
|
287
|
+
|
|
288
|
+
if style == "stats":
|
|
289
|
+
lines[0].set_label("Realization")
|
|
290
|
+
else:
|
|
291
|
+
for layer, name in zip(lines, self.names):
|
|
292
|
+
layer.set_label(name)
|
|
293
|
+
|
|
294
|
+
if style == "stats":
|
|
295
|
+
stats = self.calc_stats()
|
|
296
|
+
|
|
297
|
+
ax.plot(
|
|
298
|
+
*self._get_xy(stats["ref"], stats["median"]),
|
|
299
|
+
color="C1",
|
|
300
|
+
lw=2,
|
|
301
|
+
label="Median",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
ax.set(
|
|
305
|
+
xlabel=self.xlabel,
|
|
306
|
+
xscale=self.xscale,
|
|
307
|
+
ylabel=self.ylabel,
|
|
308
|
+
yscale=self.yscale,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if len(lines) > 1:
|
|
312
|
+
ax.legend()
|
|
313
|
+
|
|
314
|
+
return ax
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class OutputLocation(object):
|
|
318
|
+
def __init__(self, wave_field, depth=None, index=None):
|
|
319
|
+
self._depth = depth
|
|
320
|
+
self._index = index
|
|
321
|
+
if not isinstance(wave_field, WaveField):
|
|
322
|
+
wave_field = WaveField[wave_field]
|
|
323
|
+
self._wave_field = wave_field
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def depth(self):
|
|
327
|
+
return self._depth
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def index(self):
|
|
331
|
+
return self._index
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def wave_field(self):
|
|
335
|
+
return self._wave_field
|
|
336
|
+
|
|
337
|
+
def __call__(self, profile):
|
|
338
|
+
"""Lookup the location with the profile."""
|
|
339
|
+
return profile.location(self.wave_field, depth=self.depth, index=self.index)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class LocationBasedOutput(Output):
|
|
343
|
+
def __init__(self, ref, location):
|
|
344
|
+
super().__init__(ref)
|
|
345
|
+
self._location = location
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def location(self):
|
|
349
|
+
return self._location
|
|
350
|
+
|
|
351
|
+
def __call__(self, calc, name=None):
|
|
352
|
+
raise NotImplementedError
|
|
353
|
+
|
|
354
|
+
def _get_location(self, calc):
|
|
355
|
+
"""Locate location within the profile."""
|
|
356
|
+
return self._location(calc.profile)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class TimeSeriesOutput(LocationBasedOutput):
|
|
360
|
+
xlabel = "Time (sec)"
|
|
361
|
+
xscale = "linear"
|
|
362
|
+
ylabel = NotImplemented
|
|
363
|
+
yscale = "linear"
|
|
364
|
+
|
|
365
|
+
ref_name = "time"
|
|
366
|
+
|
|
367
|
+
def __init__(self, location):
|
|
368
|
+
super().__init__(None, location)
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def times(self):
|
|
372
|
+
return self.refs
|
|
373
|
+
|
|
374
|
+
def __call__(self, calc, name=None):
|
|
375
|
+
if not isinstance(calc.motion, TimeSeriesMotion):
|
|
376
|
+
raise NotImplementedError
|
|
377
|
+
Output.__call__(self, calc, name)
|
|
378
|
+
# Compute the response
|
|
379
|
+
loc = self._get_location(calc)
|
|
380
|
+
tf = self._get_trans_func(calc, loc)
|
|
381
|
+
values = calc.motion.calc_time_series(tf)
|
|
382
|
+
values = self._modify_values(calc, loc, values)
|
|
383
|
+
self._add_values(values)
|
|
384
|
+
# Add the reference
|
|
385
|
+
refs = calc.motion.time_step * np.arange(len(values))
|
|
386
|
+
self._add_refs(refs)
|
|
387
|
+
|
|
388
|
+
def _get_trans_func(self, calc, location):
|
|
389
|
+
raise NotImplementedError
|
|
390
|
+
|
|
391
|
+
def _modify_values(self, calc, location, values):
|
|
392
|
+
return values
|
|
393
|
+
|
|
394
|
+
def to_dataframe(self):
|
|
395
|
+
raise NotImplementedError
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class AccelerationTSOutput(TimeSeriesOutput):
|
|
399
|
+
ylabel = "Acceleration (g)"
|
|
400
|
+
|
|
401
|
+
def _get_trans_func(self, calc, location):
|
|
402
|
+
return calc.calc_accel_tf(calc.loc_input, location)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class AriasIntensityTSOutput(AccelerationTSOutput):
|
|
406
|
+
ylabel = "Arias Intensity (m/s)"
|
|
407
|
+
|
|
408
|
+
def _modify_values(self, calc, location, values):
|
|
409
|
+
time_step = calc.motion.time_step
|
|
410
|
+
values = scipy.integrate.cumtrapz(values**2, dx=time_step)
|
|
411
|
+
values *= GRAVITY * np.pi / 2
|
|
412
|
+
return values
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class StrainTSOutput(TimeSeriesOutput):
|
|
416
|
+
def __init__(self, location, in_percent=False):
|
|
417
|
+
super().__init__(location)
|
|
418
|
+
self._in_percent = in_percent
|
|
419
|
+
assert self.location.wave_field == WaveField.within
|
|
420
|
+
|
|
421
|
+
def _get_trans_func(self, calc, location):
|
|
422
|
+
return calc.calc_strain_tf(calc.loc_input, location)
|
|
423
|
+
|
|
424
|
+
def _modify_values(self, calc, location, values):
|
|
425
|
+
if self._in_percent:
|
|
426
|
+
# Convert to percent
|
|
427
|
+
values *= 100.0
|
|
428
|
+
return values
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def ylabel(self):
|
|
432
|
+
suffix = "(%)" if self._in_percent else "(dec)"
|
|
433
|
+
return "Shear Strain " + suffix
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class StressTSOutput(TimeSeriesOutput):
|
|
437
|
+
def __init__(self, location, damped=False, normalized=False):
|
|
438
|
+
super().__init__(location)
|
|
439
|
+
self._damped = damped
|
|
440
|
+
self._normalized = normalized
|
|
441
|
+
assert self.location.wave_field == WaveField.within
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def damped(self):
|
|
445
|
+
return self._damped
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def ylabel(self):
|
|
449
|
+
if self._normalized:
|
|
450
|
+
ylabel = "Stress Ratio (τ/σ`ᵥ)"
|
|
451
|
+
else:
|
|
452
|
+
ylabel = "Stress (τ)"
|
|
453
|
+
|
|
454
|
+
return ylabel
|
|
455
|
+
|
|
456
|
+
def _get_trans_func(self, calc, location):
|
|
457
|
+
tf = calc.calc_stress_tf(calc.loc_input, location, self.damped)
|
|
458
|
+
|
|
459
|
+
if self._normalized:
|
|
460
|
+
# Correct by effective stress at depth
|
|
461
|
+
tf /= location.stress_vert(effective=True)
|
|
462
|
+
|
|
463
|
+
return tf
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class FourierAmplitudeSpectrumOutput(LocationBasedOutput):
|
|
467
|
+
_const_ref = True
|
|
468
|
+
xlabel = "Frequency (Hz)"
|
|
469
|
+
ylabel = "Fourier Ampl. (cm/s)"
|
|
470
|
+
|
|
471
|
+
ref_name = "freq"
|
|
472
|
+
|
|
473
|
+
def __init__(self, freqs, location, ko_bandwidth=30):
|
|
474
|
+
super().__init__(freqs, location)
|
|
475
|
+
self._ko_bandwidth = ko_bandwidth
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def freqs(self):
|
|
479
|
+
return self._refs
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def ko_bandwidth(self):
|
|
483
|
+
return self._ko_bandwidth
|
|
484
|
+
|
|
485
|
+
def __call__(self, calc, name=None):
|
|
486
|
+
Output.__call__(self, calc, name)
|
|
487
|
+
loc = self._get_location(calc)
|
|
488
|
+
tf = calc.calc_accel_tf(calc.loc_input, loc)
|
|
489
|
+
|
|
490
|
+
# Only return the absolute value
|
|
491
|
+
fas = np.abs(tf * calc.motion.fourier_amps)
|
|
492
|
+
|
|
493
|
+
# Interpolate to the specified frequencies
|
|
494
|
+
if self._ko_bandwidth is None:
|
|
495
|
+
fas = np.interp(self.freqs, calc.motion.freqs, fas)
|
|
496
|
+
else:
|
|
497
|
+
fas = pykooh.smooth(
|
|
498
|
+
self.freqs,
|
|
499
|
+
calc.motion.freqs,
|
|
500
|
+
fas,
|
|
501
|
+
self.ko_bandwidth,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
self._add_values(fas)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class ResponseSpectrumOutput(LocationBasedOutput):
|
|
508
|
+
_const_ref = True
|
|
509
|
+
xlabel = "Frequency (Hz)"
|
|
510
|
+
|
|
511
|
+
ref_name = "freq"
|
|
512
|
+
|
|
513
|
+
def __init__(self, freqs, location, osc_damping):
|
|
514
|
+
super().__init__(freqs, location)
|
|
515
|
+
self._osc_damping = osc_damping
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def freqs(self):
|
|
519
|
+
return self._refs
|
|
520
|
+
|
|
521
|
+
@property
|
|
522
|
+
def periods(self):
|
|
523
|
+
return 1.0 / np.asarray(self._refs)
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def osc_damping(self):
|
|
527
|
+
return self._osc_damping
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def ylabel(self):
|
|
531
|
+
return f"{100 * self.osc_damping:g}%-Damped, Spec. Accel. (g)"
|
|
532
|
+
|
|
533
|
+
def __call__(self, calc, name=None):
|
|
534
|
+
Output.__call__(self, calc, name)
|
|
535
|
+
loc = self._get_location(calc)
|
|
536
|
+
tf = calc.calc_accel_tf(calc.loc_input, loc)
|
|
537
|
+
ars = calc.motion.calc_osc_accels(self.freqs, self.osc_damping, tf)
|
|
538
|
+
self._add_values(ars)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class RatioBasedOutput(Output):
|
|
542
|
+
_const_ref = True
|
|
543
|
+
|
|
544
|
+
def __init__(self, refs, location_in, location_out):
|
|
545
|
+
super().__init__(refs)
|
|
546
|
+
self._location_in = location_in
|
|
547
|
+
self._location_out = location_out
|
|
548
|
+
|
|
549
|
+
@property
|
|
550
|
+
def location_in(self):
|
|
551
|
+
return self._location_in
|
|
552
|
+
|
|
553
|
+
@property
|
|
554
|
+
def location_out(self):
|
|
555
|
+
return self._location_out
|
|
556
|
+
|
|
557
|
+
def __call__(self, calc, name=None):
|
|
558
|
+
raise NotImplementedError
|
|
559
|
+
|
|
560
|
+
def _get_locations(self, calc):
|
|
561
|
+
"""Locate locations within the profile."""
|
|
562
|
+
return (self._location_in(calc.profile), self._location_out(calc.profile))
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class AccelTransferFunctionOutput(RatioBasedOutput):
|
|
566
|
+
xlabel = "Frequency (Hz)"
|
|
567
|
+
ylabel = "Accel. Transfer Func."
|
|
568
|
+
|
|
569
|
+
ref_name = "freq"
|
|
570
|
+
|
|
571
|
+
def __init__(self, refs, location_in, location_out, ko_bandwidth=None, absolute=True):
|
|
572
|
+
super().__init__(refs, location_in, location_out)
|
|
573
|
+
self._ko_bandwidth = ko_bandwidth
|
|
574
|
+
self._absolute = absolute
|
|
575
|
+
|
|
576
|
+
def __call__(self, calc, name=None):
|
|
577
|
+
Output.__call__(self, calc, name)
|
|
578
|
+
# Locate position within the profile
|
|
579
|
+
loc_in, loc_out = self._get_locations(calc)
|
|
580
|
+
# Compute the response
|
|
581
|
+
if self._absolute:
|
|
582
|
+
tf = np.abs(calc.calc_accel_tf(loc_in, loc_out))
|
|
583
|
+
else:
|
|
584
|
+
tf = calc.calc_accel_tf(loc_in, loc_out)
|
|
585
|
+
|
|
586
|
+
if self._ko_bandwidth is None:
|
|
587
|
+
tf = np.interp(self.freqs, calc.motion.freqs, tf)
|
|
588
|
+
else:
|
|
589
|
+
tf = pykooh.smooth(self.freqs, calc.motion.freqs, tf, self._ko_bandwidth)
|
|
590
|
+
|
|
591
|
+
self._add_values(tf)
|
|
592
|
+
|
|
593
|
+
@property
|
|
594
|
+
def freqs(self):
|
|
595
|
+
return self._refs
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class ResponseSpectrumRatioOutput(RatioBasedOutput):
|
|
599
|
+
xlabel = "Frequency (Hz)"
|
|
600
|
+
|
|
601
|
+
ref_name = "freq"
|
|
602
|
+
|
|
603
|
+
def __init__(self, freqs, location_in, location_out, osc_damping):
|
|
604
|
+
super().__init__(freqs, location_in, location_out)
|
|
605
|
+
self._osc_damping = osc_damping
|
|
606
|
+
|
|
607
|
+
@property
|
|
608
|
+
def freqs(self):
|
|
609
|
+
return self._refs
|
|
610
|
+
|
|
611
|
+
@property
|
|
612
|
+
def periods(self):
|
|
613
|
+
return 1.0 / np.asarray(self._refs)
|
|
614
|
+
|
|
615
|
+
@property
|
|
616
|
+
def osc_damping(self):
|
|
617
|
+
return self._osc_damping
|
|
618
|
+
|
|
619
|
+
@property
|
|
620
|
+
def ylabel(self):
|
|
621
|
+
return f"{100 * self.osc_damping:g}%-Damped, Resp. Spectral Ratio"
|
|
622
|
+
|
|
623
|
+
def __call__(self, calc, name=None):
|
|
624
|
+
Output.__call__(self, calc, name)
|
|
625
|
+
loc_in, loc_out = self._get_locations(calc)
|
|
626
|
+
in_ars = calc.motion.calc_osc_accels(
|
|
627
|
+
self.freqs, self.osc_damping, calc.calc_accel_tf(calc.loc_input, loc_in)
|
|
628
|
+
)
|
|
629
|
+
out_ars = calc.motion.calc_osc_accels(
|
|
630
|
+
self.freqs, self.osc_damping, calc.calc_accel_tf(calc.loc_input, loc_out)
|
|
631
|
+
)
|
|
632
|
+
ratio = out_ars / in_ars
|
|
633
|
+
self._add_values(ratio)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class ProfileBasedOutput(Output):
|
|
637
|
+
ylabel = "Depth (m)"
|
|
638
|
+
yscale = "linear"
|
|
639
|
+
drawstyle = "steps-post"
|
|
640
|
+
|
|
641
|
+
ref_name = "depth"
|
|
642
|
+
|
|
643
|
+
def __init__(self):
|
|
644
|
+
super().__init__()
|
|
645
|
+
|
|
646
|
+
def __call__(self, calc, name=None):
|
|
647
|
+
Output.__call__(self, calc, name)
|
|
648
|
+
depths = np.r_[0, calc.profile.depth_mid[:-1]]
|
|
649
|
+
self._add_refs(depths)
|
|
650
|
+
|
|
651
|
+
def calc_stats(self, as_dataframe=False, ref=None):
|
|
652
|
+
if ref is None:
|
|
653
|
+
ref = np.linspace(0, np.nanmax(self.refs) * 1.05, num=512)
|
|
654
|
+
|
|
655
|
+
n = self.values.shape[1]
|
|
656
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
657
|
+
# Ignore zeros in the data
|
|
658
|
+
ln_values = np.array([self._ln_interp(i, ref) for i in range(n)]).T
|
|
659
|
+
median = np.exp(np.nanmean(ln_values, axis=1))
|
|
660
|
+
ln_std = np.nanstd(ln_values, axis=1)
|
|
661
|
+
|
|
662
|
+
stats = {"ref": ref, "median": median, "ln_std": ln_std}
|
|
663
|
+
if as_dataframe and pd:
|
|
664
|
+
stats = pd.DataFrame(stats).set_index("ref")
|
|
665
|
+
stats.index.name = self.ref_name
|
|
666
|
+
|
|
667
|
+
return stats
|
|
668
|
+
|
|
669
|
+
@staticmethod
|
|
670
|
+
def _get_xy(refs, values):
|
|
671
|
+
return values, refs
|
|
672
|
+
|
|
673
|
+
def plot(self, ax=None, style="stats"):
|
|
674
|
+
ax = Output.plot(self, ax, style)
|
|
675
|
+
ax.invert_yaxis()
|
|
676
|
+
return ax
|
|
677
|
+
|
|
678
|
+
def _ln_interp(self, i, ref):
|
|
679
|
+
"""Interpolate the values in log-y space."""
|
|
680
|
+
|
|
681
|
+
_ref = self.refs[:, i]
|
|
682
|
+
# Only select points with valid entries
|
|
683
|
+
mask = np.isfinite(_ref)
|
|
684
|
+
_ref = _ref[mask]
|
|
685
|
+
_ln_values = np.log(self.values[mask, i])
|
|
686
|
+
|
|
687
|
+
if np.any(mask):
|
|
688
|
+
f = interp1d(
|
|
689
|
+
_ref,
|
|
690
|
+
_ln_values,
|
|
691
|
+
kind="next",
|
|
692
|
+
fill_value=(_ln_values[0], _ln_values[-1]),
|
|
693
|
+
bounds_error=False,
|
|
694
|
+
)
|
|
695
|
+
_ln_interped = f(ref)
|
|
696
|
+
else:
|
|
697
|
+
nans = np.empty_like(ref)
|
|
698
|
+
nans[:] = np.nan
|
|
699
|
+
_ln_interped = np.array(nans)
|
|
700
|
+
|
|
701
|
+
return _ln_interped
|
|
702
|
+
|
|
703
|
+
def to_dataframe(self, ref=None):
|
|
704
|
+
if not pd:
|
|
705
|
+
raise RuntimeError("Install `pandas` library.")
|
|
706
|
+
|
|
707
|
+
if ref is None:
|
|
708
|
+
ref = np.linspace(0, np.nanmax(self.refs))
|
|
709
|
+
|
|
710
|
+
if isinstance(self.names[0], tuple):
|
|
711
|
+
columns = pd.MultiIndex.from_tuples(self.names)
|
|
712
|
+
else:
|
|
713
|
+
columns = self.names
|
|
714
|
+
|
|
715
|
+
# Ignore zeros in the data
|
|
716
|
+
n = self.values.shape[1]
|
|
717
|
+
values = np.exp(np.array([self._ln_interp(i, ref) for i in range(n)])).T
|
|
718
|
+
|
|
719
|
+
df = pd.DataFrame(values, index=ref, columns=columns)
|
|
720
|
+
|
|
721
|
+
return df
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class MaxStrainProfile(ProfileBasedOutput):
|
|
725
|
+
xlabel = "Max. Strain (dec)"
|
|
726
|
+
|
|
727
|
+
def __init__(self):
|
|
728
|
+
super().__init__()
|
|
729
|
+
|
|
730
|
+
def __call__(self, calc, name=None):
|
|
731
|
+
ProfileBasedOutput.__call__(self, calc, name)
|
|
732
|
+
values = [0] + [layer.strain_max for layer in calc.profile[:-1]]
|
|
733
|
+
self._add_values(values)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class DampingProfile(ProfileBasedOutput):
|
|
737
|
+
xlabel = "Damping (dec)"
|
|
738
|
+
|
|
739
|
+
def __call__(self, calc, name=None):
|
|
740
|
+
Output.__call__(self, calc, name)
|
|
741
|
+
# Add depth at top of layer
|
|
742
|
+
self._add_refs(calc.profile.depth)
|
|
743
|
+
|
|
744
|
+
values = [layer.damping for layer in calc.profile[:-1]]
|
|
745
|
+
# Bring the first mid-layer value to the surface
|
|
746
|
+
values.insert(0, values[0])
|
|
747
|
+
self._add_values(values)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
class ShearModReducProfile(ProfileBasedOutput):
|
|
751
|
+
xlabel = "G/Gmax"
|
|
752
|
+
|
|
753
|
+
def __call__(self, calc, name=None):
|
|
754
|
+
Output.__call__(self, calc, name)
|
|
755
|
+
# Add depth at top of layer
|
|
756
|
+
self._add_refs(calc.profile.depth)
|
|
757
|
+
|
|
758
|
+
values = [layer.shear_mod_reduc for layer in calc.profile[:-1]]
|
|
759
|
+
# Bring the first mid-layer value to the surface
|
|
760
|
+
values.insert(0, values[0])
|
|
761
|
+
self._add_values(values)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class InitialVelProfile(ProfileBasedOutput):
|
|
765
|
+
xlabel = "Initial Velocity (m/s)"
|
|
766
|
+
|
|
767
|
+
def __init__(self):
|
|
768
|
+
super().__init__()
|
|
769
|
+
|
|
770
|
+
def __call__(self, calc, name=None):
|
|
771
|
+
Output.__call__(self, calc, name)
|
|
772
|
+
# Add depth at top of layer
|
|
773
|
+
self._add_refs(calc.profile.depth)
|
|
774
|
+
|
|
775
|
+
values = [layer.initial_shear_vel for layer in calc.profile[:-1]]
|
|
776
|
+
values.insert(0, values[0])
|
|
777
|
+
self._add_values(values)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
class CompatVelProfile(ProfileBasedOutput):
|
|
781
|
+
xlabel = "Strain-Compatible Velocity (m/s)"
|
|
782
|
+
|
|
783
|
+
def __init__(self):
|
|
784
|
+
super().__init__()
|
|
785
|
+
|
|
786
|
+
def __call__(self, calc, name=None):
|
|
787
|
+
Output.__call__(self, calc, name)
|
|
788
|
+
# Add depth at top of layer
|
|
789
|
+
self._add_refs(calc.profile.depth)
|
|
790
|
+
|
|
791
|
+
values = [np.min(layer.shear_vel) for layer in calc.profile[:-1]]
|
|
792
|
+
values.insert(0, values[0])
|
|
793
|
+
self._add_values(values)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
class CyclicStressRatioProfile(ProfileBasedOutput):
|
|
797
|
+
# From Idriss and Boulanger (2008, pg. 70):
|
|
798
|
+
# The 0.65 is a constant used to represent the reference stress
|
|
799
|
+
# level. While being somewhat arbitrary it was selected in the
|
|
800
|
+
# beginning of the development of liquefaction procedures in 1966
|
|
801
|
+
# and has been in use ever since.
|
|
802
|
+
_stress_level = 0.65
|
|
803
|
+
|
|
804
|
+
def __init__(self):
|
|
805
|
+
super().__init__()
|
|
806
|
+
|
|
807
|
+
def __call__(self, calc, name=None):
|
|
808
|
+
ProfileBasedOutput.__call__(self, calc, name)
|
|
809
|
+
values = [
|
|
810
|
+
layer.stress_shear_max / layer.stress_vert(layer.thickness / 2, True)
|
|
811
|
+
for layer in calc.profile[:-1]
|
|
812
|
+
]
|
|
813
|
+
# Repeat the first value for the surface
|
|
814
|
+
values = self._stress_level * np.array([values[0]] + values)
|
|
815
|
+
self._add_values(values)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class MaxAccelProfile(ProfileBasedOutput):
|
|
819
|
+
xlabel = "Max. Accel. (g)"
|
|
820
|
+
|
|
821
|
+
def __call__(self, calc, name=None):
|
|
822
|
+
Output.__call__(self, calc, name)
|
|
823
|
+
# Add depth at top of layer
|
|
824
|
+
depths = calc.profile.depth
|
|
825
|
+
values = [self._calc_accel(calc, depth) for depth in depths]
|
|
826
|
+
self._add_refs(depths)
|
|
827
|
+
self._add_values(values)
|
|
828
|
+
|
|
829
|
+
def _calc_accel(self, calc, depth):
|
|
830
|
+
return calc.motion.calc_peak(
|
|
831
|
+
calc.calc_accel_tf(calc.loc_input, calc.profile.location("within", depth=depth))
|
|
832
|
+
)
|