Repository navigation
amplify_motion() applies the transfer function one frequency bin too high, because _get_freq_interval() starts its frequency array at df instead of 0 #78
Description
Found while reviewing #74. Pre-existing on main (b764057); #74 keeps the same behavior.
Problem
_get_freq_interval() builds the frequency array as np.linspace(df, fmax, num=half_n), so it starts at df:
PySeismoSoil/PySeismoSoil/helper_site_response.py
Lines 2150 to 2153 in b764057
| half_n = int(np.ceil(n / 2.0)) if n % 2 == 1 else int(n / 2.0 + 1) | |
| fmax = half_n * df | |
| f_array = np.linspace(df, fmax, num=half_n) |
amplify_motion() interpolates the transfer function at these frequencies and uses the result as the single-sided spectrum, whose element 0 is the DC bin:
PySeismoSoil/PySeismoSoil/helper_site_response.py
Line 1625 in b764057
| df, fmax, n, half_n, ref_f_array = _get_freq_interval(input_motion) |
PySeismoSoil/PySeismoSoil/helper_site_response.py
Lines 1656 to 1662 in b764057
| amp_ss_interp = np.interp(ref_f_array, f_array, amp_ss) | |
| phase_ss_interp = np.interp(ref_f_array, f_array, phase_ss) | |
| tf_ss = amp_ss_interp * np.exp(1j * phase_ss_interp) # reconstruction | |
| f_array = ref_f_array | |
| tf_ss[0] = np.real(tf_ss[0]) # see note (1) below |
So FFT bin k (frequency k * df) is multiplied by TF((k + 1) * df). This affects linear_site_resp(), and through it Ground_Motion.amplify() and Ground_Motion.deconvolve(). It also affects Ground_Motion.amplify_by_tf() and Site_Effect_Adjustment.run(), which call amplify_motion() directly.
The tests compare sim.linear() with linear_site_resp(). On main they are test_linear__elastic_boundary() and test_linear__rigid_boundary(); in #74 they are test_linear[elastic] and test_linear[rigid]. Their comment blames sim.linear() for the difference, but sim.linear() is the accurate one; the difference comes from this bug. The rigid threshold is 0.97, and the actual correlation is 0.97297. With the fix below, the two correlations become 0.99999 (elastic) and 0.99986 (rigid).
PySeismoSoil/tests/test_helper_simulations.py
Lines 56 to 60 in b764057
| # Only check correlation (more lenient). Because `sim.linear()` | |
| # re-discretizes soil profiles into finer layers, so numerical errors | |
| # may accumulate over the additional layers. | |
| r_1 = np.corrcoef(result_1[:, 1], result_1_[:, 1]) | |
| assert r_1[0, 1] >= 0.99 |
PySeismoSoil/tests/test_helper_simulations.py
Lines 85 to 86 in b764057
| # rigid cases can lead to higher errors | |
| assert r_2[0, 1] >= 0.97 |
import numpy as np
import PySeismoSoil.helper_simulations as sim
import PySeismoSoil.helper_site_response as sr
# 1. A 1.0 Hz cosine (exactly FFT bin 10) through |TF(f)| = f, zero phase
dt, n = 0.01, 1000 # df = 0.1 Hz
t = np.arange(n) * dt
x = np.cos(2 * np.pi * 1.0 * t)
f = np.linspace(0, 60, 6001)
y = sr.amplify_motion(np.column_stack((t, x)), (f, (f.copy(), np.zeros_like(f))))[0]
print(round(np.abs(y[:, 1]).max(), 4)) # 1.1 (expected 1.0 = |TF(1.0 Hz)|)
print(sr._get_freq_interval(np.column_stack((t, x)))[4][:3])
# [0.1 0.2 0.3] (expected [0. 0.1 0.2], the frequencies of FFT bins 0, 1, 2)
# 2. Both linear solvers vs. the exact frequency-domain solution
vs = np.genfromtxt('tests/files/profile_FKSH14.txt')
acc = np.genfromtxt('tests/files/sample_accel.txt')
n = len(acc)
df = 1 / (n * (acc[1, 0] - acc[0, 0]))
h, Vs, xi, rho = vs[:, 0], vs[:, 1], vs[:, 2], vs[:, 3]
vs_star = Vs * np.sqrt(1 + 2j * xi)
alpha = rho[:-1] * vs_star[:-1] / (rho[1:] * vs_star[1:])
def exact_tf(freqs, boundary): # surface / input, same formulas as linear_tf()
out = []
for fq in freqs:
k = 2 * np.pi * fq / vs_star
A = B = 1 + 0j
for j in range(len(h) - 1):
e = np.exp(1j * k[j] * h[j])
A, B = (0.5 * A * (1 + alpha[j]) * e + 0.5 * B * (1 - alpha[j]) / e,
0.5 * A * (1 - alpha[j]) * e + 0.5 * B * (1 + alpha[j]) / e)
out.append(1 / A if boundary == 'elastic' else 2 / (A + B))
return np.array(out)
FFT = np.fft.fft(acc[:, 1])
f_bins = np.arange((n + 1) // 2) * df # n is odd
for boundary in ['elastic', 'rigid']:
H = exact_tf(f_bins, boundary)
H[0] = 1.0
exact = np.real(np.fft.ifft(FFT * np.concatenate([H, np.conj(H[1:][::-1])])))
for name, r in [
('linear_site_resp', sr.linear_site_resp(vs, acc, boundary=boundary)[0][:, 1]),
('sim.linear', sim.linear(vs, acc, boundary=boundary)[3][:, 1]),
]:
corr = np.corrcoef(r, exact)[0, 1]
peak = np.abs(r).max() / np.abs(exact).max()
print(f'{boundary:8s} {name:17s} corr={corr:.5f} peak ratio={peak:.4f}')
# elastic linear_site_resp corr=0.99791 peak ratio=1.0149
# elastic sim.linear corr=0.99999 peak ratio=1.0005
# rigid linear_site_resp corr=0.96969 peak ratio=1.0472
# rigid sim.linear corr=0.99988 peak ratio=1.0027Fix
- In
_get_freq_interval(), build the frequency array asnp.arange(half_n) * df, so elementkis the frequency of FFT bink. With this change the first repro prints1.0, and the test correlations become 0.99999 (elastic) and 0.99986 (rigid). With the change patched in, the existing tests intest_helper_site_response.py,test_class_ground_motion.py,test_helper_simulations.py,test_class_site_effect_adjustment.py,test_class_simulation.pyandtest_class_Vs_profile.pystill pass. - Add a test like the first repro: a cosine at an exact FFT bin, amplified by a known non-constant transfer function, must come out with amplitude |TF| at that frequency.
- In
test_linear(both boundaries), raise the thresholds (for example to 0.999), and correct the comment that blamessim.linear().
Activity