Skip to content

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

Open

Description

@jsh9

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:

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:

df, fmax, n, half_n, ref_f_array = _get_freq_interval(input_motion)

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

# 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

# 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.0027

Fix

  1. In _get_freq_interval(), build the frequency array as np.arange(half_n) * df, so element k is the frequency of FFT bin k. With this change the first repro prints 1.0, and the test correlations become 0.99999 (elastic) and 0.99986 (rigid). With the change patched in, the existing tests in test_helper_site_response.py, test_class_ground_motion.py, test_helper_simulations.py, test_class_site_effect_adjustment.py, test_class_simulation.py and test_class_Vs_profile.py still pass.
  2. 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.
  3. In test_linear (both boundaries), raise the thresholds (for example to 0.999), and correct the comment that blames sim.linear().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions