# -*- 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.lavrentiadis2023` implements
the model of Lavrentiadis and Abrahamson (2023) in two classes, one per
displacement definition (the class choice IS the definition -- unlike a mere
shape/parameter selection, which is passed as a model parameter):
- :class:`Lavrentiadis2023PrimaryFD_aggregate` - the AGGREGATE-definition variants
(``output_type`` ``disp_agg_prime`` (default) or ``disp_agg_seg``);
- :class:`Lavrentiadis2023PrimaryFD_principal` - the sum-of-principal
variant (``disp_prnc_prime``, hard-pinned by the class).
"""
import numpy as np
from scipy import stats as scipystats
from openquake.pfd.params import check_bool, check_style
from openquake.pfd.primary_surf_displ.base import BasePrimarySurfDispl
[docs]class Lavrentiadis2023PrimaryFD_aggregate(BasePrimarySurfDispl):
"""Aggregate fault-displacement model of Lavrentiadis and Abrahamson (2023).
Model of aggregate fault displacement as a function of magnitude,
normalized along-strike position, and faulting style, run in the
principal (primary_surf_displ) slot.
Lavrentiadis, G., and Abrahamson, N.A. (2023). Fault-displacement models
for aggregate and principal displacements. Earthquake Spectra, 41(4),
2806-2837. https://doi.org/10.1177/87552930231201531
Model contract: DISPLACEMENT_DEFINITION = "aggregate",
DISPLACEMENT_COMPONENT = "net" -- STATIC, the class choice IS the
definition. Lavrentiadis & Abrahamson (2023) develop their model on the
FDHI aggregate displacement (net slip summed across principal and
distributed ruptures in the measurement aperture); Sarmiento et al.
(2025, Earthquake Spectra) Table 1 lists LA23 under the aggregate
definition. This class serves ONLY the aggregate-definition variants:
``output_type = "disp_agg_prime"`` (default, full rupture) or
``"disp_agg_seg"`` (single segment). The paper's third variant,
``disp_prnc_prime`` (principal-strand slip summed within the aperture,
WITHOUT distributed ruptures -- a sum-of-principal definition), is a
different physical quantity and lives in its own class,
:class:`Lavrentiadis2023PrimaryFD_principal`; requesting it here raises
ValueError (and is rejected at logic-tree validation time, FDLT-015).
As an aggregate model, this class runs single-bucket and forbids
secondary-slot models (FDLT-013).
"""
DISPLACEMENT_DEFINITION = "aggregate"
DISPLACEMENT_COMPONENT = "net"
def __init__(self, style=None, output_type=None, include_zero_slip=None):
"""
:param style: optional faulting style pinned by the logic-tree
branch; ``None`` defers to the ``get_prob`` call (legacy
default: 'normal').
:param output_type: optional output-type selector pinned by the
logic-tree branch ('disp_agg_prime' or 'disp_agg_seg');
``None`` defers to the call (legacy default:
'disp_agg_prime'). The sum-of-principal 'disp_prnc_prime'
metric is served by
:class:`Lavrentiadis2023PrimaryFD_principal` and rejected
here.
:param include_zero_slip: optional flag pinned by the logic-tree
branch; ``None`` defers to the call (legacy default: False).
"""
self.style = check_style(type(self).__name__, style)
if output_type is not None:
output_type = str(output_type)
self._check_aggregate_output_type(output_type)
self.output_type = output_type
self.include_zero_slip = check_bool(
type(self).__name__, "include_zero_slip", include_zero_slip)
@staticmethod
def _check_aggregate_output_type(output_type):
"""Reject the sum-of-principal metric on the aggregate class, both
at construction (logic-tree pin) and at call time."""
if output_type == "disp_prnc_prime":
raise ValueError(
"Lavrentiadis2023PrimaryFD_aggregate is the AGGREGATE-definition "
"model (disp_agg_prime / disp_agg_seg); the sum-of-"
"principal metric disp_prnc_prime is a different "
"displacement definition (Sarmiento et al. 2025, Table 1). "
"Select the Lavrentiadis2023PrimaryFD_principal model "
"class instead of passing output_type = disp_prnc_prime."
)
def get_prob(self, d, X_L_ratio, mag, style=None, output_type=None, include_zero_slip=None):
"""
Probability of exceeding displacement thresholds for the
Lavrentiadis et al. (2023) model (aggregate-definition variants).
:param d:
Target displacement in meters.
:param X_L_ratio:
Ratio of distance from the closest rupture end to the total rupture
length.
:param mag:
Earthquake magnitude.
:param style:
Style of faulting ("normal", "strike-slip" or "reverse").
:param output_type:
Displacement metric to evaluate: ``"disp_agg_prime"`` (default) or
``"disp_agg_seg"``. The sum-of-principal ``"disp_prnc_prime"``
metric is served by
:class:`Lavrentiadis2023PrimaryFD_principal` and is rejected here.
:param include_zero_slip:
If ``True`` the probability accounts for zero slip and gap
probabilities.
:returns:
Probability of exceeding ``d`` for the selected ``output_type``.
:raises ValueError:
If ``output_type`` is invalid or is ``"disp_prnc_prime"``.
"""
# 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 output_type is None:
output_type = (self.output_type
if self.output_type is not None else "disp_agg_prime")
if include_zero_slip is None:
include_zero_slip = (self.include_zero_slip
if self.include_zero_slip is not None else False)
self._check_aggregate_output_type(output_type)
return self._evaluate(
d, X_L_ratio, mag, style=style, output_type=output_type,
include_zero_slip=include_zero_slip)
def _evaluate(self, d, X_L_ratio, mag, style, output_type,
include_zero_slip):
"""Shared implementation for all published output_type variants
(the public classes pin/restrict output_type; this does not)."""
(
disp_agg_prime,
disp_prnc_prime,
disp_agg_seg,
sig_agg,
sig_prnc,
phi_agg,
phi_prnc,
tau_agg,
phi_add,
P_gap,
P_zero_slip,
) = self.LavrentiadisAbrahamson2023SlipProfile(X_L_ratio, mag, 1, style)
mu_agg_p = disp_agg_prime**0.3
mu_prnc_p = disp_prnc_prime**0.3
mu_agg_seg = disp_agg_seg**0.3
sig_agg_seg = np.sqrt(tau_agg**2 + phi_agg**2)
# Ensure d is an array and handle broadcasting
d = np.atleast_1d(d)
z = d ** 0.3
# Reshape for proper broadcasting: (n_displacements, n_locations)
z = z[:, np.newaxis] # Shape: (n_displacements, 1)
# mu and sig arrays have shape (n_locations,)
# Broadcasting will create arrays of shape (n_displacements, n_locations)
ccdf_agg = scipystats.norm.sf(x=z, loc=mu_agg_p, scale=sig_agg)
ccdf_prnc = scipystats.norm.sf(x=z, loc=mu_prnc_p, scale=sig_prnc)
ccdf_seg = scipystats.norm.sf(x=z, loc=mu_agg_seg, scale=sig_agg_seg)
ccdf_agg_with_zero = ccdf_agg * (1 - P_gap)
ccdf_prnc_with_zero = ccdf_prnc * (1 - P_zero_slip) * (1 - P_gap)
ccdf_seg_with_zero = ccdf_seg * (1 - P_zero_slip)
if output_type == "disp_prnc_prime":
prob = ccdf_prnc_with_zero if include_zero_slip else ccdf_prnc
elif output_type == "disp_agg_prime":
prob = ccdf_agg_with_zero if include_zero_slip else ccdf_agg
elif output_type == "disp_agg_seg":
prob = ccdf_seg_with_zero if include_zero_slip else ccdf_seg
else:
raise ValueError(f"Invalid output_type '{output_type}'")
# Normalize output shape:
# - single site -> (n_displacements,)
# - single displacement -> (n_locations,)
# - otherwise -> (n_displacements, n_locations)
if getattr(prob, "ndim", 0) == 2:
if prob.shape[1] == 1:
return prob[:, 0]
if prob.shape[0] == 1:
return prob[0, :]
return prob
def LavrentiadisAbrahamson2023SlipProfile(self, x_array, mag, srl, sof="Strike-Slip"):
"""
Lavrentiadis and Abrahamson 2023 fault displacement model for
aggregate and principal displacement.
Parameters
----------
x_array : np.array()
Along strike location - unnormalized (m).
mag : real
Moment Magnitude.
srl : real
Surface Rupture Length (m).
sof : string, optional
Style of faulting. The valid options are: Normal, Strike-Slip, and Reverse
The default is 'Strike-Slip'.
Returns
-------
disp_agg_prime : np.array()
Aggregate displacements for entire event rupture (m).
disp_prnc_prime : np.array()
Principal displacements for the entire event rupture (m).
disp_agg_seg : np.array()
Aggregate displacements for single segment (m).
sig_agg : np.array()
Total aggregate aleatory variability. # includes tau_agg, phi_agg, phi_add
sig_prnc : np.array()
Total principal aleatory variability. # includes tau_agg, phi_prnc, phi_add
phi_agg : np.array()
Within-event aggregate aleatory variability.
phi_prnc : np.array()
Within-event principal aleatory variability.
tau_agg : np.array()
Between-event aleatory variability.
sig_add : np.array()
Additional aleatory variability due to segmentation. # actually called phi_add
P_gap : np.array()
Segment gap probability.
P_zero_slip : np.array()
Zero slip probability.
"""
# model coefficient
# --- --- --- --- ---
# general agg slip
c_0 = 0.272
c_1 = 0.913
c_2 = -2.128
c_3 = 1.15
# tapering
c_5 = np.array([0.7, 0.7, -0.2])
c_6 = np.array([-0.262, -0.314, 0.0])
c_7 = 0.033
# magnitude scaling
M_1 = np.array([6.25, 6.50, 5.00])
# effect segmentation (median and shape)
c_10 = np.array([-0.406, -0.394, -0.849])
c_11 = np.array([-8.205, -2.950, 8.026])
c_12 = np.array([-165.0, -64.3, 237.2])
c_13 = np.array([-1372, -833, 927])
c_14 = np.array([-3013, -2145, 831])
c_14a = np.array([-0.904, -1.010, -3.555])
c_15 = np.array([-0.440, -0.155, -0.087])
c_16 = np.array([0.0767, 0.0263, 0.0148])
c_17 = np.array([0.0275, 0.0177, 0.0087])
c_17a = np.array([0.00276, 0.00534, 0.00234])
# effect segmentation (aleatory variability)
c_18 = np.array([-0.215, -0.201, -0.165])
c_19 = np.array([0.0430, 0.0364, 0.0304])
c_20 = np.array([0.00574, 0.0102, 0.00773])
# gap probability
c_21 = np.array([-0.082, -0.135, -0.151])
c_22 = np.array([0.027, 0.027, 0.033])
c_23 = np.array([-0.0088, 0.0063, 0.0032])
c_24a = np.array([0.020, 0.040, 0.04])
c_24b = np.array([0.00, 0.0050, 0.00])
c_24c = np.array([0.0247, 0.00273, 0.00843])
c_25 = np.array([0.60, 0.41, 0.43])
c_26a = np.array([0.162, 0.153, 0.156])
c_26b = np.array([0.084, 0.017, 0.031])
c_26c = np.array([0.0080, 0.00312, 0.00575])
# principal displacement
b_0 = np.array([-3.240, 0.867, 1.65])
b_1 = np.array([9.105, 3.767, 1.349])
b_2 = np.array([-0.097, -0.048, -0.062])
# principal aleatory variability
phi_b2 = 0.11
tau_b2 = 0.05 # noqa: F841
rho_b2 = -0.15
# style fault faulting
sof_array = np.array(["normal", "strike-slip", "reverse"])
# normalized
xl_array = x_array / srl
# Fold x/L to [0, 0.5] using modulus reflection, robust to 1±eps
r = xl_array - np.floor(xl_array) # map to [0,1)
xl_array = 0.5 - np.abs(r - 0.5) # symmetric fold to [0,0.5]
assert xl_array.min() >= 0 and xl_array.max() <= 0.5, "Error. Invalid normalization."
# convert sof to lower case
sof = sof.lower()
# general agg slip
# --- --- --- --- ---
d_agg = c_0 + c_1 * (xl_array - 0.3) + c_2 * (xl_array - 0.3) ** 2 + c_3 * (mag - 7.0) # eqn 8
d_agg = np.exp(d_agg)
# tapering
# --- --- --- --- ---
# slip tapering
c_5 = c_5[sof_array == sof]
xl1 = 0.15 - 0.10 * np.minimum(np.maximum(mag - 7.0, 0.0), 1)
T_xl = np.minimum(xl_array - xl1, 0) / xl1
# magnitude tapering
c_6 = c_6[sof_array == sof]
M_1 = M_1[sof_array == sof]
T_M = np.maximum(np.minimum((7.0 - mag) / (7.0 - M_1), 1.0), 0.0)
# median slip (pwr=0.3)
# --- --- --- --- ---
mu_agg_seg = (
(d_agg * np.exp(c_5 * T_xl)) ** 0.3 + c_6 * T_M + c_7
) # eqn 14, which is for indiv segments
# effect of segmentation
# --- --- --- --- ---
c_10 = c_10[sof_array == sof]
c_11 = c_11[sof_array == sof]
c_12 = c_12[sof_array == sof]
c_13 = c_13[sof_array == sof]
c_14 = c_14[sof_array == sof]
c_14a = c_14a[sof_array == sof]
c_15 = c_15[sof_array == sof]
c_16 = c_16[sof_array == sof]
c_17 = c_17[sof_array == sof]
c_17a = c_17a[sof_array == sof]
# normalize shape effect
xl_offset1 = np.minimum(xl_array - 0.3, 0)
xl_offset2 = np.maximum(xl_array - 0.4, 0)
f_NDmu = (
c_11 * xl_offset1
+ c_12 * xl_offset1**2
+ c_13 * xl_offset1**3
+ c_14 * xl_offset1**4
+ c_14a * xl_offset2
)
f_NDmu = c_10 + f_NDmu
# amplitude effect
Dmu_max = c_15 + c_16 * mag + c_17 * (mag - 6.7) ** 2 + c_17a * (mag - 6.7) ** 3
# cobined effect
mu_agg_prime = mu_agg_seg + Dmu_max * f_NDmu # eqn 22, which is for the full rupture
# gap probability
# --- --- --- --- ---
c_21 = c_21[sof_array == sof]
c_22 = c_22[sof_array == sof]
c_23 = c_23[sof_array == sof]
c_24a = c_24a[sof_array == sof]
c_24b = c_24b[sof_array == sof]
c_24c = c_24c[sof_array == sof]
c_25 = c_25[sof_array == sof]
c_26a = c_26a[sof_array == sof]
c_26b = c_26b[sof_array == sof]
c_26c = c_26c[sof_array == sof]
# mag dependent coeffs
c_24 = c_24a + c_24b * (mag - 5) + c_24c * (mag - 5) ** 3
c_26 = c_26a + c_26b * (mag - 5) + c_26c * (mag - 5) ** 3
# max gap probability
P_gap_max = c_21 + c_22 * mag + c_23 * (mag - 6.5) ** 2
# shape normalization
f_NPGap = (
20.0 * c_24 * np.minimum(np.maximum(xl_array - 0.10, 0.0), 0.05)
) # first and second leg
f_NPGap += (
0.13**-1 * (1.0 - c_24) * np.minimum(np.maximum(xl_array - 0.15, 0.0), 0.13)
) # third leg and fourth
f_NPGap -= (
10.0 * (1.0 - c_25) * np.minimum(np.maximum(xl_array - 0.30, 0.0), 0.10)
) # fifth leg
f_NPGap -= (
10.0 * (c_25 - c_26) * np.minimum(np.maximum(xl_array - 0.40, 0.0), 0.10)
) # sixth leg
# probability of gap
P_gap = P_gap_max * f_NPGap
# zero slip probability
# --- --- --- --- ---
b_0 = b_0[sof_array == sof]
b_1 = b_1[sof_array == sof]
P_zero_slip = 1 / (1 + np.exp(b_0 + b_1 * mu_agg_prime))
# principal displacements
# --- --- --- --- ---
# compute b2
b_2 = b_2[sof_array == sof]
mu_prnc_prime = mu_agg_prime + b_2
mu_prnc_prime = np.maximum(mu_prnc_prime, 0.0)
# aleatory variablity
# --- --- --- --- ---
phi_agg = np.maximum(np.minimum(0.120 + 0.150 * (mag - 6.0), 0.270), 0.120)
tau_agg = np.maximum(np.minimum(0.115 + 0.060 * (mag - 6.0), 0.205), 0.115)
phi_prnc = np.sqrt(phi_agg**2 + phi_b2**2 + 2 * rho_b2 * phi_agg * phi_b2)
# additional variability due to segmentation
c_18 = c_18[sof_array == sof]
c_19 = c_19[sof_array == sof]
c_20 = c_20[sof_array == sof]
# additional sigma
phi_add = c_18 + c_19 * mag + c_20 * (mag - 6.7) ** 2
# total sigma
sig_agg = np.sqrt(tau_agg**2 + phi_agg**2 + phi_add**2)
sig_prnc = np.sqrt(tau_agg**2 + phi_prnc**2 + phi_add**2)
# variable transformation
# --- --- --- --- ---
disp_agg_prime = mu_agg_prime ** (1 / 0.3)
disp_prnc_prime = mu_prnc_prime ** (1 / 0.3)
disp_agg_seg = mu_agg_seg ** (1 / 0.3)
return (
disp_agg_prime,
disp_prnc_prime,
disp_agg_seg,
sig_agg,
sig_prnc,
phi_agg,
phi_prnc,
tau_agg,
phi_add,
P_gap,
P_zero_slip,
)
def LavrentiadisAbrahamson2023SlipProfilePrc(self, x_array, mag, srl, sof="Strike-Slip", prc=0.5):
"""
Lavrentiadis and Abrahamson 2023 aggregate and principal displacement
profiles for selected percentiles.
Parameters
----------
x_array : np.array()
Along strike location - unnormalized (m).
mag : real
Moment Magnitude.
srl : real
Surface Rupture Length (m).
sof : string, optional
Style of faulting. The valid options are: Normal, Strike-Slip, and Reverse
The default is 'Strike-Slip'.
prc : real, optional
Percentile for displacement profile. The default is 0.5.
Returns
-------
disp_agg_p_prc : np.array()
DESCRIPTION.
disp_prnc_p_prc : np.array()
DESCRIPTION.
disp_agg_seg_prc : np.array()
DESCRIPTION.
"""
# evaluate LA23 model
# disp_agg_p, disp_prnc_p, _, sig_agg, sig_prnc, _, _, _, _, _, _ = LavrentiadisAbrahamson2023SlipProfile(x_array, mag, srl, sof)
# modified by alex to capture segment results
(
disp_agg_p,
disp_prnc_p,
disp_agg_seg,
sig_agg,
sig_prnc,
phi_agg,
_,
tau_agg,
_,
_,
_,
) = self.LavrentiadisAbrahamson2023SlipProfile(x_array, mag, srl, sof)
disp_agg_p = disp_agg_p ** (0.3)
disp_prnc_p = disp_prnc_p ** (0.3)
# compute percentile of aggregate profile
disp_agg_p_prc = scipystats.norm.ppf(prc, loc=disp_agg_p, scale=sig_agg)
disp_agg_p_prc[disp_agg_p_prc < 0] = 0.0
disp_agg_p_prc = disp_agg_p_prc ** (1 / 0.3)
# compute percentile of principal profile
disp_prnc_p_prc = scipystats.norm.ppf(prc, loc=disp_prnc_p, scale=sig_prnc)
disp_prnc_p_prc[disp_prnc_p_prc < 0] = 0.0
disp_prnc_p_prc = disp_prnc_p_prc ** (1 / 0.3)
# added by alex; compute percentile of segment profile
sig_agg_seg = np.sqrt(tau_agg**2 + phi_agg**2)
disp_agg_seg_prc = scipystats.norm.ppf(prc, loc=disp_agg_seg, scale=sig_agg_seg)
disp_agg_seg_prc[disp_agg_seg_prc < 0] = 0.0
disp_agg_seg_prc = disp_agg_seg_prc ** (1 / 0.3)
return disp_agg_p_prc, disp_prnc_p_prc, disp_agg_seg_prc
def LavrentiadisAbrahamson2023AvgDisp(self, mag, srl, sof="Strike-Slip"):
"""
Lavrentiadis and Abrahamson 2023 model for average displacement (median value).
Parameters
----------
mag : real
Moment Magnitude.
srl : real
Surface Rupture Length (m).
sof : string, optional
Style of faulting. The valid options are: Normal, Strike-Slip, and Reverse
The default is 'Strike-Slip'.
Returns
-------
disp_avg : real
Average slip (m).
ratio_ad : real
Ratio for average slip to principal slip at x/L=0.25.
"""
# model coefficient
# --- --- --- --- ---
b_3 = np.array([0.286, 0.191, 0.28])
b_4 = np.array([1.195, 1.058, 0.0])
b_5 = np.array([-1.526, -1.418, 0.0])
# style fault faulting
sof_array = np.array(["normal", "strike-slip", "reverse"])
# average displacement
# --- --- --- --- ---
sof = sof.lower()
b_3 = b_3[sof_array == sof]
b_4 = b_4[sof_array == sof]
b_5 = b_5[sof_array == sof]
# slip at x/L=0.25
disp_prnc_prime = self.LavrentiadisAbrahamson2023SlipProfile(0.25 * srl, mag, srl, sof)[1]
# average displacement ratio
ratio_ad = np.exp(b_3 + b_4 * np.exp(b_5 * (mag - 5.0)))
# average displacement
disp_avg = ratio_ad * disp_prnc_prime
return disp_avg, ratio_ad
def LavrentiadisAbrahamson2023MaxDisp(self, mag, srl, sof="Strike-Slip"):
"""
Lavrentiadis and Abrahamson 2023 model for maximum displacement (median value).
Parameters
----------
mag : real
Moment Magnitude.
srl : real
Surface Rupture Length (m).
sof : string, optional
Style of faulting. The valid options are: Normal, Strike-Slip, and Reverse
The default is 'Strike-Slip'.
Returns
-------
disp_max : real
Maximum slip - median value (m).
sig_dm : real
Aleatory standard deviation of max displacement
"""
# model coefficient
# --- --- --- --- ---
e_1 = 0.326
e_2 = 0.41
e_3 = np.array([-0.0166, -0.0144, -0.0172])
e_4 = np.array([0.0244, -0.0109, 0.0094])
# style fault faulting
sof_array = np.array(["normal", "strike-slip", "reverse"])
# maximum displacement
# --- --- --- --- ---
sof = sof.lower()
e_3 = e_3[sof_array == sof]
e_4 = e_4[sof_array == sof]
# slip at x/L=0.25
disp_agg_prime = self.LavrentiadisAbrahamson2023SlipProfile(0.25 * srl, mag, srl, sof)[0]
# maximum displacement offset
dm_pwr = e_1
dm_pwr += e_2 * np.minimum(np.maximum(mag - 6.0, 0.0), 1.0)
dm_pwr += e_3 * np.maximum(mag - 7.0, 0.0) + e_4 * np.maximum(mag - 7.0, 0.0) ** 2
# aleatory variability
sig_dm = 0.13 + 0.095 * np.minimum(np.maximum(mag - 6.0, 0.0), 1.0)
sig_dm += 0.050 * np.minimum(np.maximum(mag - 7.0, 0.0), 0.5)
# max displacement
disp_max = (disp_agg_prime**0.3 + dm_pwr) ** (1 / 0.3)
return disp_max, sig_dm
# added by alex from greg
def _calc_mean(mean_pn, sd_pn):
"""
Helper function to calculate the back-transformed predicted mean displacement in meters
using the model parameters. A closed-form solution is not available, so sampling is used.
Parameters
----------
mean_pn : float
mean displacement in power-normal (m^0.3) units
sd_pn : float
standard deviation in power-normal (m^0.3) units
Returns
-------
float
Predicted mean displacement in meters.
"""
# Ensure inputs are arrays
mean_pn, sd_pn = np.atleast_1d(mean_pn), np.atleast_1d(sd_pn)
# Sample
np.random.seed(1)
s = np.random.normal(
loc=mean_pn[:, np.newaxis], scale=sd_pn[:, np.newaxis], size=(len(mean_pn), 1_000_000)
)
# Replace negative values with NaN and compute mean
return np.nanmean(np.where(s >= 0, s, np.nan) ** (10 / 3), axis=1)
[docs]class Lavrentiadis2023PrimaryFD_principal(Lavrentiadis2023PrimaryFD_aggregate):
"""Sum-of-principal variant of Lavrentiadis and Abrahamson (2023).
Identical regression machinery as :class:`Lavrentiadis2023PrimaryFD_aggregate`,
hard-pinned to the paper's ``disp_prnc_prime`` metric: the principal-
strand slip summed within the measurement aperture, WITHOUT distributed
ruptures. Following the Petersen2011PrimaryFD_* variant idiom, the class
choice IS the definition -- ``output_type`` is fixed by this class and
passing any explicit value raises ValueError (also rejected at
logic-tree validation time, FDLT-015).
Model contract: DISPLACEMENT_DEFINITION = "sum-of-principal",
DISPLACEMENT_COMPONENT = "net" -- Lavrentiadis & Abrahamson (2023)
derive the principal-prime profile from the aggregate one (their b_2
offset and zero-slip probability); like Chiou et al. (2025)'s D_SP it
excludes distributed ruptures, so secondary-slot models remain
legitimate alongside this class (it is NOT aggregate; FDLT-013 does not
apply).
"""
DISPLACEMENT_DEFINITION = "sum-of-principal"
DISPLACEMENT_COMPONENT = "net"
def __init__(self, style=None, output_type=None, include_zero_slip=None):
"""
Parameters as in :class:`Lavrentiadis2023PrimaryFD_aggregate`, except
``output_type``, which must be left unset -- the metric is fixed
by the class choice (a logic-tree pin would otherwise be silently
ignored).
"""
self._check_no_output_type(output_type)
super().__init__(style=style, include_zero_slip=include_zero_slip)
@staticmethod
def _check_no_output_type(output_type):
if output_type is not None:
raise ValueError(
"Lavrentiadis2023PrimaryFD_principal evaluates the "
"disp_prnc_prime (sum-of-principal) metric; output_type "
f"is fixed by the class choice (got '{output_type}'). "
"Remove the output_type parameter, or select "
"Lavrentiadis2023PrimaryFD_aggregate for the aggregate variants."
)
def get_prob(self, d, X_L_ratio, mag, style=None,
include_zero_slip=None, output_type=None):
"""
Probability of exceeding sum-of-principal (``disp_prnc_prime``)
displacement thresholds; parameters as in
:meth:`Lavrentiadis2023PrimaryFD_aggregate.get_prob`.
:param output_type:
Must be left unset -- the metric is fixed by the class choice.
:raises ValueError:
If an explicit ``output_type`` is passed.
"""
self._check_no_output_type(output_type)
if style is None:
style = self.style if self.style is not None else "normal"
if include_zero_slip is None:
include_zero_slip = (self.include_zero_slip
if self.include_zero_slip is not None else False)
return self._evaluate(
d, X_L_ratio, mag, style=style, output_type="disp_prnc_prime",
include_zero_slip=include_zero_slip)