Source code for openquake.pfd.primary_surf_rup.mammarella2024

# -*- 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_rup.mammarella2024` implements
Mammarella et al. (2024) primary surface-rupture probability (principal faulting)
as a first-class model.

References
----------
Mammarella, L., Visini, F., Boncio, P., Baize, S., Scotti, O., Beauval, C.,
Pace, B., & Thompson, S. (2024). Conditional probability of surface rupture:
a numerical approach for principal faulting. Earthquake Spectra.
https://doi.org/10.1177/87552930241293570

Notes
-----
- Vectorized re-implementation of the reference numerical (Monte Carlo style
  discrete-integration) procedure published with the paper.
- Probabilities are clipped to [0, 1].

API
---
Class: ``Mammarella2024PrimarySR``

get_prob(mag, MSR, HDD_str, dip_mu, dip_sigma, t_d, Zs_sigma, t_z,
         rake=None, style=None, seismothickness=None, Zs_mu=None) -> float | np.ndarray

Parameters
~~~~~~~~~~
- mag: float or array-like
    Moment magnitude Mw. Scalar returns scalar; vector returns ndarray of same length.
- rake: float, optional
    Rake angle in degrees. Style-of-faulting (SoF) is inferred via thresholds:
    normal if (-120, -60), reverse if (60, 120), strike-slip otherwise. Ignored if style is provided.
- style: str, optional
    Style-of-faulting string in {"normal", "reverse", "strike-slip"}. Used to set SoF if rake is not provided.
- seismothickness: float, optional
    Seismogenic thickness (km); used as Zs_mu if Zs_mu is not provided.
- Zs_mu: float, optional
    Mean seismogenic thickness (km). If omitted, falls back to seismothickness.
- MSR: int
    Magnitude scaling relation code in {0, 1, 2}. Required by Table 1.
- HDD_str: str
    Hypocentral depth distribution label. Must be one of keys in TAB2.
- dip_mu: float
    Mean dip (degrees).
- dip_sigma: float
    Standard deviation of dip (degrees).
- t_d: float
    Truncation in units of sigma for dip distribution.
- Zs_sigma: float
    Standard deviation of seismogenic thickness Zs (km).
- t_z: float
    Truncation in units of sigma for Zs distribution.

Returns
~~~~~~~
- float if input Mw is scalar, else np.ndarray with shape (n_mw,)

Behavior
~~~~~~~~
The algorithm integrates (discrete) joint weights over grids for LogW, dip, HDR,
Zseismo, and HDD ratio using truncated/normal/uniform priors, evaluating the
surface rupture condition as in the reference implementation. Broadcasting is used
to avoid large repeat/tile expansions. Final probability is the sum of joint
weights satisfying the condition.
"""

import numpy as np
from scipy.stats import norm, truncnorm

from openquake.hazardlib import valid
from openquake.pfd.primary_surf_rup.base import BasePrimarySurfRup


# ---------------------------
# Constants and lookup tables
# ---------------------------

# Rupture-width magnitude scaling relation parameters by (MSR, SoF).
# columns: [MSR, SoF, a, b, W_sigma]
# MSR codes:
#   0 = Leonard (2014), interplate: m = a + 2.5*log10(W)
#       (a = 3.63 dip-slip, 3.88 strike-slip)
#   1 = Leonard (2014), stable continental region: same form
#       (a = 4.14 dip-slip, 4.22 strike-slip)
#   2 = Thingbaijam et al. (2017): log10(W) = a + b*m
#       (normal -0.829/0.323, reverse -1.669/0.435, strike-slip -0.543/0.261)
# SoF codes: 3 = normal, 4 = reverse, 5 = strike-slip.
TAB1 = np.array([
    [0, 3, 3.63, 2.5, 0.15],
    [0, 4, 3.63, 2.5, 0.15],
    [0, 5, 3.88, 2.5, 0.15],
    [1, 3, 4.14, 2.5, 0.15],
    [1, 4, 4.14, 2.5, 0.15],
    [1, 5, 4.22, 2.5, 0.15],
    [2, 3, -0.829, 0.323, 0.128],
    [2, 4, -1.669, 0.435, 0.087],
    [2, 5, -0.543, 0.261, 0.105],
], dtype=float)


# Table 2: HDD ratio parameters by label -> (mu, sigma)
TAB2 = {
    'ITA_N': (0.67, 0.21), 'GB_N': (0.64, 0.25), 'AGG_N': (0.65, 0.23),
    'ITA_R': (0.65, 0.24), 'TAI_R': (0.60, 0.25), 'JAP_R': (0.74, 0.20),
    'AGG_R': (0.66, 0.24), 'CA_S': (0.67, 0.21), 'NZ_S': (0.72, 0.21),
    'JAP_S': (0.79, 0.15), 'AGG_S': (0.70, 0.23),
}


# Discretization and truncation
class DISCRETIZATION:
    """Numerical discretization constants for the Mammarella et al. (2024) integration grid."""

    N_LOGW = 25
    N_DIP = 100
    HDR_GRID = np.arange(0.1, 1.0, 0.1)  # shape (9,)
    Z_STEP = 0.5  # km


# Truncations (multipliers of sigma)
T_W = 1.0  # for LogW


def _infer_sof_from_rake(rake_deg: float) -> int:
    """Map rake to SoF code consistent with user reference.

    Returns 3=normal, 4=reverse, 5=strike-slip.
    """
    if (-120.0 < rake_deg < -60.0):
        return 3
    if (60.0 < rake_deg < 120.0):
        return 4
    return 5


def _get_tab1_params(msr: int, sof: int) -> tuple[float, float, float]:
    mask = (TAB1[:, 0] == msr) & (TAB1[:, 1] == sof)
    idx = np.where(mask)[0]
    if idx.size == 0:
        # Fall back to first row (kept to mirror reference; validation covers msr)
        idx0 = 0
    else:
        idx0 = int(idx[0])
    a = float(TAB1[idx0, 2])
    b = float(TAB1[idx0, 3])
    w_sigma = float(TAB1[idx0, 4])
    return a, b, w_sigma


def _calc_LogW_mu_fn(msr: int, a: float, b: float):
    if msr in (0, 1):
        return lambda m: (m - a) / b
    if msr == 2:
        return lambda m: a + b * m
    raise ValueError("Invalid Magnitude Scaling Relation (MSR); expected 0, 1, or 2")


def _resolve_width_model(width_model):
    """Return a width scaling-relation instance for a name or instance.

    ``None`` passes through.  A string is resolved through
    :func:`openquake.hazardlib.valid.mag_scale_rel` (the canonical
    magnitude-scaling-relationship registry), so callers can use either a
    scalerel instance or its registered name - PR-2 of the oq-engine
    integration plan.  The instance must expose ``get_median_width`` and
    ``get_std_dev_width``.
    """
    if width_model is None:
        return None
    if isinstance(width_model, str):
        width_model = valid.mag_scale_rel(width_model)
    for meth in ("get_median_width", "get_std_dev_width"):
        if not hasattr(width_model, meth):
            raise ValueError(
                f"width_model must provide {meth}; got {width_model!r}")
    return width_model


[docs]class Mammarella2024PrimarySR(BasePrimarySurfRup): """ Probability of principal surface rupture after Mammarella et al. (2024). :param mag: Moment magnitude Mw (float or array-like). Scalar input returns a scalar, a vector returns an array. :param rake: Rake in degrees (float). Style-of-faulting is inferred: normal (3) for (-120,-60), reverse (4) for (60,120), strike-slip (5) otherwise. :param seismothickness: Seismogenic thickness Zs_mu (float, km). :param MSR: Magnitude scaling relation code (int) in {0, 1, 2}. :param width_model: Explicit hazardlib width scaling relation replacing the integer ``MSR`` code (scalerel instance or str, optional). Accepts a scalerel instance (exposing ``get_median_width`` / ``get_std_dev_width``) or a registered name resolved through :func:`openquake.hazardlib.valid.mag_scale_rel` (e.g. ``"Leonard2014_Interplate"``); when given, ``MSR`` is not required. :param HDD_str: Hypocentral depth distribution label (str); must be a key of TAB2. :param dip_mu: Mean dip (float, degrees). :param dip_sigma: Standard deviation of dip (float, degrees). :param t_d: Truncation factor for dip distribution (float, in sigma units). :param Zs_sigma: Standard deviation of seismogenic thickness (float, km). :param t_z: Truncation factor for Zs distribution (float, in sigma units). :returns: Probability in [0, 1] (float or np.ndarray). Scalar if mag is scalar, else shape (n_mw,). """ def __init__(self, MSR=None, HDD_str=None, dip_mu=None, dip_sigma=None, t_d=None, Zs_sigma=None, t_z=None, style=None, seismothickness=None, Zs_mu=None, width_model=None): """ Constructor-pinned model parameters, each defaulting to ``None`` ("provide at call time instead"): the magnitude-scaling-relation index ``MSR`` (0, 1 or 2), the hypocentral-depth-distribution key ``HDD_str``, the dip prior ``dip_mu``/``dip_sigma`` with truncation ``t_d``, the seismogenic-thickness prior ``Zs_mu`` (alias ``seismothickness``)/``Zs_sigma`` with truncation ``t_z``, and the faulting ``style``. A pinned value is the fallback when the corresponding ``get_prob`` argument is not passed; an explicit call-time argument always wins (``dip_mu`` in particular normally comes from the rupture context unless pinned here). """ if MSR is not None and int(MSR) not in (0, 1, 2): raise ValueError( f"{type(self).__name__}: MSR must be one of {{0, 1, 2}}; " f"got {MSR!r}") if HDD_str is not None and HDD_str not in TAB2: raise ValueError( f"{type(self).__name__}: invalid HDD_str {HDD_str!r}; " f"expected one of {sorted(TAB2)}") self.MSR = None if MSR is None else int(MSR) self.HDD_str = HDD_str self.dip_mu = None if dip_mu is None else float(dip_mu) self.dip_sigma = None if dip_sigma is None else float(dip_sigma) self.t_d = None if t_d is None else float(t_d) self.Zs_sigma = None if Zs_sigma is None else float(Zs_sigma) self.t_z = None if t_z is None else float(t_z) self.style = style self.seismothickness = (None if seismothickness is None else float(seismothickness)) self.Zs_mu = None if Zs_mu is None else float(Zs_mu) self.width_model = _resolve_width_model(width_model) def get_prob(self, mag, MSR=None, HDD_str=None, dip_mu=None, dip_sigma=None, t_d=None, Zs_sigma=None, t_z=None, rake=None, style=None, seismothickness=None, Zs_mu=None, width_model=None): """Return the probability of principal surface rupture. See the class docstring for the full description of parameters (``mag``, ``MSR``, ``HDD_str``, dip and seismogenic-thickness distribution parameters) and the return value. Parameters left as ``None`` fall back to the values pinned at construction. """ # Fall back to constructor-pinned values (call-time argument wins) MSR = self.MSR if MSR is None else MSR HDD_str = self.HDD_str if HDD_str is None else HDD_str dip_mu = self.dip_mu if dip_mu is None else dip_mu dip_sigma = self.dip_sigma if dip_sigma is None else dip_sigma t_d = self.t_d if t_d is None else t_d Zs_sigma = self.Zs_sigma if Zs_sigma is None else Zs_sigma t_z = self.t_z if t_z is None else t_z style = self.style if style is None else style seismothickness = (self.seismothickness if seismothickness is None else seismothickness) Zs_mu = self.Zs_mu if Zs_mu is None else Zs_mu # A width scalerel instance (or registered name) replaces the # integer MSR code with a hazardlib scaling relation. width_model = _resolve_width_model( self.width_model if width_model is None else width_model) missing = [n for n, v in [("MSR", MSR), ("HDD_str", HDD_str), ("dip_mu", dip_mu), ("dip_sigma", dip_sigma), ("t_d", t_d), ("Zs_sigma", Zs_sigma), ("t_z", t_z)] if v is None and not (n == "MSR" and width_model is not None)] if missing: raise ValueError( f"{type(self).__name__}: missing required parameter(s) " f"{missing}; provide them in the logic-tree branch or at " f"call time") # Validate and prepare inputs. The MSR code is only required when no # explicit width scaling relation is given. if width_model is None: msr = int(MSR) if msr not in (0, 1, 2): raise ValueError("MSR must be one of {0, 1, 2}") else: msr = None # Determine style of faulting code (SoF) if style is not None: style_str = str(style).strip().lower() if style_str in {"normal", "nm", "nmo"}: sof = 3 elif style_str in {"reverse", "rv", "rvo", "thrust"}: sof = 4 elif style_str in {"strike-slip", "ss"}: sof = 5 else: raise ValueError(f"Invalid style '{style}'. Expected one of 'normal', 'reverse', 'strike-slip'") elif rake is not None: sof = _infer_sof_from_rake(float(rake)) else: # Default to strike-slip if neither provided sof = 5 if HDD_str not in TAB2: raise ValueError(f"Invalid HDD_str '{HDD_str}'. Expected one of {sorted(TAB2)}") hdd_mu, hdd_sigma = map(float, TAB2[HDD_str]) dip_mu = float(dip_mu) dip_sigma = float(dip_sigma) t_d = float(t_d) # Zs mean from explicit Zs_mu or fallback to seismothickness if Zs_mu is None and seismothickness is None: raise ValueError("Provide either Zs_mu or seismothickness for MammarellaEtAl2024PrimarySR") zs_mu = float(Zs_mu if Zs_mu is not None else seismothickness) zs_sigma = float(Zs_sigma) t_z = float(t_z) if width_model is None: a, b, w_sigma = _get_tab1_params(msr, sof) logw_mu_fn = _calc_LogW_mu_fn(msr, a, b) else: # Representative rake for the width scaling relation: normal, # reverse and strike-slip map to the SoF codes 3, 4 and 5. width_rake = {3: -90.0, 4: 90.0, 5: 0.0}[sof] # Grids # LogW ~ truncnorm centered at LogW_mu(m), +/- T_W * w_sigma # dip ~ normal truncated at +/- t_d * dip_sigma (discrete on N_DIP points) # HDR ~ uniform on [0.1, 0.9] stepping 0.1 # Zseismo ~ normal truncated at +/- t_z * zs_sigma, step Z_STEP (km) r_hdr = DISCRETIZATION.HDR_GRID # (n_r,) # HDD ratio discretization same support as HDR per reference (0.1..0.9) r_hdd = r_hdr m_arr = np.atleast_1d(np.asarray(mag, dtype=float)) probs = np.empty(m_arr.shape, dtype=float) # Precompute dip grid and pdf weights (independent of Mw) dip_lower = dip_mu - t_d * dip_sigma dip_upper = dip_mu + t_d * dip_sigma dip_grid = np.linspace(dip_lower, dip_upper, DISCRETIZATION.N_DIP) dip_pdf = norm(loc=dip_mu, scale=dip_sigma).pdf(dip_grid) dip_w = dip_pdf / np.sum(dip_pdf) if np.any(dip_pdf) else np.zeros_like(dip_pdf) # Precompute HDR uniform weights hdr_w = np.full_like(r_hdr, 1.0 / r_hdr.size, dtype=float) # Precompute HDD ratio weights (discrete normalization) hdd_pdf = norm(loc=hdd_mu, scale=hdd_sigma).pdf(r_hdd) hdd_w = hdd_pdf / np.sum(hdd_pdf) if np.any(hdd_pdf) else np.zeros_like(hdd_pdf) # Zseismo grid depends on zs_mu, zs_sigma z_lower = zs_mu - t_z * zs_sigma z_upper = zs_mu + t_z * zs_sigma z_grid = np.arange(z_lower, z_upper + DISCRETIZATION.Z_STEP * 0.5, DISCRETIZATION.Z_STEP) z_pdf = norm(loc=zs_mu, scale=zs_sigma).pdf(z_grid) z_w = z_pdf / np.sum(z_pdf) if np.any(z_pdf) else np.zeros_like(z_pdf) # Iterate magnitudes (light loop; heavy vectorization inside) for i, m in enumerate(m_arr): # LogW discretization around mean if width_model is None: logw_mu = float(logw_mu_fn(m)) else: logw_mu = float(np.log10( width_model.get_median_width(m, width_rake))) w_sigma = float( width_model.get_std_dev_width(m, width_rake)) logw_min = logw_mu - T_W * w_sigma logw_max = logw_mu + T_W * w_sigma logw_grid = np.linspace(logw_min, logw_max, DISCRETIZATION.N_LOGW) a_trunc = (logw_min - logw_mu) / w_sigma # -> -T_W b_trunc = (logw_max - logw_mu) / w_sigma # -> +T_W # Use truncnorm.pdf over the linspace logw_pdf = truncnorm.pdf(logw_grid, a_trunc, b_trunc, loc=logw_mu, scale=w_sigma) logw_w = logw_pdf / np.sum(logw_pdf) if np.any(logw_pdf) else np.zeros_like(logw_pdf) W = np.power(10.0, logw_grid) # km # Compute W_z = W * sin(dip) sin_dip = np.sin(np.deg2rad(dip_grid)) # (n_d,) W_z = W[:, None] * sin_dip[None, :] # (n_w, n_d) # Combine W and dip weights -> outer product wd_w = logw_w[:, None] * dip_w[None, :] # (n_w, n_d) # Introduce HDR: W_top = W_z * HDR w_hdr = wd_w[:, :, None] * hdr_w[None, None, :] # (n_w, n_d, n_r) # Tile Zseismo over (w,d,r) and combine weights Zs = z_grid[None, None, None, :] # (1, 1, 1, n_z) w_zs = z_w[None, None, None, :] # (1, 1, 1, n_z) # HDD ratio grid r_hdd_grid = r_hdd[None, None, None, None, :] # (1,1,1,1,n_h) w_hdd = hdd_w[None, None, None, None, :] # (1,1,1,1,n_h) # Broadcast shapes: # W_top: (n_w, n_d, n_r) -> (n_w, n_d, n_r, 1, 1) # Zs: (1, 1, 1, n_z) # r_hdd: (1, 1, 1, 1, n_h) # Expand Zs weights to 5D before multiplying w_all = w_hdr[:, :, :, None, None] * w_zs[:, :, :, :, None] * w_hdd # (n_w,n_d,n_r,n_z,n_h) # Zhypo = Zseismo * HDD_ratio Z_hypo = Zs[:, :, :, :, None] * r_hdd_grid # (1,1,1,n_z, n_h) # Reallocation rules (vectorized): # Start from NEW_HDR = HDR; NEW_Wz = W_z; but we have W_top already. # We need to apply the logic from reference code on the 5D broadcast. # Prepare baseline arrays via broadcasting W_z_b = W_z[:, :, None, None, None] # (n_w,n_d,1,1,1) Zs_b = Zs # (1,1,1,n_z) HDR_b = r_hdr[None, None, :, None, None] # (1,1,n_r,1,1) # Conditions # Base condition (no r/h dims yet): (n_w, n_d, 1, n_z, 1) cond1_base = W_z_b >= Zs_b[:, :, :, :, None] # Broadcast to full shape including r and h n_w, n_d = W_z.shape n_r = r_hdr.size n_z = z_grid.size n_h = r_hdd.size cond1 = np.broadcast_to(cond1_base, (n_w, n_d, n_r, n_z, n_h)) # Initialize NEW_HDR and NEW_Wz NEW_HDR = np.broadcast_to(HDR_b, (n_w, n_d, n_r, n_z, n_h)).astype(float) NEW_Wz = np.broadcast_to(W_z_b, (n_w, n_d, n_r, n_z, n_h)).astype(float) # If W_z >= Zseismo: NEW_Wz = Zseismo and NEW_HDR = HDD (CPSR.m idx1) NEW_Wz = np.where(cond1, Zs_b[:, :, :, :, None], NEW_Wz) NEW_HDR = np.where(cond1, np.broadcast_to(r_hdd_grid, (n_w, n_d, n_r, n_z, n_h)), NEW_HDR) # For remaining (not cond1): apply two more rules involving HDR and HDD # term_a = (Zseismo - Zhypo); term_b = (W_z - HDR*W_z) term_a = Zs_b[:, :, :, :, None] - Z_hypo term_b = W_z_b - (HDR_b * W_z_b) cond2 = (term_b >= term_a) & (~cond1) # NEW_HDR = 1 - ((Zseismo - Zhypo)/W_z) NEW_HDR = np.where( cond2, 1.0 - (term_a / np.maximum(W_z_b, 1e-15)), NEW_HDR, ) # cond3: (HDR*W_z >= Zhypo) & (~cond1) cond3 = ((HDR_b * W_z_b) >= Z_hypo) & (~cond1) # NEW_HDR = Zhypo / W_z NEW_HDR = np.where( cond3, (Z_hypo / np.maximum(W_z_b, 1e-15)), NEW_HDR, ) NEW_Wtop = NEW_Wz * NEW_HDR # Surface rupture condition: NEW_Wtop >= Zhypo SRC = NEW_Wtop >= Z_hypo # Joint weights p_total = w_all # Sum over all cells satisfying SRC prob = float(np.sum(p_total[SRC])) probs[i] = np.clip(prob, 0.0, 1.0) return probs.item() if probs.size == 1 else probs
[docs]class MammarellaEtAl2024PrimarySR(Mammarella2024PrimarySR): """Alias of :class:`Mammarella2024PrimarySR` using the full author naming.""" pass