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/propagation.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# The MIT License (MIT)
|
|
3
|
+
#
|
|
4
|
+
# Copyright (c) 2016-2018 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 numba
|
|
24
|
+
import numpy as np
|
|
25
|
+
from scipy.optimize import minimize
|
|
26
|
+
|
|
27
|
+
from .motion import GRAVITY
|
|
28
|
+
from .motion import WaveField
|
|
29
|
+
from .site import Layer
|
|
30
|
+
from .site import Location
|
|
31
|
+
from .site import Profile
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AbstractCalculator(object):
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self._loc_input = None
|
|
37
|
+
self._motion = None
|
|
38
|
+
self._profile = None
|
|
39
|
+
|
|
40
|
+
def __call__(self, motion, profile, loc_input):
|
|
41
|
+
self._motion = motion
|
|
42
|
+
self._profile = profile
|
|
43
|
+
self._loc_input = loc_input
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def motion(self):
|
|
47
|
+
return self._motion
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def profile(self):
|
|
51
|
+
return self._profile
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def loc_input(self):
|
|
55
|
+
return self._loc_input
|
|
56
|
+
|
|
57
|
+
def calc_accel_tf(self, lin, lout):
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
|
|
60
|
+
def calc_stress_tf(self, lin, lout, damped):
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def calc_strain_tf(self, lin, lout):
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@numba.jit(nopython=True)
|
|
68
|
+
def my_trapz(thickness, property, depth_max):
|
|
69
|
+
total = 0
|
|
70
|
+
depth = 0
|
|
71
|
+
|
|
72
|
+
for t, p in zip(thickness, property):
|
|
73
|
+
depth += t
|
|
74
|
+
if depth_max < depth:
|
|
75
|
+
# Partial layer
|
|
76
|
+
total += (t - (depth - depth_max)) * p
|
|
77
|
+
break
|
|
78
|
+
total += t * p
|
|
79
|
+
else:
|
|
80
|
+
# Final infinite layer
|
|
81
|
+
total += (depth_max - depth) * p
|
|
82
|
+
|
|
83
|
+
return total / depth_max
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class QuarterWaveLenCalculator(AbstractCalculator):
|
|
87
|
+
"""Compute quarter-wave length site amplification.
|
|
88
|
+
|
|
89
|
+
No consideration for nolninearity is made by this calculator.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
name = "QWL"
|
|
93
|
+
|
|
94
|
+
def __init__(self, site_atten=None):
|
|
95
|
+
super().__init__()
|
|
96
|
+
self._site_atten = site_atten
|
|
97
|
+
|
|
98
|
+
def __call__(self, motion, profile, loc_input):
|
|
99
|
+
"""Perform the wave propagation.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
motion: :class:`~.base.motion.Motion`
|
|
104
|
+
Input motion.
|
|
105
|
+
|
|
106
|
+
profile: :class:`~.base.site.Profile`
|
|
107
|
+
Site profile.
|
|
108
|
+
|
|
109
|
+
loc_input: :class:`~.base.site.Location`
|
|
110
|
+
Location of the input motion.
|
|
111
|
+
"""
|
|
112
|
+
super().__call__(motion, profile, loc_input)
|
|
113
|
+
|
|
114
|
+
self._crustal_amp, self._site_term = self._calc_amp(
|
|
115
|
+
profile.density, profile.thickness, profile.slowness
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def crustal_amp(self):
|
|
120
|
+
return self._crustal_amp
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def site_term(self):
|
|
124
|
+
return self._site_term
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def site_atten(self):
|
|
128
|
+
return self._site_atten
|
|
129
|
+
|
|
130
|
+
def _calc_amp(self, density, thickness, slowness):
|
|
131
|
+
freqs = self.motion.freqs
|
|
132
|
+
# 1/4 wavelength depth -- estimated for mean slowness
|
|
133
|
+
qwl_depth = 1 / (4 * np.mean(slowness) * freqs)
|
|
134
|
+
|
|
135
|
+
def qwl_average(param):
|
|
136
|
+
return np.array([my_trapz(thickness, param, qd) for qd in qwl_depth])
|
|
137
|
+
|
|
138
|
+
for _ in range(20):
|
|
139
|
+
qwl_slowness = qwl_average(slowness)
|
|
140
|
+
prev_qwl_depth = qwl_depth
|
|
141
|
+
qwl_depth = 1 / (4 * qwl_slowness * freqs)
|
|
142
|
+
|
|
143
|
+
if np.allclose(prev_qwl_depth, qwl_depth, rtol=0.005):
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
# FIXME return an error if not converged?
|
|
147
|
+
|
|
148
|
+
qwl_density = qwl_average(density)
|
|
149
|
+
crustal_amp = np.sqrt((density[-1] / slowness[-1]) / (qwl_density / qwl_slowness))
|
|
150
|
+
site_term = np.array(crustal_amp)
|
|
151
|
+
if self._site_atten:
|
|
152
|
+
site_term *= np.exp(-np.pi * self.site_atten * freqs)
|
|
153
|
+
|
|
154
|
+
return crustal_amp, site_term
|
|
155
|
+
|
|
156
|
+
def fit(
|
|
157
|
+
self,
|
|
158
|
+
target_type,
|
|
159
|
+
target,
|
|
160
|
+
adjust_thickness=False,
|
|
161
|
+
adjust_site_atten=False,
|
|
162
|
+
adjust_source_vel=False,
|
|
163
|
+
):
|
|
164
|
+
"""
|
|
165
|
+
Fit to a target crustal amplification or site term.
|
|
166
|
+
|
|
167
|
+
The fitting process adjusts the velocity, site attenuation, and layer
|
|
168
|
+
thickness (if enabled) to fit a target values. The frequency range is
|
|
169
|
+
specified by the input motion.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
target_type: str
|
|
174
|
+
Options are 'crustal_amp' to only fit to the crustal amplification,
|
|
175
|
+
or 'site_term' to fit both the velocity and the site attenuation
|
|
176
|
+
parameter.
|
|
177
|
+
target: `array_like`
|
|
178
|
+
Target values.
|
|
179
|
+
adjust_thickness: bool (optional)
|
|
180
|
+
If the thickness of the layers is adjusted as well, default: False.
|
|
181
|
+
adjust_site_atten: bool (optional)
|
|
182
|
+
If the site attenuation is adjusted as well, default: False.
|
|
183
|
+
adjust_source_vel: bool (optional)
|
|
184
|
+
If the source velocity should be adjusted, default: False.
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
profile: `pyrsa.site.Profile`
|
|
188
|
+
profile optimized to fit a target amplification.
|
|
189
|
+
"""
|
|
190
|
+
density = self.profile.density
|
|
191
|
+
|
|
192
|
+
nl = len(density)
|
|
193
|
+
|
|
194
|
+
# Slowness bounds
|
|
195
|
+
slowness = self.profile.slowness
|
|
196
|
+
thickness = self.profile.thickness
|
|
197
|
+
site_atten = self._site_atten
|
|
198
|
+
|
|
199
|
+
# Slowness
|
|
200
|
+
initial = slowness
|
|
201
|
+
bounds = 1 / np.tile((4000, 100), (nl, 1))
|
|
202
|
+
if not adjust_source_vel:
|
|
203
|
+
bounds[-1] = (initial[-1], initial[-1])
|
|
204
|
+
|
|
205
|
+
# Thickness bounds
|
|
206
|
+
if adjust_thickness:
|
|
207
|
+
bounds = np.r_[bounds, [[t / 2, 2 * t] for t in thickness]]
|
|
208
|
+
initial = np.r_[initial, thickness]
|
|
209
|
+
|
|
210
|
+
# Site attenuation bounds
|
|
211
|
+
if adjust_site_atten:
|
|
212
|
+
bounds = np.r_[bounds, [[0.0001, 0.200]]]
|
|
213
|
+
initial = np.r_[initial, self.site_atten]
|
|
214
|
+
|
|
215
|
+
def calc_rmse(this, that):
|
|
216
|
+
return np.mean(((this - that) / that) ** 2)
|
|
217
|
+
|
|
218
|
+
def err(x):
|
|
219
|
+
_slowness = x[0:nl]
|
|
220
|
+
if adjust_thickness:
|
|
221
|
+
_thickness = x[nl : (2 * nl)]
|
|
222
|
+
else:
|
|
223
|
+
_thickness = thickness
|
|
224
|
+
if adjust_site_atten:
|
|
225
|
+
self._site_atten = x[-1]
|
|
226
|
+
|
|
227
|
+
crustal_amp, site_term = self._calc_amp(density, _thickness, _slowness)
|
|
228
|
+
|
|
229
|
+
calc = crustal_amp if target_type == "crustal_amp" else site_term
|
|
230
|
+
|
|
231
|
+
err = 10 * calc_rmse(target, calc)
|
|
232
|
+
# Prefer the original values so add the difference to the error
|
|
233
|
+
err += calc_rmse(slowness, _slowness)
|
|
234
|
+
if adjust_thickness:
|
|
235
|
+
err += calc_rmse(thickness, _thickness)
|
|
236
|
+
if adjust_site_atten:
|
|
237
|
+
err += calc_rmse(self._site_atten, site_atten)
|
|
238
|
+
return err
|
|
239
|
+
|
|
240
|
+
res = minimize(err, initial, method="L-BFGS-B", bounds=bounds)
|
|
241
|
+
|
|
242
|
+
slowness = res.x[0:nl]
|
|
243
|
+
if adjust_thickness:
|
|
244
|
+
thickness = res.x[nl : (2 * nl)]
|
|
245
|
+
|
|
246
|
+
profile = Profile(
|
|
247
|
+
[Layer(l.soil_type, t, 1 / s) for l, t, s in zip(self.profile, thickness, slowness)],
|
|
248
|
+
self.profile.wt_depth,
|
|
249
|
+
)
|
|
250
|
+
# Update the calculated amplificaiton
|
|
251
|
+
self(self.motion, profile, self.loc_input)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class LinearElasticCalculator(AbstractCalculator):
|
|
255
|
+
"""Class for performing linear elastic site response."""
|
|
256
|
+
|
|
257
|
+
name = "LE"
|
|
258
|
+
|
|
259
|
+
def __init__(self):
|
|
260
|
+
super().__init__()
|
|
261
|
+
|
|
262
|
+
self._waves_a = np.array([])
|
|
263
|
+
self._waves_b = np.array([])
|
|
264
|
+
self._wave_nums = np.array([])
|
|
265
|
+
|
|
266
|
+
def __call__(self, motion, profile, loc_input):
|
|
267
|
+
"""Perform the wave propagation.
|
|
268
|
+
|
|
269
|
+
Parameters
|
|
270
|
+
----------
|
|
271
|
+
motion: :class:`~.base.motion.Motion`
|
|
272
|
+
Input motion.
|
|
273
|
+
|
|
274
|
+
profile: :class:`~.base.site.Profile`
|
|
275
|
+
Site profile.
|
|
276
|
+
|
|
277
|
+
loc_input: :class:`~.base.site.Location`
|
|
278
|
+
Location of the input motion.
|
|
279
|
+
"""
|
|
280
|
+
super().__call__(motion, profile, loc_input)
|
|
281
|
+
|
|
282
|
+
# Set initial properties
|
|
283
|
+
for l in profile:
|
|
284
|
+
l.reset()
|
|
285
|
+
if l.strain is None:
|
|
286
|
+
l.strain = 0.0
|
|
287
|
+
|
|
288
|
+
self._calc_waves(motion.angular_freqs, profile)
|
|
289
|
+
|
|
290
|
+
def _calc_waves(self, angular_freqs, profile):
|
|
291
|
+
"""Compute the wave numbers and amplitudes (up- and down-going).
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
angular_freqs: :class:`numpy.ndarray`
|
|
296
|
+
Angular frequency at which the waves are computed.
|
|
297
|
+
|
|
298
|
+
profile: :class:`~.base.site.Profile`
|
|
299
|
+
Site profile.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
# Compute the complex wave numbers of the system
|
|
303
|
+
wave_nums = np.empty((len(profile), len(angular_freqs)), complex)
|
|
304
|
+
for i, l in enumerate(profile):
|
|
305
|
+
wave_nums[i, :] = angular_freqs / l.comp_shear_vel
|
|
306
|
+
|
|
307
|
+
# Compute the waves. In the top surface layer, the up-going and
|
|
308
|
+
# down-going waves have an amplitude of 1 as they are completely
|
|
309
|
+
# reflected at the surface.
|
|
310
|
+
waves_a = np.ones_like(wave_nums, complex)
|
|
311
|
+
waves_b = np.ones_like(wave_nums, complex)
|
|
312
|
+
for i, l in enumerate(profile[:-1]):
|
|
313
|
+
# Complex impedance -- wave number can be zero which causes an
|
|
314
|
+
# error.
|
|
315
|
+
with np.errstate(invalid="ignore"):
|
|
316
|
+
cimped = (wave_nums[i] * l.comp_shear_mod) / (
|
|
317
|
+
wave_nums[i + 1] * profile[i + 1].comp_shear_mod
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Complex term to simplify equations -- uses full layer height
|
|
321
|
+
cterm = 1j * wave_nums[i, :] * l.thickness
|
|
322
|
+
|
|
323
|
+
waves_a[i + 1, :] = 0.5 * waves_a[i] * (1 + cimped) * np.exp(cterm) + 0.5 * waves_b[
|
|
324
|
+
i
|
|
325
|
+
] * (1 - cimped) * np.exp(-cterm)
|
|
326
|
+
waves_b[i + 1, :] = 0.5 * waves_a[i] * (1 - cimped) * np.exp(cterm) + 0.5 * waves_b[
|
|
327
|
+
i
|
|
328
|
+
] * (1 + cimped) * np.exp(-cterm)
|
|
329
|
+
|
|
330
|
+
# Set wave amplitudes with zero frequency to 1
|
|
331
|
+
mask = ~np.isfinite(cimped)
|
|
332
|
+
waves_a[i + 1, mask] = 1.0
|
|
333
|
+
waves_b[i + 1, mask] = 1.0
|
|
334
|
+
|
|
335
|
+
# fixme: Better way to handle this?
|
|
336
|
+
# Set wave amplitudes to 1 at frequencies near 0
|
|
337
|
+
mask = np.isclose(angular_freqs, 0)
|
|
338
|
+
waves_a[-1, mask] = 1.0
|
|
339
|
+
waves_b[-1, mask] = 1.0
|
|
340
|
+
|
|
341
|
+
self._waves_a = waves_a
|
|
342
|
+
self._waves_b = waves_b
|
|
343
|
+
self._wave_nums = wave_nums
|
|
344
|
+
|
|
345
|
+
def wave_at_location(self, l):
|
|
346
|
+
"""Compute the wave field at specific location.
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
l : site.Location
|
|
351
|
+
:class:`site.Location` of the input
|
|
352
|
+
|
|
353
|
+
Returns
|
|
354
|
+
-------
|
|
355
|
+
`np.ndarray`
|
|
356
|
+
Amplitude and phase of waves
|
|
357
|
+
"""
|
|
358
|
+
cterm = 1j * self._wave_nums[l.index] * l.depth_within
|
|
359
|
+
|
|
360
|
+
if l.wave_field == WaveField.within:
|
|
361
|
+
return self._waves_a[l.index] * np.exp(cterm) + self._waves_b[l.index] * np.exp(-cterm)
|
|
362
|
+
elif l.wave_field == WaveField.outcrop:
|
|
363
|
+
return 2 * self._waves_a[l.index] * np.exp(cterm)
|
|
364
|
+
elif l.wave_field == WaveField.incoming_only:
|
|
365
|
+
return self._waves_a[l.index] * np.exp(cterm)
|
|
366
|
+
else:
|
|
367
|
+
raise NotImplementedError
|
|
368
|
+
|
|
369
|
+
def calc_accel_tf(self, lin, lout):
|
|
370
|
+
"""Compute the acceleration transfer function.
|
|
371
|
+
|
|
372
|
+
Parameters
|
|
373
|
+
----------
|
|
374
|
+
lin : :class:`~site.Location`
|
|
375
|
+
Location of input
|
|
376
|
+
lout : :class:`~site.Location`
|
|
377
|
+
Location of output. Note that this would typically be midheight
|
|
378
|
+
of the layer.
|
|
379
|
+
|
|
380
|
+
"""
|
|
381
|
+
tf = self.wave_at_location(lout) / self.wave_at_location(lin)
|
|
382
|
+
return tf
|
|
383
|
+
|
|
384
|
+
def calc_stress_tf(self, lin, lout, damped):
|
|
385
|
+
"""Compute the stress transfer function.
|
|
386
|
+
|
|
387
|
+
Parameters
|
|
388
|
+
----------
|
|
389
|
+
lin : :class:`~site.Location`
|
|
390
|
+
Location of input
|
|
391
|
+
lout : :class:`~site.Location`
|
|
392
|
+
Location of output. Note that this would typically be midheight
|
|
393
|
+
of the layer.
|
|
394
|
+
|
|
395
|
+
"""
|
|
396
|
+
tf = self.calc_strain_tf(lin, lout)
|
|
397
|
+
if damped:
|
|
398
|
+
# Scale by complex shear modulus to include the influence of
|
|
399
|
+
# damping
|
|
400
|
+
tf *= lout.layer.comp_shear_mod
|
|
401
|
+
else:
|
|
402
|
+
tf *= lout.layer.shear_mod
|
|
403
|
+
|
|
404
|
+
return tf
|
|
405
|
+
|
|
406
|
+
def calc_strain_tf(self, lin, lout):
|
|
407
|
+
"""Compute the strain transfer function from `lout` to
|
|
408
|
+
`location_in`.
|
|
409
|
+
|
|
410
|
+
The strain transfer function from the acceleration at layer `n`
|
|
411
|
+
(outcrop) to the mid-height of layer `m` (within) is defined as
|
|
412
|
+
|
|
413
|
+
Parameters
|
|
414
|
+
----------
|
|
415
|
+
lin : :class:`~site.Location`
|
|
416
|
+
Location of input
|
|
417
|
+
lout : :class:`~site.Location`
|
|
418
|
+
Location of output. Note that this would typically be midheight
|
|
419
|
+
of the layer.
|
|
420
|
+
|
|
421
|
+
Returns
|
|
422
|
+
-------
|
|
423
|
+
strain_tf : :class:`numpy.ndarray`
|
|
424
|
+
Transfer function to be applied to an acceleration FAS.
|
|
425
|
+
"""
|
|
426
|
+
# FIXME: Correct discussion for using acceleration FAS
|
|
427
|
+
# Strain(angFreq, z=h_m/2)
|
|
428
|
+
# ------------------------ =
|
|
429
|
+
# accel_n(angFreq)
|
|
430
|
+
#
|
|
431
|
+
# i k*_m [ A_m exp(i k*_m h_m / 2) - B_m exp(-i k*_m h_m / 2)]
|
|
432
|
+
# ------------------------------------------------------------
|
|
433
|
+
# -angFreq^2 (2 * A_n)
|
|
434
|
+
#
|
|
435
|
+
assert lout.wave_field == WaveField.within
|
|
436
|
+
|
|
437
|
+
ang_freqs = self.motion.angular_freqs
|
|
438
|
+
# The numerator cannot be computed using wave_at_location() because
|
|
439
|
+
# it is A - B.
|
|
440
|
+
cterm = 1j * self._wave_nums[lout.index, :] * lout.depth_within
|
|
441
|
+
numer = (
|
|
442
|
+
1j
|
|
443
|
+
* self._wave_nums[lout.index, :]
|
|
444
|
+
* (
|
|
445
|
+
self._waves_a[lout.index, :] * np.exp(cterm)
|
|
446
|
+
- self._waves_b[lout.index, :] * np.exp(-cterm)
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
denom = -(ang_freqs**2) * self.wave_at_location(lin)
|
|
450
|
+
|
|
451
|
+
# Only compute transfer function for non-zero frequencies
|
|
452
|
+
mask = ~np.isclose(ang_freqs, 0)
|
|
453
|
+
tf = np.zeros_like(mask, dtype=complex)
|
|
454
|
+
# Scale into units from gravity
|
|
455
|
+
tf[mask] = GRAVITY * numer[mask] / denom[mask]
|
|
456
|
+
|
|
457
|
+
return tf
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class EquivalentLinearCalculator(LinearElasticCalculator):
|
|
461
|
+
"""Class for performing equivalent-linear elastic site response."""
|
|
462
|
+
|
|
463
|
+
name = "EQL"
|
|
464
|
+
|
|
465
|
+
def __init__(self, strain_ratio=0.65, tolerance=0.01, max_iterations=15, strain_limit=0.05):
|
|
466
|
+
"""Initialize the class.
|
|
467
|
+
|
|
468
|
+
Parameters
|
|
469
|
+
----------
|
|
470
|
+
strain_ratio: float, default=0.65
|
|
471
|
+
Ratio between the maximum strain and effective strain used to
|
|
472
|
+
compute strain compatible properties.
|
|
473
|
+
|
|
474
|
+
tolerance: float, default=0.01
|
|
475
|
+
Tolerance in the iterative properties, which would cause the
|
|
476
|
+
iterative process to terminate.
|
|
477
|
+
|
|
478
|
+
max_iterations: int, default=15
|
|
479
|
+
Maximum number of iterations to perform.
|
|
480
|
+
|
|
481
|
+
strain_limit: float, default=0.05
|
|
482
|
+
Limit of strain in calculations. If this strain is exceed, the
|
|
483
|
+
iterative calculation is ended.
|
|
484
|
+
"""
|
|
485
|
+
super().__init__()
|
|
486
|
+
self._strain_ratio = strain_ratio
|
|
487
|
+
self._tolerance = tolerance
|
|
488
|
+
self._max_iterations = max_iterations
|
|
489
|
+
self._strain_limit = strain_limit
|
|
490
|
+
|
|
491
|
+
def __call__(self, motion, profile, loc_input):
|
|
492
|
+
"""Perform the wave propagation.
|
|
493
|
+
|
|
494
|
+
Parameters
|
|
495
|
+
----------
|
|
496
|
+
motion: :class:`~.base.motion.Motion`
|
|
497
|
+
Input motion.
|
|
498
|
+
|
|
499
|
+
profile: :class:`~.base.site.Profile`
|
|
500
|
+
Site profile.
|
|
501
|
+
|
|
502
|
+
loc_input: :class:`~.base.site.Location`
|
|
503
|
+
Location of the input motion.
|
|
504
|
+
"""
|
|
505
|
+
super().__call__(motion, profile, loc_input)
|
|
506
|
+
|
|
507
|
+
self._estimate_strains()
|
|
508
|
+
|
|
509
|
+
iteration = 0
|
|
510
|
+
# The iteration at which strains were last limited
|
|
511
|
+
limited_iter = -2
|
|
512
|
+
limited_strains = False
|
|
513
|
+
|
|
514
|
+
while iteration < self.max_iterations:
|
|
515
|
+
limited_strains = False
|
|
516
|
+
self._calc_waves(motion.angular_freqs, profile)
|
|
517
|
+
|
|
518
|
+
for index, layer in enumerate(profile[:-1]):
|
|
519
|
+
loc_layer = Location(index, layer, "within", layer.thickness / 2)
|
|
520
|
+
|
|
521
|
+
# Compute the representative strain(s) within the layer. FDM
|
|
522
|
+
# will provide a vector of strains.
|
|
523
|
+
strain = self._calc_strain(loc_input, loc_layer, motion)
|
|
524
|
+
if self._strain_limit and np.any(strain > self._strain_limit):
|
|
525
|
+
limited_strains = True
|
|
526
|
+
strain = np.maximum(strain, self._strain_limit)
|
|
527
|
+
layer.strain = strain
|
|
528
|
+
|
|
529
|
+
# Maximum error (damping and shear modulus) over all layers
|
|
530
|
+
max_error = max(profile.max_error)
|
|
531
|
+
if max_error < self.tolerance:
|
|
532
|
+
break
|
|
533
|
+
|
|
534
|
+
# Break, if the strains were limited the last two iterations.
|
|
535
|
+
if limited_strains:
|
|
536
|
+
if limited_iter == (iteration - 1):
|
|
537
|
+
break
|
|
538
|
+
else:
|
|
539
|
+
limited_iter = iteration
|
|
540
|
+
|
|
541
|
+
iteration += 1
|
|
542
|
+
|
|
543
|
+
# Compute the maximum strain within the profile.
|
|
544
|
+
for index, layer in enumerate(profile[:-1]):
|
|
545
|
+
loc_layer = Location(index, layer, "within", layer.thickness / 2)
|
|
546
|
+
layer.strain_max = self._calc_strain_max(loc_input, loc_layer, motion)
|
|
547
|
+
|
|
548
|
+
def _estimate_strains(self):
|
|
549
|
+
"""Compute an estimate of the strains."""
|
|
550
|
+
# Estimate the strain based on the PGV and shear-wave velocity
|
|
551
|
+
for l in self._profile:
|
|
552
|
+
l.reset()
|
|
553
|
+
l.strain = self._motion.pgv / l.initial_shear_vel
|
|
554
|
+
|
|
555
|
+
@property
|
|
556
|
+
def strain_ratio(self):
|
|
557
|
+
return self._strain_ratio
|
|
558
|
+
|
|
559
|
+
@property
|
|
560
|
+
def tolerance(self):
|
|
561
|
+
return self._tolerance
|
|
562
|
+
|
|
563
|
+
@property
|
|
564
|
+
def max_iterations(self):
|
|
565
|
+
return self._max_iterations
|
|
566
|
+
|
|
567
|
+
@property
|
|
568
|
+
def strain_limit(self):
|
|
569
|
+
return self._strain_limit
|
|
570
|
+
|
|
571
|
+
@classmethod
|
|
572
|
+
def calc_strain_ratio(cls, mag):
|
|
573
|
+
"""Compute the effective strain ratio using Idriss and Sun (1992).
|
|
574
|
+
|
|
575
|
+
Parameters
|
|
576
|
+
----------
|
|
577
|
+
mag: float
|
|
578
|
+
Magnitude of the input motion.
|
|
579
|
+
|
|
580
|
+
Returns
|
|
581
|
+
-------
|
|
582
|
+
strain_ratio : float
|
|
583
|
+
Effective strain ratio
|
|
584
|
+
|
|
585
|
+
References
|
|
586
|
+
----------
|
|
587
|
+
.. [1] Idriss, I. M., & Sun, J. I. (1992). SHAKE91: A computer program
|
|
588
|
+
for conducting equivalent linear seismic response analyses of
|
|
589
|
+
horizontally layered soil deposits. Center for Geotechnical
|
|
590
|
+
Modeling, Department of Civil and Environmental Engineering,
|
|
591
|
+
University of California, Davis, CA.
|
|
592
|
+
"""
|
|
593
|
+
return (mag - 1) / 10
|
|
594
|
+
|
|
595
|
+
def _calc_strain(self, loc_input, loc_layer, motion, *args):
|
|
596
|
+
"""Compute the strain used for iterations of material properties."""
|
|
597
|
+
strain_max = self._calc_strain_max(loc_input, loc_layer, motion, *args)
|
|
598
|
+
return self.strain_ratio * strain_max
|
|
599
|
+
|
|
600
|
+
def _calc_strain_max(self, loc_input, loc_layer, motion, *args):
|
|
601
|
+
"""Compute the effective strain at the center of a layer."""
|
|
602
|
+
return motion.calc_peak(self.calc_strain_tf(loc_input, loc_layer))
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class FrequencyDependentEqlCalculator(EquivalentLinearCalculator):
|
|
606
|
+
"""Class for performing equivalent-linear elastic site response with
|
|
607
|
+
frequency-dependent modulii and damping.
|
|
608
|
+
|
|
609
|
+
Parameters
|
|
610
|
+
----------
|
|
611
|
+
use_smooth_spectrum: bool, default=False
|
|
612
|
+
Use the Kausel & Assimaki (2002) smooth spectrum for the strain.
|
|
613
|
+
Otherwise, the complete Fourier amplitude spectrum is used.
|
|
614
|
+
strain_ratio: float, default=1.00
|
|
615
|
+
ratio between the maximum strain and effective strain used to compute
|
|
616
|
+
strain compatible properties. There is not clear guidance the use of
|
|
617
|
+
the effective strain ratio. However, given the nature of the method,
|
|
618
|
+
it would make sense not to include the an effective strain ratio.
|
|
619
|
+
tolerance: float, default=0.01
|
|
620
|
+
tolerance in the iterative properties, which would cause the iterative
|
|
621
|
+
process to terminate.
|
|
622
|
+
max_iterations: int, default=15
|
|
623
|
+
maximum number of iterations to perform.
|
|
624
|
+
|
|
625
|
+
References
|
|
626
|
+
----------
|
|
627
|
+
.. [1] Kausel, E., & Assimaki, D. (2002). Seismic simulation of inelastic
|
|
628
|
+
soils via frequency-dependent moduli and damping. Journal of
|
|
629
|
+
Engineering Mechanics, 128(1), 34-47.
|
|
630
|
+
"""
|
|
631
|
+
|
|
632
|
+
name = "FDM-KA"
|
|
633
|
+
|
|
634
|
+
def __init__(
|
|
635
|
+
self,
|
|
636
|
+
use_smooth_spectrum=False,
|
|
637
|
+
strain_ratio=1.0,
|
|
638
|
+
tolerance=0.01,
|
|
639
|
+
max_iterations=15,
|
|
640
|
+
):
|
|
641
|
+
"""Initialize the class."""
|
|
642
|
+
super().__init__(strain_ratio, tolerance, max_iterations)
|
|
643
|
+
|
|
644
|
+
# Use the smooth strain spectrum as proposed by Kausel and Assimaki
|
|
645
|
+
self._use_smooth_spectrum = use_smooth_spectrum
|
|
646
|
+
|
|
647
|
+
def _estimate_strains(self):
|
|
648
|
+
"""Estimate the strains by running an EQL site response.
|
|
649
|
+
|
|
650
|
+
This step was recommended in Section 8.3.1 of Zalachoris (2014).
|
|
651
|
+
"""
|
|
652
|
+
eql = EquivalentLinearCalculator()
|
|
653
|
+
eql(self._motion, self._profile, self._loc_input)
|
|
654
|
+
|
|
655
|
+
def _calc_strain(self, loc_input, loc_layer, motion, *args):
|
|
656
|
+
freqs = np.array(motion.freqs)
|
|
657
|
+
strain_tf = self.calc_strain_tf(loc_input, loc_layer)
|
|
658
|
+
strain_fas = np.abs(strain_tf * motion.fourier_amps)
|
|
659
|
+
# Maximum strain in the time domain modified by the effective strain
|
|
660
|
+
# ratio
|
|
661
|
+
strain_eff = self.strain_ratio * motion.calc_peak(strain_tf)
|
|
662
|
+
|
|
663
|
+
if self._use_smooth_spectrum:
|
|
664
|
+
# Equation (8)
|
|
665
|
+
freq_avg = np.trapz(freqs * strain_fas, x=freqs) / np.trapz(strain_fas, x=freqs)
|
|
666
|
+
|
|
667
|
+
# Find the average strain at frequencies less than the average
|
|
668
|
+
# frequency
|
|
669
|
+
# Equation (8)
|
|
670
|
+
mask = freqs < freq_avg
|
|
671
|
+
strain_avg = np.trapz(strain_fas[mask], x=freqs[mask]) / freq_avg
|
|
672
|
+
|
|
673
|
+
# Normalize the frequency and strain by the average values
|
|
674
|
+
freqs /= freq_avg
|
|
675
|
+
strain_fas /= strain_avg
|
|
676
|
+
|
|
677
|
+
# Fit the smoothed model at frequencies greater than the average
|
|
678
|
+
A = np.c_[-freqs[~mask], -np.log(freqs[~mask])]
|
|
679
|
+
a, b = np.linalg.lstsq(A, np.log(strain_fas[~mask]), rcond=None)[0]
|
|
680
|
+
# This is a modification of the published method that ensures a
|
|
681
|
+
# smooth transition in the strain. Make sure the frequencies are zero.
|
|
682
|
+
shape = np.minimum(
|
|
683
|
+
1,
|
|
684
|
+
np.exp(-a * freqs) / np.maximum(np.finfo(float).eps, np.power(freqs, b)),
|
|
685
|
+
)
|
|
686
|
+
strains = strain_eff * shape
|
|
687
|
+
else:
|
|
688
|
+
strains = strain_eff * strain_fas / np.max(strain_fas)
|
|
689
|
+
|
|
690
|
+
return strains
|