Source code for pynetdesign.modelling.homo

from typing import Union, Optional
from pynetdesign.modelling.classes import *
from pynetdesign.modelling.utils import *
from pynetdesign.modelling.io import *

import numpy as np
import pandas as pd
import inspect

[docs] def mag_sensitivity_grid(grid_coords: np.ndarray, velocity_df: pd.DataFrame, geometry_df: pd.DataFrame = None, station_coords: np.ndarray = None, min_amps_p: np.ndarray = None, min_amps_s: np.ndarray = None, min_stations_p: int = None, min_stations_s: int = None, rad_pattern_p: float = None, rad_pattern_s: float = None, rad_patterns: dict = None, f_p: float = None, f_s: float = None, f_p_corner: float = None, f_s_corner: float = None, wave_mode: str = 'PS', fs_mode: str = 'auto', fs_level: float = None, fs_deviation: float = None, use_station_directionality: bool = False, return_stations: bool = False, strict_nan_check: bool = False, amps_type: str = None): r""" Computes magnitude sensitivity - the minimum moment magnitude (Mw) of a microseismic event which will theoretically produce particle velocity amplitudes (of P and/or S waves) on stations necessary for event detection assuming 3D elastic homogeneous medium with attenuation. It takes into account minimum number of stations on which the event should be detected. By request it is possible to output only minimum moment magnitude for each station. Parameters ---------- grid_coords : :obj:`numpy.ndarray` Array of grid coordinates [[xs1, ys1, zs1], [xs2, ys2, zs2], ...] velocity_df : :obj:`pandas.DataFrame` Velocity model DataFrame geometry_df : :obj:`pandas.DataFrame`, optional Geometry DataFrame, use if station_coords is None station_coords : :obj:`numpy.ndarray`, optional Array of station coordinates [[xr1, yr1, zr1], [xr2, yr2, zr2], ...], use if geometry_df is None min_amps_p : :obj:`numpy.ndarray`, optional Minimal measurable displacement amplitudes of on stations [a1, a2, ...] for P waves min_amps_s : :obj:`numpy.ndarray`, optional Minimal measurable displacement amplitudes on stations [a1, a2, ...] for S waves min_stations_p : :obj:`int`, optional, default: None (3) Minimum number of stations on which event must be detected with P waves min_stations_s : :obj:`int`, optional, default: None (3) Minimum number of stations on which event must be detected with S waves rad_pattern_p : :obj:`float`, optional Override of the radiation pattern factor for P waves. If ``None``, the root-mean-square value :math:`\sqrt{4/15} \approx 0.52` is used. rad_pattern_s : :obj:`float`, optional Override of the radiation pattern factor applied to both S phases. If ``None``, the root-mean-square values :math:`\sqrt{7/30} \approx 0.48` for SV and :math:`\sqrt{1/6} \approx 0.41` for SH are used. Pass 0.63 to reproduce results computed with the combined S-wave value. rad_patterns : :obj:`dict`, optional Per-phase overrides of the radiation pattern factor, e.g. ``{'SV': 0.63}``. Takes precedence over ``rad_pattern_p`` and ``rad_pattern_s``. f_p : :obj:`float`, optional Frequency of the P wave (Hz) f_s : :obj:`float`, optional Frequency of the S wave (Hz) f_p_corner : :obj:`float`, optional Corner frequency of the P wave (Hz) f_s_corner : :obj:`float`, optional Corner frequency of the S wave (Hz) wave_mode: :obj:`str`, optional, default: 'PS' Wave mode to use, can be 'P', 'SV', 'SH', 'S' or 'PS' fs_mode : :obj:`str`, optional, default: 'auto' Free surface correction mode, can be 'auto', 'on' or 'off'. The correction accounts for the amplification of amplitudes recorded at the daily surface due to the free surface boundary condition, and is applied per station so that geometries mixing surface and downhole receivers are handled correctly. See :func:`~pynetdesign.modelling.utils.compute_free_surface_coefficients`. fs_level : :obj:`float`, optional, default: None (0.0) Level of the free surface in metres above the reference level. fs_deviation : :obj:`float`, optional, default: None (1.0) Permitted deviation from the free surface level in metres. use_station_directionality : :obj:`bool`, optional, default: False Force projection of the arriving polarization onto the local tangent defined by the adjacent stations. Projection is applied automatically for DAS geometries and for stations recording a single vertical component, so this is only needed to force tangent projection for a station geometry. return_stations : :obj:`bool`, optional, default: False Return magnitude sensitivity for each station, don't take into account detectability strict_nan_check: :obj:`bool`, optional, default: False flag to check if NaN values appear in the result amps_type : :obj:`str`, optional, default: None Type of amplitudes, can be 'displacement', 'velocity', 'acceleration', 'strain rate', 'strain'. Returns ------- Mw_min_grid : :obj:`numpy.ndarray` Array of computed detectable minimum moment magnitudes on the grid, if return_stations = False Mw_min_stations : :obj:`numpy.ndarray` Array of computed minimum moment magnitudes for individual stations, if return_stations = True Mw_min_stations is of shape (2, npairs) where npairs is a number of of source-grid pairs determined from station_coords and grid_coords. First dimension corresponds to P and S waves. Raises ------ ValueError : if both station_coords and geometry_df are not None or None ValueError : if there is inconsistency in P/S wave parameters ValueError : if number of minimal measurable amplitudes do not match number of stations ValueError : if density, wave velocity or attenuation factor is not positive ValueError : if radiation pattern factor for P or S wave is less or equal to 0 or higher than 1 ValueError : if number of minimal measurable amplitudes does not match number of stations RuntimeError : if strict_nan_check is True and NaN appears in the result Notes ----- This is a refactored vectorized version of the code """ if (station_coords is not None and geometry_df is not None) or (station_coords is None and geometry_df is None): raise ValueError("You must provide either `station_coords` or `geometry_df`, but not both.") # Get station coordinates from geometry if station_coords is None: station_coords = geometry_df[['X', 'Y', 'Z']].to_numpy() # Get the free surface information from geometry, if provided if geometry_df is not None and 'Surface' in geometry_df.columns: station_coords_on_surface = geometry_df['Surface'].to_numpy() else: station_coords_on_surface = None # Handle the case if station_coords has only one point (3 elements) if station_coords.ndim == 1 and station_coords.size == 3: station_coords = station_coords[np.newaxis, :] # reshape to (1, 3) # Handle the case if grid_coords has only one point (3 elements) if grid_coords.ndim == 1 and grid_coords.size == 3: grid_coords = grid_coords[np.newaxis, :] # reshape to (1, 3) # Check if amplitudes type is provided if geometry_df is None: if amps_type is None: raise ValueError("You must provide amplitude type if geometry is not provided") else: amps_type = geometry_df.attrs.get('noise_type') # Check velocity model if len(velocity_df) != 1: raise ValueError("Velocity model is not homogeneous!") # Expand the wave mode into the phases that have to be evaluated wave_mode = normalize_wave_mode(wave_mode) phases = wave_mode_phases(wave_mode) s_phases = wave_mode_s_phases(wave_mode) # Check the free surface parameters if fs_level is None: fs_level = 0.0 if fs_deviation is None: fs_deviation = 1.0 elif fs_deviation < 0: raise ValueError("Free surface level deviation must be non-negative") if fs_mode not in ('auto', 'on', 'off'): raise ValueError("Free surface mode must be 'auto', 'on' or 'off'") # Retrieve medium parameters density = velocity_df.at[0,'Rho'] v_p = velocity_df.at[0,'Vp'] Q_p = velocity_df.at[0,'Qp'] v_s = velocity_df.at[0,'Vp']/velocity_df.at[0,'VpVsRatio'] Q_s = velocity_df.at[0,'Qs'] # Validate parameters validate_parameters(density=density, v_p=v_p, v_s=v_s, Q_s=Q_s, Q_p=Q_p, min_amps_p=min_amps_p, min_amps_s=min_amps_s, wave_mode=wave_mode, station_coords=station_coords) # Check the minimum number of stations required for detection if not return_stations: check_min_stations(min_stations_p=min_stations_p, min_stations_s=min_stations_s, n_stations=len(station_coords), wave_mode=wave_mode) # Precompute distances between all grid points and all stations # Shape: (n_grid_points, n_stations) distances = np.linalg.norm(grid_coords[:, np.newaxis] - station_coords, axis=2) if strict_nan_check: if np.any(distances == 0): raise ValueError("Zero distance: source and receiver must have different coordinates") # Calculate free surface correction coefficients, applied per station fs_coef_p, fs_coef_s = compute_free_surface_coefficients(station_coords=station_coords, grid_coords=grid_coords, station_coords_on_surface=station_coords_on_surface, fs_mode=fs_mode, fs_level=fs_level, fs_deviation=fs_deviation) # Calculate receiver projection coefficients if needed. Projection applies # automatically to DAS geometries and to single-component stations. geometry_projection_required = geometry_requires_receiver_projection(geometry_df) if use_station_directionality or geometry_projection_required: directionality = get_phase_station_directionality(station_coords=station_coords, grid_coords=grid_coords, phases=phases, geometry_df=geometry_df, force_tangent=use_station_directionality and not geometry_projection_required, strict_nan_check=strict_nan_check) else: # If not using projection, use ones (no effect on calculations) directionality = {phase: np.ones_like(distances) for phase in phases} # Helper function to calculate Mw_min for a given phase def calculate_Mw_min(f, fcorner, v, Q, phase, min_amps, fs_coef): M0 = calculate_M0(density=density, v=v, Q=Q, rad_pattern=resolve_radiation_pattern(phase, rad_pattern_p=rad_pattern_p, rad_pattern_s=rad_pattern_s, rad_patterns=rad_patterns), r=distances, amps = min_amps * directionality[phase] / fs_coef, f=f, fcorner=fcorner, amps_type=amps_type) return calculate_Mw(M0) # Preallocate array for Mw_min calculations # Shape: (2, n_grid_points, n_stations) where index 0 is for P waves, 1 for S waves Mw_min_stations = np.full((2, *distances.shape), np.nan) # Calculate Mw_min for P waves if applicable if wave_mode_has_p(wave_mode): Mw_min_stations[0] = calculate_Mw_min(f=f_p, fcorner=f_p_corner, v=v_p, Q=Q_p, phase='P', min_amps=min_amps_p, fs_coef=fs_coef_p) # Calculate Mw_min for S waves if applicable. For the composite S mode the # more detectable of the SV and SH branches is kept, i.e. the smaller magnitude. if s_phases: mw_s = [calculate_Mw_min(f=f_s, fcorner=f_s_corner, v=v_s, Q=Q_s, phase=phase, min_amps=min_amps_s, fs_coef=fs_coef_s) for phase in s_phases] Mw_min_stations[1] = mw_s[0] if len(mw_s) == 1 else np.fmin(mw_s[0], mw_s[1]) # Return depending on the request if return_stations: return Mw_min_stations else: Mw_min_grid = mag_detectable(Mw_min_stations=Mw_min_stations, min_stations_p=min_stations_p, min_stations_s=min_stations_s, wave_mode=wave_mode) # Check for NaNs if strict_nan_check: if np.any(np.isnan(Mw_min_grid)): current_function = inspect.currentframe().f_code.co_name raise RuntimeError(f"Error in function {current_function}: NaN values detected while computing the magnitude sensitivity") return Mw_min_grid
[docs] def calculate_fpeak(v: float, Q: float, r: float, fcorner: float=None): r""" Calculates the peak frequency :math:`f_{peak}` for particle velocity in a homogeneous medium given the wave velocity :math:`v`, the attenuation factor :math:`Q`, and the distance from a source :math:`r` using: .. math:: f_{peak} = \frac{vQ}{\pi r}. The peak frequency estimate is only valid for frequencies below the corner frequency :cite:p:`EisnerGeiEtAl2013`. Parameters ---------- v : :obj:`float` wave velocity Q : :obj:`float` attenuation factor r : :obj:`float` distance from the source fcorner : :obj:`float`, optional, default: ``None`` Corner frequency that limits the peak frequency. If ``None``, defaults to 100 Hz. References ---------- Eisner, L., Gei, D., Hallo, M., Opršal, I., & Ali, M. Y. (2013). The peak frequency of direct waves for microseismic events. Geophysics, 78(6), A45–A49. https://doi.org/10.1190/geo2013-0197.1 Returns ------- fpeak : :obj:`float` Peak frequency for particle velocity """ # Compute the peak frequency for particle velocity fpeak = v*Q/(np.pi*r) # Default corner frequency if fcorner is None: fcorner = 100 # Limit to corner frequency fpeak = np.minimum(fpeak,fcorner) return fpeak
[docs] def calculate_M0(density: float, v: float, Q: float, rad_pattern: float, r: Optional[Union[float, np.ndarray]], amps: Optional[Union[float, np.ndarray]], f: float = None, fcorner: float = None, amps_type: str='displacement'): r""" Calculates the seismic moment :math:`M_0` in a homogeneous medium given the density :math:`\rho`, wave velocity :math:`v`, distance from the source :math:`r` and the displacement amplitude spectrum :math:`\left|U(f)\right|` using: .. math:: M_0 = \frac{4 \pi \rho v^3 r}{\left|R\right|}\,\left|U(f)\right|\,e^{\pi f t^*}, where :math:`R` is the provided radiation pattern factor and the exponential term corrects for the intrinsic attenuation accumulated along the ray path. The amplitudes passed in ``amps`` are the displacement amplitude spectrum :math:`\left|U(f)\right|`. Amplitudes of another type are converted to it with the coefficients of :func:`~pynetdesign.modelling.utils.get_scaling_displacement`, which follow from :math:`\left|V(f)\right| = 2 \pi f \left|U(f)\right|`. The term :math:`t^*` is the integral along the ray path of the inverse value of attenuation factor :math:`Q` multiplied by the inverse of the wave velocity :math:`v`: .. math:: t^* = \int_R \frac{dr}{v(r) Q(r)}, which in a homogenous medium reads as .. math:: t^* = \frac{r}{vQ}. Frequency :math:`f` is the representative frequency of the wave. If it is not provided, it defaults to the peak frequency computed from the model, and limited by the corner frequency. Parameters ---------- density : :obj:`float` Density (kg/m^3) v : :obj:`float` Wave velocity (m/s) Q : :obj:`float` Attenuation factor rad_pattern : :obj:`float` Radiation pattern factor r : :obj:`float` or :obj:`numpy.ndarray` Distance(s) from the source (m) amps : :obj:`float` or :obj:`numpy.ndarray` Displacement amplitude spectrum :math:`\left|U(f)\right|`, single value or array of amplitudes, one for each ray. If ``r`` is a single float, ``amps`` must be a single float If ``r`` is an array, ``amps`` must be an array of the same shape. f : :obj:`float`, optional, default: ``None`` Frequency. If ``None``, peak frequency is computed from the model fcorner : :obj:`float`, optional, default: ``None`` Corner frequency, limit for peak frequency. amps_type : :obj:`str`, optional, default: ``displacement`` Type of amplitudes in ``amps``, can be 'displacement', 'velocity', 'acceleration', 'strain rate', 'strain'. Used for scaling of amplitudes in case ``f`` is ``None``. Returns ------- M0 : :obj:`float` or array_like Seismic moment (N m) """ # Check validity of 'r' and 'amps' if amps is None: raise ValueError("Missing amplitude values: 'amps' cannot be None.") if r is None: raise ValueError("Missing distances: 'r' cannot be None.") # Check shape and type consistency between 'r' and 'amps' r_is_array = isinstance(r, np.ndarray) amps_is_array = isinstance(amps, np.ndarray) if r_is_array and not amps_is_array: raise ValueError("If 'r' is an array, 'amps' must also be an array.") if not r_is_array and amps_is_array: raise ValueError("If 'r' is a float, 'amps' must also be a float.") if r_is_array and amps_is_array: if r.shape != amps.shape: raise ValueError("If 'r' is an array, 'amps' must have the same shape as 'r'.") # Default corner frequency if fcorner is None: fcorner = 100 # Compute t* t_star = r / (v * Q) # Get peak frequency and amplitude scaling if f is None: # Get fpeak. A grid point that coincides with a receiver gives r = 0, hence # t* = 0 and an infinite peak frequency, which the clamp below turns into the # corner frequency. That is the intended answer, so the division is allowed to # reach infinity quietly rather than warning about it. Coincidence is easy to # hit with a dense DAS cable, where every channel sits on a grid line. with np.errstate(divide='ignore'): fpeak = 1/(np.pi*t_star) # Limit to corner frequency fpeak = np.minimum(fpeak,fcorner) # Final scaling coefficient(s) scaling = get_scaling_displacement(amps_type=amps_type,f=fpeak) else: fpeak = f scaling = 1.0 # Precompute constants pif = np.pi * fpeak constant = 4 * np.pi * density * v**3 / rad_pattern # Compute exp(pi * f * t*) exp_term = np.exp(pif * t_star) # Compute seismic moment: M0 = (4 pi rho v^3 r / |R|) * |U(f)| * exp(pi f t*) M0 = constant * r * scaling * amps * exp_term return M0
[docs] def validate_parameters(density, v_p, v_s, Q_p, Q_s, min_amps_p, min_amps_s, wave_mode, station_coords): r""" Validates the input parameters. Parameters ---------- density : :obj:`float` Density of the medium v_p : :obj:`float` P wave velocity v_s : :obj:`float` S wave velocity Q_p : :obj:`float` P wave attenuation factor Q_s : :obj:`float` S wave attenuation factor min_amps_p : :obj:`numpy.ndarray` Minimal measurable displacement amplitudes of on stations [a1, a2, ...] for P waves min_amps_s : :obj:`numpy.ndarray` Minimal measurable displacement amplitudes on stations [a1, a2, ...] for S waves wave_mode: :obj:`str` Wave mode to use, can be 'P', 'SV', 'SH', 'S' or 'PS' station_coords : :obj:`numpy.ndarray` Array of station coordinates [[xr1, yr1, zr1], [xr2, yr2, zr2], ...] Raises ------ ValueError : if density, wave velocity or attenuation factor is not positive ValueError : if number of minimal measurable amplitudes does not match number of stations ValueError : if there is inconsistency in P/S wave parameters """ # Check density if density <= 0: raise ValueError("Density must be positive.") if v_p is None and v_s is None: raise ValueError("Both P wave and S wave velocities can not be None.") if Q_p is None and Q_s is None: raise ValueError("Both P wave and S wave attenuation factors can not be None.") # Validate P wave parameters if applicable if wave_mode_has_p(wave_mode): if any(x <= 0 for x in (v_p, Q_p)): raise ValueError("P wave parameters must be positive.") if min_amps_p is None: raise ValueError("Invalid minimal measurable amplitudes for P waves.") if np.isscalar(min_amps_p): if len(station_coords)!=1: raise ValueError("Invalid minimal measurable amplitudes for P waves.") elif len(min_amps_p) != len(station_coords): raise ValueError("Invalid minimal measurable amplitudes for P waves.") # Validate S wave parameters if applicable if wave_mode_has_s(wave_mode): if any(x <= 0 for x in (v_s, Q_s)): raise ValueError("S wave parameters must be positive.") if min_amps_s is None: raise ValueError("Invalid minimal measurable amplitudes for S waves.") if np.isscalar(min_amps_s): if len(station_coords)!=1: raise ValueError("Invalid minimal measurable amplitudes for S waves.") elif len(min_amps_s) != len(station_coords): raise ValueError("Invalid minimal measurable amplitudes for S waves.")