Source code for openquake.pfd.secondary_surf_displ.youngs2003

# -*- coding: utf-8 -*-
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (C) 2024-2026 Yen-Shin Chen, OGS
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""
Module :mod:`openquake.pfd.secondary_surf_displ.youngs2003` implements the
model of Youngs et al. (2003) in :class:`Youngs2003SecondaryFD`.
"""

import numpy as np
from scipy.stats import gamma, norm
from openquake.pfd.params import check_choice, check_style
from openquake.pfd.primary_surf_displ.base import BaseSecondarySurfDispl


[docs]class Youngs2003SecondaryFD(BaseSecondarySurfDispl): """Distributed fault-displacement model of Youngs et al. (2003). Youngs, R.R., et al. (2003). A methodology for probabilistic fault displacement hazard analysis (PFDHA). Earthquake Spectra, 19(1), 191-219. Model contract: DISPLACEMENT_DEFINITION = "distributed", DISPLACEMENT_COMPONENT = "vertical" -- distributed (off-fault) vertical separation of normal-faulting earthquakes, normalised by the principal maximum displacement (Youngs et al. 2003; Sarmiento et al. 2025 Table 1 component convention as for YEA03). Declared applicability: r up to 15 km from the principal fault (dataset range summarised in Valentini et al. 2025, Rev. Geophys., Table 4). """ DISPLACEMENT_DEFINITION = "distributed" DISPLACEMENT_COMPONENT = "vertical" APPLICABILITY_RANGE = { "r_max_km": 15.0, "source": "Valentini et al. (2025) Rev. Geophys. Table 4 " "(Youngs et al. 2003 dataset range)", } # Constants for Wells & Coppersmith (1994) formulas for normal faulting _WC94_MD_INTERCEPT = -5.90 _WC94_MD_SLOPE = 0.89 _WC94_MD_SIGMA = 0.38 _D_TRUNCATION = 3.0 # ±3 sigma for truncation _NUM_INTEGRATION_POINTS = 100 _ACCEPTED_PERCENTILES = {"85", "95", 85, 95} _GAMMA_SHAPE = 2.5 # Shape parameter 'a' for gamma distribution # Scaling factors for different percentiles _PERCENTILE_SCALING = { "85": 4.058, "95": 5.535, 85: 4.058, 95: 5.535 } def __init__(self, percentile=None, style=None): """ :param percentile: optional hanging-wall percentile curve pinned by the logic-tree branch ('85' or '95'; integers accepted); ``None`` defers to the ``get_prob`` call (legacy default: '85'). :param style: optional faulting style declared by the logic-tree branch. The Youngs et al. (2003) secondary displacement regressions carry no style selector, so the value does not change the numbers; it is stored (validated against the global style vocabulary) as a declaration of the branch context. """ self.percentile = check_choice( type(self).__name__, "percentile", percentile, frozenset(["85", "95"]), canon=str) self.style = check_style(type(self).__name__, style) # Pre-calculate common values self._norm_pdf_cache = {} def get_prob(self, d, mag, rx, r, percentile=None): """ Model of Youngs et al. (2003) for the probability of exceeding threshold values of secondary displacement [m] :param d: Target displacement in meters (array of shape (n_displacements,)) :param mag: Earthquake magnitude (scalar) :param rx: Distance from the closest rupture (scalar or array of shape (n_sites,)) :param r: Distance from the rupture trace (scalar or array of shape (n_sites,)) :param percentile: The percentile used in calculations ("85" or "95") :returns: Probability of exceeding the given displacement (shape (n_sites, n_displacements)) """ # Fall back to constructor-pinned value, then legacy default if percentile is None: percentile = (self.percentile if self.percentile is not None else "85") # Validate percentile if percentile not in self._ACCEPTED_PERCENTILES: raise ValueError( f"Invalid percentile '{percentile}'. Accepted values are: {', '.join(self._ACCEPTED_PERCENTILES)}" ) # Ensure inputs are arrays d = np.asarray(d) # Shape (n_displacements,) rx = np.asarray(rx) # Shape (n_sites,) or scalar r = np.asarray(r) # Shape (n_sites,) or scalar if rx.ndim == 0: rx = np.array([rx]) if r.ndim == 0: r = np.array([r]) n_sites = rx.shape[0] # Calculate log-normal distribution parameters log_mean = self._WC94_MD_INTERCEPT + self._WC94_MD_SLOPE * mag sigma = self._WC94_MD_SIGMA # Calculate truncation range for integration lower = 10 ** (log_mean - self._D_TRUNCATION * sigma) upper = 10 ** (log_mean + self._D_TRUNCATION * sigma) # Use logarithmic spacing for the numerical integration logspace_vals = np.logspace(np.log10(lower), np.log10(upper), self._NUM_INTEGRATION_POINTS) # Initialize output array prob_exceeding_d = np.zeros((n_sites, d.shape[0])) # Shape (n_sites, n_displacements) # Calculate probabilities for each max displacement for max_disp in logspace_vals: D_MD = d / max_disp # Shape (n_displacements,) prob_D_MD = self.get_prob_D_MD(D_MD, rx, r, percentile) # Shape (n_sites, n_displacements) prob_max_disp = self.get_prob_max_displacement(max_disp, mag) # Scalar prob_exceeding_d += prob_D_MD * prob_max_disp return prob_exceeding_d def get_prob_D_MD(self, D_MD, rx, r, percentile="85"): """ Model of Youngs et al. (2003) for the probability of normalized displacement :param D_MD: Normalized displacement (D/Max_D) (array of shape (n_displacements,)) :param rx: Distance from the closest rupture (scalar or array of shape (n_sites,)) :param r: Distance from the rupture trace (scalar or array of shape (n_sites,)) :param percentile: The percentile used for scaling ("85" or "95") :returns: Probability of exceeding 'norm_disp' (shape (n_sites, n_displacements)) """ # Validate percentile if percentile not in self._ACCEPTED_PERCENTILES: raise ValueError( f"Invalid percentile '{percentile}'. Accepted values are: {', '.join(self._ACCEPTED_PERCENTILES)}" ) # Ensure inputs are arrays D_MD = np.asarray(D_MD) # Shape (n_displacements,) rx = np.asarray(rx) # Shape (n_sites,) or scalar r = np.asarray(r) # Shape (n_sites,) or scalar if rx.ndim == 0: rx = np.array([rx]) if r.ndim == 0: r = np.array([r]) # Calculate the scaling factor based on distance and rupture position x = np.where(rx > 0., 0.35 * np.exp(-0.091 * r), 0.16 * np.exp(-0.137 * r)) # Shape (n_sites,) # Calculate gamma distribution parameters a = self._GAMMA_SHAPE b = x / self._PERCENTILE_SCALING[percentile] # Shape (n_sites,) # Compute survival function for each site and displacement # Reshape arrays for broadcasting: D_MD (1, n_displacements), b (n_sites, 1) D_MD = D_MD[np.newaxis, :] # Shape (1, n_displacements) b = b[:, np.newaxis] # Shape (n_sites, 1) return gamma.sf(D_MD, a, loc=0, scale=b) # Shape (n_sites, n_displacements) def get_prob_max_displacement(self, target_md, mag): """ Calculate the log-normal pdf of maximum displacement based on magnitude using Wells and Coppersmith (1994) for normal faulting :param target_md: Maximum displacement in meters :param mag: Earthquake magnitude :returns: Probability of maximum displacement """ # Create a cache key cache_key = round(mag, 2) if cache_key in self._norm_pdf_cache: log_mean, log_max_disp, sigma, norm_factor = self._norm_pdf_cache[cache_key] else: # Calculate parameters log_mean = self._WC94_MD_INTERCEPT + self._WC94_MD_SLOPE * mag log_max_disp = np.log10(10 ** log_mean) # This simplifies to log_mean but kept for clarity sigma = self._WC94_MD_SIGMA # Calculate normalization factor lower = 10 ** (log_mean - self._D_TRUNCATION * sigma) upper = 10 ** (log_mean + self._D_TRUNCATION * sigma) logspace_vals = np.logspace(np.log10(lower), np.log10(upper), self._NUM_INTEGRATION_POINTS) prob = norm.pdf(np.log10(logspace_vals), loc=log_max_disp, scale=sigma) norm_factor = np.sum(prob) # Store in cache self._norm_pdf_cache[cache_key] = (log_mean, log_max_disp, sigma, norm_factor) # Calculate probability prob_max_displacement = norm.pdf(np.log10(target_md), loc=log_max_disp, scale=sigma) return prob_max_displacement / norm_factor