Source code for openquake.pfd.secondary_surf_displ.moss2022

# -*- 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.moss2022` implements
Moss et al. (2022) distributed (secondary) surface fault displacement model.

References
----------
Moss, R., Thompson, S., Kuo, C.-H., Younesi, K., and Baumont, D. (2022).
Reverse Fault PFDHA. Report GIRS-2022-05 (Revised 1/17/2024).
DOI: 10.34948/N3F595
"""
import numpy as np
from scipy import stats
from openquake.pfd.primary_surf_displ.base import BaseSecondarySurfDispl
from openquake.pfd.primary_surf_displ.moss2022 import SCALING, GAMMA_GLOBAL

# ── Table 5.7: median (50th pct) d/MD envelope ──────────────────────────
# Equation 5.8:  d/MD = c · exp(d · r_km)
ENVELOPE_50 = {
    'simple':  {'fw': {'c': 0.245, 'd': -0.18},
                'hw': {'c': 0.245, 'd': -0.34}},
    'complex': {'fw': {'c': 0.245, 'd': -0.09},
                'hw': {'c': 0.245, 'd': -0.015}},
}

# ── Table 5.8: 85th percentile d/MD envelope ────────────────────────────
# Equation 5.8:  d/MD = c · exp(d · r_km)
ENVELOPE_85 = {
    'simple':  {'fw': {'c': 0.68, 'd': -0.13},
                'hw': {'c': 0.43, 'd': -0.40}},
    'complex': {'fw': {'c': 0.68, 'd': -0.13},
                'hw': {'c': 0.43, 'd': -0.012}},
}


[docs]class Moss2022SecondaryFD(BaseSecondarySurfDispl): """ Distributed (secondary) displacement exceedance model for reverse faults. Two methods: * ``'gamma'`` - Uses the global gamma distribution (Eqs 4.2–4.3) for the d/MD ratio, with its mean rescaled by the distance-dependent envelope (Eq. 5.8). Integrated over MD(M) uncertainty the same way as the primary FD model. * ``'envelope'`` - Deterministic: assumes d_secondary = MD × envelope(r), then integrates over MD uncertainty to obtain P(d > d₀). Distance ``r`` is received in **km** (adapter convention, matching ``ctx.r``). Envelope Eq. 5.8 operates in km directly. Reference: GIRS-2022-05, Sections 5.3–5.4. DOI: 10.34948/N3F595 Model contract: DISPLACEMENT_DEFINITION = "distributed", DISPLACEMENT_COMPONENT = "vertical" -- distributed reverse-fault displacement normalised by the principal MD/AD, from vertical-offset measurements (GIRS-2022-05 Section 5). No APPLICABILITY_RANGE is declared: the report documents its envelopes per Section 5 without a single distance limit comparable to the Valentini et al. (2025) Table 4 entries (report-specific validity). """ DISPLACEMENT_DEFINITION = "distributed" DISPLACEMENT_COMPONENT = "vertical" def get_prob(self, d, mag, r, rx, version="MD", completeness="complete", sigma_type="recommended", faulting="simple", percentile="85", method="gamma", gamma_a=None, **kwargs): """ Parameters ---------- d : array-like Displacement thresholds in metres. mag : float Moment magnitude (scalar). r : float or array-like Distance from principal fault in **km**. rx : float or array-like Signed cross-fault distance in km (positive = HW). version : str ``'MD'`` or ``'AD'``. completeness : str ``'complete'``, ``'incomplete'`` (MD only), or ``'all'``. sigma_type : str ``'recommended'`` or ``'regression'``. faulting : str ``'simple'`` or ``'complex'``. percentile : str ``'50'`` or ``'85'`` for the d/MD envelope. method : str ``'gamma'`` or ``'envelope'``. gamma_a : float, optional Override the global gamma shape parameter (defaults from ``GAMMA_GLOBAL``). Only affects the ``gamma`` method. Returns ------- numpy.ndarray Shape (n_sites, n_displacements). """ if "dataset" in kwargs: completeness = kwargs.pop("dataset") if "use_revised_sigma" in kwargs: sigma_type = ( "recommended" if kwargs.pop("use_revised_sigma") else "regression" ) if "gamma_a" in kwargs: gamma_a = kwargs.pop("gamma_a") # Legacy no-op hint (site side is determined by ``rx``). kwargs.pop("hw_fw", None) if kwargs: unexpected = ", ".join(sorted(kwargs)) raise TypeError( f"Moss2022SecondaryFD.get_prob() got unexpected keyword arguments: {unexpected}" ) ver = version.upper() if ver not in ('AD', 'MD'): raise ValueError(f"Invalid version '{version}'. Accept: AD, MD") comp = completeness.lower() valid_comp = (('complete', 'incomplete', 'all') if ver == 'MD' else ('complete', 'all')) if comp not in valid_comp: raise ValueError( f"Invalid completeness '{completeness}' for '{ver}'. " f"Accept: {valid_comp}") st = sigma_type.lower() if st not in ('recommended', 'regression'): raise ValueError( f"Invalid sigma_type '{sigma_type}'. " "Accept: recommended, regression") fault_l = faulting.lower() if fault_l not in ('simple', 'complex'): raise ValueError( f"Invalid faulting '{faulting}'. Accept: simple, complex") pct = str(percentile) if pct not in ('50', '85'): raise ValueError( f"Invalid percentile '{percentile}'. Accept: 50, 85") env_table = ENVELOPE_50 if pct == '50' else ENVELOPE_85 method_l = method.lower() if method_l not in ('gamma', 'envelope'): raise ValueError( f"Invalid method '{method}'. Accept: gamma, envelope") d_arr = np.atleast_1d(np.asarray(d, dtype=float)) r_arr = np.atleast_1d(np.asarray(r, dtype=float)) rx_arr = np.atleast_1d(np.asarray(rx, dtype=float)) hw_mask = rx_arr >= 0 n_sites = r_arr.shape[0] # ── Compute d/MD envelope at each site (r already in km) ── env = np.empty(n_sites, dtype=float) for side_label, mask in [('hw', hw_mask), ('fw', ~hw_mask)]: if not np.any(mask): continue ec = env_table[fault_l][side_label] env[mask] = ec['c'] * np.exp(ec['d'] * r_arr[mask]) # ── Magnitude scaling (same Table 4.4 as primary FD) ── sc = SCALING[ver][comp] mu = sc['a'] + sc['b'] * mag sigma = (sc['s_rec'] if (st == 'recommended' and sc['s_rec'] is not None) else sc['s']) if method_l == 'gamma': return self._gamma_method( d_arr, env, mu, sigma, ver, n_sites, gamma_shape=gamma_a) return self._envelope_method(d_arr, env, mu, sigma, n_sites) # ───────────────────────────────────────────────────────────────────── def _gamma_method(self, d_arr, env, mu, sigma, ver, n_sites, gamma_shape=None): """ Gamma-distribution approach. At distance r the d/MD ratio follows gamma(shape, scale) where shape = global shape and scale = envelope(r) / shape, giving mean = envelope(r). Integrated over log-normal MD uncertainty. """ g = GAMMA_GLOBAL[ver] shape = float(gamma_shape) if gamma_shape is not None else g['a'] # scale so that gamma mean = envelope value at each site site_scale = np.maximum(env / shape, 1e-30) dz = 0.1 eps = np.arange(-6.0, 6.0 + dz / 2, dz) z = 10 ** (mu + eps * sigma) # MD samples (E,) p_eps = stats.norm.pdf(eps) # weights (E,) # y[e, disp, site] = d[disp] / MD[e] y = d_arr[None, :, None] / z[:, None, None] # (E, D, 1) ss = site_scale[None, None, :] # (1, 1, S) cdf_mat = stats.gamma.cdf(y, a=shape, scale=ss) if ver == 'MD': c1 = stats.gamma.cdf(1.0, a=shape, scale=ss) cdf_mat = np.where(y > 1.0, 1.0, cdf_mat / c1) cdf = np.tensordot(p_eps, cdf_mat, axes=(0, 0)) * dz # (D, S) prob = np.clip(1.0 - cdf, 0.0, 1.0) # Return (n_sites, n_disp) return prob.T.squeeze() if prob.ndim == 2 else prob.squeeze() # ───────────────────────────────────────────────────────────────────── def _envelope_method(self, d_arr, env, mu, sigma, n_sites): """ Deterministic envelope: P(d > d₀) = P(MD · env(r) > d₀). Since log₁₀(MD) ~ N(μ, σ²), this reduces to a normal survival function on log₁₀(d₀ / env). """ safe_env = np.maximum(env, 1e-30) # required_log_md[site, disp] = log10(d₀ / envelope) required_log_md = np.log10( d_arr[None, :] / safe_env[:, None]) # (S, D) prob = 1.0 - stats.norm.cdf( (required_log_md - mu) / sigma) return np.clip(prob, 0.0, 1.0).squeeze()