Source code for openquake.pfd.secondary_surf_displ.visini2025

# -*- 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.visini2025` implements
the distributed (secondary) fault displacement model of Visini et al. (2025)
in :class:`Visini2025SecondaryFD`.

References
----------
Visini, F., Boncio, P., Valentini, A., Scotti, O., Nurminen, F., Baize, S.,
& Pace, B. (2025). Empirical regressions for distributed faulting of dip-slip
earthquakes. Earthquake Spectra, 41(4), 2968-3001.
https://doi.org/10.1177/87552930241308860
"""
import warnings

import numpy as np
from scipy.stats import norm
from openquake.pfd.params import check_choice, check_style
from openquake.pfd.primary_surf_displ.base import BaseSecondarySurfDispl
from openquake.hazardlib.scalerel.wc1994 import WC1994
from openquake.hazardlib.scalerel.thingbaijam2017 import Thingbaijam2017
from openquake.hazardlib.scalerel.leonard2010 import Leonard2010


# Map of scaling relation names used by this module.  The relations live in
# hazardlib (PR-2 of the oq-engine integration plan); the canonical oq-pfdha
# name "WC1994" is mapped onto the hazardlib :class:`WC1994` class.
_SCALERS = {
    "WC1994": WC1994(),
    "THINGBAIJAM2017": Thingbaijam2017(),
    "LEONARD2010": Leonard2010(),
}


[docs]class Visini2025SecondaryFD(BaseSecondarySurfDispl): """ Distributed (secondary) displacement exceedance model (Visini et al., 2025). This implements the empirical regression for the exceedance probability P(Y > d), assuming a lognormal residual on ln(Y). The regression median uses predictors ln(s), ln(TPFm), Mw, style, footwall/hanging-wall, and combination offset (A/B/C). Parameters used across methods ------------------------------ - d (m): Displacement threshold. - mag (Mw): Earthquake magnitude. - s (m): Distance to the principal fault trace. - rx (m): Signed cross-fault distance (negative on the footwall). - X_L_ratio: Normalized along-strike position (0..1). - dip (deg): Fault dip angle (affects throw = slip * sin(dip)). - tpfm (m): Mean throw on principal fault. If None, computed from scaling. - style: 'normal' or 'reverse'. - combination: 'A', 'B', or 'C'. - scaling_model: 'WC1994' | 'THINGBAIJAM2017' | 'LEONARD2010' - n_sigma: half-width of ln(Y) truncation in σ units (MATLAB scripts use 3). Model contract: DISPLACEMENT_DEFINITION = "distributed", DISPLACEMENT_COMPONENT = "vertical" -- the predicted quantity Y is the vertical throw of Rank 2 distributed ruptures (Visini et al. 2025; the regression's TPFm predictor is likewise a throw). Declared applicability (in the model's own segments-r metric, see MULTIFAULT_REFERENCE_LINE): the paper excludes data closer than 5 m to the principal rupture (Visini et al. 2025, pp. 11, 20 -- such near-trace scarps are not distinguishable from principal faulting), so r_min = 5 m; the outer edges are 10 km on the hanging wall and 8 km on the footwall, the dataset range summarised in Valentini et al. (2025, Rev. Geophys., Table 4). The paper further differentiates its recommended ranges per Combination A/B/C (Visini et al. 2025, p. 14 and Conclusion; e.g. Combination B is only meaningful within ~1 km of a declared Rank 1.5 trace, cf. the user-manual model page); the values declared here are the outermost HW/FW envelope, which is what the once-per-run extrapolation warning needs. """ DISPLACEMENT_DEFINITION = "distributed" DISPLACEMENT_COMPONENT = "vertical" APPLICABILITY_RANGE = { "r_min_km": 0.005, "r_max_hw_km": 10.0, "r_max_fw_km": 8.0, "source": "Visini et al. (2025) pp. 11, 20 (5 m exclusion); " "Valentini et al. (2025) Rev. Geophys. Table 4 " "(HW 10 km / FW 8 km dataset envelope)", } # The Visini regressions are calibrated on distances to the ACTUAL # segmented principal rupture, so on multi-fault ruptures s must be the # distance to the nearest surface-reaching section (gaps not bridged) - # no smoothed ECS/LCP reference line applies. MULTIFAULT_REFERENCE_LINE = "segments" # Route the distributed contribution through VisiniSecondaryCalculator # (combined A/B/C combination + rank-2 Monte Carlo), not the generic # P(SR) x P(FD) adapter path (see BaseSecondarySurfDispl.SECONDARY_PIPELINE). SECONDARY_PIPELINE = "visini" def __init__(self, n_sigma: float = 3.0, truncation_eps: float = None, style=None, scaling_model=None, tpfm=None, case=None, rupture_traces=None) -> None: """ :param n_sigma: half-width of the ln(Y) truncation in sigma units (``truncation_eps`` is the deprecated alias). :param style: optional coefficient-set selector pinned by the logic-tree branch ('normal' or 'reverse'); ``None`` defers to the ``get_prob`` call. :param scaling_model: optional magnitude-scaling relation for the TPFm computation ('WC1994', 'THINGBAIJAM2017' or 'LEONARD2010'); ``None`` defers to the call (legacy default: 'WC1994'). :param tpfm: optional fixed total-principal-fault-length measure in meters pinned by the logic-tree branch; ``None`` = computed. :param case: optional Visini case label (e.g. 'case1'..'case3'), consumed by the secondary calculation pipeline; stored as given. :param rupture_traces: optional list of rank-1.5 trace names used by combination B, consumed by the secondary calculation pipeline; stored as given. """ # ``truncation_eps`` is the deprecated former name for ``n_sigma``; it is # still accepted (e.g. from older logic-tree configs) and takes priority. self.n_sigma = float(truncation_eps if truncation_eps is not None else n_sigma) if self.n_sigma <= 0.0: raise ValueError(f"n_sigma must be positive; got {self.n_sigma}") self.style = check_style(type(self).__name__, style, frozenset(["normal", "reverse"])) self.scaling_model = check_choice( type(self).__name__, "scaling_model", scaling_model, frozenset(["WC1994", "THINGBAIJAM2017", "LEONARD2010"]), canon=lambda v: str(v).upper()) self.tpfm = None if tpfm is None else float(tpfm) self.case = None if case is None else str(case) self.rupture_traces = rupture_traces # Empirical regression coefficients (ln Y) self.coeffs = { "a": -8.0651, # intercept "b": -0.2126, # ln(s) coefficient (s in meters) "c": 0.1518, # ln(TPFm) coefficient "d": 1.1426, # magnitude coefficient "e": -0.5259, # style indicator (normal=1, reverse=0) "f": -0.0131, # footwall indicator (footwall=1, hanging=0) "g": { # combination offsets "A": 0.0, "B": 0.0589, "C": 1.1193, }, "sigma": 1.0271, # std. dev. on ln(Y) } # --------------------------------------------------------------------- # # Public API # --------------------------------------------------------------------- # def get_prob( self, d, mag, s, rx, X_L_ratio=0.5, dip=90.0, tpfm=None, style=None, combination="A", scaling_model=None, **kwargs, ): """ Return the probability P(Y > d) for secondary displacement exceedance. Notes on units: - `s` and `rx` are **meters**. - `tpfm` is **meters** if provided. If None, it will be computed. Optional ``n_sigma`` in ``kwargs`` overrides the instance default (e.g. from logic-tree / INI parameters). The legacy name ``truncation_eps`` is also accepted. """ n_sigma_override = kwargs.pop("n_sigma", kwargs.pop("truncation_eps", None)) # Fall back to constructor-pinned values, then legacy defaults if style is None: style = self.style if self.style is not None else "normal" if scaling_model is None: scaling_model = (self.scaling_model if self.scaling_model is not None else "WC1994") if tpfm is None: tpfm = self.tpfm # Sanitize inputs d = np.asarray(d, dtype=float) s = np.asarray(s, dtype=float) rx = np.asarray(rx, dtype=float) # Compute (or verify) TPFm [m] if tpfm is None: # Per-site TPFm: compute using a km-based search window equal to each site's s_km s_km = s / 1000.0 # Broadcast X_L_ratio and dip to s's shape xlr_b = np.broadcast_to(np.asarray(X_L_ratio, dtype=float), s.shape) dip_b = np.broadcast_to(np.asarray(dip, dtype=float), s.shape) # DIAGNOSTIC: Check for invalid X_L_ratio values xlr_min, xlr_max = float(np.min(xlr_b)), float(np.max(xlr_b)) if xlr_min < 0.0 or xlr_max > 1.0: n_invalid = int(np.sum((xlr_b < 0.0) | (xlr_b > 1.0))) warnings.warn( f"Visini2025SecondaryFD.get_prob: X_L_ratio has {n_invalid} values outside [0, 1]. " f"Range: [{xlr_min:.6f}, {xlr_max:.6f}]. " f"This indicates upstream calculation error in rupture_distance.py. " f"Clamping to [0, 1] to prevent NaN.", RuntimeWarning ) xlr_b = np.clip(xlr_b, 0.0, 1.0) flat_skm = s_km.reshape(-1) flat_xlr = xlr_b.reshape(-1) flat_dip = dip_b.reshape(-1) flat_tpfm = np.empty_like(flat_skm, dtype=float) for i in range(flat_skm.size): tpfm_i, _ = self.compute_tpfm_from_scaling( mag, style=style, model=scaling_model, norm_pos=float(flat_xlr[i]), distance=float(flat_skm[i]), # <-- km per site dip=float(flat_dip[i]), ) flat_tpfm[i] = tpfm_i tpfm = flat_tpfm.reshape(s.shape) else: tpfm = np.asarray(tpfm, dtype=float) # Compute regression median and exceedance probability median_y = self.get_median_displacement( mag=mag, s=s, rx=rx, tpfm=tpfm, style=style, combination=combination ) # Ensure arrays for vectorized sf; guard against non-positive thresholds d = np.maximum(d, 1e-16) ln_d = np.log(d) ln_med = np.log(np.maximum(median_y, 1e-16)) sigma = float(self.coeffs["sigma"]) eps = float( self.n_sigma if n_sigma_override is None else n_sigma_override ) if eps <= 0.0: raise ValueError(f"n_sigma must be positive; got {eps}") denom = norm.cdf(eps) - norm.cdf(-eps) # Broadcast to (n_sites, n_displ): displacement thresholds along # columns, per-site medians along rows. The orientation must NEVER be # inferred from shape equality - when n_sites happens to equal # n_displ that heuristic silently produced the element-wise diagonal # (site i paired with threshold i) instead of the full matrix. ln_d_arr = np.asarray(ln_d) ln_med_arr = np.asarray(ln_med) if (ln_d_arr.ndim == 1) and (ln_med_arr.ndim == 1): ln_d_b = ln_d_arr.reshape((1, -1)) ln_med_b = ln_med_arr.reshape((-1, 1)) else: ln_d_b = ln_d_arr ln_med_b = ln_med_arr # P(Y > d) under truncated Normal(ln_med, sigma) with bounds [ln_med - eps*sigma, ln_med + eps*sigma] z = (ln_d_b - ln_med_b) / sigma p_exceed = (norm.cdf(eps) - norm.cdf(z)) / denom # Hard bounds from truncation p_exceed = np.where(z <= -eps, 1.0, p_exceed) p_exceed = np.where(z >= eps, 0.0, p_exceed) p_exceed = np.clip(p_exceed, 0.0, 1.0) # Return scalar if scalar input if np.asarray(p_exceed).shape == (): return float(p_exceed) return p_exceed def get_median_displacement(self, mag, s, rx, tpfm, style, combination): """ Return the regression median displacement (meters). """ style_l = str(style).lower() if style_l not in ("normal", "reverse"): raise ValueError(f"style must be 'normal' or 'reverse'; got '{style}'") if combination not in ("A", "B", "C"): raise ValueError(f"combination must be 'A', 'B', or 'C'; got '{combination}'") # Indicators sof_indicator = 1 if style_l == "normal" else 0 rx_arr = np.atleast_1d(rx) fw_indicator = np.where(rx_arr < 0.0, 1, 0) # 1=footwall, 0=hanging wall # Coefficients p = self.coeffs # Prepare predictors (ensure arrays) distance = np.maximum(np.atleast_1d(s).astype(float), 1e-8) # m tpfm = np.maximum(np.atleast_1d(tpfm).astype(float), 1e-16) # m ln_s = np.log(distance) ln_tpfm = np.log(tpfm) g_offset = float(p["g"][combination]) # Broadcast everything to a common shape ln_s, ln_tpfm, rx_arr = np.broadcast_arrays(ln_s, ln_tpfm, rx_arr) sof_b = np.broadcast_to(sof_indicator, ln_s.shape) fw_b = np.broadcast_to(fw_indicator, ln_s.shape) # Equation: ln(Y_med) = a + b*ln(s) + c*ln(TPFm) + d*Mw + e*I_style + f*I_fw + g_comb ln_y_med = ( p["a"] + p["b"] * ln_s + p["c"] * ln_tpfm + p["d"] * float(mag) + p["e"] * sof_b + p["f"] * fw_b + g_offset ) median = np.exp(ln_y_med) if median.shape == (): return float(median) return median # --------------------------------------------------------------------- # # TPFm construction (scaling + along-strike smoothing) # --------------------------------------------------------------------- # def compute_tpfm_from_scaling( self, mag, style="normal", model="WC1994", norm_pos=0.5, distance=0.0, dip=90.0, ): """ Compute mean throw on the principal fault (TPFm) from a magnitude-scaling law, then smooth along strike using a window whose half-width increases with the site's distance to the PF. Parameters ---------- mag : float Earthquake moment magnitude (Mw). style : str 'normal' or 'reverse'. model : str 'WC1994' | 'THINGBAIJAM2017' | 'LEONARD2010' norm_pos : float Normalized along-strike position in [0, 1]. distance : float Site-to-PF distance in **km** (used only to define the smoothing window). dip : float Fault dip in degrees. Returns ------- (tpfm_m, sigma_log10) : (float, float) TPFm in meters and the log10 sigma of the average-slip scaling. """ model_u = str(model).upper() if model_u not in _SCALERS: raise ValueError(f"Unknown scaling model '{model}'") sr = _SCALERS[model_u] # Average slip AD and its log10 sigma ad_m, sigma_log10 = sr.get_average_displacement(mag, style, return_sigma=True) # Maximum slip MD (if not available, use 2*AD as a proxy) if hasattr(sr, "get_maximum_displacement"): md_m = sr.get_maximum_displacement(mag, style) else: md_m = 2.0 * ad_m # Fault length L [km] for computing the along-strike search window if hasattr(sr, "get_length_km"): L_km = sr.get_length_km(mag, style) elif hasattr(sr, "get_median_length"): rake = 90.0 if str(style).lower() == "reverse" else -90.0 L_km = sr.get_median_length(mag, rake) elif hasattr(sr, "get_rupture_length"): L_km = sr.get_rupture_length(mag) elif hasattr(sr, "get_surface_rupture_length"): # WC1994 exposes SRL(M) under this name; without this probe the # DEFAULT scaling model would silently never activate the # distance-dependent along-strike smoothing window (L_km = None # forces r = 0 below). L_km = sr.get_surface_rupture_length(mag, str(style).lower()) else: L_km = None # Half-window r in normalized along-strike coordinate (clip to 0.5) if L_km and distance > 0.0: r = 0.5 * float(distance) / float(L_km) r = min(r, 0.5) # DIAGNOSTIC: Check for NaN r (could happen if L_km is NaN) if np.isnan(r): warnings.warn( f"Visini2025SecondaryFD: r is NaN! L_km={L_km}, distance={distance}. " f"Check scaling relation {model} for this magnitude.", RuntimeWarning ) r = 0.0 else: r = 0.0 # Construct mean slip (triangular + tapered) and convert to throw by sin(dip) x = np.linspace(0.0, 1.0, 1001) tri_shape = np.maximum(1.0 - 2.0 * np.abs(x - 0.5), 0.0) # triangular tap_shape = np.sqrt(np.sin(np.pi * x)) # tapered D_tri = md_m * tri_shape D_tap = 1.311 * ad_m * tap_shape D_mean = 0.5 * (D_tri + D_tap) T_mean = D_mean * np.sin(np.radians(dip)) # Sample or smooth around norm_pos # DIAGNOSTIC: Check for invalid norm_pos values and warn (not silently clamp) if norm_pos < 0.0 or norm_pos > 1.0: warnings.warn( f"Visini2025SecondaryFD: norm_pos={norm_pos:.6f} is outside [0, 1]. " f"This indicates upstream X_L_ratio computation is incorrect. " f"Check rupture_distance.py calculate_x_l_ratios(). " f"Parameters: mag={mag}, style={style}, distance_km={distance:.4f}, r={r:.6f}. " f"Clamping to [0, 1] to prevent NaN.", RuntimeWarning ) norm_pos = max(0.0, min(1.0, norm_pos)) if r == 0.0: tpfm_loc = float(np.interp(norm_pos, x, T_mean)) else: left = max(0.0, norm_pos - r) right = min(1.0, norm_pos + r) mask = (x >= left) & (x <= right) # Safety: if mask is empty (should not happen after clamping), fallback to interpolation if not np.any(mask): tpfm_loc = float(np.interp(norm_pos, x, T_mean)) else: tpfm_loc = float(np.mean(T_mean[mask])) return tpfm_loc, float(sigma_log10)