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