# -*- 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_rup.visini2025` implements the
distributed (secondary) rupture occurrence model of Visini et al. (2025) in
:class:`Visini2025SecondarySR`.
Supported fault styles: normal and reverse (dip-slip).
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
Implementation notes
--------------------
The across-strike occurrence probability uses the logistic regressions of
Table 2; the along-strike participation uses the F-ratio lookups of Table 3
combined with a Monte Carlo sampling of distributed-rupture segment lengths
(truncated lognormal). Monte Carlo results are cached per
``(fault_length, mechanism, hw_fw, near_far, site_width)`` because the
along-strike probability depends only on those geometric parameters, and the
per-site evaluation is vectorized.
"""
import warnings
import zlib
from bisect import bisect_right
import numpy as np
from scipy.stats import lognorm
from openquake.pfd.params import check_choice, check_positive, check_style
from openquake.pfd.secondary_surf_rup.base import BaseSecondarySurfRup
[docs]class Visini2025SecondarySR(BaseSecondarySurfRup):
"""
Distributed (secondary) rupture occurrence model of Visini et al. (2025)
for normal- and reverse-faulting earthquakes, as a function of magnitude,
distance from the principal trace, hanging-wall/footwall position, and
analysis cell (slice) width.
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
"""
# The Visini regressions are calibrated on distances to the ACTUAL
# segmented principal rupture, so on multi-fault ruptures r 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 BaseSecondarySurfRup.SECONDARY_PIPELINE).
SECONDARY_PIPELINE = "visini"
def __init__(self, style=None, pixel_size=None, segment_sampling=None,
rupture_traces=None, along_strike_width=None,
distribution_type=None):
"""
:param style: optional coefficient-set selector pinned by the
logic-tree branch ('normal' or 'reverse'); ``None`` defers to
the ``get_prob`` call.
:param pixel_size: optional across-strike cell size in meters pinned
by the logic-tree branch; ``None`` defers to the call.
:param segment_sampling: optional Rank-2 along-strike sampling
variant (e.g. 'truncated', 'legacy'), consumed by the secondary
(Visini) 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.
:param along_strike_width: optional along-strike site-cell width in
meters (defaults to ``pixel_size`` downstream), consumed by the
secondary calculation pipeline.
:param distribution_type: optional Rank-2 placement algorithm
('uniform', 'exponential' or 'average'), consumed by the
secondary calculation pipeline.
"""
self.style = check_style(type(self).__name__, style,
frozenset(["normal", "reverse"]))
self.pixel_size = check_positive(type(self).__name__, "pixel_size",
pixel_size)
self.segment_sampling = (None if segment_sampling is None
else str(segment_sampling))
self.rupture_traces = rupture_traces
self.along_strike_width = check_positive(
type(self).__name__, "along_strike_width", along_strike_width)
self.distribution_type = check_choice(
type(self).__name__, "distribution_type", distribution_type,
("uniform", "exponential", "average"))
# Logistic regression coefficients from Table 2 (unchanged)
self.coeffs_occurrence = {
'normal': {
10: {'A': {'a': 5.903214, 'b': -0.950792, 'c': 0.000891, 'd': 1.373730},
'B': {'a': 3.942908, 'b': -0.472749, 'c': 0.002441, 'd': 0.070138},
'C': {'a': 5.992004, 'b': -0.828323, 'c': 0.000185, 'd': 0.869644}},
20: {'A': {'a': 5.898758, 'b': -0.950792, 'c': 0.000891, 'd': 1.373730},
'B': {'a': 3.930705, 'b': -0.472749, 'c': 0.002441, 'd': 0.070138},
'C': {'a': 5.991077, 'b': -0.828323, 'c': 0.000185, 'd': 0.869644}},
50: {'A': {'a': 5.885388, 'b': -0.950792, 'c': 0.000891, 'd': 1.373730},
'B': {'a': 3.895606, 'b': -0.472668, 'c': 0.002435, 'd': 0.070126},
'C': {'a': 5.988296, 'b': -0.828323, 'c': 0.000185, 'd': 0.869644}},
100:{'A': {'a': 5.863104, 'b': -0.950792, 'c': 0.000891, 'd': 1.373730},
'B': {'a': 3.836451, 'b': -0.472584, 'c': 0.002430, 'd': 0.070114},
'C': {'a': 5.983659, 'b': -0.828323, 'c': 0.000185, 'd': 0.869644}},
200:{'A': {'a': 5.818538, 'b': -0.950792, 'c': 0.000891, 'd': 1.373730},
'B': {'a': 3.720450, 'b': -0.472362, 'c': 0.002415, 'd': 0.070081},
'C': {'a': 5.974377, 'b': -0.828323, 'c': 0.000185, 'd': 0.869644}},
500:{'A': {'a': 5.684838, 'b': -0.950792, 'c': 0.000891, 'd': 1.373730},
'B': {'a': 3.388553, 'b': -0.471574, 'c': 0.002355, 'd': 0.069965},
'C': {'a': 5.946468, 'b': -0.828323, 'c': 0.000185, 'd': 0.869644}},
},
'reverse': {
10: {'A': {'a': 9.247414, 'b': -1.318981, 'c': 0.000782, 'd': 1.102631},
'B': {'a': 24.976166, 'b': -3.292095, 'c': 0.003027, 'd': 2.441285},
'C': {'a': 18.650294, 'b': -2.516707, 'c': 0.000398, 'd': 2.360062}},
20: {'A': {'a': 9.243505, 'b': -1.318981, 'c': 0.000782, 'd': 1.102631},
'B': {'a': 24.961029, 'b': -3.292095, 'c': 0.003027, 'd': 2.441285},
'C': {'a': 18.648305, 'b': -2.516707, 'c': 0.000398, 'd': 2.360062}},
50: {'A': {'a': 9.231778, 'b': -1.318981, 'c': 0.000782, 'd': 1.102631},
'B': {'a': 24.914213, 'b': -3.291712, 'c': 0.003024, 'd': 2.440953},
'C': {'a': 18.642338, 'b': -2.516707, 'c': 0.000398, 'd': 2.360062}},
100:{'A': {'a': 9.212234, 'b': -1.318981, 'c': 0.000782, 'd': 1.102631},
'B': {'a': 24.837260, 'b': -3.291312, 'c': 0.003020, 'd': 2.440606},
'C': {'a': 18.632394, 'b': -2.516707, 'c': 0.000398, 'd': 2.360063}},
200:{'A': {'a': 9.173145, 'b': -1.318981, 'c': 0.000782, 'd': 1.102631},
'B': {'a': 24.683195, 'b': -3.290227, 'c': 0.003009, 'd': 2.439663},
'C': {'a': 18.612507, 'b': -2.516709, 'c': 0.000398, 'd': 2.360064}},
500:{'A': {'a': 9.055878, 'b': -1.318981, 'c': 0.000782, 'd': 1.102631},
'B': {'a': 24.227872, 'b': -3.286136, 'c': 0.002965, 'd': 2.436083},
'C': {'a': 18.552863, 'b': -2.516718, 'c': 0.000398, 'd': 2.360074}},
}
}
# F-ratio lookup (97.5th percentile) from Table 3
self._f_ratio = {
"normal": {
"HW": {
"near": {10:0.02052,20:0.03171,50:0.05298,100:0.07413,200:0.09771,500:0.13565},
"far": {10:0.00201,20:0.00398,50:0.01035,100:0.02005,200:0.03689,500:0.04013}
},
"FW": {
"near": {10:0.00800,20:0.01197,50:0.02148,100:0.02941,200:0.03619,500:0.05050},
"far": {10:0.00082,20:0.00153,50:0.00388,100:0.00797,200:0.01539,500:0.01712}
}
},
"reverse": {
"HW": {
"near": {10:0.03105,20:0.04809,50:0.08194,100:0.11202,200:0.13873,500:0.18748},
"far": {10:0.00208,20:0.00390,50:0.01041,100:0.02128,200:0.03806,500:0.02006}
},
"FW": {
"near": {10:0.01179,20:0.01773,50:0.02452,100:0.03102,200:0.03999,500:0.04453},
"far": {10:0.00020,20:0.00037,50:0.00098,100:0.00215,200:0.00360,500:0.00138}
}
}
}
# Lognormal parameters (precomputed)
self._logn_params = {
"reverse": {"HW": (3.622, 1.589), "FW": (3.801, 1.542)},
"normal": {"HW": (3.546, 1.358), "FW": (3.390, 1.472)}
}
# DR length min/max bounds
self._drlengths_min_max = {
"reverse": {"HW": (8.0, 248.0), "FW": (10.0, 249.0)},
"normal": {"HW": (8.0, 137.0), "FW": (6.0, 131.0)}
}
# Width bins for F-ratio lookup
self._width_bins = [10, 20, 50, 100, 200, 500]
# Monte Carlo cache: key -> P_along_strike value
# This is the critical optimization - we cache MC results
self._mc_cache = {}
# Precompute PDF tables for each (mechanism, hw_fw) pair
self._pdf_tables = {}
self._draw_tables = {}
self._precompute_pdf_tables()
def _precompute_pdf_tables(self):
"""Precompute truncated lognormal PDF tables for fast sampling."""
for mechanism in ["normal", "reverse"]:
for hw_fw in ["HW", "FW"]:
logn_mu, logn_sigma = self._logn_params[mechanism][hw_fw]
t1, t2 = self._drlengths_min_max[mechanism][hw_fw]
t1i, t2i = int(t1), int(t2)
x = np.arange(t1i, t2i + 1, dtype=float)
pdf = lognorm(s=logn_sigma, scale=np.exp(logn_mu)).pdf(x)
pdf = pdf / np.sum(pdf)
self._pdf_tables[(mechanism, hw_fw)] = (x, pdf, t1i, t2i)
# Fast-draw table: numpy Generator.choice(x, p=pdf) draws ONE
# uniform and inverts the CDF built as cumsum(p)/cumsum(p)[-1]
# with searchsorted(side='right'). Precomputing that exact CDF
# once (choice rebuilds it - and re-validates dtypes - on
# EVERY draw) and inverting with bisect keeps the random
# stream bit-identical while removing ~90% of the MC cost.
cdf = pdf.cumsum()
cdf = cdf / cdf[-1]
self._draw_tables[(mechanism, hw_fw)] = (
x.tolist(), cdf.tolist(), len(x) - 1)
def get_prob(self, mag, r, rx, style=None, pixel_size=None,
combination="A"):
"""
Required method for BaseSecondarySurfRup.
OPTIMIZED: Accepts both scalar and array inputs for r and rx.
"""
# Fall back to constructor-pinned values (call argument wins)
if style is None:
style = self.style
if pixel_size is None:
pixel_size = self.pixel_size
if style is None or pixel_size is None:
raise ValueError(
f"{type(self).__name__}: style and pixel_size must be given "
f"either in the logic-tree branch or at call time")
return self.get_prob_slice(mag, r, rx, style, pixel_size, combination)
def get_prob_slice(self, mag, r, rx, style, pixel_size, combination):
"""
Calculate logistic regression probability P_slice.
OPTIMIZED: Fully vectorized over all sites.
Parameters:
-----------
mag : float
Moment magnitude
r : float or array
Minimum distance from the slice to PF (m)
rx : float or array
Horizontal distance to surface projection of fault (negative for footwall)
style : str
'normal' or 'reverse'
pixel_size : int
Width of the slice (m)
combination : str
'A', 'B', or 'C'
Returns:
--------
float or array
P_slice probability for each input site
"""
if style not in self.coeffs_occurrence:
raise ValueError("style must be 'normal' or 'reverse'")
if pixel_size not in self.coeffs_occurrence[style]:
raise ValueError(
f"pixel_size must be one of {list(self.coeffs_occurrence[style])}")
if combination not in ('A','B','C'):
raise ValueError("combination must be 'A','B' or 'C'")
# Get coefficients (single lookup)
coeffs = self.coeffs_occurrence[style][pixel_size][combination]
a, b, c, d = coeffs['a'], coeffs['b'], coeffs['c'], coeffs['d']
# Convert to arrays for vectorized computation
r_arr = np.atleast_1d(np.asarray(r, dtype=float))
rx_arr = np.atleast_1d(np.asarray(rx, dtype=float))
# Ensure same shape via broadcasting
r_arr, rx_arr = np.broadcast_arrays(r_arr, rx_arr)
# FW indicator: 1 for footwall (rx < 0), 0 for hanging wall
# VECTORIZED over all sites
fw = np.where(rx_arr < 0, 1, 0)
# Equation 2 from paper: linear predictor (VECTORIZED)
y = a + b * mag + c * r_arr + d * fw
# Equation 1 from paper: logistic function
# Using numerically stable form: 1/(1+exp(y)) = sigmoid(-y)
# p = 1 - exp(y)/(1+exp(y)) = 1/(1+exp(y))
exp_y = np.exp(y)
p = 1.0 - exp_y / (1.0 + exp_y)
# Return scalar if input was scalar
if p.size == 1:
return float(p[0])
return p
def get_prob_slice_vectorized(self, mag, r, rx, style, pixel_size, combination):
"""
Alias for vectorized P_slice computation.
Same as get_prob_slice but explicitly named for clarity.
"""
return self.get_prob_slice(mag, r, rx, style, pixel_size, combination)
def _get_mc_cache_key(self, fault_length, across_strike_width, along_strike_width,
hanging_wall_or_footwall, mechanism, near_or_far,
distribution_type="uniform"):
"""
Generate cache key for Monte Carlo results.
MATHEMATICAL JUSTIFICATION:
P_along_strike depends ONLY on these parameters because:
1. fault_length determines total DR length = fault_length × F_ratio
2. across_strike_width determines F_ratio lookup (Table 3)
3. along_strike_width determines Monte Carlo site window
4. mechanism determines lognormal parameters and F_ratio
5. HW/FW determines lognormal parameters and F_ratio
6. near/far determines F_ratio
7. distribution_type determines which placement algorithm is used
The site is always placed at fault center (standardized), so individual
site distances do NOT affect P_along_strike.
We discretize fault_length to 100m bins and along_strike_width to 10m bins
to limit cache size while maintaining accuracy.
"""
# Round fault_length to nearest 100m for cache key
fl_rounded = int(round(fault_length / 100.0) * 100)
# Round along_strike_width to nearest 10m for cache efficiency
along_rounded = int(round(along_strike_width / 10.0) * 10)
return (fl_rounded, across_strike_width, along_rounded, hanging_wall_or_footwall,
mechanism, near_or_far, distribution_type)
def monte_carlo_rank2_occurrence(self, fault_length, across_strike_width,
hanging_wall_or_footwall, mechanism, near_or_far,
num_simulations=10000, use_cache=True,
distribution_type="uniform",
segment_sampling="truncated",
along_strike_width=None):
"""
Monte Carlo simulation to compute P_along_strike.
OPTIMIZED: Results are cached by (fault_length, across_strike_width,
along_strike_width, mechanism, hw_fw, near_far, distribution_type, segment_sampling).
Parameters:
-----------
fault_length : float
Total fault length in meters
across_strike_width : int
Site width perpendicular to PF (m).
Must be one of: 10, 20, 50, 100, 200, 500.
Used for F-ratio lookup (Table 3).
(Formerly named 'site_width' - backward compatible)
hanging_wall_or_footwall : str
'HW' or 'FW'
mechanism : str
'normal' or 'reverse' (will be normalized to lowercase)
near_or_far : str
'near' or 'far'
num_simulations : int
Number of Monte Carlo simulations (default 10000)
use_cache : bool
Whether to use caching (default True)
distribution_type : str
"uniform" (default), "exponential", or "average"
- "uniform": Only run uniform distribution (MATLAB randperm style)
- "exponential": Only run exponential distribution (clustered)
- "average": Run both and return (P_uniform + P_exponential) / 2
segment_sampling : str
"truncated" (default): Use truncated lognormal with 16th-84th percentile
bounds per MATLAB specification
"legacy": Use raw lognormal clipped to [10, fault_length]
for backward compatibility with previous results
along_strike_width : float, optional
Site extent parallel to PF (m). Can be any positive value.
Used only for intersection check in Monte Carlo.
If None, uses across_strike_width for backward compatibility (square site).
Returns:
--------
float
P_along_strike probability
"""
# Backward compatibility: if along_strike_width not provided, use across_strike_width
if along_strike_width is None:
along_strike_width = across_strike_width
mechanism_lower = mechanism.lower()
# Validate across_strike_width (should be in predefined bins, but find closest)
closest_across = min(self._width_bins, key=lambda x: abs(x - across_strike_width))
if across_strike_width not in self._width_bins:
warnings.warn(
f"across_strike_width={across_strike_width} not in {self._width_bins}, "
f"using closest value {closest_across} for F-ratio lookup"
)
# Validate along_strike_width (any positive value)
if along_strike_width <= 0:
raise ValueError(f"along_strike_width must be positive, got {along_strike_width}")
# Validate distribution_type
if distribution_type not in ("uniform", "exponential", "average"):
raise ValueError(
f"distribution_type must be 'uniform', 'exponential', or 'average', "
f"got '{distribution_type}'"
)
# Validate segment_sampling
if segment_sampling not in ("legacy", "truncated"):
raise ValueError(
f"segment_sampling must be 'legacy' or 'truncated', "
f"got '{segment_sampling}'"
)
# Check cache first (include segment_sampling in cache key)
if use_cache:
cache_key = self._get_mc_cache_key(
fault_length, closest_across, along_strike_width, hanging_wall_or_footwall,
mechanism_lower, near_or_far, distribution_type
) + (segment_sampling,)
if cache_key in self._mc_cache:
return self._mc_cache[cache_key]
# F-ratio lookup uses ACROSS-STRIKE width (must be in predefined bins)
F = self._f_ratio[mechanism_lower][hanging_wall_or_footwall][near_or_far][closest_across]
total_DR_length = fault_length * F
# Monte Carlo site window uses ALONG-STRIKE width (can be any value)
L = int(fault_length)
site_lo = int(fault_length / 2 - along_strike_width / 2)
site_hi = int(fault_length / 2 + along_strike_width / 2)
# Get lognormal parameters for segment length sampling
logn_mu, logn_sigma = self._logn_params[mechanism_lower][hanging_wall_or_footwall]
# For truncated sampling, get the precomputed fast-draw table
# (exact CDF that Generator.choice would rebuild on every draw)
if segment_sampling == "truncated":
x_list, cdf_list, i_max = self._draw_tables[
(mechanism_lower, hanging_wall_or_footwall)]
# Local RNG (no global np.random.seed() pollution), seeded
# deterministically from the physical inputs: identical parameters
# must give identical P_along_strike across runs and processes,
# otherwise hazard curves carry O(1/sqrt(num_simulations)) run-to-run
# noise (~8% observed at 10000 samples). crc32 rather than hash():
# Python string hashing is salted per process.
seed_key = (int(round(fault_length)), closest_across,
int(round(along_strike_width)), hanging_wall_or_footwall,
mechanism_lower, near_or_far, distribution_type,
segment_sampling, int(num_simulations))
rng = np.random.default_rng(zlib.crc32(repr(seed_key).encode()))
hits_uniform = 0
hits_exponential = 0
run_uniform = distribution_type in ("uniform", "average")
run_exponential = distribution_type in ("exponential", "average")
for _ in range(num_simulations):
# Sample segment lengths based on segment_sampling mode
segments = []
total = 0.0
while total < total_DR_length and len(segments) < 1000:
if segment_sampling == "truncated":
# MATLAB-style: truncated lognormal with 16th-84th
# percentile bounds. Bit-identical fast path for
# rng.choice(x, p=pdf): same single uniform, same
# CDF-inversion index, precomputed table.
seg_len = x_list[
min(bisect_right(cdf_list, rng.random()), i_max)]
else:
# Legacy: raw lognormal clipped to [10, fault_length]
seg_len = float(rng.lognormal(logn_mu, logn_sigma))
seg_len = max(seg_len, 10.0)
seg_len = min(seg_len, float(fault_length))
segments.append(seg_len)
total += seg_len
if not segments:
continue
num_segments = len(segments)
# ===== UNIFORM DISTRIBUTION (Fix 4: randperm style) =====
if run_uniform:
# Fix 4: Use choice(replace=False) + sort + overlap adjustment
# MATLAB: Centro_Seg_Simulati_unif = randperm(SpaceDRLength, NumSeg);
if num_segments <= L:
positions = rng.choice(L, size=num_segments, replace=False)
else:
# Fallback if more segments than positions
positions = rng.integers(0, L, size=num_segments)
centro_unif = np.sort(positions).astype(float)
# Compute cumulative semi-lengths for overlap adjustment
semi_lengths = np.array(segments) / 2.0
cum_semi = np.cumsum(semi_lengths)
# Adjust overlapping segments by shifting (MATLAB overlap fix)
for g in range(1, len(centro_unif)):
gap = centro_unif[g] - centro_unif[g - 1]
threshold = int(cum_semi[g - 1])
if gap < threshold:
centro_unif[g] = centro_unif[g] + threshold
# Check if any segment hits the site
site_hit_unif = False
for j, seg_len in enumerate(segments):
center = centro_unif[j]
semi = seg_len / 2.0
start = int(max(0, np.floor(center - semi)))
end = int(min(L, np.ceil(center + semi)))
# Check overlap with site window
if end >= start and end >= site_lo and start <= site_hi:
site_hit_unif = True
break
if site_hit_unif:
hits_uniform += 1
# ===== EXPONENTIAL DISTRIBUTION (Fix 2: clustered placement) =====
if run_exponential:
# Fix 2: Random starting position + exponential inter-segment gaps
mean_distance = L / num_segments
starting_pos = rng.integers(1, L + 1)
interdistance = rng.exponential(mean_distance, num_segments)
ini_s = float(starting_pos)
site_hit_exp = False
for j in range(num_segments):
end_s = ini_s + segments[j]
# Wrap-around when position exceeds fault length
if ini_s > L or end_s > L:
ini_s = ini_s - L
end_s = end_s - L
start_i = int(np.floor(ini_s))
end_i = int(np.ceil(end_s))
# Check overlap with site window
if end_i >= start_i and end_i >= site_lo and start_i <= site_hi:
site_hit_exp = True
# Next segment starts after current end + exponential gap
ini_s = end_s + int(interdistance[j])
if site_hit_exp:
hits_exponential += 1
# Compute result based on distribution_type
if distribution_type == "uniform":
prob_result = hits_uniform / num_simulations if num_simulations > 0 else 0.0
elif distribution_type == "exponential":
prob_result = hits_exponential / num_simulations if num_simulations > 0 else 0.0
else: # "average"
prob_uniform = hits_uniform / num_simulations if num_simulations > 0 else 0.0
prob_exponential = hits_exponential / num_simulations if num_simulations > 0 else 0.0
prob_result = 0.5 * (prob_uniform + prob_exponential)
# Cache the result
if use_cache:
self._mc_cache[cache_key] = prob_result
return prob_result
def calculate_rank2_total_probability(self, mag, r, rx, style,
across_strike_width, along_strike_width=None,
combination=None, fault_length=None,
near_or_far=None, num_simulations=10000,
distribution_type="uniform",
segment_sampling="truncated"):
"""
Calculate total probability P(site) = P(across) × P(along).
OPTIMIZED: P_slice is vectorized, P_along is cached.
Parameters:
-----------
mag : float
Moment magnitude
r : float or array
Distance from site to PF (m)
rx : float or array
Signed distance (negative = footwall)
style : str
'normal' or 'reverse'
across_strike_width : int
Site width perpendicular to PF (m).
Must be one of: 10, 20, 50, 100, 200, 500.
Used for P(across) coefficient lookup (Table 2) and F-ratio lookup (Table 3).
along_strike_width : float, optional
Site extent parallel to PF (m). Can be any positive value.
Used only for Monte Carlo intersection check.
If None, uses across_strike_width for backward compatibility.
combination : str
'A', 'B', or 'C'
fault_length : float
Total fault length (m)
near_or_far : str
'near' or 'far'
num_simulations : int
Number of Monte Carlo simulations
distribution_type : str
"uniform", "exponential", or "average"
segment_sampling : str
"truncated" or "legacy"
Returns:
--------
dict with P_slice (P_across), P_along_strike, P_total
"""
# Backward compatibility: if along_strike_width not provided, use across_strike_width
if along_strike_width is None:
along_strike_width = across_strike_width
# P(across) uses across_strike_width for coefficient lookup
P_slice = self.get_prob_slice(mag, r, rx, style, across_strike_width, combination)
# P(along) uses both dimensions
hw_or_fw = 'FW' if np.any(np.asarray(rx) < 0) else 'HW'
mechanism = style.lower()
P_along = self.monte_carlo_rank2_occurrence(
fault_length,
across_strike_width,
hw_or_fw,
mechanism,
near_or_far,
num_simulations=num_simulations,
distribution_type=distribution_type,
segment_sampling=segment_sampling,
along_strike_width=along_strike_width
)
# Total probability
P_total = P_slice * P_along
return {
"P_slice": P_slice,
"P_along_strike": P_along,
"P_total": P_total
}
def calculate_rank2_total_probability_vectorized(self, mag, r_array, rx_array,
style, across_strike_width, combination,
fault_length, along_strike_width=None,
near_far_array=None, num_simulations=10000,
distribution_type="uniform",
segment_sampling="truncated"):
"""
FULLY VECTORIZED calculation for all sites at once.
This is the main optimization entry point for hazard map calculations.
Parameters:
-----------
mag : float
Moment magnitude
r_array : array
Distance from each site to PF (m)
rx_array : array
Signed distance for each site (m, negative = footwall)
style : str
'normal' or 'reverse'
across_strike_width : int
Site width perpendicular to PF (m).
Must be one of: 10, 20, 50, 100, 200, 500.
Used for P(across) coefficient lookup (Table 2) and F-ratio lookup (Table 3).
combination : str
'A', 'B', or 'C'
fault_length : float
Total fault length (m)
along_strike_width : float, optional
Site extent parallel to PF (m). Can be any positive value.
Used only for Monte Carlo intersection check.
If None, uses across_strike_width for backward compatibility.
near_far_array : array of str
'near' or 'far' for each site
num_simulations : int
MC simulations
distribution_type : str
"uniform", "exponential", or "average"
segment_sampling : str
"truncated" or "legacy"
Returns:
--------
dict with arrays:
P_slice: array of per-site slice probabilities
P_along_strike: array of per-site along-strike probabilities
P_total: array of per-site total probabilities
"""
# Backward compatibility: if along_strike_width not provided, use across_strike_width
if along_strike_width is None:
along_strike_width = across_strike_width
r_arr = np.atleast_1d(np.asarray(r_array, dtype=float))
rx_arr = np.atleast_1d(np.asarray(rx_array, dtype=float))
near_far_arr = np.atleast_1d(near_far_array)
n_sites = len(r_arr)
mechanism = style.lower()
# Step 1: Compute P_slice for ALL sites at once (vectorized)
# P(across) uses across_strike_width for coefficient lookup
P_slice = self.get_prob_slice(mag, r_arr, rx_arr, style, across_strike_width, combination)
P_slice = np.atleast_1d(P_slice)
# Step 2: Compute P_along for each unique (hw_fw, near_far) group
# There are at most 4 groups: (HW,near), (HW,far), (FW,near), (FW,far)
hw_fw_arr = np.where(rx_arr < 0, 'FW', 'HW')
# Get unique groups
P_along = np.zeros(n_sites, dtype=float)
for hw_fw in ['HW', 'FW']:
for near_far in ['near', 'far']:
mask = (hw_fw_arr == hw_fw) & (near_far_arr == near_far)
if not np.any(mask):
continue
# Single MC call for this group (cached)
# P(along) uses both dimensions
p_along_group = self.monte_carlo_rank2_occurrence(
fault_length,
across_strike_width,
hw_fw,
mechanism,
near_far,
num_simulations=num_simulations,
distribution_type=distribution_type,
segment_sampling=segment_sampling,
along_strike_width=along_strike_width
)
P_along[mask] = p_along_group
P_total = P_slice * P_along
return {
"P_slice": P_slice,
"P_along_strike": P_along,
"P_total": P_total
}
def clear_cache(self):
"""Clear the Monte Carlo cache."""
self._mc_cache.clear()
def get_cache_stats(self):
"""Return cache statistics for debugging."""
return {
"cache_size": len(self._mc_cache),
"cache_keys": list(self._mc_cache)[:10] # First 10 keys
}