Source code for openquake.pfd.secondary_surf_displ.takao2013

# -*- 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.takao2013` implements the
model of Takao et al. (2013) in :class:`Takao2013SecondaryFD`.

Supported Fault Styles: Reverse and Strike-slip

References
----------
Takao, M., Tsuchiyama, J., Annaka, T., & Kurita, T. (2013). Application of
probabilistic fault displacement hazard analysis in Japan. Journal of Japan
Association for Earthquake Engineering, 13(1), 17-36.
https://doi.org/10.5610/jaee.13.17
"""

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


[docs]class Takao2013SecondaryFD(BaseSecondarySurfDispl): """Distributed fault-displacement model of Takao et al. (2013). The distributed displacement DD is normalized by the maximum (PMD) or average (PAD) displacement of the principal fault. The 90% non-exceedance level decays exponentially with the closest distance r [km] from the principal fault (paper Eqs. 15-16): DD/PMD = 0.55 exp(-0.17 r) DD/PAD = 1.9 exp(-0.17 r) Following Youngs et al. (2003), the conditional distribution of the normalized displacement is a gamma distribution with shape a = 2.5 whose scale b(r) is anchored so its 90th percentile equals the regression above (paper Eq. 17). PMD and PAD follow the paper's magnitude scaling (Eqs. 9-10), lognormal with the Wells & Coppersmith (1994) sigmas: log10(PMD) = -5.16 + 0.82 Mw (sigma 0.42) log10(PAD) = -4.80 + 0.69 Mw (sigma 0.36) The PMD/PAD lognormal is integrated over ``mean ± n_sigma·sigma`` (log10 space); ``n_sigma`` defaults to 3 and may be overridden from the logic tree via ``[Takao2013SecondaryFD] n_sigma = <value>``. Model contract: DISPLACEMENT_DEFINITION = "distributed", DISPLACEMENT_COMPONENT = "net" -- distributed displacement normalised by the principal-fault PMD/PAD net-slip scaling (Takao et al. 2013, Eqs. 15-17; component convention per Valentini et al. 2025, Rev. Geophys., Table 4). Declared applicability: r up to 20 km (ibid., dataset range of the Eqs. 15-16 regressions). """ DISPLACEMENT_DEFINITION = "distributed" DISPLACEMENT_COMPONENT = "net" APPLICABILITY_RANGE = { "r_max_km": 20.0, "source": "Valentini et al. (2025) Rev. Geophys. Table 4 " "(Takao et al. 2013 dataset range)", } _N_INTEGRATION = 1000 _GAMMA_SHAPE = 2.5 # Coefficients of the 90% non-exceedance regressions (Eqs. 15-16) and of # the magnitude scaling of the normalizing displacement (Eqs. 9-10). _COEFFS = { "MD": {"c90": 0.55, "log_intercept": -5.16, "log_slope": 0.82, "sigma": 0.42}, "AD": {"c90": 1.9, "log_intercept": -4.80, "log_slope": 0.69, "sigma": 0.36}, } _DECAY = -0.17 # per km, shared by Eqs. 15 and 16 def __init__(self, n_sigma=3.0, norm_disp_type=None): """ :param n_sigma: truncation half-width of the AD/MD distribution. :param norm_disp_type: optional normalization type pinned by the logic-tree branch ('AD' or 'MD'); ``None`` defers to the ``get_prob`` call (legacy default: 'AD'). """ self.n_sigma = float(n_sigma) if self.n_sigma <= 0.0: raise ValueError(f"n_sigma must be positive; got {self.n_sigma}") self.norm_disp_type = check_choice( type(self).__name__, "norm_disp_type", norm_disp_type, frozenset(["AD", "MD"]), canon=lambda v: str(v).upper()) # 90th percentile of the unit-scale gamma(a=2.5); dividing the # regression level by this anchors gamma.ppf(0.9) at the regression. self._p90_factor = float(gamma.ppf(0.90, self._GAMMA_SHAPE)) def get_prob(self, d, mag, r, norm_disp_type=None): """ Model of Takao et al. (2013) for the probability of exceeding threshold values of secondary (distributed) displacement [m] :param d: Target displacement in meters (array of shape (n_displacements,)) :param mag: Earthquake magnitude (scalar) :param r: Closest distance from the principal fault trace in km (scalar or array of shape (n_sites,)) :param norm_disp_type: Normalization displacement type, "AD" or "MD". :returns: Probability of exceeding the given displacement (shape (n_sites, n_displacements)) """ # Fall back to constructor-pinned value, then legacy default if norm_disp_type is None: norm_disp_type = (self.norm_disp_type if self.norm_disp_type is not None else "AD") if norm_disp_type not in self._COEFFS: raise ValueError( f"Invalid norm_disp_type '{norm_disp_type}'. " f"Accepted values are: {', '.join(self._COEFFS)}" ) coeffs = self._COEFFS[norm_disp_type] d = np.atleast_1d(np.asarray(d, dtype=float)) r = np.atleast_1d(np.asarray(r, dtype=float)) n_sites = r.shape[0] log_mean = coeffs["log_intercept"] + coeffs["log_slope"] * mag sigma = coeffs["sigma"] # Truncation bounds of the PMD/PAD lognormal in log10 space lower = 10 ** (log_mean - self.n_sigma * sigma) upper = 10 ** (log_mean + self.n_sigma * sigma) logspace_vals = np.logspace(np.log10(lower), np.log10(upper), self._N_INTEGRATION) # Normalized pdf weights of the truncated lognormal (Eq. 11 pattern) pdf = norm.pdf(np.log10(logspace_vals), loc=log_mean, scale=sigma) pdf /= pdf.sum() # Marginalize P(DD > d | r, PMD_or_PAD) over the PMD/PAD distribution # (Eq. 12 applied to the distributed case, as noted below Eq. 17) prob_exceeding_d = np.zeros((n_sites, d.shape[0])) for norm_disp, weight in zip(logspace_vals, pdf): prob_exceeding_d += weight * self.get_prob_norm_displ( d / norm_disp, r, norm_disp_type) return prob_exceeding_d def get_prob_norm_displ(self, DD_norm, r, norm_disp_type="AD"): """ Conditional exceedance probability of the normalized distributed displacement DD/PMD or DD/PAD at distance r (paper Eq. 17). :param DD_norm: Normalized displacement (array of shape (n_displacements,)) :param r: Closest distance from the principal fault trace in km (scalar or array of shape (n_sites,)) :param norm_disp_type: Normalization displacement type, "AD" or "MD". :returns: Exceedance probability (shape (n_sites, n_displacements)) """ if norm_disp_type not in self._COEFFS: raise ValueError( f"Invalid norm_disp_type '{norm_disp_type}'. " f"Accepted values are: {', '.join(self._COEFFS)}" ) DD_norm = np.atleast_1d(np.asarray(DD_norm, dtype=float)) r = np.atleast_1d(np.asarray(r, dtype=float)) level90 = self._COEFFS[norm_disp_type]["c90"] * np.exp(self._DECAY * r) b = level90 / self._p90_factor # Shape (n_sites,) return gamma.sf(DD_norm[np.newaxis, :], self._GAMMA_SHAPE, loc=0, scale=b[:, np.newaxis])