Source code for openquake.hazardlib.calc.displacement

# -*- coding: utf-8 -*-
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (C) 2026 GEM Foundation
#
# OpenQuake 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.
#
# OpenQuake 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 OpenQuake. If not, see <http://www.gnu.org/licenses/>.

"""
Probabilistic Fault Displacement (PFD) rate kernel (PR-6 of the oq-engine
integration plan, Workstream D).

The kernel is the engine-idiomatic port of oq-pfdha's
``calc/hazard.py::_compute_rupture_contribution`` plus its
``calc/location_weight.py``. It computes, per surface-rupturing rupture and
per site, the annual exceedance rates of fault displacement:

    lambda_principal   = rate * P_sr * P_fd_primary       * W_p(r)
    lambda_distributed = rate * P_sr * P_dist_combined(r) * G(r)

``W_p(r)`` is the rupture-location weight (see :func:`location_weight`); the
distributed weight ``G`` depends on the ``W_p`` path:

* ``r_sigma_km == 0`` -- boxcar ``W_p = 1{|r| <= r_threshold_km}`` and the
  COMPLEMENTARY split ``G = 1 - W_p`` (Youngs 2003 / Takao 2013 either/or);
* ``r_sigma_km > 0``  -- Petersen et al. (2011) pinned, +/-2-sigma Gaussian
  ``W_p`` and the ADDITIVE split ``G = 1`` (eq. 1 + eq. 2, "total hazard").

An aggregate-definition primary FD model (Sarmiento et al. 2025 Table 1, the
class choice IS the definition) already includes the distributed
contribution: ``lambda_total = rate * P_sr * P_fd_aggregate * W_p`` flows
through the principal bucket and the distributed bucket stays zero.

The kernel deliberately does NOT call ``get_mean_stds``/``get_poes``
(decision D12): the PFD models yield exceedance probabilities directly.
The calculator wraps the returned rate arrays into the engine's ``MapArray``
and reuses all downstream stats/export machinery.

The pure functions here take objects exposing the adapter protocol
(:class:`openquake.pfd.adapter.PFDModelAdapter`: ``compute_primary_sr``,
``compute_primary_fd``, ``compute_secondary_sr``, ``compute_secondary_fd``,
each ``(ctx, ...) -> array``), keyed by the slot names
``primary_sr`` / ``primary_fd`` / ``secondary_sr`` / ``secondary_fd``. The
Visini rank-2 combined pipeline (Workstream H) is not part of this kernel
yet; a Visini chain is routed through the generic SR x FD path until then.
"""
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple

import numpy as np

#: default Monte-Carlo / epistemic reduction; the mean is oq-pfdha's
#: default and is exact inside the rate sum (expectation is linear), so
#: the engine reproduces oq-pfdha's mean hazard curve. The median is a
#: legacy central-estimate heuristic that deviates at high displacement
#: levels.
DEFAULT_RED_CFG = {'method': 'mean'}


[docs]def location_weight(r, r_threshold_km: float, r_sigma_km: float, r_sigma_truncation: float = 2.0) -> np.ndarray: """ Principal-contribution rupture-location weight ``W_p(r)``. Two mutually exclusive paths (Petersen et al. 2011, Tables 2-3 and p. 819; see oq-pfdha ``docs/design/rupture_location_uncertainty.md``): * ``r_sigma_km == 0``: the legacy boxcar ``1{|r| <= r_threshold_km}``; * ``r_sigma_km > 0``: Petersen's Gaussian mapping-error weight, pinned to 1 on the trace and truncated beyond ``+/- r_sigma_truncation * sigma``, with ``r_threshold_km`` playing no role. :param r: across-strike distance(s) to the mapped trace, km (``ctx.rtor``) :param r_threshold_km: boxcar half-width ``h`` (sigma == 0 path only) :param r_sigma_km: two-sided mapping-error sigma (km); 0 selects the boxcar :param r_sigma_truncation: truncation ``n`` in ``+/-n*sigma`` for the Gaussian path (Petersen p. 819: n = 2) :returns: ``float64`` array with the shape of ``r``; ``W_p(0) == 1`` """ r = np.abs(np.asarray(r, dtype=np.float64)) sigma = float(r_sigma_km) if sigma == 0.0: return (r <= float(r_threshold_km)).astype(np.float64) n = float(r_sigma_truncation) wp = np.exp(-(r * r) / (2.0 * sigma * sigma)) return np.where(r > n * sigma, 0.0, wp)
def _definition(model): """The static DISPLACEMENT_DEFINITION of a model (or ``None``).""" return getattr(model, 'DISPLACEMENT_DEFINITION', None)
[docs]def calc_rupture_contribution( ctx, adapters: Mapping[str, Any], imls: np.ndarray, r_threshold_km: float, r_sigma_km: float, red_cfg: Optional[Dict[str, Any]] = None, ) -> Tuple[np.ndarray, np.ndarray]: """ Principal and distributed annual-rate contributions of one rupture. :param ctx: a rupture context with ``occurrence_rate``, ``rtor`` and (for the adapters) the usual rupture/site fields :param adapters: slot name -> adapter; missing slots contribute zero (primary SR defaults to 1, the "always surface-ruptures" assumption) :param imls: displacement levels (m), shape ``(D,)`` :param r_threshold_km: boxcar half-width for the ``W_p`` sigma == 0 path :param r_sigma_km: mapping-error sigma; 0 selects the complementary split :param red_cfg: Monte-Carlo reduction config :returns: ``(principal, distributed)``, each ``(N, D)`` annual rates """ red_cfg = DEFAULT_RED_CFG if red_cfg is None else red_cfg imls = np.asarray(imls, dtype=np.float64) n_ctx = len(ctx) n_displ = len(imls) rate = float(ctx.occurrence_rate[0]) if 'primary_sr' in adapters: p_sr = np.asarray( adapters['primary_sr'].compute_primary_sr(ctx, red_cfg), dtype=np.float64) else: p_sr = np.ones(n_ctx, dtype=np.float64) if 'primary_fd' in adapters: p_fd_primary = np.asarray( adapters['primary_fd'].compute_primary_fd(ctx, imls, red_cfg), dtype=np.float64) else: p_fd_primary = np.zeros((n_ctx, n_displ), dtype=np.float64) wp = location_weight(ctx.rtor, r_threshold_km, r_sigma_km) # Aggregate-definition primary model: single bucket, no distributed term. if ('primary_fd' in adapters and _definition(adapters['primary_fd'].model) == 'aggregate'): principal = (rate * p_sr[:, np.newaxis] * p_fd_primary * wp[:, np.newaxis]) return principal, np.zeros((n_ctx, n_displ), dtype=np.float64) if 'secondary_combined' in adapters: # combined distributed pipeline (Visini et al. 2025 A/B/C) p_dist = np.asarray( adapters['secondary_combined'].compute(ctx, imls, red_cfg), dtype=np.float64) else: if 'secondary_sr' in adapters: p_sr_sec = np.asarray( adapters['secondary_sr'].compute_secondary_sr(ctx, red_cfg), dtype=np.float64) else: p_sr_sec = np.zeros(n_ctx, dtype=np.float64) if 'secondary_fd' in adapters: p_fd_sec = np.asarray( adapters['secondary_fd'].compute_secondary_fd( ctx, imls, red_cfg), dtype=np.float64) else: p_fd_sec = np.zeros((n_ctx, n_displ), dtype=np.float64) p_dist = p_sr_sec[:, np.newaxis] * p_fd_sec if float(r_sigma_km) == 0.0: g = 1.0 - wp # complementary (legacy boxcar split) else: g = np.ones_like(wp) # additive (Petersen eq. 1 + eq. 2) principal = rate * p_sr[:, np.newaxis] * p_fd_primary * wp[:, np.newaxis] distributed = rate * p_sr[:, np.newaxis] * p_dist * g[:, np.newaxis] return principal, distributed
[docs]def calc_rates( contexts: Sequence[Any], n_sites: int, adapters: Mapping[str, Any], imls: np.ndarray, r_threshold_km: float, r_sigma_km: float, red_cfg: Optional[Dict[str, Any]] = None, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Accumulate the PFD annual exceedance rates over a sequence of contexts. :returns: ``(rates, principal, distributed)``, each ``(n_sites, D)``; ``rates = principal + distributed`` """ imls = np.asarray(imls, dtype=np.float64) n_displ = len(imls) rates = np.zeros((n_sites, n_displ), dtype=np.float64) principal = np.zeros((n_sites, n_displ), dtype=np.float64) distributed = np.zeros((n_sites, n_displ), dtype=np.float64) for ctx in contexts: p, d = calc_rupture_contribution( ctx, adapters, imls, r_threshold_km, r_sigma_km, red_cfg) contrib = p + d sids = np.asarray(ctx.sids, dtype=np.int64) if len(sids) != contrib.shape[0]: raise ValueError( "context/contribution shape mismatch: %d sids vs %d rows" % (len(sids), contrib.shape[0])) np.add.at(rates, sids, contrib) np.add.at(principal, sids, p) np.add.at(distributed, sids, d) return rates, principal, distributed