Skip to content

pysmo.tools.noise

Generate realistic synthetic noise that matches the naturally observed amplitude spectrum.

Examples:

Given the spectral amplitude in observed seismic noise on Earth is not flat (i.e. not consisting of white noise), it makes sense to calculate more realistic noise for things like resolution tests with synthetic data.

In this example, random noise seismograms are generated from three different noise models. These are Peterson's NHNM (red), NLNM (blue), and an interpolated model that lies between the two (green).

peterson example peterson example

Example source code
peterson.py
"""
Example script for pysmo.tools.noise
"""

#!/usr/bin/env python

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from pysmo.tools.noise import generate_noise, peterson
from pysmo.tools.signal import psd


def main(outfile: str = "peterson.png") -> None:
    # Set parameters
    npts: int = 200000  # multiple of 4
    delta = pd.Timedelta(seconds=0.1)
    nperseg = int(npts / 4)
    nfft = int(npts / 2)
    time_in_seconds = np.linspace(0, npts * delta.total_seconds(), npts)

    # Calculate noise models
    low_noise_model = peterson(noise_level=0)
    mid_noise_model = peterson(noise_level=0.5)
    high_noise_model = peterson(noise_level=1)

    # Generate random noise seismograms
    low_noise_seismogram = generate_noise(npts=npts, model=low_noise_model, delta=delta)
    mid_noise_seismogram = generate_noise(npts=npts, model=mid_noise_model, delta=delta)
    high_noise_seismogram = generate_noise(
        npts=npts, model=high_noise_model, delta=delta
    )

    # Calculate power spectral density
    f_low, Pxx_dens_low = psd(low_noise_seismogram, nperseg=nperseg, nfft=nfft)
    f_mid, Pxx_dens_mid = psd(mid_noise_seismogram, nperseg=nperseg, nfft=nfft)
    f_high, Pxx_dens_high = psd(high_noise_seismogram, nperseg=nperseg, nfft=nfft)

    _ = plt.figure(figsize=(13, 9), layout="tight")

    # Plot random high and low noise seismograms
    ax1 = plt.subplot2grid((4, 3), (0, 0))
    plt.plot(time_in_seconds, high_noise_seismogram.data, "r", linewidth=0.2)
    plt.ylabel("Ground Accelaration")
    plt.xlabel("Time [s]")
    plt.xlim(time_in_seconds[0], time_in_seconds[-1])
    xticks = np.arange(
        tmin := time_in_seconds[0], (tmax := time_in_seconds[-1]) + 1, (tmax - tmin) / 5
    )
    ax1.set_xticks(xticks)

    ax2 = plt.subplot2grid((4, 3), (0, 1))
    ax2.set_xticks(xticks)
    plt.plot(time_in_seconds, mid_noise_seismogram.data, "g", linewidth=0.2)
    plt.xlabel("Time [s]")
    plt.xlim(time_in_seconds[0], time_in_seconds[-1])

    ax3 = plt.subplot2grid((4, 3), (0, 2))
    ax3.set_xticks(xticks)
    plt.plot(time_in_seconds, low_noise_seismogram.data, "b", linewidth=0.2)
    plt.xlabel("Time [s]")
    plt.xlim(time_in_seconds[0], time_in_seconds[-1])

    # Plot PSD of noise
    _ = plt.subplot2grid((4, 3), (1, 0), rowspan=3, colspan=3)
    plt.plot(
        1 / f_high,
        10 * np.log10(Pxx_dens_high),
        "r",
        linewidth=0.5,
        label="generated high noise",
    )
    plt.plot(
        1 / f_mid,
        10 * np.log10(Pxx_dens_mid),
        "g",
        linewidth=0.5,
        label="generated medium noise",
    )
    plt.plot(
        1 / f_low,
        10 * np.log10(Pxx_dens_low),
        "b",
        linewidth=0.5,
        label="generated low noise",
    )
    plt.plot(
        high_noise_model.T.total_seconds(),
        high_noise_model.psd,
        color=plt.rcParams["text.color"],
        linewidth=1,
        linestyle="dotted",
        label="NHNM",
    )
    plt.plot(
        mid_noise_model.T.total_seconds(),
        mid_noise_model.psd,
        color=plt.rcParams["text.color"],
        linewidth=1,
        linestyle="dashdot",
        label="Interpolated noise model",
    )
    plt.plot(
        low_noise_model.T.total_seconds(),
        low_noise_model.psd,
        color=plt.rcParams["text.color"],
        linewidth=1,
        linestyle="dashed",
        label="NLNM",
    )
    plt.gca().set_xscale("log")
    plt.xlim(
        low_noise_model.T.total_seconds()[0], low_noise_model.T.total_seconds()[-1]
    )
    plt.xlabel("Period [s]")
    plt.ylabel("Power Spectral Density (dB/Hz)")
    plt.legend()
    plt.savefig(outfile, transparent=True)
    plt.show()


if __name__ == "__main__":
    main()

Classes:

Name Description
NoiseModel

Class to store seismic noise models.

Functions:

Name Description
generate_noise

Generate a random seismogram from a noise model.

peterson

Generate a noise model by interpolating between Peterson's[^1]

NoiseModel dataclass

Class to store seismic noise models.

Parameters:

Name Type Description Default
psd ndarray

Power spectral density of ground acceleration [dB].

(lambda: array([]))()
T TimedeltaIndex

Period.

(lambda: TimedeltaIndex([]))()

Examples:

A NoiseModel freezes its own copy of psd, so the array passed in remains writeable and independent of the stored copy:

>>> import numpy as np
>>> import pandas as pd
>>> from pysmo.tools.noise import NoiseModel
>>> psd = np.array([-150.0, -140.0, -130.0])
>>> T = pd.to_timedelta([1.0, 10.0, 100.0], unit="s")
>>> model = NoiseModel(psd=psd, T=T)
>>> model.psd
array([-150., -140., -130.])
>>> psd[0] = -999.0  # does not affect the NoiseModel's own copy
>>> model.psd[0]
np.float64(-150.0)
>>>
Source code in src/pysmo/tools/noise.py
@dataclass(frozen=True)
class NoiseModel:
    """Class to store seismic noise models.

    Args:
        psd: Power spectral density of ground acceleration [dB].
        T: Period.

    Examples:
        A `NoiseModel` freezes its own copy of `psd`, so the array passed in
        remains writeable and independent of the stored copy:

        ```python
        >>> import numpy as np
        >>> import pandas as pd
        >>> from pysmo.tools.noise import NoiseModel
        >>> psd = np.array([-150.0, -140.0, -130.0])
        >>> T = pd.to_timedelta([1.0, 10.0, 100.0], unit="s")
        >>> model = NoiseModel(psd=psd, T=T)
        >>> model.psd
        array([-150., -140., -130.])
        >>> psd[0] = -999.0  # does not affect the NoiseModel's own copy
        >>> model.psd[0]
        np.float64(-150.0)
        >>>
        ```
    """

    psd: np.ndarray = field(
        default_factory=lambda: np.array([]),
        metadata={"description": "Power spectral density of ground acceleration [dB]."},
    )
    T: pd.TimedeltaIndex = field(
        default_factory=lambda: pd.TimedeltaIndex([]),
        metadata={"description": "Period."},
    )

    def __post_init__(self) -> None:
        if np.size(self.psd) != np.size(self.T):
            raise ValueError(
                f"psd ({np.size(self.psd)}) and T ({np.size(self.T)}) arrays are not of same size"
            )
        # Freeze a copy rather than the caller's own array, so constructing a
        # NoiseModel has no side effect on the array the caller passed in.
        # T needs no equivalent treatment: unlike a plain ndarray, a
        # pd.TimedeltaIndex's underlying data is already read-only.
        psd = np.array(self.psd, copy=True)
        psd.flags.writeable = False
        object.__setattr__(self, "psd", psd)

generate_noise

generate_noise(
    model: NoiseModel,
    npts: int,
    delta: Timedelta = delta,
    begin_time: Timestamp = begin_time,
    return_velocity: bool = False,
    seed: int | None = None,
) -> MiniSeismogram

Generate a random seismogram from a noise model.

The amplitude spectrum is prescribed by the noise model and random phases are drawn uniformly from [-π, π]. The combined spectrum is transformed back to the time domain via an inverse FFT. Internally the computation is performed on the next power-of-two length greater than or equal to npts to ensure an efficient FFT; the central npts samples are then extracted from the result to avoid edge artefacts near the start and end of the generated buffer.

Parameters:

Name Type Description Default
model NoiseModel

Noise model used to compute seismic noise.

required
npts int

Number of samples in the output seismogram.

required
delta Timedelta

Sampling interval of the generated noise.

delta
begin_time Timestamp

Begin time of the output seismogram.

begin_time
return_velocity bool

If True, integrate the acceleration spectrum (division by in the frequency domain) to return ground velocity instead.

False
seed int | None

Random seed for reproducibility (e.g. in tests).

None

Raises:

Type Description
ValueError

If npts is not a positive integer.

Returns:

Type Description
MiniSeismogram

Seismogram containing the generated noise. Data represent ground

MiniSeismogram

acceleration (arbitrary units matching the noise model's PSD) unless

MiniSeismogram

return_velocity=True, in which case they represent ground velocity.

Examples:

>>> import pandas as pd
>>> from pysmo import MiniSeismogram
>>> from pysmo.tools.noise import peterson, generate_noise
>>> model = peterson(0.0)
>>> noise = generate_noise(
...     model=model, npts=64, delta=pd.Timedelta(seconds=1.0), seed=42
... )
>>> isinstance(noise, MiniSeismogram)
True
>>> len(noise.data)
64
>>> noise.data[:3]
array([-4.20418403e-09, -1.25152943e-08, -8.42501771e-09])
>>>
Source code in src/pysmo/tools/noise.py
def generate_noise(
    model: NoiseModel,
    npts: int,
    delta: pd.Timedelta = SeismogramDefaults.delta,
    begin_time: pd.Timestamp = SeismogramDefaults.begin_time,
    return_velocity: bool = False,
    seed: int | None = None,
) -> MiniSeismogram:
    """Generate a random seismogram from a noise model.

    The amplitude spectrum is prescribed by the noise model and random phases
    are drawn uniformly from `[-π, π]`. The combined spectrum is transformed
    back to the time domain via an inverse FFT. Internally the computation is
    performed on the next power-of-two length greater than or equal to `npts`
    to ensure an efficient FFT; the central `npts` samples are then extracted
    from the result to avoid edge artefacts near the start and end of the
    generated buffer.

    Args:
        model: Noise model used to compute seismic noise.
        npts: Number of samples in the output seismogram.
        delta: Sampling interval of the generated noise.
        begin_time: Begin time of the output seismogram.
        return_velocity: If `True`, integrate the acceleration spectrum
            (division by `iω` in the frequency domain) to return ground
            velocity instead.
        seed: Random seed for reproducibility (e.g. in tests).

    Raises:
        ValueError: If `npts` is not a positive integer.

    Returns:
        Seismogram containing the generated noise. Data represent ground
        acceleration (arbitrary units matching the noise model's PSD) unless
        `return_velocity=True`, in which case they represent ground velocity.

    Examples:
        ```python
        >>> import pandas as pd
        >>> from pysmo import MiniSeismogram
        >>> from pysmo.tools.noise import peterson, generate_noise
        >>> model = peterson(0.0)
        >>> noise = generate_noise(
        ...     model=model, npts=64, delta=pd.Timedelta(seconds=1.0), seed=42
        ... )
        >>> isinstance(noise, MiniSeismogram)
        True
        >>> len(noise.data)
        64
        >>> noise.data[:3]
        array([-4.20418403e-09, -1.25152943e-08, -8.42501771e-09])
        >>>
        ```
    """
    if npts < 1:
        raise ValueError(f"npts={npts} must be a positive integer.")

    dt = delta.total_seconds()

    # Next power of 2 of the number of points (and at least 2, so the FFT is
    # always well defined), for an efficient FFT.
    NPTS = max(int(2 ** np.ceil(np.log2(npts))), 2)

    # Frequencies from DC to Nyquist for a real signal of length NPTS.
    freqs = np.fft.rfftfreq(NPTS, d=dt)

    # Period corresponding to each frequency; DC has no period and is handled
    # separately below (amplitude forced to zero there).
    periods = np.empty_like(freqs)
    periods[0] = np.inf
    periods[1:] = 1 / freqs[1:]

    T = model.T.total_seconds()
    if periods[1:].min() < T.min() or periods[1:].max() > T.max():
        warnings.warn(
            "Requested frequencies extend beyond the noise model's tabulated "
            "period range; extrapolating with the nearest tabulated value.",
            stacklevel=2,
        )

    # Interpolate psd in log-period space: Peterson's model is a piecewise
    # power law, i.e. linear in log10(period), not in period itself.
    Pxx = np.interp(np.log10(periods), np.log10(T), model.psd)

    # Recreate the amplitude spectrum, with the DC term set to 0 (mean=0).
    # Pxx is a one-sided PSD (Peterson's convention, matching
    # pysmo.tools.signal.psd's scipy.signal.welch default), which doubles the
    # power of every interior bin to account for the folded negative
    # frequencies. Since only the positive-frequency half is synthesised here,
    # the target power must be halved to compensate, otherwise a one-sided
    # PSD estimate of the generated noise comes out 3 dB too high.
    amplitude = np.sqrt(10 ** (Pxx / 10) * NPTS / (2 * dt))
    amplitude[0] = 0.0

    # Phase is randomly generated; DC and Nyquist are forced real, as
    # required for a Hermitian-symmetric spectrum representing a real signal.
    rng = np.random.default_rng(seed)
    phase = (rng.random(freqs.size) - 0.5) * np.pi * 2
    phase[0] = 0.0
    phase[-1] = 0.0

    spectrum = amplitude * (np.cos(phase) + 1j * np.sin(phase))

    if return_velocity:
        # Integrate acceleration to velocity in the frequency domain
        # (division by iω), rather than the time domain: a cumulative
        # time-domain integrator is only marginally stable and introduces
        # unbounded low-frequency drift.
        omega = 2 * np.pi * freqs
        velocity_spectrum = np.zeros_like(spectrum)
        velocity_spectrum[1:] = spectrum[1:] / (1j * omega[1:])
        spectrum = velocity_spectrum

    result = np.fft.irfft(spectrum, n=NPTS)

    start = (NPTS - npts) // 2
    end = start + npts
    return MiniSeismogram(begin_time=begin_time, delta=delta, data=result[start:end])

peterson

peterson(noise_level: float) -> NoiseModel

Generate a noise model by interpolating between Peterson's1 New Low Noise Model (NLNM) and New High Noise Model (NHNM).


  1. Peterson, Jon R. Observations and Modeling of Seismic Background Noise. Report, 93–322, 1993, https://doi.org/10.3133/ofr93322. USGS Publications Warehouse. 

Parameters:

Name Type Description Default
noise_level float

Determines the noise level of the generated noise model. A noise level of 0 returns the NLNM, 1 returns the NHNM, and anything > 0 and < 1 returns an interpolated model that lies between the NLNM and NHNM.

required

Returns:

Type Description
NoiseModel

Noise model.

Examples:

>>> from pysmo.tools.noise import peterson, NLNM, NHNM
>>> peterson(0.0) == NLNM
True
>>> peterson(1.0) == NHNM
True
>>> model = peterson(0.5)
>>> model.psd[0]  # midpoint of NLNM and NHNM at the shortest period
np.float64(-129.75)
>>>
Source code in src/pysmo/tools/noise.py
def peterson(noise_level: float) -> NoiseModel:
    """Generate a noise model by interpolating between Peterson's[^1]
    New Low Noise Model (NLNM) and New High Noise Model (NHNM).

    [^1]: Peterson, Jon R. Observations and Modeling of Seismic Background
        Noise. Report, 93–322, 1993, https://doi.org/10.3133/ofr93322. USGS
        Publications Warehouse.

    Args:
        noise_level: Determines the noise level of the generated noise model.
            A noise level of 0 returns the NLNM, 1 returns the NHNM, and
            anything > 0 and < 1 returns an interpolated model that lies
            between the NLNM and NHNM.

    Returns:
        Noise model.

    Examples:
        ```python
        >>> from pysmo.tools.noise import peterson, NLNM, NHNM
        >>> peterson(0.0) == NLNM
        True
        >>> peterson(1.0) == NHNM
        True
        >>> model = peterson(0.5)
        >>> model.psd[0]  # midpoint of NLNM and NHNM at the shortest period
        np.float64(-129.75)
        >>>
        ```
    """
    # check for valid input
    if not 0 <= noise_level <= 1:
        raise ValueError(
            f"Parameter noise_level={noise_level} is not within 0-1 range."
        )

    # calculate noise model
    if noise_level == 0:
        return NLNM
    elif noise_level == 1:
        return NHNM
    else:
        T_common = np.unique(
            np.concatenate((NLNM.T.total_seconds(), NHNM.T.total_seconds()))
        )
        # Peterson's model is a piecewise power law, i.e. linear in
        # log10(period), not in period itself, so interpolate in log-period
        # space to stay close to the true curve between tabulated points.
        log_T_common = np.log10(T_common)
        NLNM_interp = np.interp(
            log_T_common, np.log10(NLNM.T.total_seconds()), NLNM.psd
        )
        NHNM_interp = np.interp(
            log_T_common, np.log10(NHNM.T.total_seconds()), NHNM.psd
        )
        dB = NLNM_interp + (NHNM_interp - NLNM_interp) * noise_level
        return NoiseModel(psd=dB, T=pd.to_timedelta(T_common, unit="s"))