Source code for openquake.pfd.primary_surf_displ.chiou2025

# -*- 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.primary_surf_displ.chiou2025` implements
Chiou et al. (2025) primary surface fault displacement model (sum-of-principal)
for strike-slip faults, adapted to the PFDHA API.

This adapter mirrors the API used by other primary surface displacement models:

    get_prob(d, X_L_ratio, mag, style="strike-slip", version="model7")

Behavior and constraints:
- style must be strike-slip (case-insensitive); otherwise a ValueError is raised.
- Recommended magnitude range is (6.0, 8.3). Out-of-range magnitudes emit a warning but are computed.
- The model returns exceedance probabilities for the provided displacement threshold(s).

Coefficients are loaded from
``openquake/pfd/primary_surf_displ/data/chiou_2025_coefficients.csv``.

References
----------
Chiou, B., Chen, R., Thomas, K., Milliner, C., Dawson, T., & Petersen, M.
(2025). Fault displacement model for surface principal rupture of strike-slip
faults. Earthquake Spectra, 41(4), 2746-2782.
https://doi.org/10.1177/87552930251337703
"""

import os
import logging
import numpy as np
from scipy import stats

from openquake.pfd.params import check_style
from openquake.pfd.primary_surf_displ.base import BasePrimarySurfDispl


_LOGGER = logging.getLogger(__name__)


def _load_coefficients() -> np.recarray:
    here = os.path.dirname(__file__)
    data_path = os.path.join(here, "data", "chiou_2025_coefficients.csv")
    # Use numpy genfromtxt to avoid adding pandas dependency here
    arr = np.genfromtxt(
        data_path,
        delimiter=",",
        names=True,
        dtype=None,
        encoding="utf-8",
    )
    return arr


[docs]class Chiou2025PrimaryFD(BasePrimarySurfDispl): """ Chiou et al. (2025) primary surface displacement exceedance model for sum-of-principal displacement on strike-slip faults. :param d: Target displacement threshold(s) in meters (float or array-like). :param X_L_ratio: Normalized position x/L in [0, 1] (float or array-like). Supports vectorization over sites. :param mag: Moment magnitude Mw (float). Recommended range is (6.0, 8.3); outside this range a warning is logged and the computation proceeds. :param style: Must be "strike-slip" (case-insensitive), optional. If not, a ValueError is raised. :param version: Model formulation, optional. One of {"model7", "model8.1", "model8.2", "model8.3"}. Defaults to "model7". :returns: Exceedance probability(s) as an ndarray. Shape follows broadcasting of (n_sites, n_displacements) when X_L_ratio is a vector and d is a vector; otherwise reduced appropriately. Model contract: DISPLACEMENT_DEFINITION = "sum-of-principal", DISPLACEMENT_COMPONENT = "net" -- Chiou et al. (2025, Earthquake Spectra, "Fault displacement model for surface principal rupture of strike-slip faults") predict displacement summed across the PRINCIPAL strands crossed by a profile (distributed ruptures excluded, so the secondary slot remains legitimate alongside this model); Sarmiento et al. (2025) Table 1 lists CEA25 as D_SP,N* (sum-of-principal, pseudo-net component). """ DISPLACEMENT_DEFINITION = "sum-of-principal" DISPLACEMENT_COMPONENT = "net" # Chiou et al. (2025) define displacement position along their ECS # (equivalent continuous surface-rupture) line, so multi-fault ruptures # must build the ECS reference line for this model, not the LCP default. MULTIFAULT_REFERENCE_LINE = "ecs" # cache coefficients at class-level _COEFFS = _load_coefficients() def __init__(self, version=None, style=None): """ :param version: optional coefficient-set / model-variant identifier pinned by the logic-tree branch; ``None`` defers to the ``get_prob`` call (legacy default: 'model7'). :param style: optional faulting style pinned by the logic-tree branch; only 'strike-slip' is supported by this model. """ self.version = None if version is None else str(version) self.style = check_style(type(self).__name__, style, frozenset(["strike-slip"])) def get_prob(self, d, X_L_ratio, mag, style=None, version=None): """Return P(D > d) for the given displacement, position, and magnitude. Parameters ---------- d : float or array-like Target displacement value(s) in metres. X_L_ratio : float or array-like Normalized along-strike position (0 to 1). mag : float Moment magnitude (Mw). style : str, optional Faulting style; only ``'strike-slip'`` is supported. version : str, optional Coefficient set / model variant identifier (default ``'model7'``). Returns ------- float or ndarray Exceedance probability P(D > d | mag, X_L_ratio). Raises ------ ValueError If ``style`` is not a strike-slip style. """ # Fall back to constructor-pinned values, then legacy defaults if style is None: style = self.style if self.style is not None else "strike-slip" if version is None: version = self.version if self.version is not None else "model7" # Validate style style_str = str(style).strip().lower() if style_str not in {"strike-slip", "strikeslip", "ss"}: raise ValueError("Chiou2025PrimaryFD only supports style='strike-slip'.") # Magnitude check m = float(mag) if not (6.0 < m < 8.3): _LOGGER.warning( "Chiou2025PrimaryFD: magnitude %.3f is outside recommended range (6.0, 8.3)", m, ) # Version lookup (case-insensitive) ver = str(version).strip().lower() versions_available = {row[0].lower(): i for i, row in enumerate(self._COEFFS)} if ver not in versions_available: raise ValueError( f"Unknown version '{version}'. Expected one of: {sorted(versions_available)}" ) row = self._COEFFS[versions_available[ver]] # Extract coefficients (names align with CSV header) c0 = float(row[1]) m1 = float(row[2]) m2 = float(row[3]) m3 = float(row[4]) c1 = float(row[5]) cv1 = float(row[6]) cv2 = float(row[7]) cv3 = float(row[8]) cv4 = float(row[9]) cv5 = float(row[10]) cn = float(row[11]) ccap = float(row[12]) # Inputs d_arr = np.atleast_1d(np.asarray(d, dtype=float)) # (n_displ,) x_arr = np.atleast_1d(np.asarray(X_L_ratio, dtype=float)) # (n_sites,) # Clip X/L to [0,1] x_arr = np.clip(x_arr, 0.0, 1.0) # Folded x/L in [0, 0.5] using modulus reflection, robust to 1±eps r = x_arr - np.floor(x_arr) # map to [0,1) folded_x = 0.5 - np.abs(r - 0.5) # symmetric fold to [0,0.5] # Elliptical x* per author's reference: sqrt(1 - (1/0.5)^2 * (xl - 0.5)^2) x_star = np.sqrt(np.maximum(0.0, 1.0 - 4.0 * np.power(x_arr - 0.5, 2.0))) # (n_sites,) # Magnitude scaling f_M(M) fm = m2 * (m - m3) + (m2 - m1) / cn * np.log(0.5 * (1.0 + np.exp(-cn * (m - m3)))) # Mean of Gaussian component mu = c0 + fm + c1 * (x_star - 1.0) # (n_sites,) # Aleatory components sigma_mag = np.maximum(0.4, cv1 * np.exp(cv2 * np.maximum(0.0, m - 6.1))) sigma_xl = cv3 * np.exp(cv4 * np.maximum(0.0, folded_x - ccap)) # (n_sites,) sigma_prime = np.sqrt(np.power(sigma_mag, 2.0) + np.power(sigma_xl, 2.0)) # (n_sites,) # Exponential mixing parameter and shape for scipy's exponnorm nu = cv5 K = nu / sigma_prime # (n_sites,) # Compute exceedance probabilities using negative EMG (nEMG) z = np.log(np.maximum(d_arr, 1e-20)) # (n_displ,) # Broadcast to (n_sites, n_displ) loc = (-mu)[:, None] scale = sigma_prime[:, None] K_mat = K[:, None] # Using scipy.stats.exponnorm CDF with flipped argument as in reference # Author model: prob_exceed = exponnorm.cdf(-ln(d); loc=-mu, scale=sigma', K) prob_exceed = stats.exponnorm.cdf(x=(-z)[None, :], loc=loc, scale=scale, K=K_mat) # Safety clip prob_exceed = np.clip(prob_exceed, 0.0, 1.0) return prob_exceed.squeeze()