Source code for openquake.pfd.primary_surf_displ.kuehn2024.kuehn2024

# -*- 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.kuehn2024.kuehn2024`
implements the model of Kuehn et al. (2024) in :class:`Kuehn2024PrimaryFD`.

References
----------
Kuehn, N. M., Kottke, A. R., Sarmiento, A. C., Madugo, C. M., & Bozorgnia,
Y. (2024). A fault displacement model based on the FDHI database. Earthquake
Spectra, 41(4), 2783-2805. https://doi.org/10.1177/87552930241291077
"""


import numpy as np
import pandas as pd
from scipy import stats
from openquake.pfd.params import check_bool, check_style
from openquake.pfd.primary_surf_displ.base import BasePrimarySurfDispl
from openquake.pfd.primary_surf_displ.kuehn2024.load_data import DATA as DATA_COEFFICIENTS



# Model constants
MAG_BREAK = 7.0
DELTA = 0.1

[docs]class Kuehn2024PrimaryFD(BasePrimarySurfDispl): """Aggregate fault-displacement model of Kuehn et al. (2024), run in the principal (primary_surf_displ) slot. Bayesian hierarchical model of fault displacement as a function of magnitude, normalized along-strike position, and faulting style, with optional epistemic-uncertainty sampling over the posterior coefficients. References ---------- Kuehn, N. M., Kottke, A. R., Sarmiento, A. C., Madugo, C. M., & Bozorgnia, Y. (2024). A fault displacement model based on the FDHI database. Earthquake Spectra, 41(4), 2783-2805. https://doi.org/10.1177/87552930241291077 Model contract: DISPLACEMENT_DEFINITION = "aggregate", DISPLACEMENT_COMPONENT = "net" -- Kuehn et al. (2024) fit the FDHI *aggregate* net displacement (total slip across principal and distributed ruptures within the measurement aperture); Sarmiento et al. (2025, Earthquake Spectra) Table 1 lists KEA24 under the aggregate definition, and there is no cross-definition conversion (ibid.). Because the prediction already contains the distributed contribution, the hazard kernel runs this model as a single bucket (rate * P_sr * P_fd_aggregate * W_p) and any secondary-slot model in the same chain is rejected (FDLT-013; docs/design/rupture_location_uncertainty.md, D8). """ DISPLACEMENT_DEFINITION = "aggregate" DISPLACEMENT_COMPONENT = "net" _ACCEPTED_STYLES = frozenset(["strike-slip", "reverse", "normal"]) def __init__(self, style=None, epistemic_uncertainty=None, folded=None, coefficient_type=None): """ :param style: optional coefficient-set selector pinned by the logic-tree branch ('strike-slip', 'reverse' or 'normal'); ``None`` defers to the ``get_prob`` call. :param epistemic_uncertainty: optional flag pinned by the logic-tree branch (accepts booleans or the strings 'true'/'false'); ``None`` defers to the ``get_prob`` call (legacy default: True). :param folded: optional x/L folding flag; legacy default True. :param coefficient_type: optional legacy alias ('full' enables epistemic uncertainty); ``None`` defers to the call. """ self.style = check_style(type(self).__name__, style, self._ACCEPTED_STYLES) self.epistemic_uncertainty = check_bool( type(self).__name__, "epistemic_uncertainty", epistemic_uncertainty) self.folded = check_bool(type(self).__name__, "folded", folded) self.coefficient_type = coefficient_type def get_prob(self, d, X_L_ratio, mag, style=None, folded=None, epistemic_uncertainty=None, coefficient_type=None): """ Calculate the probability of exceeding displacement thresholds [m] for Kuehn et al. (2024). """ # Fall back to constructor-pinned values, then legacy defaults if style is None: style = self.style if style is None: raise ValueError( f"{type(self).__name__}: style must be given either in the " f"logic-tree branch or at call time") if folded is None: folded = self.folded if self.folded is not None else True if epistemic_uncertainty is None: epistemic_uncertainty = ( self.epistemic_uncertainty if self.epistemic_uncertainty is not None else True) if coefficient_type is None: coefficient_type = self.coefficient_type style = style.lower() valid_styles = ['strike-slip', 'reverse', 'normal'] if style not in valid_styles: raise ValueError(f"Invalid style '{style}'. Accepted values are: {', '.join(valid_styles)}") # Normalize inputs (allow vectorized X_L_ratio and d) mag_arr = np.atleast_1d(mag) if mag_arr.size != 1: raise ValueError("Only single values allowed for mag") current_mag = float(mag_arr[0]) x_arr = np.atleast_1d(X_L_ratio).astype(float) d_arr = np.atleast_1d(d).astype(float) # Map coefficient_type if provided if coefficient_type is not None: ct = str(coefficient_type).lower() epistemic_uncertainty = (ct == 'full') if epistemic_uncertainty: all_coeffs_df = DATA_COEFFICIENTS[style]['full'] if not isinstance(all_coeffs_df, pd.DataFrame): raise TypeError(f"Expected pandas DataFrame for full coefficients for style '{style}'.") all_prob_folded = [] all_prob_site = [] for _, coeffs_row_series in all_coeffs_df.iterrows(): # Parameters for position u1 (at x_arr) _, lam, mean_site, std_site, _, _ = self._calc_params( coeffs_row_series, current_mag, x_arr, style) # Parameters for complementary position u2 (at 1 - x_arr) # For folding: calculate at both x and 1-x, then average x_comp = 1.0 - x_arr _, _, mean_comp, std_comp, _, _ = self._calc_params( coeffs_row_series, current_mag, x_comp, style) # Transform displacements (broadcast to (n_displ, n_sites)) if lam == 0: trans_displ = np.log(d_arr)[:, None] else: trans_displ = ((d_arr[:, None] ** lam) - 1.0) / lam # Gaussian exceedance, broadcast loc/scale over sites prob_site_single = 1.0 - stats.norm.cdf( trans_displ, loc=np.asarray(mean_site)[None, :], scale=np.asarray(std_site)[None, :] ) prob_comp_single = 1.0 - stats.norm.cdf( trans_displ, loc=np.asarray(mean_comp)[None, :], scale=np.asarray(std_comp)[None, :] ) prob_folded_single = 0.5 * (prob_site_single + prob_comp_single) all_prob_folded.append(prob_folded_single) all_prob_site.append(prob_site_single) # Stack along model axis -> (n_models, n_displ, n_sites) all_prob_folded_arr = np.stack(all_prob_folded, axis=0) all_prob_site_arr = np.stack(all_prob_site, axis=0) out = all_prob_folded_arr if folded else all_prob_site_arr # If single site, drop the site axis to match historical tests: (n_models, n_displ) if x_arr.size == 1 and out.ndim == 3 and out.shape[-1] == 1: return out[:, :, 0] return out else: # Not epistemic_uncertainty mean_coeffs_data = DATA_COEFFICIENTS[style]['mean'] if isinstance(mean_coeffs_data, pd.DataFrame): if not mean_coeffs_data.empty: if 'median' in mean_coeffs_data.index: single_coeffs_series = mean_coeffs_data.loc['median'] else: single_coeffs_series = mean_coeffs_data.iloc[0] else: raise ValueError( f"Mean coefficients DataFrame for style '{style}' is empty." ) elif isinstance(mean_coeffs_data, pd.Series): single_coeffs_series = mean_coeffs_data else: raise TypeError( f"Mean coefficients for style '{style}' must be a pandas Series or a DataFrame. " f"Got {type(mean_coeffs_data)}." ) if not isinstance(single_coeffs_series, pd.Series): raise TypeError( f"Failed to derive a pandas Series for mean coefficients for style '{style}'. " f"Got type: {type(single_coeffs_series)}." ) # Parameters at all sites _, lam, mean_site, std_site, _, _ = self._calc_params( single_coeffs_series, current_mag, x_arr, style ) # Calculate complementary position: simply 1 - x # (no symmetric folding - the folded probability averages x and 1-x) x_comp = 1.0 - x_arr _, _, mean_comp, std_comp, _, _ = self._calc_params( single_coeffs_series, current_mag, x_comp, style ) # Transform displacements -> (n_displ, n_sites) if lam == 0: trans_displ = np.log(d_arr)[:, None] else: trans_displ = ((d_arr[:, None] ** lam) - 1.0) / lam prob_site = 1.0 - stats.norm.cdf( trans_displ, loc=np.asarray(mean_site)[None, :], scale=np.asarray(std_site)[None, :] ) prob_comp = 1.0 - stats.norm.cdf( trans_displ, loc=np.asarray(mean_comp)[None, :], scale=np.asarray(std_comp)[None, :] ) prob_folded = 0.5 * (prob_site + prob_comp) # Select output based on folded parameter out = prob_folded if folded else prob_site # If single site, return (n_displ,) for backward-compatibility tests if x_arr.size == 1 and out.shape[1] == 1: return out[:, 0] return out.T def _calc_params(self, coeffs, mag, X_L_ratio, style): style = style.lower() func_map = { 'strike-slip': self._calc_strike_slip, 'reverse': self._calc_reverse, 'normal': self._calc_normal} return func_map[style](coeffs, mag, X_L_ratio) def _calc_strike_slip(self, coeffs, mag, X_L_ratio): mu = self._calc_mean(coeffs, mag, X_L_ratio) std_mode = self._calc_std_mode_bilinear(coeffs, mag) std_within = self._calc_std_within(coeffs, X_L_ratio) std_total = np.sqrt(std_mode**2 + std_within**2) lam = coeffs['lambda'] model_id = coeffs.get('model_id', 1) return model_id, lam, mu, std_total, std_within, std_mode def _calc_normal(self, coeffs, mag, X_L_ratio): mu = self._calc_mean(coeffs, mag, X_L_ratio) std_mode = self._calc_std_mode_sigmoid(coeffs, mag) # Within-event sigma is constant across sites for normal style; vectorize to sites sigma_val = float(coeffs['sigma']) std_within = np.full(mu.shape, sigma_val, dtype=float) std_total = np.sqrt(std_mode**2 + std_within**2) lam = coeffs['lambda'] model_id = coeffs.get('model_id', 1) return model_id, lam, mu, std_total, std_within, std_mode def _calc_reverse(self, coeffs, mag, X_L_ratio): mu = self._calc_mean(coeffs, mag, X_L_ratio) std_within = self._calc_std_within(coeffs, X_L_ratio) std_mode = float(coeffs['s_m,r']) std_total = np.sqrt(std_mode**2 + std_within**2) lam = coeffs['lambda'] model_id = coeffs.get('model_id', 1) return model_id, lam, mu, std_total, std_within, std_mode # Helper functions now support vectorized X_L_ratio (arrays) def _calc_mean(self, coeffs, mag, X_L_ratio): mode = self._calc_mode(coeffs, mag) # scalar alpha = float(coeffs['alpha']) beta = float(coeffs['beta']) gamma = float(coeffs['gamma']) x = np.atleast_1d(X_L_ratio).astype(float) denom = alpha + beta if denom != 0.0: term_powers_ab = ((alpha / denom) ** alpha) * ((beta / denom) ** beta) else: term_powers_ab = 0.0 a = mode - gamma * term_powers_ab term1 = np.power(x, alpha) term2 = np.power(1.0 - x, beta) mu_val = a + gamma * term1 * term2 return mu_val def _calc_mode(self, coeffs, mag): val = (coeffs['c1'] + coeffs['c2'] * (mag - MAG_BREAK) + (coeffs['c3'] - coeffs['c2']) * DELTA * np.log(1 + np.exp((mag - MAG_BREAK) / DELTA))) return float(np.asarray(val)) def _calc_std_mode_bilinear(self, coeffs, mag): val = (coeffs['s_m,s1'] + coeffs['s_m,s2'] * (mag - coeffs['s_m,s3']) - coeffs['s_m,s2'] * DELTA * np.log(1 + np.exp((mag - coeffs['s_m,s3']) / DELTA))) return float(np.asarray(val)) def _calc_std_mode_sigmoid(self, coeffs, mag): val = coeffs['s_m,n1'] - coeffs['s_m,n2'] / (1 + np.exp(-coeffs['s_m,n3'] * (mag - MAG_BREAK))) return float(np.asarray(val)) def _calc_std_within(self, coeffs, X_L_ratio): s1 = coeffs.get('s_s1', coeffs.get('s_r1')) s2 = coeffs.get('s_s2', coeffs.get('s_r2')) if s1 is None or s2 is None: # This function is specific to strike-slip and reverse. # Normal faulting uses 'sigma' directly in _calc_normal. raise KeyError( "Required coefficients for std_within (s_s1/s_r1 or s_s2/s_r2) not found for strike-slip/reverse style." ) alpha = float(coeffs['alpha']) beta = float(coeffs['beta']) denom_ab = alpha + beta term_shape_center = (alpha / denom_ab) if denom_ab != 0.0 else 0.0 x = np.atleast_1d(X_L_ratio).astype(float) val = s1 + s2 * (x - term_shape_center) ** 2 return val