From e61ff9d3a39703714090661b21b3e4b82ef5e467 Mon Sep 17 00:00:00 2001
From: Graeme Weatherill <gweather@gfz-potsdam.de>
Date: Thu, 11 Mar 2021 15:42:56 +0000
Subject: [PATCH] Implements initial complete shakemap feature

---
 shakyground2/earthquake.py                  |  96 +++--
 shakyground2/rupture_mechanism.py           |  50 ++-
 shakyground2/shakemap.py                    | 286 +++++++++++++++
 shakyground2/site_model.py                  |  14 +-
 shakyground2/synthetic_rupture_generator.py | 388 ++++++++++++++++++++
 shakyground2/valid.py                       |  19 +-
 tests/data/__init__.py                      |   0
 tests/earthquake_test.py                    |  39 +-
 tests/rupture_mechanism_test.py             |  59 ++-
 tests/shakemap_test.py                      | 112 ++++++
 tests/synthetic_rupture_generator_test.py   | 335 +++++++++++++++++
 11 files changed, 1327 insertions(+), 71 deletions(-)
 create mode 100644 shakyground2/shakemap.py
 create mode 100644 shakyground2/synthetic_rupture_generator.py
 create mode 100644 tests/data/__init__.py
 create mode 100644 tests/shakemap_test.py
 create mode 100644 tests/synthetic_rupture_generator_test.py

diff --git a/shakyground2/earthquake.py b/shakyground2/earthquake.py
index 7dc90b8..6aff1c1 100644
--- a/shakyground2/earthquake.py
+++ b/shakyground2/earthquake.py
@@ -2,12 +2,13 @@
 Contains the core classes to represent an earthquake
 """
 import datetime
-from typing import Tuple
+from typing import Tuple, Optional, List
 from math import radians, sin, tan
 from openquake.hazardlib.geo import Point, PlanarSurface
 from openquake.hazardlib.geo.surface.base import BaseSurface
 from openquake.hazardlib.source.rupture import ParametricProbabilisticRupture
 from shakyground2 import valid
+from shakyground2.rupture_mechanism import RuptureMechanism
 
 
 class Earthquake(object):
@@ -28,6 +29,9 @@ class Earthquake(object):
             Earthquake latitude in decimal degrees (-90, to 90)
         depth:
             Hypocentral depth (in km)
+        hypocenter:
+            Representation of the hypocenter as an instance of
+            :class:`openquake.hazardlib.geo.Point`
         mag:
             Earthquake magnitude
         strike:
@@ -63,14 +67,15 @@ class Earthquake(object):
         lat,
         hypo_depth,
         magnitude,
-        strike=0.0,
-        dip=90.0,
-        rake=0.0,
+        strike=None,
+        dip=None,
+        rake=None,
         aspect=1.0,
         mag_scaling_relation=None,
         upper_seismogenic_depth=0.0,
         lower_seismogenic_depth=1000.0,
         surface=None,
+        focal_mechanism=None,
         hypocenter_position=(0.5, 0.5),
         date=None,
         time=None,
@@ -121,6 +126,7 @@ class Earthquake(object):
         self.lon = valid.longitude(lon)
         self.lat = valid.latitude(lat)
         self.depth = valid.positive_float(hypo_depth, "hypocentre depth")
+        self.hypocenter = Point(self.lon, self.lat, self.depth)
         self.mag = magnitude
         self.strike = valid.strike(strike)
         self.dip = valid.dip(dip)
@@ -138,8 +144,41 @@ class Earthquake(object):
         self.time = time
         assert isinstance(surface, BaseSurface) or surface is None
         self.surface = surface
+        if self.surface:
+            # Can calculate rupture dimensions from the surface
+            self.area = self.surface.get_area()
+            self.width = self.surface.get_width()
+            self.length = self.area / self.width
+        else:
+            # Default rupture dimensions to none to none
+            self.area = self.width = self.length = None
         self.hypo_pos = valid.hypocenter_position(hypocenter_position)
         self._rupture = None
+        # Get a valid focal mechanism with two nodal planes
+        self.focal_mechanism = valid.focal_mechanism(focal_mechanism)
+        self.mechanism = self._get_mechanism()
+        self.tectonic_region = None
+
+    def _get_mechanism(self):
+        """
+        Defines the focal mechanism according to three different cases:
+        1. A unique mechanism is defined explicitly from the strike, dip and rake
+        2. A pair of equiprobable mechanisms is defined from the focal mechanism
+        3. No mechanism is defined, in which case a global distribution is assumed
+
+        Returns:
+            Mechanism distribution as an instance of the
+            :class:`shakyground2.rupture_mechanism.RuptureMechanism`
+        """
+        if (self.strike is not None) and (self.dip is not None) and (self.rake is not None):
+            # Fixed and fully defined rupture mechanism
+            return RuptureMechanism.from_strike_dip_rake(self.strike, self.dip, self.rake)
+        elif self.focal_mechanism:
+            # Rupture mechanism defines from nodal planes
+            return RuptureMechanism.from_focal_mechanism(self.focal_mechanism)
+        else:
+            # Global distribution
+            return RuptureMechanism()
 
     def __repr__(self):
         # Returns a summary string of the event, with datetime if specified
@@ -156,6 +195,34 @@ class Earthquake(object):
                 self.id, self.lon, self.lat, self.depth, self.mag
             )
 
+    def get_maximum_distance_bbox(self, max_distance: Optional[float] = None) -> List:
+        """
+        Defines a bounding box around the event up to a maximum distance.
+
+        Args:
+            max_distance: Maximum horizontal and vertical distance from the epicentre for the
+                          bounding box to be defined. If None then a default bounding box
+                          size is determined that is based on magnitude between Mw 3.0 (100 km)
+                          and Mw 8.0 (1000 km)
+        Returns:
+            Bounding box as a list of [llon, llat, ulon, ulat]
+        """
+        if not max_distance:
+            # Scale from 100 km (for Mw 3 or less) to 1000 km for Mw >= 8.0
+            if self.mag <= 3.0:
+                max_distance = 100.0
+            elif self.mag >= 8.0:
+                max_distance = 1000.0
+            else:
+                # Interpolate
+                max_distance = 100.0 + (self.mag - 3.0) * (900.0 / 5.0)
+        # Define the bounding box from the specified maximum distance
+        north = self.hypocenter.point_at(max_distance, 0.0, 0.0)
+        south = self.hypocenter.point_at(max_distance, 0.0, 180.0)
+        east = self.hypocenter.point_at(max_distance, 0.0, 90.0)
+        west = self.hypocenter.point_at(max_distance, 0.0, 270.0)
+        return [west.longitude, south.latitude, east.longitude, north.latitude]
+
     @property
     def rupture(self):
         """
@@ -172,27 +239,6 @@ class Earthquake(object):
                 self.mag, self.rake, None, centroid, self.surface, 1.0, None
             )
             return self._rupture
-        # Rupture is not defined, so needs to be constructed. Use simple case for now
-        # TODO To be replaced by the synthetic rupture generator operation
-        thickness = self.lsd - self.usd
-        # Get rupture dimensions
-        area, length, width = self.mag_scale_rel.get_rupture_dimensions(
-            self.mag, aspect=self.aspect, dip=self.dip, thickness=thickness
-        )
-        # Build the rupture surface
-        surface = self.build_planar_surface(
-            centroid,
-            self.strike,
-            self.dip,
-            length,
-            width,
-            self.lsd,
-            self.usd,
-            self.hypo_pos,
-        )
-        self._rupture = ParametricProbabilisticRupture(
-            self.mag, self.rake, None, centroid, surface, 1.0, None
-        )
         return self._rupture
 
     @staticmethod
diff --git a/shakyground2/rupture_mechanism.py b/shakyground2/rupture_mechanism.py
index 03c43e7..62fb339 100644
--- a/shakyground2/rupture_mechanism.py
+++ b/shakyground2/rupture_mechanism.py
@@ -4,7 +4,7 @@ Class to manage the distribution of rupture mechanisms
 from __future__ import annotations
 import numpy as np
 from enum import Enum
-from typing import Optional, Dict, Tuple
+from typing import Optional, Dict, Tuple, List
 from openquake.hazardlib.geo import NodalPlane
 from openquake.hazardlib.pmf import PMF
 from shakyground2 import valid
@@ -56,6 +56,10 @@ class RuptureMechanism(object):
         for prob, nodal_plane in self.mechanism.data:
             yield prob, nodal_plane
 
+    def __len__(self):
+        # Lenth corresponds to the number of mechanisms in the distribution
+        return len(self.mechanism.data)
+
     def sample(self, nsamples: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
         """
         Samples the focal mechanism distribution to return "nsamples" strike,
@@ -100,30 +104,48 @@ class RuptureMechanism(object):
         )
 
     @classmethod
-    def from_nodal_planes(cls, nodal_plane_1: Dict, nodal_plane_2: Dict) -> RuptureMechanism:
+    def from_focal_mechanism(cls, focal_mechanism: Dict) -> RuptureMechanism:
         """
-        Constructs the rupture mechanism distribution from either one single
-        valid rupture plane or two valud nodal planes
+        Constructs the focal mechanism from an evenly pair of nodal planes such as that
+        of a focal mechanism
 
         Args:
-            nodal_plane_1: Dictionary containing strike, dip and rake of the first nodel plane
-            nodal_plane_1: Dictionary containing strike, dip and rake of the second nodel plane
+            focal_mechanism: Dictionary containing two :class:
+            `openquake.hazardlib.geo.NodalPlane` objects, each labeled as "nodal_plane_1"
+            and "nodal_plane_2"
         """
-        strike_1 = valid.strike(nodal_plane_1["strike"])
-        dip_1 = valid.dip(nodal_plane_1["dip"])
-        rake_1 = valid.rake(nodal_plane_1["rake"])
-        strike_2 = valid.strike(nodal_plane_2["strike"])
-        dip_2 = valid.dip(nodal_plane_2["dip"])
-        rake_2 = valid.rake(nodal_plane_2["rake"])
         return cls(
             PMF(
                 [
-                    (0.5, NodalPlane(strike_1, dip_1, rake_1)),
-                    (0.5, NodalPlane(strike_2, dip_2, rake_2)),
+                    (0.5, focal_mechanism["nodal_plane_1"]),
+                    (0.5, focal_mechanism["nodal_plane_2"]),
                 ]
             )
         )
 
+    @classmethod
+    def from_nodal_planes(cls, nodal_planes: List, probabilities: List) -> RuptureMechanism:
+        """
+        Constructs the rupture mechanism distribution from a list of nodal planes and their
+        associated probabilities
+
+        Args:
+            nodal_planes: Set of nodal planes as a list of dictionaries, eac containing strike,
+                          dip and rake
+            probabilities: List of probabilities of the nodal planes (must sum to 1)
+        """
+        assert len(nodal_planes) == len(
+            probabilities
+        ), "Number of nodal planes not equal to number of probabilities"
+        assert np.isclose(
+            sum(probabilities), 1.0
+        ), "Probabilities do not sum to 1.0 (sum = %.6f)" % sum(probabilities)
+        mechanism_distribution = []
+        for prob, npl in zip(probabilities, nodal_planes):
+            mechanism = valid.mechanism(npl["strike"], npl["dip"], npl["rake"])
+            mechanism_distribution.append((prob, mechanism))
+        return cls(PMF(mechanism_distribution))
+
     @staticmethod
     def build_mechanism_distribution(
         strike: Optional[float] = None,
diff --git a/shakyground2/shakemap.py b/shakyground2/shakemap.py
new file mode 100644
index 0000000..9a460d2
--- /dev/null
+++ b/shakyground2/shakemap.py
@@ -0,0 +1,286 @@
+"""
+Implements the core shakemap class
+"""
+import h5py
+import numpy as np
+import warnings
+from typing import Dict, Optional, Tuple, List
+from openquake.hazardlib import const, imt
+from openquake.hazardlib.contexts import ContextMaker
+from shakyground2 import valid
+from shakyground2.earthquake import Earthquake
+from shakyground2.site_model import SiteModel
+from shakyground2.synthetic_rupture_generator import FiniteRuptureSampler
+
+
+class Shakemap(object):
+    """
+    The core class for calculating shakemaps. The general workflow contained within
+    takes the input earthquake and site model in order to build the source, path and site
+    context objects (i.e. containers that hold the data needed for the ground motion models).
+
+    The contexts objects are built upon instantiation of the shakemap class to avoid
+    recalculation if further shakemaps are needed.
+
+    The Shakemap class also caches the information into an hdf5 binary object, if desired by
+    the user. This means that the shakemap information for an earthquake ccould be retrieved at
+    a later time without the need for recalculation.
+    """
+
+    def __init__(
+        self,
+        earthquake: Earthquake,
+        site_model: SiteModel,
+        ground_motion_model: Dict,
+        tectonic_region: str,
+        cache_file: Optional[str] = None,
+        num_rupture_samples: int = 100,
+        rdim: float = 0.0,
+        synth_dist_weights: List = [0.25, 0.25, 0.25, 0.25],
+        synthetic_rupture_max_site_distance: float = 200.0,
+        synthetic_rupture_site_spacing: float = 0.05,
+    ):
+        """
+        Args:
+            earthquake:
+                Earthquake as an instance of the :class:shakyground2.earthquake.Earthquake
+            site_model:
+                Target sites used for calculation of the ground motion values, as instances of
+                :class:shakyground2.site_model.SiteModel
+            ground_motion_model:
+                Set of ground motion models and their respective weights as a dictionary
+                {"GMMs": [...], "weights": [...]}
+            tectonic_region:
+                Tectonic region to which the earthquake is assigned
+            cache_file:
+                Path to the hdf5 file for caching the results. It does not need to exist
+                but if the hdf5 file exists and contains an instance of the same event
+                (according to the earthquake.id) then the user will be warned here and the
+                existing shakemaps for that earthquake ID subsequently overwitten if the
+                user persists to implement the shakemap.
+            num_rupture_samples:
+                In the likely case that no rupture surface is available for the earthquake,
+                one will be generated by the :class:`shakyground2.synthetic_rupture_generator.
+                FiniteRuptureSampler`. This controls the number of samples to be used
+            rdim:
+                Exponent of the distance-dependent site weighing adopted by the :class:
+                `shakyground2.synthetic_rupture_generator.FiniteRuptureSampler`
+
+            synth_dist_weights: Can adjust the weight assigned to the different distance
+                                metrics used by the FiniteRuptureSampler
+            synthetic_rupture max_site_distance: In the case that the target sites for the
+                                                 FiniteRuptureSampler need to be built from the
+                                                 bounding box, this defines the maximum
+                                                 distances of the target sites to define the
+                                                 bounding box
+            synthetic_rupture_site_spacing: In the case that the target sites for the
+                                            FiniteRuptureSampler need to be built from the
+                                            bounding box, this defines the site spacing of the
+                                            sites
+        """
+        self.earthquake = earthquake
+        self.site_model = site_model
+        self.tectonic_region = tectonic_region
+        self.ground_motion_model = ground_motion_model
+        self.num_rupture_samples = num_rupture_samples
+        self.rdim = rdim
+        self.synthetic_rupture_distance_weights = synth_dist_weights
+        if cache_file:
+            self.caching = True
+            self.cache_file = cache_file
+            # Check if the earthquake is cleady in the case and warn the user if so
+            fle = h5py.File(self.cache_file, "a")
+            if earthquake.id in list(fle):
+                warnings.warn(
+                    "Earthquake %s already in cache file %s - "
+                    "Running the shakemaps will overwrite this"
+                    % (self.earthquake.id, self.cache_file)
+                )
+            fle.close()
+        else:
+            self.caching = False
+            self.cache_file = None
+        self.rctx = None
+        self.dctx = None
+        self.sctx = None
+        self.synth_rupture_max_site_dist = valid.positive_float(
+            synthetic_rupture_max_site_distance, "Max. Synthetic Rupture Site Distance"
+        )
+        self.synth_rupture_site_spacing = valid.positive_float(
+            synthetic_rupture_site_spacing, "Synthetic Rupture Site Spacing"
+        )
+        self._build_contexts()
+
+    def _build_contexts(self):
+        """
+        Construct the rupture, site and distances contexts from the earthquake, site and
+        ground motion models
+        """
+        cmaker = ContextMaker(self.tectonic_region, self.ground_motion_model["GMMs"])
+
+        if not self.earthquake.rupture:
+            # Use the finite rupture sampler to generate the rupture and corresponding
+            # distances from the available information
+            self.earthquake._rupture = FiniteRuptureSampler().get_finite_rupture(
+                self.num_rupture_samples,
+                self.earthquake,
+                rdim=self.rdim,
+                weights=self.synthetic_rupture_distance_weights,
+                maximum_site_distance=self.synth_rupture_max_site_dist,
+                site_spacing=self.synth_rupture_site_spacing,
+            )[0]
+        # For the sites and rupture context we can use the context maker to get all of the
+        # source, site and rupture distances
+        self.rctx, self.sctx, self.dctx = cmaker.make_contexts(
+            self.site_model.get_site_collection(), self.earthquake.rupture
+        )
+
+    def _cache_contexts(self, grp: h5py.Group):
+        """
+        If caching the shakemaps, then this stores the context information to the file
+        """
+        # Add the contexts to the group object
+        ctxt = grp.create_group("contexts")
+        rup_ctxt = ctxt.create_group("rupture")
+        dist_ctxt = ctxt.create_group("distances")
+        for gmm in self.ground_motion_model["GMMs"]:
+            for rup_attr in gmm.REQUIRES_RUPTURE_PARAMETERS:
+                if rup_attr not in rup_ctxt.attrs:
+                    rup_ctxt.attrs[rup_attr] = getattr(self.rctx, rup_attr)
+            for attr in gmm.REQUIRES_DISTANCES:
+                if attr not in list(dist_ctxt):
+                    distance = getattr(self.dctx, attr)
+                    dist_dset = dist_ctxt.create_dataset(attr, distance.shape, dtype="f")
+                    dist_dset[:] = distance
+        site_ctxt = ctxt.create_dataset(
+            "sites", self.site_model.site_array.shape, dtype=self.site_model.site_array.dtype
+        )
+        site_ctxt[:] = self.site_model.site_array
+        if self.site_model.bbox_properties:
+            # If the site model has bounding box properties then store these
+            # as attributes
+            site_ctxt.attrs["has_bbox"] = True
+            site_ctxt.attrs["llon"] = self.site_model.bbox_properties["bbox"][0]
+            site_ctxt.attrs["llat"] = self.site_model.bbox_properties["bbox"][1]
+            site_ctxt.attrs["ulon"] = self.site_model.bbox_properties["bbox"][2]
+            site_ctxt.attrs["ulat"] = self.site_model.bbox_properties["bbox"][3]
+            site_ctxt.attrs["spcx"] = self.site_model.bbox_properties["spcx"]
+            site_ctxt.attrs["spcy"] = self.site_model.bbox_properties["spcy"]
+            site_ctxt.attrs["ncol"] = self.site_model.bbox_properties["ncol"]
+            site_ctxt.attrs["nrow"] = self.site_model.bbox_properties["nrow"]
+        else:
+            site_ctxt.attrs["has_bbox"] = False
+        return
+
+    def get_shakemap(
+        self, intensity_measure_types: List
+    ) -> Tuple[np.ndarray, np.ndarray, Dict]:
+        """
+        Main function to constructs the shakemaps for the specified intensity measures,
+        caching the information to hdf5 if requested
+
+        Args:
+            intensity_measure_types: List of intensity measures for which the shakemaps will
+                                     be calculated
+
+        Returns:
+            aggregated_means: The mean of the ground motions from the different ground motion
+                              models weighted by the assigned input weights
+            aggregated_stddevs: The total standard deviation of the ground motions from the
+                                different ground motion models weighted by the assigned
+                                input weights
+            shakemaps: Dictionary of individual shakemaps for each ground motion model
+        """
+        shakemaps = {}
+        shakemap_dtypes = np.dtype(
+            [
+                (intensity_measure_type, np.float64)
+                for intensity_measure_type in intensity_measure_types
+            ]
+        )
+        if self.caching:
+            # If caching, open (or create) the file and cache the contexts
+            fle = h5py.File(self.cache_file, "r+")
+            if self.earthquake.id in list(fle):
+                del fle[self.earthquake.id]
+            fle_eq = fle.create_group(self.earthquake.id)
+            self._cache_contexts(fle_eq)
+
+        # Pre-allocate the aggregated shakemaps with zeros
+        aggregated_means = np.zeros(self.site_model.shape, dtype=shakemap_dtypes)
+        aggregated_stddevs = np.zeros(self.site_model.shape, dtype=shakemap_dtypes)
+        for weight, gmm in zip(
+            self.ground_motion_model["weights"], self.ground_motion_model["GMMs"]
+        ):
+            gmm_str = gmm.__class__.__name__
+            shakemaps[gmm_str] = {
+                "weight": weight,
+                "mean": np.zeros(self.site_model.shape, dtype=shakemap_dtypes),
+                "stddev": np.zeros(self.site_model.shape, dtype=shakemap_dtypes),
+            }
+            for intensity_measure_type in intensity_measure_types:
+                input_imt = imt.from_string(intensity_measure_type)
+                try:
+                    mean, [stddev] = gmm.get_mean_and_stddevs(
+                        self.sctx, self.rctx, self.dctx, input_imt, [const.StdDev.TOTAL]
+                    )
+                except KeyError:
+                    warnings.warn(
+                        "Ground motion model %s not defined for intensity "
+                        "measure type %s" % (str(gmm), intensity_measure_type)
+                    )
+                    continue
+                aggregated_means[intensity_measure_type] += weight * mean
+                aggregated_stddevs[intensity_measure_type] += weight * stddev
+                shakemaps[gmm_str]["mean"][intensity_measure_type] = mean
+                shakemaps[gmm_str]["stddev"][intensity_measure_type] = stddev
+        if self.caching:
+            self._cache_shakemap(
+                fle_eq, shakemaps, aggregated_means, aggregated_stddevs, shakemap_dtypes
+            )
+            fle.close()
+        return aggregated_means, aggregated_stddevs, shakemaps
+
+    def _cache_shakemap(
+        self,
+        fle: h5py.Group,
+        shakemaps: Dict,
+        aggregated_means: np.ndarray,
+        aggregated_stddevs: np.ndarray,
+        shakemap_dtypes: np.dtype,
+    ):
+        """
+        If caching is required then the shakemaps are written to the hdf5 file
+
+        Args:
+            fle: HDF5 group object to store the shakemaps
+            shakemaps: Dictionary of individual shakemaps for each ground motion model
+            aggregated_means: The mean of the ground motions from the different ground motion
+                              models weighted by the assigned input weights
+            aggregated_stddevs: The total standard deviation of the ground motions from the
+                                different ground motion models weighted by the assigned
+                                input weights
+            shakemap_dtypes: IMT-dependent data type of the shakemaps
+        """
+        shakemap_grp = fle.create_group("shakemaps")
+        for gmm_string in shakemaps:
+            gmm_grp = shakemap_grp.create_group(gmm_string)
+            gmm_grp.attrs["weight"] = shakemaps[gmm_string]["weight"]
+            mean_dset = gmm_grp.create_dataset(
+                "mean", shakemaps[gmm_string]["mean"].shape, dtype=shakemap_dtypes
+            )
+            mean_dset[:] = shakemaps[gmm_string]["mean"]
+            stddev_dset = gmm_grp.create_dataset(
+                "stddev", shakemaps[gmm_string]["stddev"].shape, dtype=shakemap_dtypes
+            )
+            stddev_dset[:] = shakemaps[gmm_string]["stddev"]
+        # Store the agregated results
+        aggregated_grp = fle.create_group("aggregated")
+        aggregated_mean_dset = aggregated_grp.create_dataset(
+            "mean", aggregated_means.shape, shakemap_dtypes
+        )
+        aggregated_mean_dset[:] = aggregated_means
+        aggregated_stddev_dset = aggregated_grp.create_dataset(
+            "stddev", aggregated_stddevs.shape, shakemap_dtypes
+        )
+        aggregated_stddev_dset[:] = aggregated_stddevs
diff --git a/shakyground2/site_model.py b/shakyground2/site_model.py
index e835e0a..7ce9daa 100644
--- a/shakyground2/site_model.py
+++ b/shakyground2/site_model.py
@@ -5,7 +5,7 @@ from __future__ import annotations
 import warnings
 import numpy as np
 import pandas as pd
-from typing import Tuple, Optional, Dict
+from typing import Tuple, Optional, Dict, Union
 from openquake.hazardlib.site import SiteCollection
 
 
@@ -56,6 +56,14 @@ class SiteModel(object):
         # Returns the number of sites in the site array
         return self.site_array.shape[0]
 
+    def __getitem__(self, key: Union[str, int]):
+        # Returns the named column from the site array (if provided with a string),
+        # or the corresponding row if provided with an integer
+        if isinstance(key, str) or isinstance(key, int):
+            return self.site_array[key]
+        else:
+            raise KeyError("Site model has no attribute %s" % key)
+
     def __repr__(self):
         # Returns an informative string regarding the composition of the sites,
         # including the bounding box information when relevant
@@ -72,6 +80,10 @@ class SiteModel(object):
             info_string += bbox_string
         return info_string
 
+    @property
+    def shape(self):
+        return self.site_array.shape[0]
+
     @property
     def dataframe(self):
         # Return the site array as a dataframe
diff --git a/shakyground2/synthetic_rupture_generator.py b/shakyground2/synthetic_rupture_generator.py
new file mode 100644
index 0000000..e628cb8
--- /dev/null
+++ b/shakyground2/synthetic_rupture_generator.py
@@ -0,0 +1,388 @@
+"""
+Generates a set of synthetic ruptures based on a point source configuration
+"""
+import numpy as np
+from typing import List, Tuple, Optional
+from scipy.stats import truncnorm
+from openquake.hazardlib.geo import Point, Mesh
+from openquake.hazardlib.geo.surface.base import BaseSurface
+from openquake.hazardlib.source.rupture import ParametricProbabilisticRupture
+from shakyground2.earthquake import Earthquake
+from shakyground2.site_model import SiteModel
+
+
+# The hypocentre distributions are defined from the Next Generation Attenuation
+# (NGA) West 2 rupture database
+NGAWEST_HYPO_DISTRIBUTIONS = {
+    "All": {
+        "Mean": np.array([0.46851357, 0.67460261]),
+        "Sigma": np.array([0.20691535, 0.22040932]),
+        "COV": np.array([[0.04293594, 0.00103987], [0.00103987, 0.04871868]]),
+        "DipRange": [30.0, 90.0],
+    },
+    "SS": {
+        "Mean": np.array([0.48545206, 0.64942746]),
+        "Sigma": np.array([0.22415657, 0.21677068]),
+        "COV": np.array([[0.05064814, -0.00603003], [-0.00603003, 0.04736544]]),
+        "DipRange": [80.0, 90.0],
+    },
+    "R": {
+        "Mean": np.array([0.4674859, 0.58483914]),
+        "Sigma": np.array([0.16275562, 0.22017015]),
+        "COV": np.array([[0.02673021, 0.0113362], [0.0113362, 0.04891558]]),
+        "DipRange": [25.0, 50.0],
+    },
+    "N": {
+        "Mean": np.array([0.50887254, 0.82404]),
+        "Sigma": np.array([0.22416128, 0.13647917]),
+        "COV": np.array([[0.05085368, -0.00332741], [-0.00332741, 0.01885098]]),
+        "DipRange": [50.0, 80.0],
+    },
+}
+
+
+# In case other definitions (thrust fault, undefined etc.) are used
+NGAWEST_HYPO_DISTRIBUTIONS["TF"] = NGAWEST_HYPO_DISTRIBUTIONS["R"]
+NGAWEST_HYPO_DISTRIBUTIONS["U"] = NGAWEST_HYPO_DISTRIBUTIONS["All"]
+
+
+class FiniteRuptureSampler(object):
+    """
+    Module to produce a set of finite rupture samples for an event
+    Process:
+    1. From scaling relation and seismogenic thicknesses sample rupture
+       parameters (area, length, width)
+    2. Sample hypocentre location from multivariate Gaussian distribution
+    3. a. If mechanism is known, use strike and dip
+       b. If mechanism is not known sample strike and dip from distribution
+    4. For each sample calculate Rjb, Rrup, Rx and Ry0
+    5. For each distance calculate the median value
+    6. For each rupture define a penalty function
+
+    P = sum_{i=rjb, rrup, ry0, rx} (Ri - median(Ri)) ** 2.
+    Take the rupture with the lowest penalty function!
+    """
+
+    def get_finite_rupture(
+        self,
+        nsamples: int,
+        earthquake: Earthquake,
+        site_model: Optional[SiteModel] = None,
+        rdim: float = 0.0,
+        weights: list = [0.25, 0.25, 0.25, 0.25],
+        maximum_site_distance: float = 200.0,
+        site_spacing: float = 0.05,
+    ):
+        """
+        Retrieves the finite rupture that produces the distances closest to the median
+        distance
+
+        Args:
+            nsamples: Number of rupture samples to use
+            earthquake: Earthquake object as instance of
+                        :class:`shakyground2.earthquake.Earthquake`
+            site_model: If desired, can define the target sites to be used for the distance
+                        calculation, as instance of :class:`shakyground2.site_model.SiteModel`
+            rdim: The penalty functions can be weighted according to the source-to-site
+                  distance with the weighting determined via w = 1 / (distance ^ R). rdim
+                  sets the value of R
+            weights: Can adjust the weight assigned to the different distance metrics. Defines
+                     the list of weights for the four distance metrics: Rjb, Rrup, Rx and Ry0
+            maximum_site_distance: In the case that the target sites need to be built from the
+                                   bounding box, this defines the maximum distances of the
+                                   target sites to define the bounding box
+            site_spacing: In the case that the target sites need to be built from the
+                          bounding box, this defines the site spacing
+
+        Returns:
+            central_rupture: The rupture corresponding to the `most central` as an instance of
+                :class:`openquake.hazardlib.source.rupture.ParametricProbabilisticRupture`
+            distances: Dictionary containing all of the possible distances calculated from
+                       the rupture surface
+        """
+        # Sample the rupture surfaces
+        rupture_surfaces, strikes, dips, rakes, hypo_locs = self.sample_rupture_surfaces(
+            nsamples, earthquake
+        )
+
+        if site_model:
+            target_lons = site_model["lon"]
+            target_lats = site_model["lat"]
+        else:
+            # If no specific site model is defined then use a bounding box within 200 km of
+            # the hypocentre spaced every 0.05 degrees - longer distances are less likely
+            # to be significantly influenced by uncertainties in the rupture plane
+            llon, llat, ulon, ulat = earthquake.get_maximum_distance_bbox(maximum_site_distance)
+            target_lons, target_lats = np.meshgrid(
+                np.arange(llon, ulon + site_spacing, site_spacing),
+                np.arange(llat, ulat + site_spacing, site_spacing),
+            )
+            target_lons = target_lons.flatten()
+            target_lats = target_lats.flatten()
+
+        # Calculate the distances
+        distances, rhypo, repi = self.get_distances(
+            earthquake, rupture_surfaces, target_lons, target_lats
+        )
+        # Get the most central rupture closest to the median finite rupture distance
+        # from all finite ruptures
+        central_surface, central_distances, min_loc = self.get_central_rupture(
+            rupture_surfaces, distances, rhypo, rdim, weights
+        )
+
+        # Return the parametric probabilitic rupture and the corrsponding distance
+        central_rupture = ParametricProbabilisticRupture(
+            earthquake.mag,
+            rakes[min_loc],
+            None,  # No tectonic region type
+            earthquake.hypocenter,
+            central_surface,
+            1.0,
+            None,
+        )
+
+        return central_rupture, {
+            "lon": target_lons,
+            "lat": target_lats,
+            "rjb": central_distances[:, 0],
+            "rrup": central_distances[:, 1],
+            "rx": central_distances[:, 2],
+            "ry0": central_distances[:, 3],
+            "rhypo": rhypo,
+            "repi": repi,
+        }
+
+    def sample_rupture_surfaces(
+        self, nsamples: int, earthquake: Earthquake
+    ) -> Tuple[List, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
+        """
+        Mechanisms should be a list of Nodal plane objects
+
+        Args:
+            nsamples: Number of samples to take
+            earthquake: Earthquake object as instance of
+                        :class:`shakyground2.earthquake.Earthquake`
+        Returns:
+            surfaces: List of rupture surfaces as instances of the :class:
+                      `openquake.hazardlib.geo.PlanarSurface
+            strikes: Vector of strike values (in decimal degrees in the range 0 to 360)
+            dips: Vector of dip values (in decimal degrees in the range 0 to 90)
+            rakes: Vector of rake values (in decimal degrees in the range -180 to 180)
+            hypo_positions: 2D array of hypocentre positions (as along-strike and down-dip)
+                            positions
+        """
+        strikes, dips, rakes = earthquake.mechanism.sample(nsamples)
+        dimensions = (
+            (earthquake.area is not None)
+            and (earthquake.length is not None)
+            and (earthquake.width is not None)
+        )
+        if dimensions:
+            areas = earthquake.area * np.ones(nsamples)
+            lengths = earthquake.length * np.ones(nsamples)
+            widths = earthquake.width * np.ones(nsamples)
+        else:
+            # Get the physical rupture dimensions by sampling from the magnitude scaling
+            # relation
+            areas, lengths, widths = self.sample_rupture_dimensions(
+                nsamples, earthquake, dips, rakes
+            )
+
+        # Get hypocentres
+        hypo_positions = self.sample_hypocenter_position(nsamples, self.get_sofs(rakes))
+
+        # Build the ruptures
+        surfaces = []
+        for strike, dip, length, width, hypo_pos in zip(
+            strikes, dips, lengths, widths, hypo_positions
+        ):
+            surfaces.append(
+                earthquake.build_planar_surface(
+                    earthquake.hypocenter,
+                    strike,
+                    dip,
+                    length,
+                    width,
+                    earthquake.lsd,
+                    earthquake.usd,
+                    hypo_pos,
+                )
+            )
+        return surfaces, strikes, dips, rakes, hypo_positions
+
+    @staticmethod
+    def get_sofs(rakes: np.ndarray) -> List:
+        """
+        Returns the style of faulting as a string given an input list of rakes
+        """
+        sofs = []
+        for rake in rakes:
+            if (rake > 45.0) and (rake <= 135.0):
+                sofs.append("R")
+            elif (rake > -135.0) and (rake <= -45.0):
+                sofs.append("N")
+            else:
+                sofs.append("SS")
+        return sofs
+
+    @staticmethod
+    def sample_rupture_dimensions(
+        nsamples: int, earthquake: Earthquake, dips: np.ndarray, rakes: np.ndarray
+    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+        """
+        Samples the rupture dimension from the magnitude scaling relation accounting for
+        uncertainty where it is defined
+
+        Args:
+            nsamples: Number of samples to take
+            earthquake: Earthquake object as instance of
+                        :class:`shakyground2.earthquake.Earthquake`
+            dips: Vector of dip values
+            rakes: Vector of rake values
+        Returns:
+            areas: Vector of sampled areas (in km ^ 2)
+            lengths: Vector of sampled lengths (in km)
+            widths: Vector of sampled widths (in km)
+        """
+        # Sample epsilon values from a truncated normal distribution between
+        # -3 and 3 standard deviations
+        msr_epsilons = truncnorm.rvs(-3.0, 3.0, loc=0.0, scale=1.0, size=nsamples)
+        # Generate rupture dimensions
+        lengths = np.empty(nsamples)
+        widths = np.empty(nsamples)
+        areas = np.empty(nsamples)
+        thickness = earthquake.lsd - earthquake.usd
+        for i, (dip, rake, epsilon) in enumerate(zip(dips, rakes, msr_epsilons)):
+            areas[i], lengths[i], widths[i] = earthquake.mag_scale_rel.get_rupture_dimensions(
+                earthquake.mag,
+                rake,
+                dip,
+                aspect=earthquake.aspect,
+                thickness=thickness,
+                epsilon=epsilon,
+            )
+        return areas, lengths, widths
+
+    @staticmethod
+    def sample_hypocenter_position(nsamples: int, sofs: List) -> np.ndarray:
+        """
+        Samples the hypocentral position from the pre-defined NGA West 2 hypocentre
+        distributions
+
+        Note here that the hypocentre position distributions are multivariate Gaussian, yet
+        they need to be truncated within the range 0 to 1. Sampling from a truncated
+        multivariate Gaussian distribution is not a trivial problem and requires a Markov Chain
+        Monte Carlo sampling approach that is too complex for application here. Instead,
+        invalid hypocentre positions are re-sampled repeatedly until all positions are valid
+
+        Args:
+            nsamples: Number of samples to take
+            sofs: List of styles-of-faulting
+        Returns:
+            2D array of hypocentre position samples (along strike and down dip)
+        """
+        # Count the unique styles of faulting present in the sampling set
+        sof_indices = {}
+        sof_counter = {}
+        sof_array = np.array(sofs)
+        for key in NGAWEST_HYPO_DISTRIBUTIONS:
+            sof_indices[key] = np.where(sof_array == key)[0]
+            sof_counter[key] = len(sof_indices[key])
+
+        hypo_pos = np.zeros([nsamples, 2])
+        for key in sof_counter:
+            if not sof_counter[key]:
+                # Style-of-faulting not present in sample set
+                continue
+            mean = NGAWEST_HYPO_DISTRIBUTIONS[key]["Mean"]
+            covariance = NGAWEST_HYPO_DISTRIBUTIONS[key]["COV"]
+            # Draw N samples from a multivariate distribution, where N
+            # is the corresponding number of samples with the required style of
+            # faulting
+            pos = np.random.multivariate_normal(mean, covariance, sof_counter[key])
+            # Are all positions valid?
+            all_valid = np.logical_and(
+                np.logical_and(pos[:, 0] >= 0.05, pos[:, 0] <= 0.95),
+                np.logical_and(pos[:, 1] >= 0.05, pos[:, 1] <= 0.95),
+            )
+
+            while np.sum(all_valid) < sof_counter[key]:
+                # Some positions are invalid - re-sample these
+                idx = np.logical_not(all_valid)
+                pos[idx] = np.random.multivariate_normal(mean, covariance, sum(idx))
+                all_valid = np.logical_and(
+                    np.logical_and(pos[:, 0] >= 0.05, pos[:, 0] <= 0.95),
+                    np.logical_and(pos[:, 1] >= 0.05, pos[:, 1] <= 0.95),
+                )
+
+            hypo_pos[sof_indices[key], :] = pos
+        return hypo_pos
+
+    @staticmethod
+    def get_distances(
+        earthquake: Earthquake, surfaces: List, lons: np.ndarray, lats: np.ndarray
+    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+        """
+        Calculates the source to site distances for each rupture surface in
+        the set of rupture surfaces
+
+        Args:
+            earthquake: Earthquake object as instance of
+                        :class:`shakyground2.earthquake.Earthquake`
+            lons: Vector of target longitudes for distance calculation
+            lats: Vector of target latitudes for distance calculation
+        Returns:
+            distances: 2D array of finite fault rupture distances in km (Rjb, Rrup, Rx, Ry0)
+            rhypo: hypocentral distance in km
+            repi: epicentral distance in km
+        """
+        nsites = len(lons)
+        mesh = Mesh(lons, lats)
+        distances = np.zeros([len(surfaces), nsites, 4])
+        for i, surf in enumerate(surfaces):
+            distances[i, :, 0] = surf.get_joyner_boore_distance(mesh)
+            distances[i, :, 1] = surf.get_min_distance(mesh)
+            distances[i, :, 2] = surf.get_rx_distance(mesh)
+            distances[i, :, 3] = surf.get_ry0_distance(mesh)
+        # Include hypocentral and epicentral distance
+        rhypo = Point(earthquake.lon, earthquake.lat, earthquake.depth).distance_to_mesh(mesh)
+        repi = np.sqrt(rhypo ** 2.0 - earthquake.depth ** 2.0)
+        return distances, rhypo, repi
+
+    @staticmethod
+    def get_central_rupture(
+        rupture_surfaces: List,
+        distances: np.ndarray,
+        hypocentral_distances: np.ndarray,
+        rdim: float = 0.0,
+        weights: List = [0.25, 0.25, 0.25, 0.25],
+    ) -> Tuple[BaseSurface, np.ndarray, int]:
+        """
+        Returns the rupture closest to the median distances
+        """
+        nsites = distances.shape[1]
+        # If desired, it is possible to weight the distribution of distances
+        # such that the weight decays with distance from the centroid, with the
+        # exponent of the decay factor described by rdim (if 0 then no weighting is applied).
+        site_weights = 1.0 / (hypocentral_distances ** rdim)
+        site_weights = site_weights / np.sum(site_weights)
+
+        # For each of the four different distance types retrieve the median source-to-site
+        # distance for each site
+        median_distances = np.zeros([4, nsites])
+        for i in range(4):
+            median_distances[i, :] = np.percentile(distances[:, :, i], 50, axis=0)
+        # For each of the rupture surfaces determine the degree of divergence from the
+        # median distance and assign a penalty that is proportional to the divergence
+        penalty_function = np.zeros(distances.shape[0])
+        for i in range(distances.shape[2]):
+            site_penalty = np.zeros(distances.shape[0])
+            for k in range(distances.shape[1]):
+                site_penalty += site_weights[k] * (
+                    np.sqrt((distances[:, k, i] - median_distances[i, k]) ** 2.0)
+                )
+            penalty_function += weights[i] * site_penalty
+        # Determine which rupture gives the minimal divergence from the median and return
+        # that rupture and corresponding distances
+        min_loc = np.argmin(penalty_function)
+        return rupture_surfaces[min_loc], distances[min_loc, :, :], min_loc
diff --git a/shakyground2/valid.py b/shakyground2/valid.py
index 07c769f..99424cd 100644
--- a/shakyground2/valid.py
+++ b/shakyground2/valid.py
@@ -2,7 +2,7 @@
 Defines a set of input validation methods to check physically correct or
 consistent quantities
 """
-from typing import Tuple, Optional, Union
+from typing import Tuple, Optional, Union, Dict
 from openquake.hazardlib.geo.nodalplane import NodalPlane
 from shakyground2.magnitude_scaling_relations import BaseScalingRelation, PEERScalingRelation
 
@@ -83,6 +83,23 @@ def mechanism(
         return NodalPlane(*mechanism)
 
 
+def focal_mechanism(focal_mech: Optional[Dict]) -> Dict:
+    """
+    A focal mechanism is represented by two orthogonal planes, each plane described by a
+    strike, dip and rake
+    """
+    if not focal_mech:
+        return focal_mech
+    assert "nodal_plane_1" in focal_mech, "Focal mechanism missing nodal plane 1"
+    assert "nodal_plane_2" in focal_mech, "Focal mechanism missing nodal plane 2"
+    focal_mechanism = {}
+    for plane in ["nodal_plane_1", "nodal_plane_2"]:
+        focal_mechanism[plane] = mechanism(
+            focal_mech[plane]["strike"], focal_mech[plane]["dip"], focal_mech[plane]["rake"]
+        )
+    return focal_mechanism
+
+
 def seismogenic_thickness(
     upper_seismo_depth: float, lower_seismo_depth: float
 ) -> Tuple[float, float]:
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/earthquake_test.py b/tests/earthquake_test.py
index 038303d..108fd63 100644
--- a/tests/earthquake_test.py
+++ b/tests/earthquake_test.py
@@ -191,27 +191,20 @@ class EarthquakeTestCase(unittest.TestCase):
         np.testing.assert_array_almost_equal(
             plane0.corner_depths, eq0.rupture.surface.corner_depths
         )
-        # Instantiate earthquake object without surface
-        eq1 = Earthquake(
-            "XYZ",
-            self.lon,
-            self.lat,
-            self.depth,
-            self.mag,
-            strike=strike,
-            dip=dip,
-            rake=0.0,
-            upper_seismogenic_depth=usd,
-            lower_seismogenic_depth=lsd,
-        )
-        self.assertAlmostEqual(eq1.rupture.mag, self.mag)
-        self.assertAlmostEqual(eq1.rupture.rake, 0.0)
-        np.testing.assert_array_almost_equal(
-            plane0.corner_lons, eq1.rupture.surface.corner_lons
-        )
-        np.testing.assert_array_almost_equal(
-            plane0.corner_lats, eq1.rupture.surface.corner_lats
-        )
+
+    def test_maximum_distance_bbox(self):
+        # Tests the generation of a bounding box to a maximum distance
+        # Test case 1 - Defined maximum distance
+        eq0 = Earthquake("XYZ", 0.0, 0.0, 10.0, 6.0)
+        bbox = eq0.get_maximum_distance_bbox(111.195)
         np.testing.assert_array_almost_equal(
-            plane0.corner_depths, eq1.rupture.surface.corner_depths
-        )
+            np.array(bbox), np.array([-1.0, -1.0, 1.0, 1.0]), 5
+        )
+        # Test case 2 - Default magnitude values
+        for mag, max_dist in zip([2.5, 6.0, 8.5], [200.0, 1280.0, 2000.0]):
+            eq0 = Earthquake("XYZ", 0.0, 0.0, 10.0, mag)
+            bbox = eq0.get_maximum_distance_bbox()
+            ew_distance = Point(bbox[0], 0.0, 0.0).distance(Point(bbox[2], 0.0, 0.0))
+            ns_distance = Point(0.0, bbox[1], 0.0).distance(Point(0.0, bbox[3], 0.0))
+            self.assertAlmostEqual(max_dist, ew_distance, 5)
+            self.assertAlmostEqual(max_dist, ns_distance, 5)
diff --git a/tests/rupture_mechanism_test.py b/tests/rupture_mechanism_test.py
index 79a7f3d..d3ad2b5 100644
--- a/tests/rupture_mechanism_test.py
+++ b/tests/rupture_mechanism_test.py
@@ -3,6 +3,7 @@ Unit tests for rupture mechanism
 """
 import unittest
 import numpy as np
+from openquake.hazardlib.geo import NodalPlane
 from shakyground2.rupture_mechanism import RuptureMechanism
 
 
@@ -42,17 +43,21 @@ class RuptureMechanismTestCase(unittest.TestCase):
     def test_rupture_mechanism_single_plane(self):
         # Checks instantiation when a single combination of strike, dip and rake are input
         mech = RuptureMechanism.from_strike_dip_rake(strike=20.0, dip=90.0, rake=10.0)
+        self.assertEqual(len(mech), 1)
         probs, strikes, dips, rakes = self._mechanism_pmf_to_arrays(mech)
         np.testing.assert_array_almost_equal(probs, np.array([1.0]))
         np.testing.assert_array_almost_equal(strikes, np.array([20.0]))
         np.testing.assert_array_almost_equal(dips, np.array([90.0]))
         np.testing.assert_array_almost_equal(rakes, np.array([10.0]))
 
-    def test_rupture_mechanism_from_nodal_planes(self):
+    def test_rupture_mechanism_from_focal_mechanism(self):
         # Checks the creation of the class from a pair of nodal planes
-        npl1 = {"strike": 45.0, "dip": 80.0, "rake": 10.0}
-        npl2 = {"strike": 135.0, "dip": 90.0, "rake": -170.0}
-        mech = RuptureMechanism.from_nodal_planes(npl1, npl2)
+        focal_mechanism = {
+            "nodal_plane_1": NodalPlane(strike=45.0, dip=80.0, rake=10.0),
+            "nodal_plane_2": NodalPlane(strike=135.0, dip=90.0, rake=-170.0),
+        }
+        mech = RuptureMechanism.from_focal_mechanism(focal_mechanism)
+        self.assertEqual(len(mech), 2)
         probs, strikes, dips, rakes = self._mechanism_pmf_to_arrays(mech)
         np.testing.assert_array_almost_equal(probs, np.array([0.5, 0.5]))
         np.testing.assert_array_almost_equal(strikes, np.array([45.0, 135.0]))
@@ -61,9 +66,11 @@ class RuptureMechanismTestCase(unittest.TestCase):
 
     def test_sample_mechanism(self):
         # Test the random sampling
-        npl1 = {"strike": 45.0, "dip": 80.0, "rake": 10.0}
-        npl2 = {"strike": 135.0, "dip": 90.0, "rake": -170.0}
-        mech = RuptureMechanism.from_nodal_planes(npl1, npl2)
+        focal_mechanism = {
+            "nodal_plane_1": NodalPlane(strike=45.0, dip=80.0, rake=10.0),
+            "nodal_plane_2": NodalPlane(strike=135.0, dip=90.0, rake=-170.0),
+        }
+        mech = RuptureMechanism.from_focal_mechanism(focal_mechanism)
         np.random.seed(1000)
         strikes, dips, rakes = mech.sample(5)
         np.testing.assert_array_almost_equal(
@@ -73,3 +80,41 @@ class RuptureMechanismTestCase(unittest.TestCase):
         np.testing.assert_array_almost_equal(
             rakes, np.array([-170.0, 10.0, -170.0, 10.0, -170.0])
         )
+
+    def test_rupture_mechanism_from_nodal_planes(self):
+        # Tests the creation of the class from a list of nodal planes
+        nodal_planes = [
+            {"strike": 0.0, "dip": 90.0, "rake": 0.0},
+            {"strike": 90.0, "dip": 90.0, "rake": 0.0},
+            {"strike": 0.0, "dip": 60.0, "rake": -90.0},
+            {"strike": 90.0, "dip": 60.0, "rake": -90.0},
+        ]
+        probabilities = [0.3, 0.3, 0.2, 0.2]
+        mech = RuptureMechanism.from_nodal_planes(nodal_planes, probabilities)
+        self.assertEqual(len(mech), 4)
+        probs, strikes, dips, rakes = self._mechanism_pmf_to_arrays(mech)
+        np.testing.assert_array_almost_equal(probs, np.array(probabilities))
+        np.testing.assert_array_almost_equal(strikes, np.array([0.0, 90.0, 0.0, 90.0]))
+        np.testing.assert_array_almost_equal(dips, np.array([90.0, 90.0, 60.0, 60.0]))
+        np.testing.assert_array_almost_equal(rakes, np.array([0.0, 0.0, -90.0, -90.0]))
+
+    def test_rupture_mechanism_from_nodal_planes_errors(self):
+        # Test that assertion errors are raised when incorrect inputs are used
+        # 1) Unequal number of planes and probabilities
+        nodal_planes = [
+            {"strike": 0.0, "dip": 90.0, "rake": 0.0},
+            {"strike": 90.0, "dip": 90.0, "rake": 0.0},
+            {"strike": 0.0, "dip": 60.0, "rake": -90.0},
+            {"strike": 90.0, "dip": 60.0, "rake": -90.0},
+        ]
+        probabilities = [0.3, 0.3, 4]
+        with self.assertRaises(AssertionError) as ae:
+            RuptureMechanism.from_nodal_planes(nodal_planes, probabilities)
+        self.assertEqual(
+            str(ae.exception), "Number of nodal planes not equal to number of probabilities"
+        )
+        # 2) Probabilities don't sum to 1
+        probabilities = [0.3, 0.3, 0.2, 0.1]
+        with self.assertRaises(AssertionError) as ae:
+            RuptureMechanism.from_nodal_planes(nodal_planes, probabilities)
+        self.assertEqual(str(ae.exception), "Probabilities do not sum to 1.0 (sum = 0.900000)")
diff --git a/tests/shakemap_test.py b/tests/shakemap_test.py
new file mode 100644
index 0000000..fdba4b7
--- /dev/null
+++ b/tests/shakemap_test.py
@@ -0,0 +1,112 @@
+"""
+Set of test cases for the shakemap class
+"""
+import os
+import unittest
+import h5py
+import numpy as np
+from shakyground2.earthquake import Earthquake
+from shakyground2.site_model import SiteModel
+from shakyground2.shakemap import Shakemap
+from openquake.hazardlib.gsim import get_available_gsims
+
+
+# List of available GMMs
+GSIM_LIST = get_available_gsims()
+
+
+# Path for storing the cache file that will be generated
+DATA_PATH = os.path.join(os.path.dirname(__file__), "data")
+
+
+class ShakemapTestCase(unittest.TestCase):
+    """
+    The Shakemap class effectively draws together different components of the shakyground
+    tools in order to build the inputs for the OpenQuake ground motion models (GMMs) and
+    execute them to retrieve the shakemap. Each component is already tested, including the
+    OpenQuake GMMs themselves, verifying the actual values in the shakemap is not worthwhile
+    here. Instead the tests verify that the shakemap workflow is executed without error and
+    that the outputs are of the correct form and shape.
+    """
+
+    def setUp(self):
+        self.cache_test_file = os.path.join(DATA_PATH, "tmp_cache_file1.hdf5")
+        self.focal_mechanism = {
+            "nodal_plane_1": {"strike": 0.0, "dip": 90.0, "rake": 0.0},
+            "nodal_plane_2": {"strike": 90.0, "dip": 90.0, "rake": 0.0},
+        }
+        self.site_model = SiteModel.from_bbox(
+            [28.0, 38.0, 32.0, 42.0], spcx=0.5, spcy=0.5, vs30=600.0, vs30measured=True
+        )
+        self.ground_motion_model = {
+            "GMMs": [
+                GSIM_LIST["BindiEtAl2014Rjb"](),
+                GSIM_LIST["CauzziEtAl2014"](),
+                GSIM_LIST["ChiouYoungs2014"](),
+            ],
+            "weights": [0.3, 0.4, 0.3],
+        }
+
+    def test_shakemap_creation_with_caching(self):
+        # Complete run through workflow with caching, verifying that the information stored in
+        # the cache file is the same (within the tolerated precision) as that output directly
+        eq_id = "XYZ001"
+        eq1 = Earthquake(eq_id, 30.0, 40.0, 10.0, 6.5, focal_mechanism=self.focal_mechanism)
+        sm1 = Shakemap(
+            eq1,
+            self.site_model,
+            self.ground_motion_model,
+            "Active Shallow Crust",
+            cache_file=self.cache_test_file,
+        )
+        intensity_measures = ["PGA", "SA(0.3)", "SA(1.0)"]
+        agg_means, agg_stddev, shakemaps = sm1.get_shakemap(intensity_measures)
+        # Verify that shakemaps have been stored in the cache correctly
+        fle = h5py.File(self.cache_test_file, "r")
+        for intensity_measure in intensity_measures:
+            np.testing.assert_array_almost_equal(
+                agg_means[intensity_measure],
+                fle["{:s}/aggregated/mean".format(eq_id)][:][intensity_measure],
+                5,
+            )
+            np.testing.assert_array_almost_equal(
+                agg_stddev[intensity_measure],
+                fle["{:s}/aggregated/stddev".format(eq_id)][:][intensity_measure],
+                5,
+            )
+        for gmm in shakemaps:
+            for intensity_measure in intensity_measures:
+                np.testing.assert_array_almost_equal(
+                    shakemaps[gmm]["mean"][intensity_measure],
+                    fle["{:s}/shakemaps/{:s}/mean".format(eq_id, gmm)][:][intensity_measure],
+                    5,
+                )
+        fle.close()
+
+    def test_shakemap_creation_no_caching(self):
+        # Complete run through workflow verifying that the outputs have the correct content
+        # and shape (actual values are not verified)
+        fle = h5py.File(self.cache_test_file, "a")
+        fle.close()
+        eq1 = Earthquake("XYZ001", 30.0, 40.0, 10.0, 6.5, focal_mechanism=self.focal_mechanism)
+        sm1 = Shakemap(eq1, self.site_model, self.ground_motion_model, "Active Shallow Crust")
+        intensity_measures = ["PGA", "SA(0.3)", "SA(1.0)"]
+        agg_means, agg_stddev, shakemaps = sm1.get_shakemap(intensity_measures)
+        # Assert that the aggregated means and stddevs have the correct shape and dtypes
+        self.assertTupleEqual(agg_means.shape, self.site_model.site_array.shape)
+        self.assertTupleEqual(agg_stddev.shape, self.site_model.site_array.shape)
+        self.assertTupleEqual(agg_means.dtype.names, ("PGA", "SA(0.3)", "SA(1.0)"))
+        self.assertListEqual(
+            list(shakemaps), ["BindiEtAl2014Rjb", "CauzziEtAl2014", "ChiouYoungs2014"]
+        )
+        # Verify correct aggregation
+        expected_aggregation_pga = (
+            shakemaps["BindiEtAl2014Rjb"]["mean"]["PGA"] * 0.3
+            + shakemaps["CauzziEtAl2014"]["mean"]["PGA"] * 0.4
+            + shakemaps["ChiouYoungs2014"]["mean"]["PGA"] * 0.3
+        )
+        np.testing.assert_array_almost_equal(agg_means["PGA"], expected_aggregation_pga)
+
+    def tearDown(self):
+        # Delete the cache file
+        os.remove(self.cache_test_file)
diff --git a/tests/synthetic_rupture_generator_test.py b/tests/synthetic_rupture_generator_test.py
new file mode 100644
index 0000000..cdaeb5e
--- /dev/null
+++ b/tests/synthetic_rupture_generator_test.py
@@ -0,0 +1,335 @@
+"""
+Test cases for the synthetic finite rupture generator. Note that the rupture generator
+uses random sampling so we choose to unittest using fixed seeding
+"""
+import unittest
+import numpy as np
+import pandas as pd
+from openquake.hazardlib.geo import Point, PlanarSurface
+from openquake.hazardlib.source.rupture import ParametricProbabilisticRupture
+from shakyground2.earthquake import Earthquake
+from shakyground2.site_model import SiteModel
+from shakyground2.magnitude_scaling_relations import Stafford2014
+from shakyground2.synthetic_rupture_generator import FiniteRuptureSampler
+
+
+class FiniteRuptureSamplerTestCase(unittest.TestCase):
+    """"""
+
+    def setUp(self):
+        self.sampler = FiniteRuptureSampler()
+        # Define a set of 5 parallel rupture surfaces
+        top_left = Point(29.5, 40.0, 1.0)
+        top_right = Point(30.5, 40.0, 1.0)
+        bottom_left = Point(29.5, 40.0, 15.0)
+        bottom_right = Point(30.5, 40.0, 15.0)
+        sfc1 = PlanarSurface.from_corner_points(
+            top_left.point_at(22.0, 0.0, 0.0),
+            top_right.point_at(22.0, 0.0, 0.0),
+            bottom_right.point_at(22.0, 0.0, 0.0),
+            bottom_left.point_at(22.0, 0.0, 0.0),
+        )
+        sfc2 = PlanarSurface.from_corner_points(
+            top_left.point_at(11.0, 0.0, 0.0),
+            top_right.point_at(11.0, 0.0, 0.0),
+            bottom_right.point_at(11.0, 0.0, 0.0),
+            bottom_left.point_at(11.0, 0.0, 0.0),
+        )
+        sfc3 = PlanarSurface.from_corner_points(top_left, top_right, bottom_right, bottom_left)
+        sfc4 = PlanarSurface.from_corner_points(
+            top_left.point_at(11.0, 0.0, 180.0),
+            top_right.point_at(11.0, 0.0, 180.0),
+            bottom_right.point_at(11.0, 0.0, 180.0),
+            bottom_left.point_at(11.0, 0.0, 180.0),
+        )
+        sfc5 = PlanarSurface.from_corner_points(
+            top_left.point_at(22.0, 0.0, 180.0),
+            top_right.point_at(22.0, 0.0, 180.0),
+            bottom_right.point_at(22.0, 0.0, 180.0),
+            bottom_left.point_at(22.0, 0.0, 180.0),
+        )
+        self.rupture_surfaces = [sfc1, sfc2, sfc3, sfc4, sfc5]
+
+    def test_get_sofs(self):
+        rakes = np.array([-90.0, 0.0, 90.0])
+        sofs = ["N", "SS", "R"]
+        self.assertListEqual(self.sampler.get_sofs(rakes), sofs)
+
+    def test_sample_rupture_dimensions_no_uncertainty(self):
+        # Tests the sampling of the rupture dimensons in the case when the
+        # default scaling relation is used, i.e. no uncertainty on the scaling
+        eq1 = Earthquake("XYZ", 30.0, 40.0, 10.0, 7.0, lower_seismogenic_depth=20.0)
+        rakes = np.array([-90.0, 0.0, 0.0])
+        dips = np.array([45.0, 90.0, 45.0])
+
+        target_areas = np.array([1000.0, 1000.0, 1000.0])
+        target_lengths = np.array([35.35533906, 50.0, 35.35533906])
+        target_widths = np.array([28.28427125, 20.0, 28.28427125])
+        areas, lengths, widths = self.sampler.sample_rupture_dimensions(3, eq1, dips, rakes)
+        np.testing.assert_array_almost_equal(areas, target_areas)
+        np.testing.assert_array_almost_equal(lengths, target_lengths)
+        np.testing.assert_array_almost_equal(widths, target_widths)
+
+    def test_sample_rupture_dimensions_with_uncertainty(self):
+        # Tests the sampling of rupture dimensions in the case when a scaling relation
+        # is used that has uncertainty, i.e. Stafford (2014)
+        eq1 = Earthquake(
+            "XYZ",
+            30.0,
+            40.0,
+            10.0,
+            7.0,
+            mag_scaling_relation=Stafford2014(),
+            lower_seismogenic_depth=20.0,
+        )
+
+        # Test with controlled seed
+        nsamples = 3
+        rakes = np.array([-90.0, 0.0, 90.0])
+        dips = np.array([45.0, 90.0, 45.0])
+        np.random.seed(1000)
+        areas, lengths, widths = self.sampler.sample_rupture_dimensions(
+            nsamples, eq1, dips, rakes
+        )
+        np.testing.assert_array_almost_equal(
+            areas, np.array([1083.31109826, 667.07807089, 1371.79933828])
+        )
+
+        np.testing.assert_array_almost_equal(
+            lengths, np.array([38.90911277, 38.68736142, 48.50043073])
+        )
+
+        np.testing.assert_array_almost_equal(
+            widths, np.array([27.84209202, 17.24279057, 28.28427125])
+        )
+
+        nsamples = 1000
+        # Consider strike-slip events with vertical ruptures
+        rakes = np.zeros(nsamples)
+        dips = 90.0 + np.zeros(nsamples)
+        areas, lengths, widths = self.sampler.sample_rupture_dimensions(
+            nsamples, eq1, dips, rakes
+        )
+        self.assertTrue(np.all(widths <= 20.0))
+
+    def test_sample_hypocentre_locations(self):
+        # Tests the sampling of hypocentres - no clear target distribution can be defined
+        # to random sampling is used here
+        sofs = ["R", "SS", "N"]
+        n = len(sofs)
+        np.random.seed(1000)
+        hypo_pos = self.sampler.sample_hypocenter_position(n, sofs)
+        target_hypo_pos = np.array(
+            [[0.55321552, 0.54237704], [0.6759978, 0.58733529], [0.58199734, 0.8697838]]
+        )
+        np.testing.assert_array_almost_equal(hypo_pos, target_hypo_pos)
+
+    def test_get_distances(self):
+        # Tests the distance calculation for a set of pre-defined rupture surfaces
+        eq1 = Earthquake("XYZ", 30.0, 40.0, 10.0, 7.0, lower_seismogenic_depth=20.0)
+
+        target_lons = np.array([29.5, 30.5])
+        target_lats = np.array([40.0, 40.0])
+        distances, rhypo, repi = self.sampler.get_distances(
+            eq1, self.rupture_surfaces, target_lons, target_lats
+        )
+        target_distances = np.array(
+            [
+                [[22.0, 22.0, 22.0, 0.1], [22.0, 22.0, 22.0, 0.0]],
+                [[11.0, 11.0, 11.0, 0.1], [11.0, 11.0, 11.0, 0.0]],
+                [[00.0, 1.0, 0.0, 0.0], [00.0, 1.0, 0.0, 0.0]],
+                [[11.0, 11.0, -11.0, 0.0], [11.0, 11.0, -11.0, 0.1]],
+                [[22.0, 22.0, -22.0, 0.0], [22.0, 22.0, -22.0, 0.1]],
+            ]
+        )
+        np.testing.assert_array_almost_equal(np.around(distances, 1), target_distances)
+        np.testing.assert_array_almost_equal(rhypo, np.array([43.74830548, 43.74830548]))
+        np.testing.assert_array_almost_equal(repi, np.array([42.59007199, 42.59007199]))
+
+    def test_get_central_rupture(self):
+        # Tests the function to get the "most central" rupture. In this case it is the
+        # second rupture of the set of 5 defined in the setUp.
+        eq1 = Earthquake("XYZ", 30.0, 40.0, 10.0, 7.0, lower_seismogenic_depth=20.0)
+
+        target_lons = np.array([29.5, 30.5])
+        target_lats = np.array([40.0, 40.0])
+        distances, rhypo, repi = self.sampler.get_distances(
+            eq1, self.rupture_surfaces, target_lons, target_lats
+        )
+        cent_rupt, distances_min_loc, min_loc = self.sampler.get_central_rupture(
+            self.rupture_surfaces, distances, rhypo
+        )
+        self.assertEqual(min_loc, 1)
+        np.testing.assert_array_almost_equal(distances_min_loc, distances[1, :, :])
+
+    def test_sample_rupture_surface(self):
+        # Tests the sampling of the rupture surfaces
+        focal_mechanism = {
+            "nodal_plane_1": {"strike": 0.0, "dip": 90.0, "rake": -10.0},
+            "nodal_plane_2": {"strike": 90.0, "dip": 80.0, "rake": 170.0},
+        }
+        eq1 = Earthquake(
+            "XYZ",
+            30.0,
+            40.0,
+            10.0,
+            7.0,
+            lower_seismogenic_depth=20.0,
+            focal_mechanism=focal_mechanism,
+        )
+        nsamples = 1000
+        # Generate the surfaces
+        sample_surfaces, strikes, dips, rakes, hypo_pos = self.sampler.sample_rupture_surfaces(
+            nsamples, eq1
+        )
+        # Given that the distributions are random, we adopt a set of sanity checks on the
+        # resulting values
+        self.assertEqual(len(sample_surfaces), nsamples)
+        surface_strikes = []
+        surface_dips = []
+        for surface in sample_surfaces:
+            self.assertIsInstance(surface, PlanarSurface)
+            surface_strikes.append(surface.get_strike())
+            surface_dips.append(surface.get_dip())
+        surface_strikes = np.array(surface_strikes)
+        surface_dips = np.array(surface_dips)
+        # The strikes and dips are not perfectly distributed at the initial angles
+        # so very that they are close and that the number of each strike and each dip
+        # is within 400 and 600
+
+        # Strikes should be distributed at 0.0 and 90.0
+        idx1 = np.logical_and(surface_strikes >= -1.0, surface_strikes <= 1.0)
+        idx2 = np.logical_and(surface_strikes >= 89.0, surface_strikes <= 91.0)
+        self.assertTrue(np.sum(idx1) >= 400 and np.sum(idx1) <= 600)
+        self.assertTrue(np.sum(idx2) >= 400 and np.sum(idx2) <= 600)
+
+        # Dips should be distributed at 80.0 and 90.0
+        idx1 = np.logical_and(surface_dips >= 79.0, surface_dips <= 81.0)
+        idx2 = np.logical_and(surface_dips >= 89.0, surface_dips <= 91.0)
+        self.assertTrue(np.sum(idx1) >= 400 and np.sum(idx1) <= 600)
+        self.assertTrue(np.sum(idx2) >= 400 and np.sum(idx2) <= 600)
+
+        # For the rakes, take this from the outputs of the function
+        unique_rakes, rake_counter = np.unique(rakes, return_counts=True)
+        np.testing.assert_array_almost_equal(unique_rakes, np.array([-10.0, 170.0]))
+        # The instances of each mechanism should be approximately equal
+        self.assertTrue(rake_counter[0] >= 400 and rake_counter[0] <= 600)
+        self.assertTrue(rake_counter[1] >= 400 and rake_counter[1] <= 600)
+
+    def _check_points_equal(self, pnt1, pnt2):
+        """
+        Determine if two points are equal
+        """
+        for attr in ["longitude", "latitude", "depth"]:
+            self.assertAlmostEqual(getattr(pnt1, attr), getattr(pnt2, attr), 6)
+
+    def _check_point_set_equal(self, pnt_set1, pnt_set2):
+        """
+        Determine if two sets of points are equal
+        """
+        for pnt1, pnt2 in zip(pnt_set1, pnt_set2):
+            self._check_points_equal(pnt1, pnt2)
+
+    def test_complete_finite_rupture_generator_unknown_sites(self):
+        np.random.seed(1000)
+        sampler = FiniteRuptureSampler()
+        focal_mechanism = {
+            "nodal_plane_1": {"strike": 0.0, "dip": 90.0, "rake": 0.0},
+            "nodal_plane_2": {"strike": 90.0, "dip": 90.0, "rake": 180.0},
+        }
+        eq1 = Earthquake(
+            "XYZ",
+            30.0,
+            40.0,
+            10.0,
+            7.0,
+            lower_seismogenic_depth=20.0,
+            focal_mechanism=focal_mechanism,
+        )
+
+        # Get the central rupture
+        cent_rupt, distance_set = sampler.get_finite_rupture(
+            1000, eq1, maximum_site_distance=100.0, site_spacing=0.1
+        )
+        self.assertIsInstance(cent_rupt, ParametricProbabilisticRupture)
+        self.assertAlmostEqual(cent_rupt.mag, 7.0)
+        self.assertAlmostEqual(cent_rupt.rake, 0.0)
+        self._check_points_equal(cent_rupt.hypocenter, eq1.hypocenter)
+        # Target plane defined by set of corner points
+        top_left = Point(30.0, 39.780900, 0.0)
+        top_right = Point(30.0, 40.230561, 0.0)
+        bottom_right = Point(30.0, 40.230561, 20.0)
+        bottom_left = Point(30.0, 39.780900, 20.0)
+        self._check_point_set_equal(
+            [
+                cent_rupt.surface.top_left,
+                cent_rupt.surface.top_right,
+                cent_rupt.surface.bottom_right,
+                cent_rupt.surface.bottom_left,
+            ],
+            [top_left, top_right, bottom_right, bottom_left],
+        )
+
+        # Check that the distance set has the right keys and the right shape
+        self.assertListEqual(
+            list(distance_set), ["lon", "lat", "rjb", "rrup", "rx", "ry0", "rhypo", "repi"]
+        )
+        self.assertTrue(distance_set["rjb"].shape[0], 475)
+
+    def test_complete_finite_rupture_generator_known_sites(self):
+
+        # Define target sites - should be 5 altogether
+        target_lats = np.arange(39.0, 41.05, 0.5)
+        target_lons = 30.0 * np.ones(target_lats.shape)
+        vs30 = 800.0 * np.ones(target_lats.shape)
+        site_dframe = pd.DataFrame(
+            {
+                "lon": target_lons,
+                "lat": target_lats,
+                "vs30": vs30,
+                "vs30measured": np.ones(target_lats.shape, dtype=bool),
+            }
+        )
+        site_model = SiteModel.from_dataframe(site_dframe)
+        # Setup the calculation
+        np.random.seed(1000)
+        sampler = FiniteRuptureSampler()
+        focal_mechanism = {
+            "nodal_plane_1": {"strike": 0.0, "dip": 90.0, "rake": 0.0},
+            "nodal_plane_2": {"strike": 90.0, "dip": 90.0, "rake": 180.0},
+        }
+        eq1 = Earthquake(
+            "XYZ",
+            30.0,
+            40.0,
+            10.0,
+            7.0,
+            lower_seismogenic_depth=20.0,
+            focal_mechanism=focal_mechanism,
+        )
+        # Get the central rupture
+        cent_rupt, distance_set = sampler.get_finite_rupture(1000, eq1, site_model=site_model)
+        self.assertIsInstance(cent_rupt, ParametricProbabilisticRupture)
+        # Check outputs
+        self.assertAlmostEqual(cent_rupt.mag, 7.0)
+        self.assertAlmostEqual(cent_rupt.rake, 0.0)
+        self._check_points_equal(cent_rupt.hypocenter, eq1.hypocenter)
+        top_left = Point(30.0, 39.709612, 0.0)
+        top_right = Point(30.0, 40.159273, 0.0)
+        bottom_right = Point(30.0, 40.159273, 20.0)
+        bottom_left = Point(30.0, 39.709612, 20.0)
+        self._check_point_set_equal(
+            [
+                cent_rupt.surface.top_left,
+                cent_rupt.surface.top_right,
+                cent_rupt.surface.bottom_right,
+                cent_rupt.surface.bottom_left,
+            ],
+            [top_left, top_right, bottom_right, bottom_left],
+        )
+
+        # Check that the distance set has the right keys and the right shape
+        self.assertListEqual(
+            list(distance_set), ["lon", "lat", "rjb", "rrup", "rx", "ry0", "rhypo", "repi"]
+        )
+        self.assertTrue(distance_set["rjb"].shape[0], 5)
-- 
GitLab