Source code for openquake.hazardlib.source.base

# The Hazard Library
# Copyright (C) 2012-2026 GEM Foundation
#
# 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 <http://www.gnu.org/licenses/>.
"""
Module :mod:`openquake.hazardlib.source.base` defines a base class for
seismic sources.
"""
import abc
import zlib
from dataclasses import dataclass
import numpy
from openquake.baselib import general
from openquake.hazardlib import mfd
from openquake.hazardlib.aspect_ratio import MagDepAspectRatio
from openquake.hazardlib.pmf import PMF
from openquake.hazardlib.tom import PoissonTOM
from openquake.hazardlib.calc.filters import magstr, split_source
from openquake.hazardlib.geo import Point
from openquake.hazardlib.geo.surface.planar import build_planar, PlanarSurface
from openquake.hazardlib.geo.surface.multi import MultiSurface
from openquake.hazardlib.source.rupture import (
    ParametricProbabilisticRupture, NonParametricProbabilisticRupture,
    EBRupture)

F64 = numpy.float64
I64 = numpy.int64
TWO30 = I64(2**30)


[docs]@dataclass class SourceParam: source_id: str name: str tectonic_region_type: str mfd: object rupture_mesh_spacing: float magnitude_scaling_relationship: object rupture_aspect_ratio: float temporal_occurrence_model: object
[docs]def get_code2cls(): """ :returns: a dictionary source code -> source class """ dic = {} for cls in general.gen_subclasses(BaseSeismicSource): if hasattr(cls, 'code'): dic[cls.code] = cls return dic
[docs]def is_poissonian(src): """ :returns: True if the underlying source is poissonian, false otherwise """ if src.code == b'F': # multiFault return src.infer_occur_rates elif src.code == b'N': # nonParametric return False return True
[docs]def poisson_sample(src, eff_num_ses, seed): """ :param src: a poissonian source :param eff_num_ses: number of stochastic event sets * number of samples :param seed: stochastic seed :yields: triples (rupture, rup_id, num_occurrences) """ rng = numpy.random.default_rng(seed) if hasattr(src, 'temporal_occurrence_model'): tom = src.temporal_occurrence_model else: # multifault tom = PoissonTOM(src.investigation_time) rupids = src.offset + numpy.arange(src.num_ruptures) if not hasattr(src, 'nodal_plane_distribution'): if src.code == b'F': # multifault s = src.get_sections() for i, rate in enumerate(src.occur_rates): # NB: rng.poisson called inside to save memory num_occ = rng.poisson(rate * tom.time_span * eff_num_ses) if num_occ == 0: # skip continue idxs = src.rupture_idxs[i] if len(idxs) == 1: sfc = s[idxs[0]] else: sfc = MultiSurface([s[idx] for idx in idxs]) hypo = s[idxs[0]].get_middle_point() rup = ParametricProbabilisticRupture( src.mags[i], src.rakes[i], src.tectonic_region_type, hypo, sfc, src.occur_rates[i], tom) yield rup, rupids[i], num_occ else: # simple or complex fault ruptures = list(src.iter_ruptures()) rates = numpy.array([rup.occurrence_rate for rup in ruptures]) occurs = rng.poisson(rates * tom.time_span * eff_num_ses) for rup, rupid, num_occ in zip(ruptures, rupids, occurs): if num_occ: yield rup, rupid, num_occ return # else (multi)point sources and area sources usd = src.upper_seismogenic_depth lsd = src.lower_seismogenic_depth rup_args = [] rates = [] for ps in split_source(src): if not hasattr(ps, 'location'): # unsplit containing a single source [ps] = src lon, lat = ps.location.x, ps.location.y for mag, mag_occ_rate in ps.get_annual_occurrence_rates(): for np_prob, np in ps.nodal_plane_distribution.data: for hc_prob, hc_depth in ps.hypocenter_distribution.data: args = (mag_occ_rate, np_prob, hc_prob, mag, np, lon, lat, hc_depth, ps) rup_args.append(args) rates.append(mag_occ_rate * np_prob * hc_prob) eff_rates = numpy.array(rates) * tom.time_span * eff_num_ses occurs = rng.poisson(eff_rates) for num_occ, args, rupid, rate in zip(occurs, rup_args, rupids, rates): if num_occ: _, np_prob, hc_prob, mag, np, lon, lat, hc_depth, ps = args hc = Point(lon, lat, hc_depth) hdd = numpy.array([(1., hc.depth)]) [[[planar]]] = build_planar( ps.get_planin([(1., mag)], [(1., np)]), hdd, lon, lat, usd, lsd, ps.get_aspect_ratio(mag)) rup = ParametricProbabilisticRupture( mag, np.rake, ps.tectonic_region_type, hc, PlanarSurface.from_(planar), rate, tom) yield rup, rupid, num_occ
[docs]def timedep_sample(src, eff_num_ses, seed): """ :param src: a time-dependent source :param eff_num_ses: number of stochastic event sets * number of samples :param seed: stochastic seed :yields: triples (rupture, rup_id, num_occurrences) """ rng = numpy.random.default_rng(seed) rupids = src.offset + numpy.arange(src.num_ruptures) if src.code == b'F': # time-dependent multifault s = src.get_sections() for i, probs in enumerate(src.probs_occur): cdf = numpy.cumsum(probs) num_occ = numpy.digitize(rng.random(eff_num_ses), cdf).sum() if num_occ == 0: # ignore non-occurring ruptures continue idxs = src.rupture_idxs[i] if len(idxs) == 1: sfc = s[idxs[0]] else: sfc = MultiSurface([s[idx] for idx in idxs]) hypo = sfc.get_middle_point() pmf = PMF([(p, o) for o, p in enumerate(probs)]) yield (NonParametricProbabilisticRupture( src.mags[i], src.rakes[i], src.tectonic_region_type, hypo, sfc, pmf), rupids[i], num_occ) else: # time-dependent nonparametric mutex_weight = getattr(src, 'mutex_weight', 1) for rup, rupid in zip(src.iter_ruptures(), rupids): occurs = rup.sample_number_of_occurrences(eff_num_ses, rng) if mutex_weight < 1: # consider only the occurrencies below the mutex_weight occurs *= (rng.random(eff_num_ses) < mutex_weight) num_occ = occurs.sum() if num_occ: yield rup, rupid, num_occ
[docs]class BaseSeismicSource(metaclass=abc.ABCMeta): """ Base class representing a seismic source, that is a structure generating earthquake ruptures. :param source_id: Some (numeric or literal) source identifier. Supposed to be unique within the source model. :param name: String, a human-readable name of the source. :param tectonic_region_type: Source's tectonic regime. See :class:`openquake.hazardlib.const.TRT`. """ id = -1 # to be set trt_smr = 0 # set by the engine nsites = 1 # set when filtering the source splittable = True checksum = 0 # set in source_reader weight = 0.001 # set in contexts nctxs = 1 # updated in estimate_weight offset = 0 # set in fix_src_offset trt_smr = -1 # set by the engine _num_ruptures = 0 # set by the engine seed = None # set by the engine smweight = 1. # set by the engine dt = 0 # set by the engine @abc.abstractproperty def MODIFICATIONS(self): pass @property def num_ruptures(self): """ Call count_ruptures() once and cache the result """ if self._num_ruptures == 0: self._num_ruptures = self.count_ruptures() return self._num_ruptures @property def trt_smrs(self): """ :returns: a list of integers (usually of 1 element) """ trt_smr = self.trt_smr return (trt_smr,) if isinstance(trt_smr, int) else trt_smr
[docs] def serial(self, ses_seed): """ :returns: a random seed derived from source_id and ses_seed """ return zlib.crc32(self.source_id.encode('ascii'), ses_seed)
[docs] def is_gridded(self): """ :returns: True if the source contains only gridded ruptures """ return False
[docs] @abc.abstractmethod def iter_ruptures(self, **kwargs): """ Get a generator object that yields probabilistic ruptures the source consists of. :returns: Generator of instances of sublclass of :class: `~openquake.hazardlib.source.rupture.BaseProbabilisticRupture`. """
[docs] def iter_meshes(self): """ Yields the meshes underlying the ruptures """ for rup in self.iter_ruptures(): if isinstance(rup.surface, MultiSurface): for surf in rup.surface.surfaces: yield surf.mesh else: yield rup.surface.mesh
[docs] def sample_ruptures(self, eff_num_ses, ses_seed): """ :param eff_num_ses: number of stochastic event sets * number of samples :yields: triples (rupture, trt_smr, num_occurrences) """ seed = self.serial(ses_seed) sample = poisson_sample if is_poissonian(self) else timedep_sample for rup, rupid, num_occ in sample(self, eff_num_ses, seed): if self.smweight < 1 and hasattr(rup, 'occurrence_rate'): # defined only for poissonian sources # needed to get convergency of the frequency to the rate # tested only in oq-risk-tests etna0 rup.occurrence_rate *= self.smweight ebr = EBRupture(rup, self.id, self.trt_smr, num_occ, rupid, seed=rupid + TWO30 * self.id + ses_seed) yield ebr
[docs] def get_mags(self): """ :returns: the magnitudes of the ruptures contained in the source """ if hasattr(self, 'get_annual_occurrence_rates'): return F64([m for m, r in self.get_annual_occurrence_rates()]) mags = set() if hasattr(self, 'mags'): # MultiFaultSource mags.update(self.mags) else: # nonparametric for rup, pmf in self.data: mags.add(rup.mag) return sorted(mags)
[docs] def get_magstrs(self): """ :returns: the magnitudes of the ruptures contained as strings """ if hasattr(self, 'mags'): # MultiFaultSource mags = {magstr(mag) for mag in self.mags} elif hasattr(self, 'data'): # nonparametric mags = {magstr(item[0].mag) for item in self.data} else: mags = {magstr(item[0]) for item in self.get_annual_occurrence_rates()} return sorted(mags)
def __iter__(self): """ Override to implement source splitting """ yield self
[docs] @abc.abstractmethod def count_ruptures(self): """ Return the number of ruptures that will be generated by the source. """
[docs] @abc.abstractmethod def get_min_max_mag(self): """ Return minimum and maximum magnitudes of the ruptures generated by the source. """
[docs] def modify(self, modification, parameters): """ Apply a single modificaton to the source parameters Reflects the modification method and calls it passing ``parameters`` as keyword arguments. Modifications can be applied one on top of another. The logic of stacking modifications is up to a specific source implementation. :param modification: String name representing the type of modification. :param parameters: Dictionary of parameters needed for modification. :raises ValueError: If ``modification`` is missing from the attribute `MODIFICATIONS`. """ if modification not in self.MODIFICATIONS: raise ValueError('Modification %s is not supported by %s' % (modification, type(self).__name__)) meth = getattr(self, 'modify_%s' % modification) meth(**parameters)
[docs] def to_xml(self): """ Convert the source into an XML string, very useful for debugging """ from openquake.hazardlib import nrml, sourcewriter return nrml.to_string(sourcewriter.obj_to_node(self))
def __repr__(self): """ String representation of a source, displaying the source class name and the source id. """ return '<%s %s, weight=%.1f>' % ( self.__class__.__name__, self.source_id, self.weight)
[docs]class ParametricSeismicSource(BaseSeismicSource, metaclass=abc.ABCMeta): """ Parametric Seismic Source generates earthquake ruptures from source parameters, and associated probabilities of occurrence are defined through a magnitude frequency distribution and a temporal occurrence model. :param mfd: Magnitude-Frequency distribution for the source. See :mod:`openquake.hazardlib.mfd`. :param rupture_mesh_spacing: The desired distance between two adjacent points in source's ruptures' mesh, in km. Mainly this parameter allows to balance the trade-off between time needed to compute the :meth:`distance <openquake.hazardlib.geo.surface.base.BaseSurface.get_min_distance>` between the rupture surface and a site and the precision of that computation. :param magnitude_scaling_relationship: Instance of subclass of :class:`openquake.hazardlib.scalerel.base.BaseMSR` to describe how does the area of the rupture depend on magnitude and rake. :param rupture_aspect_ratio: Float number representing how much source's ruptures are more wide than tall. Aspect ratio of 1 means ruptures have square shape, value below 1 means ruptures stretch vertically more than horizontally and vice versa. :param temporal_occurrence_model: Instance of :class:`openquake.hazardlib.tom.PoissonTOM` defining temporal occurrence model for calculating rupture occurrence probabilities :raises ValueError: If either rupture aspect ratio or rupture mesh spacing is not positive (if not None). """ def __init__(self, source_id, name, tectonic_region_type, mfd, rupture_mesh_spacing, magnitude_scaling_relationship, rupture_aspect_ratio, temporal_occurrence_model): self.source_id = source_id self.name = name self.tectonic_region_type = tectonic_region_type if rupture_mesh_spacing is not None and not rupture_mesh_spacing > 0: raise ValueError('rupture mesh spacing must be positive') if (rupture_aspect_ratio is not None and isinstance(rupture_aspect_ratio, (int, float)) and not rupture_aspect_ratio > 0): raise ValueError('rupture aspect ratio must be positive') self.mfd = mfd self.rupture_mesh_spacing = rupture_mesh_spacing self.magnitude_scaling_relationship = magnitude_scaling_relationship self.rupture_aspect_ratio = rupture_aspect_ratio self.temporal_occurrence_model = temporal_occurrence_model
[docs] def get_annual_occurrence_rates(self, min_rate=0): """ Get a list of pairs "magnitude -- annual occurrence rate". The list is taken from assigned MFD object (see :meth: `openquake.hazardlib.mfd.base.BaseMFD.get_annual_occurrence_rates`) with simple filtering by rate applied. :param min_rate: A non-negative value to filter magnitudes by minimum annual occurrence rate. Only magnitudes with rates greater than that are included in the result list. :returns: A list of two-item tuples -- magnitudes and occurrence rates. """ scaling_rate = getattr(self, 'scaling_rate', 1) return [(mag, occ_rate * scaling_rate) for mag, occ_rate in self.mfd.get_annual_occurrence_rates() if min_rate is None or occ_rate > min_rate]
[docs] def get_min_max_mag(self): """ Get the minimum and maximum magnitudes of the ruptures generated by the source from the underlying MFD. """ return self.mfd.get_min_max_mag()
[docs] def modify_set_msr(self, new_msr): """ Updates the MSR originally assigned to the source :param new_msr: An instance of the :class:`openquake.hazardlib.scalerel.BaseMSR` """ self.magnitude_scaling_relationship = new_msr
[docs] def get_aspect_ratio(self, mag): """ Return the rupture aspect ratio """ rar = self.rupture_aspect_ratio if not isinstance(rar, MagDepAspectRatio): return rar # Must be MagDepAspectRatio so get the expression to be eval return rar.get(mag)
def _check_scalar_aspect_ratio(self, modification): if isinstance(self.rupture_aspect_ratio, MagDepAspectRatio): # Prevent use of aratio epistemic uncertainties when also # using a non-scalar value for aspect ratio in the src XML raise ValueError( 'Cannot apply %s to source %r: epistemic uncertainties on ' 'aspect ratio are not compatible with aspectRatioFunction' % (modification, self.source_id))
[docs] def modify_set_aspect_ratio(self, aspect_ratio): """ Replaces the rupture aspect ratio with the given value :param float aspect_ratio: New value of the rupture aspect ratio (must be positive) """ self._check_scalar_aspect_ratio('set_aspect_ratio') if not aspect_ratio > 0: raise ValueError('rupture aspect ratio must be positive, got %s' % aspect_ratio) self.rupture_aspect_ratio = aspect_ratio
[docs] def modify_adjust_aspect_ratio(self, increment): """ Adjusts the rupture aspect ratio by an incremental value :param float increment: Value by which to increase or decrease the rupture aspect ratio """ self._check_scalar_aspect_ratio('adjust_aspect_ratio') new_ratio = self.rupture_aspect_ratio + increment if not new_ratio > 0: raise ValueError( 'increment %s would result in a non-positive rupture aspect ' 'ratio (%s)' % (increment, new_ratio)) self.rupture_aspect_ratio = new_ratio
[docs] def modify_set_slip_rate(self, slip_rate: float): """ Updates the slip rate assigned to the source :param slip_rate: The value of slip rate [mm/yr] """ self.slip_rate = slip_rate
[docs] def modify_set_mmax_truncatedGR(self, mmax: float): """ Updates the mmax assigned. This works on for parametric MFDs. :param mmax: The value of the new maximum magnitude """ # Check that the current src has a TruncatedGRMFD MFD msg = 'This modification works only when the source MFD is a ' msg += 'TruncatedGRMFD' assert self.mfd.__class__.__name__ == 'TruncatedGRMFD', msg self.mfd.max_mag
[docs] def modify_recompute_mmax(self, epsilon: float = 0): """ Updates the value of mmax using the msr and the area of the fault :param epsilon: Number of standard deviations to be added or substracted """ msr = self.magnitude_scaling_relationship area = self.get_fault_surface_area() # area in km^2 mag = msr.get_median_mag(area=area, rake=self.rake) std = msr.get_std_dev_mag(area=area, rake=self.rake) self.mfd.max_mag = mag + epsilon * std
[docs] def modify_adjust_mfd_from_slip(self, slip_rate: float, rigidity: float, constant_term: float = 9.1, recompute_mmax: float = None): """ :param slip_rate: A float defining slip rate [in mm] :param rigidity: A float defining material rigidity [in GPa] :param constant_term: Constant term of the equation used to compute log M0 from magnitude """ # Check that the current src has a TruncatedGRMFD MFD msg = 'This modification works only when the source MFD is a ' msg += 'TruncatedGRMFD' assert self.mfd.__class__.__name__ == 'TruncatedGRMFD', msg # Compute moment area = self.get_fault_surface_area() * 1e6 # area in m^2 rigidity *= 1e9 # rigidity in Pa slip_rate *= 1e-3 # slip rate in m mo = rigidity * area * slip_rate # Update the MFD min_mag = self.mfd.min_mag max_mag = self.mfd.max_mag bin_w = self.mfd.bin_width b_val = self.mfd.b_val self.mfd = mfd.TruncatedGRMFD.from_moment(min_mag, max_mag, bin_w, b_val, mo, constant_term)