Source code for pynetdesign.modelling.utils

import numpy as np
import pandas as pd
import inspect
from fractions import Fraction
from math import gcd
from typing import Union, Optional
import warnings

VALID_WAVE_MODES = ("P", "SV", "SH", "S", "PS")
S_WAVE_PHASES = ("SV", "SH")

# Root-mean-square radiation-pattern magnitudes over the focal sphere of a
# double-couple source, after Boore and Boatwright (1984) and Hallo and Eisner
# (2013). Note that the frequently quoted value sqrt(2/5) ~ 0.63 is the combined
# S-wave magnitude, since <R_SV^2> + <R_SH^2> = 2/5, and therefore does not apply
# to SV or SH individually.
RAD_PATTERN_P_DEFAULT = float(np.sqrt(4.0 / 15.0))    # approx 0.516
RAD_PATTERN_SV_DEFAULT = float(np.sqrt(7.0 / 30.0))   # approx 0.483
RAD_PATTERN_SH_DEFAULT = float(np.sqrt(1.0 / 6.0))    # approx 0.408


[docs] def normalize_wave_mode(wave_mode: str) -> str: r""" Return the canonical form of a wave mode. ``'S'`` is a composite mode representing the better recorded of the two isotropic S polarizations, SV and SH. Parameters ---------- wave_mode : :obj:`str` Wave mode, one of ``'P'``, ``'SV'``, ``'SH'``, ``'S'`` or ``'PS'``, in any letter case and with surrounding whitespace ignored. Returns ------- mode : :obj:`str` Canonical upper-case wave mode. Raises ------ ValueError If ``wave_mode`` is not one of the supported modes. """ mode = str(wave_mode).strip().upper() if mode not in VALID_WAVE_MODES: raise ValueError( f"Invalid wave_mode '{wave_mode}'. Must be one of {VALID_WAVE_MODES}." ) return mode
[docs] def wave_mode_phases(wave_mode: str) -> tuple: r""" Expand a wave mode into the physical phases that must be evaluated. Parameters ---------- wave_mode : :obj:`str` Wave mode, see :func:`normalize_wave_mode`. Returns ------- phases : :obj:`tuple` of :obj:`str` Phases to evaluate, drawn from ``'P'``, ``'SV'`` and ``'SH'``. """ mode = normalize_wave_mode(wave_mode) if mode == "P": return ("P",) if mode in S_WAVE_PHASES: return (mode,) if mode == "S": return S_WAVE_PHASES return ("P", *S_WAVE_PHASES)
[docs] def wave_mode_has_p(wave_mode: str) -> bool: r"""Return whether ``wave_mode`` includes the P branch.""" return "P" in wave_mode_phases(wave_mode)
[docs] def wave_mode_s_phases(wave_mode: str) -> tuple: r"""Return the S-family phases included in ``wave_mode``.""" return tuple(phase for phase in wave_mode_phases(wave_mode) if phase in S_WAVE_PHASES)
[docs] def wave_mode_has_s(wave_mode: str) -> bool: r"""Return whether ``wave_mode`` includes any S-family branch.""" return bool(wave_mode_s_phases(wave_mode))
[docs] def phase_radiation_pattern(phase: str) -> float: r""" Return the root-mean-square radiation-pattern magnitude of a phase. The values are averages over the focal sphere of a double-couple source :cite:p:`BooreBoatwright1984`, as used for microseismicity with random source orientations :cite:p:`HalloEisner2013`: .. math:: R_P = \sqrt{4/15} \approx 0.52, \quad R_{SV} = \sqrt{7/30} \approx 0.48, \quad R_{SH} = \sqrt{1/6} \approx 0.41. Parameters ---------- phase : :obj:`str` Phase, one of ``'P'``, ``'SV'`` or ``'SH'``. Returns ------- rad_pattern : :obj:`float` Radiation-pattern magnitude of the phase. Raises ------ ValueError If ``phase`` is a composite mode rather than a single phase. Notes ----- The value :math:`\sqrt{2/5} \approx 0.63` often quoted for S-waves is the magnitude of the *combined* S-wave amplitude, since :math:`\langle R_{SV}^2 \rangle + \langle R_{SH}^2 \rangle = 2/5`, and is therefore not applicable to SV or SH individually. """ phase = normalize_wave_mode(phase) if phase == "P": return RAD_PATTERN_P_DEFAULT if phase == "SV": return RAD_PATTERN_SV_DEFAULT if phase == "SH": return RAD_PATTERN_SH_DEFAULT raise ValueError("Radiation pattern is only defined for P, SV, and SH.")
[docs] def resolve_radiation_pattern(phase: str, rad_pattern_p: float = None, rad_pattern_s: float = None, rad_patterns: dict = None) -> float: r""" Resolve the radiation-pattern magnitude to use for a phase. Overrides take precedence over the root-mean-square defaults of :func:`phase_radiation_pattern`, which makes it possible to reproduce results published with other conventions, for instance the combined S-wave value :math:`R_S = 0.63`. Parameters ---------- phase : :obj:`str` Phase, one of ``'P'``, ``'SV'`` or ``'SH'``. rad_pattern_p : :obj:`float`, optional Override for the P phase. If ``None``, the default is used. rad_pattern_s : :obj:`float`, optional Override applied to both S phases. If ``None``, the defaults are used. rad_patterns : :obj:`dict`, optional Per-phase overrides, e.g. ``{'SV': 0.63}``. Takes precedence over ``rad_pattern_p`` and ``rad_pattern_s``. Returns ------- rad_pattern : :obj:`float` Radiation-pattern magnitude to use. Raises ------ ValueError If a resolved value is not in the interval (0, 1]. """ phase = normalize_wave_mode(phase) value = None if rad_patterns: normalized = {normalize_wave_mode(key): val for key, val in rad_patterns.items()} value = normalized.get(phase) if value is None: if phase == 'P': value = rad_pattern_p else: value = rad_pattern_s if value is None: return phase_radiation_pattern(phase) value = float(value) if not 0 < value <= 1: raise ValueError(f"Invalid radiation pattern factor for {phase}: {value}.") return value
[docs] def calculate_Mw(M0): r""" Calculates the moment magnitude :math:`M_w` according to :ref:`Kanamori (1977) <Kanamori1977_calculate_Mw>` using formula .. math:: M_w = \frac{2}{3}\left(\log_{10}(M_0)-9.1\right) where :math:`M_0` is the seismic moment. Parameters ---------- M0 : array_like seismic moment References ---------- .. _Kanamori1977_calculate_Mw: Kanamori, H. (1977). The energy release in great earthquakes. Journal of Geophysical Research, 82(20), 2981–2987. https://doi.org/10.1029/jb082i020p02981 Returns ------- Mw : array_like moment magnitude Notes ----- Replaces all non-positive values of M0 by nan """ # Ensure input is a NumPy array M0 = np.asarray(M0) # Replace all non-positive values with NaN M0 = np.where(M0 <= 0, np.nan, M0) return 2/3 * (np.log10(M0) - 9.1)
[docs] def calculate_seismic_moment(Mw): r""" Calculates the seismic moment :math:`M_0` from moment magnitude :math:`M_w` according to :ref:`Kanamori (1977) <Kanamori1977_calculate_M0>` .. math:: M_0 = 10^{\frac{3}{2} M_w + 9.1} where :math:`M_w` is the moment magnitude. Parameters ---------- Mw : array_like moment magnitude References ---------- .. _Kanamori1977_calculate_M0: Kanamori, H. (1977). The energy release in great earthquakes. Journal of Geophysical Research, 82(20), 2981–2987. https://doi.org/10.1029/jb082i020p02981 Returns ------- M0 : array_like seismic moment """ # Ensure input is a NumPy array Mw = np.asarray(Mw) # Compute moment M0 = 10**((3/2)*Mw + 9.1) return M0
[docs] def reshape_data(data: np.ndarray, x: np.ndarray, y: np.ndarray): r""" Reshape data to 2D array using axes vectors. Parameters ---------- data : :obj:`numpy.ndarray` 1D data array to be reshaped x : :obj:`numpy.ndarray` x axis vector y : :obj:`numpy.ndarray` y axis vector Returns ------- reshaped_data : :obj:`numpy.ndarray` 2D reshaped data array Raises ------ ValueError when data array is not 1D """ if data.ndim!=1: raise ValueError("Input data array must be 1D!") else: return np.reshape(data, (len(x), len(y)))
[docs] def generate_grid(x: np.ndarray, y: np.ndarray, z: np.ndarray): r""" From the grid vectors x,y,z generates an array of grid point coordinates of size (nx*ny*nz,3). Parameters ---------- x : :obj:`numpy.ndarray` x axis grid vector y : :obj:`numpy.ndarray` y axis grid vector z : :obj:`numpy.ndarray` z axis grid vector Returns ------- grid_points : :obj:`numpy.ndarray` 2D grid coordinates array """ # Generate the grid points X, Y, Z = np.meshgrid(x, y, z, indexing='ij') # Flatten the grid points into an array of coordinates grid_points = np.vstack([X.ravel(), Y.ravel(), Z.ravel()]).T return grid_points
[docs] def reshape_grid(grid_array: np.ndarray, nx: int, ny: int, nz: int): r""" Reshape a flat grid array into a multidimensional array based on the provided grid vectors. Parameters ---------- grid_array : :obj:`numpy.ndarray` The flat grid array to reshape. The first dimension should match the total number of grid points. nx : :obj:`numpy.int` Number of grid points along the x axis. ny : :obj:`numpy.int` Number of grid points along the y axis. nz : :obj:`numpy.int` Number of grid points along the z axis. Raises ------ ValueError : If the number of grid points does not match the product of nx, ny, and nz. Returns ------- reshaped_array : :obj:`numpy.ndarray` The reshaped array with shape (nx, ny, nz, ...), where "..." corresponds to the remaining dimensions of the input array. """ # Calculate the target shape target_shape = (nx, ny, nz) + grid_array.shape[1:] # Reshape the array try: reshaped_array = grid_array.reshape(target_shape) except ValueError: raise ValueError( f"Cannot reshape grid_array with shape {grid_array.shape} to target shape {target_shape}. " "Ensure the number of grid points matches the product of nx, ny, and nz." ) return reshaped_array
[docs] def are_points_inside(grid_coords1, grid_coords2): r"""Check if all points in ``grid_coords1`` are inside ``grid_coords2``. This function checks whether all points in the array ``grid_coords1`` are also present in the array ``grid_coords2``. Both arrays should have a shape of ``(n, 3)``, where each row represents a 3D coordinate. Parameters ---------- grid_coords1 : :obj:`numpy.ndarray` A 2D array of shape (n_grid1, 3) representing the points to check. grid_coords2 : :obj:`numpy.ndarray` A 2D array of shape (n_grid2, 3) representing the reference points. Returns ------- :obj:`bool` ``True`` if all points in ``grid_coords1`` are found in ``grid_coords2``, ``False`` otherwise. Raises ------ ValueError If ``grid_coords1`` or ``grid_coords2`` does not have the required shape of ``(n, 3)``. Examples -------- >>> grid_coords1 = np.array([[1, 2, 3], [4, 5, 6]]) >>> grid_coords2 = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) >>> are_points_inside(grid_coords1, grid_coords2) True >>> grid_coords1 = np.array([[1, 2, 3], [10, 11, 12]]) >>> grid_coords2 = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) >>> are_points_inside(grid_coords1, grid_coords2) False """ # Validate input shapes if grid_coords1.shape[1] != 3 or grid_coords2.shape[1] != 3: raise ValueError("Both input arrays must have a shape of (n, 3).") # Convert grid_coords2 to a set of tuples for efficient lookup grid_coords2_set = set(map(tuple, grid_coords2)) # Check if all points in grid_coords1 are in grid_coords2 return all(tuple(point) in grid_coords2_set for point in grid_coords1)
[docs] def find_indices_of_points(grid_coords1, grid_coords2): r"""Find indices of points in ``grid_coords1`` that are inside ``grid_coords2``. This function finds the indices of points in ``grid_coords1`` that are also present in ``grid_coords2``. Parameters ---------- grid_coords1 : :obj:`numpy.ndarray` A 2D array of shape ``(n_grid1, 3)`` representing the points to check. grid_coords2 : :obj:`numpy.ndarray` A 2D array of shape ``(n_grid2, 3)`` representing the reference points. Returns ------- indices : :obj:`numpy.ndarray` An array of indices corresponding to the points in ``grid_coords1`` that are found in ``grid_coords2``. Raises ------ ValueError If ``grid_coords1`` or ``grid_coords2`` does not have the required shape of ``(n, 3)``. Examples -------- >>> grid_coords1 = np.array([[1, 2, 3], [4, 5, 6], [10, 11, 12]]) >>> grid_coords2 = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) >>> find_indices_of_points(grid_coords1, grid_coords2) array([0, 1]) """ # Validate input shapes if grid_coords1.shape[1] != 3 or grid_coords2.shape[1] != 3: raise ValueError("Both input arrays must have a shape of (n, 3).") # Convert grid_coords2 to a set of tuples for efficient lookup grid_coords2_set = set(map(tuple, grid_coords2)) # Find indices of points in grid_coords1 that are in grid_coords2 indices = [i for i, point in enumerate(grid_coords1) if tuple(point) in grid_coords2_set] return np.array(indices, dtype=int)
[docs] def get_slice_coordinates(grid_points: np.ndarray, slice_dimension: int, slice_coordinate: float): r""" From the array of grid point coordinates of size (nx*ny*nz,3) retrieve coordinates of the slice plane at a given slice coordinate in a given slice dimension. Parameters ---------- grid_points : :obj:`numpy.ndarray` 2D grid coordinates array dimension : :obj:`int` integer defining the desired slice dimension (0,1 or 2) slice_coordinate : :obj:`numpy.ndarray` coordinate of the slice to extract in the desired dimension Returns ------- plane_points : :obj:`numpy.ndarray` 2D slice plane coordinates array indices : :obj:`numpy.ndarray` Indices of plane_points in grid_points Raises ------ ValueError when slice_dimension is not 0, 1 or 2 RuntimeError when slice can't be extracted (slice coordinate does not coincide with the grid) """ if slice_dimension not in range(3): raise ValueError("Slice dimension must be 0,1 or 2. Wrong dimension: " + str(slice_dimension)) # Find indices where the slice condition is true indices = np.where(grid_points[:, slice_dimension] == slice_coordinate)[0] # Get coordinates of the slice plane using the indices plane_points = grid_points[indices] # Check output if plane_points.size == 0: raise RuntimeError("Slice could not be extracted, check slice coordinate: " + str(slice_coordinate)) return plane_points, indices
[docs] def nth_minimum(arr: np.ndarray, n: int) -> np.ndarray: r""" Find the n-th minimum value in each row of a given 2D numpy array. Parameters ---------- arr : :obj:`numpy.ndarray` A 2D numpy array of shape (i, j) where i is the number of rows (features) and j is the number of columns (samples). n : :obj:`int` The order of the minimum value to find (1-based index). For example, if n=2, the function finds the 2nd minimum value in each row. Returns ------- nth_min_values : :obj:`numpy.ndarray` A 1D numpy array of shape (i,) containing the n-th minimum value from each row. Raises ------ ValueError If n is less than 1 or greater than the number of columns in the array. """ if n < 1 or n > arr.shape[1]: raise ValueError("n must be between 1 and the number of columns in the array.") # Partially sort each row to get the n-th minimum value partitioned_arr = np.partition(arr, n-1, axis=1) # Extract the n-th minimum value (zero-based index, so n-1) nth_min_values = partitioned_arr[:, n-1] return nth_min_values
[docs] def check_zero_values(a: float, ErrorMessage: str=None): r""" Check if the input value is close to zero and raise an error if true. Parameters ---------- a : :obj:`float` or array_like The input value or values to be checked. ErrorMessage : :obj:`str` , optional Custom error message to be raised if the value is close to zero. Default is "Array values are close to zero". Returns ------- bool Returns True if the value is not close to zero. Raises ------ RuntimeError If the value is close to zero, raises a RuntimeError with the provided or default error message. Examples -------- >>> check_zero_values(0.0000001) RuntimeError: Array values are close to zero >>> check_zero_values(1.0) True """ if ErrorMessage is None: ErrorMessage = "Array values are close to zero" if np.any(np.isclose(a,0)): raise RuntimeError(ErrorMessage) else: return True
[docs] def contains_only_numeric(seq): r""" Check if a sequence (tuple, list, or numpy array) contains only numeric values. Parameters ---------- seq : :obj:`tuple`, :obj:`list`, or :obj:`numpy.ndarray` The sequence to be checked. Returns ------- bool True if all elements in the sequence are numeric (int, float, or complex), False otherwise. Examples -------- >>> t1 = (1, 2.5, 3+4j) >>> contains_only_numeric(t1) True >>> l1 = [1, 2.5, 3] >>> contains_only_numeric(l1) True >>> a1 = np.array([1, 2.5, 3+4j]) >>> contains_only_numeric(a1) True >>> t2 = (1, 'a', 2.5) >>> contains_only_numeric(t2) False """ if isinstance(seq, (tuple, list, np.ndarray)): return all(isinstance(i, (int, float, complex)) for i in seq) else: raise TypeError("Input must be a tuple, list, or numpy.ndarray")
[docs] def compute_gcd_of_floats(rx, ry, rz): r""" Computes the greatest common divisor (GCD) of three positive float values. Parameters ---------- rx : :obj:`float` First float value. ry : :obj:`float` Second float value. rz : :obj:`float` Third float value. Returns ------- :obj:`float` The GCD of the three float values. """ # Convert floats to Fractions for exact arithmetic frac_rx = Fraction(rx).limit_denominator() frac_ry = Fraction(ry).limit_denominator() frac_rz = Fraction(rz).limit_denominator() # Extract numerators and denominators num_rx, den_rx = frac_rx.numerator, frac_rx.denominator num_ry, den_ry = frac_ry.numerator, frac_ry.denominator num_rz, den_rz = frac_rz.numerator, frac_rz.denominator # Compute GCD of numerators gcd_num = gcd(gcd(num_rx, num_ry), num_rz) # Compute LCM of denominators def lcm(a, b): return abs(a * b) // gcd(a, b) lcm_den = lcm(den_rx, lcm(den_ry, den_rz)) # Compute GCD as a Fraction gcd_fraction = Fraction(gcd_num, lcm_den) # Return the GCD as a float return float(gcd_fraction)
[docs] def get_scaling_displacement(amps_type: str, f: Optional[Union[float, np.ndarray]]): r""" Get scaling coefficient(s) to convert to the amplitudes to displacement provided the type of original amplitudes and the frequency (or array of frequencies). If f is an array and is used for computation of scaling, output is also an array of the same size. Parameters ---------- amps_type : :obj:`str` Type of amplitudes, can be 'displacement', 'velocity', 'acceleration', 'strain rate', 'strain'. f : :obj:`float` or :obj:`np.ndarray` Frequency or array of frequencies. Raises ------ ValueError If ``amps_type`` is not one of the supported types. Returns ------- scaling : :obj:`float` or :obj:`np.ndarray` Resulting scaling coefficient. """ fscaling = 2 * np.pi * f if amps_type == 'displacement': scaling = 1 elif amps_type == 'velocity': scaling = 1 / fscaling elif amps_type == 'acceleration': scaling = 1 / fscaling**2 elif amps_type == 'strain rate': scaling = 1 / fscaling elif amps_type == 'strain': scaling = 1 else: raise ValueError(f"Unsupported amplitude type: {amps_type}") return scaling
[docs] def retrieve_min_amps(geometry_df: pd.DataFrame, SNR_p: float, SNR_s: float, f_p: float=None, f_s: float=None, sample_interval: float=None): r""" Retrieve minimum amplitudes for P and S waves from geometry data, scaled based on noise type. This function takes geometry data frame with noise levels, then scales these noise levels based on the noise type to displacement using the provided frequencies for P and S waves (optional), and applies SNR scaling for P and S waves. Parameters ---------- geometry_df : :obj:`pandas.DataFrame` The path to the input CSV (ASCII) file containing the geometry data. SNR_p : :obj:`float` Signal-to-Noise Ratio for P waves SNR_s : :obj:`float` Signal-to-Noise Ratio for S waves f_p: :obj:`float`, optional, default: ``None`` Frequency of the P-wave. If ``None``, scaling is postponed to be later done with peak frequency f_s: :obj:`float`, optional, default: ``None`` Frequency of the S-wave. If ``None``, scaling is postponed to be later done with peak frequency sample_interval : :obj:`float` Interval along the cable at which strain measurements are made Returns ------- min_amps_p : :obj:`numpy.ndarray` Minimum displacement amplitudes for P waves min_amps_s : :obj:`numpy.ndarray` Minimum displacement amplitudes for S waves Raises ------ ValueError If an unsupported noise type is encountered. ValueError If the 'NoiseLevel' column is missing in geometry_df. ValueError If the sample_interval is None for strain and strain rate noise types """ # Check if 'NoiseLevel' column exists if 'NoiseLevel' not in geometry_df.columns: raise ValueError("The 'NoiseLevel' column is missing from the geometry data.") # Get the noise levels and type noise_levels = geometry_df['NoiseLevel'].to_numpy() noise_type = geometry_df.attrs.get('noise_type') # Check sample_interval if (noise_type == 'strain rate' or noise_type == 'strain') and sample_interval is None: raise ValueError(f"Sample interval must be provided for {noise_type} data type." ) # Scale noise levels to displacement based on noise type if f_p is None: scaling_p = 1 # scaling will be done later using peak frequency else: scaling_p = get_scaling_displacement(amps_type=noise_type,f=f_p) if f_s is None: scaling_s = 1 # scaling will be done later using peak frequency else: scaling_s = get_scaling_displacement(amps_type=noise_type,f=f_s) # Apply sample interval for strain and strain rate if noise_type == 'strain rate' or noise_type == 'strain': scaling_p *= sample_interval scaling_s *= sample_interval # Calculate final minimum amplitudes for P and S waves by applying scaling and SNR min_amps_p = noise_levels * scaling_p * SNR_p min_amps_s = noise_levels * scaling_s * SNR_s return min_amps_p, min_amps_s
[docs] def mag_detectable(Mw_min_stations: np.ndarray, min_stations_p: int = None, min_stations_s: int = None, wave_mode: str = 'PS'): r""" Computes detectable minimum magnitudes from magnitude sensitivities for individual stations with respect to number of stations necessary for event detection using P and/or S waves. Parameters ---------- Mw_min_stations : :obj:`numpy.ndarray` Array of computed minimum magnitudes for individual stations min_stations_p : :obj:`int`, optional, default: 3 Minimum number of stations on which event must be detected with P waves min_stations_s : :obj:`int`, optional, default: 3 Minimum number of stations on which event must be detected with S waves wave_mode: :obj:`str`, optional, default: 'PS' Wave mode to use, can be 'P', 'SV', 'SH', 'S' or 'PS' Returns ------- Mw_min_grid : :obj:`numpy.ndarray` Array of detectable minimum magnitudes on the grid Raises ------ ValueError : if minimum number of stations on which event must be detected with P or S waves is less than 1 or exceeds number of stations """ wave_mode = normalize_wave_mode(wave_mode) if min_stations_p is None: min_stations_p = 3 if min_stations_s is None: min_stations_s = 3 # Number of stations is the last axis of the (2, n_grid_points, n_stations) array check_min_stations(min_stations_p=min_stations_p, min_stations_s=min_stations_s, n_stations=Mw_min_stations.shape[2], wave_mode=wave_mode) # Compute final Mw_min_grid based on wave mode if wave_mode == 'P': # For P waves, find the nth smallest value (where n = min_stations_p) Mw_min_grid = np.partition(Mw_min_stations[0], min_stations_p - 1, axis=1)[:, min_stations_p - 1] elif wave_mode in ('S', 'SV', 'SH'): # For S waves, find the nth smallest value (where n = min_stations_s) Mw_min_grid = np.partition(Mw_min_stations[1], min_stations_s - 1, axis=1)[:, min_stations_s - 1] else: # wave_mode == 'PS' # For both P and S waves, find the maximum of the nth smallest values for each Mw_min_grid_p = np.partition(Mw_min_stations[0], min_stations_p - 1, axis=1)[:, min_stations_p - 1] Mw_min_grid_s = np.partition(Mw_min_stations[1], min_stations_s - 1, axis=1)[:, min_stations_s - 1] Mw_min_grid = np.maximum(Mw_min_grid_p, Mw_min_grid_s) return Mw_min_grid
[docs] def check_min_stations(min_stations_p: int, min_stations_s: int, n_stations: int, wave_mode: str = 'PS'): r""" Check that the minimum numbers of stations required for detection are valid. Parameters ---------- min_stations_p : :obj:`int` Minimum number of stations on which event must be detected with P waves. min_stations_s : :obj:`int` Minimum number of stations on which event must be detected with S waves. n_stations : :obj:`int` Total number of stations in the geometry. wave_mode : :obj:`str`, optional, default: ``'PS'`` Wave mode to use, can be 'P', 'SV', 'SH', 'S' or 'PS'. Raises ------ ValueError If a minimum number of stations is not positive or exceeds the number of stations. Notes ----- Each of ``min_stations_p`` and ``min_stations_s`` is checked only if it is not ``None`` and only if the corresponding branch is part of ``wave_mode``. """ wave_mode = normalize_wave_mode(wave_mode) if wave_mode_has_p(wave_mode) and min_stations_p is not None: if min_stations_p <= 0: raise ValueError("Minimum number of stations on which event must be detected with P waves must be positive.") if min_stations_p > n_stations: raise ValueError("Minimum number of stations on which event must be detected with P waves must not exceed number of stations.") if wave_mode_has_s(wave_mode) and min_stations_s is not None: if min_stations_s <= 0: raise ValueError("Minimum number of stations on which event must be detected with S waves must be positive.") if min_stations_s > n_stations: raise ValueError("Minimum number of stations on which event must be detected with S waves must not exceed number of stations.")
[docs] def compute_free_surface_coefficients(station_coords: np.ndarray, grid_coords: np.ndarray, station_coords_on_surface: np.ndarray = None, fs_mode: str = 'auto', fs_level: float = 0.0, fs_deviation: float = 1.0): r""" Compute free surface amplification coefficients for each station and grid point. A receiver recording at the free surface sees an amplitude approximately twice that of the incident wave, which is the exact amplification for normal or near-normal incidence. The coefficient is applied per station, so that a geometry combining surface receivers with receivers at depth is treated correctly. Parameters ---------- station_coords : :obj:`numpy.ndarray` Array of station coordinates [[xr1, yr1, zr1], [xr2, yr2, zr2], ...] grid_coords : :obj:`numpy.ndarray` Array of grid coordinates [[xs1, ys1, zs1], [xs2, ys2, zs2], ...] station_coords_on_surface : :obj:`numpy.ndarray`, optional Per-station indicator of whether the station is on the free surface, as a 1D array of the same length as the number of stations, with values in [0, 1]. Values between 0 and 1 act as a weighting factor between no amplification and full amplification. If ``None``, the indicator is derived from the station depths. fs_mode : :obj:`str`, optional, default: ``'auto'`` Free surface correction mode, can be ``'auto'``, ``'on'`` or ``'off'``. If ``'on'``, the correction is applied to stations close to the free surface irrespective of ``station_coords_on_surface``. If ``'off'``, the correction is never applied. If ``'auto'``, ``station_coords_on_surface`` is used when available, otherwise stations are considered to be on the free surface when their Z coordinate deviates from the free surface level by no more than ``fs_deviation``. fs_level : :obj:`float`, optional, default: 0.0 Level of the free surface in metres above the reference level. fs_deviation : :obj:`float`, optional, default: 1.0 Permitted deviation from the free surface level in metres, must be non-negative. Returns ------- fs_coef_p : :obj:`numpy.ndarray` Free surface coefficients for P waves, of shape (n_grid_points, n_stations) fs_coef_s : :obj:`numpy.ndarray` Free surface coefficients for S waves, of shape (n_grid_points, n_stations) Raises ------ ValueError If ``fs_level`` is not a scalar, ``fs_deviation`` is negative, ``station_coords_on_surface`` has the wrong shape or values outside [0, 1], or ``fs_mode`` is not one of the supported modes. Notes ----- The free surface level is negated internally to match the convention that Z increases downwards. """ if station_coords.ndim == 1 and station_coords.size == 3: station_coords = station_coords[np.newaxis, :] if grid_coords.ndim == 1 and grid_coords.size == 3: grid_coords = grid_coords[np.newaxis, :] n_stations = station_coords.shape[0] n_grid_points = grid_coords.shape[0] # Negate the free surface level to match the downward-positive Z convention if np.ndim(fs_level) != 0: raise ValueError("Free surface level must be a scalar.") fs_depth = -fs_level if fs_deviation < 0: raise ValueError("Free surface level deviation must be non-negative.") # Check and normalize the optional per-station weights surface_weights = None if station_coords_on_surface is not None: station_coords_on_surface = np.asarray(station_coords_on_surface) if station_coords_on_surface.ndim != 1 or station_coords_on_surface.shape[0] != n_stations: raise ValueError("station_coords_on_surface must be a 1D array with the same length as the number of stations.") surface_weights = station_coords_on_surface.astype(float, copy=False) if np.any((surface_weights < 0) | (surface_weights > 1)): raise ValueError("station_coords_on_surface must contain only values in the range [0, 1].") # Simple free-surface approximation: amplitudes are doubled at the surface full_fs_coef = 2.0 # Decide how much of the full amplification applies to each station if fs_mode == 'auto': if surface_weights is None: surface_weights = (np.abs(station_coords[:, 2] - fs_depth) <= fs_deviation).astype(float) elif fs_mode == 'on': surface_weights = (np.abs(station_coords[:, 2] - fs_depth) <= fs_deviation).astype(float) elif fs_mode == 'off': surface_weights = np.zeros(n_stations, dtype=float) else: raise ValueError("Invalid free surface mode. Must be 'auto', 'on', or 'off'.") # Interpolate between no amplification (1.0) and full amplification fs_coef = np.broadcast_to( 1.0 + (full_fs_coef - 1.0) * surface_weights[np.newaxis, :], (n_grid_points, n_stations) ).astype(float, copy=True) # The same coefficients are used for P and S waves return fs_coef, fs_coef
def _unit_vectors(vectors: np.ndarray) -> np.ndarray: r"""Normalize vectors along the last axis, leaving zero-length rows as NaN.""" norms = np.linalg.norm(vectors, axis=-1, keepdims=True) with np.errstate(invalid='ignore', divide='ignore'): return vectors / norms def _station_reference_vectors(station_coords: np.ndarray) -> np.ndarray: r""" Estimate the local cable tangent at each channel from its neighbours. The forward difference to the next channel is used by default. Where the cable turns by more than 30 degrees, or where the forward step exceeds ten times the backward step, the backward difference is used instead, so that a channel at a kink or at the end of a segment still receives a sensible tangent. """ if len(station_coords) < 2: raise ValueError("There must be at least two stations.") station_vectors = np.diff(station_coords, axis=0) cn_vectors = np.vstack([station_vectors, station_vectors[-1]]) cp_vectors = np.vstack([station_vectors[0], station_vectors]) eps = 1e-12 cn_distances = np.linalg.norm(cn_vectors, axis=1) + eps cp_distances = np.linalg.norm(cp_vectors, axis=1) + eps cos_vals = np.sum(cn_vectors * cp_vectors, axis=1) / (cn_distances * cp_distances) cos_vals = np.clip(cos_vals, -1.0, 1.0) angles_cn_cp = np.arccos(cos_vals) use_cp = (angles_cn_cp > np.radians(30)) | (cn_distances > 10 * cp_distances) return np.where(use_cp[:, np.newaxis], cp_vectors, cn_vectors) def _polarization_vectors(ray_vectors: np.ndarray, phase: str) -> np.ndarray: r""" Arriving polarization unit vectors for a phase. P is polarized along the ray, SH horizontally and perpendicular to the vertical ray plane, and SV perpendicular to both. For a vertical ray the SH direction is degenerate, so a stable horizontal fallback basis is used instead. """ phase = normalize_wave_mode(phase) n = _unit_vectors(ray_vectors) if phase == "P": return n vertical = np.array([0.0, 0.0, 1.0]) sh = np.cross(vertical, n) sh_norm = np.linalg.norm(sh, axis=-1) fallback_x = np.cross(np.array([1.0, 0.0, 0.0]), n) fallback_y = np.cross(np.array([0.0, 1.0, 0.0]), n) fallback_x_norm = np.linalg.norm(fallback_x, axis=-1) fallback = np.where((fallback_x_norm > 1e-12)[..., np.newaxis], fallback_x, fallback_y) sh = np.where((sh_norm > 1e-12)[..., np.newaxis], sh, fallback) sh = _unit_vectors(sh) if phase == "SH": return sh if phase == "SV": return _unit_vectors(np.cross(sh, n)) raise ValueError("Polarization vectors are defined only for P, SV, and SH.") def _validate_station_components(components) -> pd.Series: r"""Validate a ``Components`` column, filling missing entries with ``'3C'``.""" component_series = pd.Series(components).dropna() invalid_components = sorted(set(component_series).difference({'3C', 'Z'})) if invalid_components: raise ValueError( "Components values must be either '3C' or 'Z'. " f"Invalid values: {', '.join(invalid_components)}" ) return pd.Series(components).fillna('3C')
[docs] def geometry_requires_receiver_projection(geometry_df: Optional[pd.DataFrame]) -> bool: r""" Return whether a geometry needs receiver-component projection. A DAS geometry, identified by the presence of a gauge length, always requires projection onto the local cable tangent. A station geometry requires projection only if at least one station records a single vertical component. Parameters ---------- geometry_df : :obj:`pandas.DataFrame` or ``None`` Geometry DataFrame. Returns ------- required : :obj:`bool` Whether projection is required. """ if geometry_df is None: return False if geometry_df.attrs.get('gauge_length') is not None: return True if 'Components' not in geometry_df.columns: return False components = _validate_station_components(geometry_df['Components']) return bool((components == 'Z').any())
[docs] def get_phase_station_directionality(station_coords: np.ndarray, grid_coords: np.ndarray, phases=("P", "SV", "SH"), geometry_df: Optional[pd.DataFrame] = None, force_tangent: bool = False, strict_nan_check: bool = False) -> dict: r""" Derive receiver projection coefficients for P, SV and SH polarizations. The recorded amplitude of a phase is the projection of its arriving polarization onto the direction the receiver is sensitive to. For a phase :math:`\phi` the correction applied to the minimum detectable amplitude is .. math:: C_{\phi} = \frac{1}{\left| \mathbf{d} \cdot \mathbf{e}_{\phi} \right|}, where :math:`\mathbf{e}_{\phi}` is the arriving polarization and :math:`\mathbf{d}` is the local cable tangent for DAS, or the vertical axis for a single-component station. Three-component stations record the full vector and therefore use a coefficient of one. Parameters ---------- station_coords : :obj:`numpy.ndarray` Array of station coordinates [[xr1, yr1, zr1], [xr2, yr2, zr2], ...] grid_coords : :obj:`numpy.ndarray` Array of grid coordinates [[xs1, ys1, zs1], [xs2, ys2, zs2], ...] phases : sequence of :obj:`str`, optional, default: ``("P", "SV", "SH")`` Phases to compute coefficients for. Must be drawn from 'P', 'SV' and 'SH'. geometry_df : :obj:`pandas.DataFrame`, optional Geometry DataFrame, used to detect a DAS geometry from its gauge length and to read a ``Components`` column for station geometries. If ``None``, projection onto the local tangent is used. force_tangent : :obj:`bool`, optional, default: ``False`` Force projection onto the local tangent even when ``geometry_df`` describes a station geometry. strict_nan_check : :obj:`bool`, optional, default: ``False`` Flag to check whether NaN values appear in the result. Returns ------- coefficients : :obj:`dict` Mapping from phase name to an array of coefficients of shape (n_grid_points, n_stations). Raises ------ ValueError If a requested phase is not P, SV or SH, if the geometry and station coordinates disagree in length, or if a DAS geometry also carries a ``Components`` column. RuntimeError If ``strict_nan_check`` is True and NaN appears in the result. Notes ----- A vanishing projection means the phase cannot be recorded at all, for example SH on a vertical cable, and yields NaN so that the phase is excluded rather than reported as infinitely detectable. Straight rays between each station and each grid point are assumed, which is exact in a homogeneous medium. """ station_coords = np.asarray(station_coords, dtype=float) grid_coords = np.asarray(grid_coords, dtype=float) if station_coords.ndim == 1 and station_coords.size == 3: station_coords = station_coords[np.newaxis, :] if grid_coords.ndim == 1 and grid_coords.size == 3: grid_coords = grid_coords[np.newaxis, :] phases = tuple(dict.fromkeys(normalize_wave_mode(phase) for phase in phases)) if any(phase not in ("P", "SV", "SH") for phase in phases): raise ValueError("Directionality phases must be P, SV, or SH.") use_tangent_projection = force_tangent or geometry_df is None components = None if geometry_df is not None: if len(geometry_df) != len(station_coords): raise ValueError("Geometry and station coordinates must have the same number of stations.") has_gauge_length = geometry_df.attrs.get('gauge_length') is not None has_components = 'Components' in geometry_df.columns if has_gauge_length: if has_components and geometry_df['Components'].notna().any(): raise ValueError("Components column is only allowed for station geometries, not DAS geometries.") use_tangent_projection = True elif has_components: components = _validate_station_components(geometry_df['Components']).to_numpy() else: components = np.full(len(station_coords), '3C', dtype=object) if use_tangent_projection: reference_unit = _unit_vectors(_station_reference_vectors(station_coords)) else: vertical_unit = np.array([0.0, 0.0, 1.0]) # Straight rays from each station to each grid point ray_vectors = grid_coords[:, np.newaxis, :] - station_coords[np.newaxis, :, :] result = {} for phase in phases: polarization = _polarization_vectors(ray_vectors, phase) if use_tangent_projection: projection = np.abs( np.sum(polarization * reference_unit[np.newaxis, :, :], axis=2) ) projection[np.isclose(projection, 0.0)] = np.nan result[phase] = 1.0 / projection else: coefficients = np.ones(polarization.shape[:2], dtype=float) z_mask = components == 'Z' if np.any(z_mask): projection = np.abs( np.sum(polarization[:, z_mask, :] * vertical_unit, axis=2) ) projection[np.isclose(projection, 0.0)] = np.nan coefficients[:, z_mask] = 1.0 / projection result[phase] = coefficients if strict_nan_check: if any(np.any(np.isnan(values)) for values in result.values()): current_function = inspect.currentframe().f_code.co_name raise RuntimeError( f"NaN values detected while computing directionality coefficients in {current_function}." ) return result
[docs] def get_ray_station_directionality(station_coords: np.ndarray, grid_coords: np.ndarray, strict_nan_check: bool = False): r""" Derive directionality coefficients for P and S waves along a cable. .. deprecated:: Use :func:`get_phase_station_directionality`, which resolves SV and SH separately and projects the actual polarization vectors. Parameters ---------- station_coords : :obj:`numpy.ndarray` Array of station coordinates [[xr1, yr1, zr1], [xr2, yr2, zr2], ...] grid_coords : :obj:`numpy.ndarray` Array of grid coordinates [[xs1, ys1, zs1], [xs2, ys2, zs2], ...] strict_nan_check : :obj:`bool`, optional, default: ``False`` Flag to check whether NaN values appear in the result. Returns ------- dir_coef_p : :obj:`numpy.ndarray` Directionality coefficients for P waves, of shape (n_grid_points, n_stations) dir_coef_s : :obj:`numpy.ndarray` Directionality coefficients for SV waves, of shape (n_grid_points, n_stations) Notes ----- Returns the P and SV coefficients of :func:`get_phase_station_directionality` with tangent projection forced. For a vertical cable these are the familiar :math:`1/|\cos\theta|` and :math:`1/|\sin\theta|`; for an inclined or curved cable they are the correct polarization projections rather than that approximation. """ warnings.warn( "get_ray_station_directionality is deprecated; use " "get_phase_station_directionality, which resolves SV and SH separately.", DeprecationWarning, stacklevel=2, ) coefficients = get_phase_station_directionality( station_coords=station_coords, grid_coords=grid_coords, phases=("P", "SV"), geometry_df=None, force_tangent=True, strict_nan_check=strict_nan_check, ) return coefficients["P"], coefficients["SV"]