import math
from typing import Any
import numpy as np
from numpy.random import Generator
from scipy.optimize import root_scalar
from cratermaker.components.projectile import Projectile
from cratermaker.components.scaling import Scaling
from cratermaker.components.target import Target
from cratermaker.constants import FloatLike
from cratermaker.utils import montecarlo_utils as mc
from cratermaker.utils.general_utils import (
_create_catalogue,
_set_properties,
format_large_units,
parameter,
)
[docs]
@Scaling.register("montecarlo")
class MonteCarloScaling(Scaling):
"""
An operations class for computing the scaling relationships between projectiles and craters.
This class encapsulates the logic for converting between projectile properties and crater properties, as well as determining crater morphology based on size and target properties. This implements the scaling laws similar to those in Richardson (2009) that were implemented in CTEM. However, unlike in CTEM, we apply monte carlo methods to the scaling laws to account for the uncertainty in the scaling laws. We have also included updated simple-to-complex transition diameter values from Schenk et al. (2021).
Parameters
----------
target : Target | str, default="Moon"
The target body for the impact. Can be a Target object or a string representing the target name.
projectile : Projectile | str, default="asteroids"
The projectile model for the impact. Can be an Projectile object or a string representing the projectile name.
material : str or None
Name of the target material composition of the target body to look up from the built-in catalogue. Options include "water", "sand", "dry soil", "wet soil", "soft rock", "hard rock", and "ice".
K1 : FloatLike, optional
Variable used in crater scaling (see Richardson 2009)
mu : FloatLike, optional
Variable used in crater scaling (see Richardson 2009)
Ybar : FloatLike, optional
The strength of the target material, (Pa)
density : FloatLike, optional
Volumentric density of target material, (kg/m³)
monte_carlo_scaling : bool, default=True
If True, the scaling laws will be applied using monte carlo methods to account for the uncertainty in the scaling laws. If False, the scaling laws will be applied deterministically.
rng : numpy.random.Generator | None
|rng|
rng_seed : Any type allowed by the rng_seed argument of numpy.random.Generator, optional
|rng_seed|
rng_state : dict, optional
|rng_state|
**kwargs : Any
|kwargs|
Notes
-----
- The `target` parameter is required and must be an instance of the `Target` class.
- The `material` parameter is optional. If not provided, it will be retrieved from `target`. Setting it explicitly will override the value in `target`.
- The `K1`, `mu`, `Ybar`, and `density` parameters are optional. If not provided, they will be retrieved from the material catalogue based on the `material`. Setting them explicitly will override the values in the catalogue.
- The built-in material property values are from Holsapple (1993) and Kraus et al. (2011).
- Complex crater scaling parameters are a synthesis of Pike (1980), Croft (1985), and Schenk et al. (2004), with updated simple-to-complex transition diameter values from Schenk et al. (2021).
References
----------
- Richardson, J.E., 2009. Cratering saturation and equilibrium: A new model looks at an old problem. Icarus 204, 697-715. `doi:10.1016/j.icarus.2009.07.029 <https://doi.org/10.1016/j.icarus.2009.07.029>`_
- Holsapple, K.A., 1993. The scaling of impact processes in planetary sciences 21, 333-373. `doi:10.1146/annurev.ea.21.050193.002001 <https://doi.org/10.1146/annurev.ea.21.050193.002001>`_
- Kraus, R.G., Senft, L.E., Stewart, S.T., 2011. Impacts onto H2O ice: Scaling laws for melting, vaporization, excavation, and final crater size. Icarus 214, 724-738. `doi:10.1016/j.icarus.2011.05.016 <https://doi.org/10.1016/j.icarus.2011.05.016>`_
- Pike, R.J., 1980. Control of crater morphology by gravity and target type - Mars, earth, moon. In: Lunar and Planetary Science Conference 11, 2159-2189.
- Croft, S.K., 1985. The scaling of complex craters. Proceedings of the Fifteenth Lunar and Planetary Science Conference, Part 2 Journal of Geophysical Research 90, Supplement, C828-C842.
- Schenk, P.M., Chapman, C.R., Zahnle, K., Moore, J.M., 2004. Ages and interiors: the cratering record of the Galilean satellites, Cambridge University Press.
- Schenk, P., Castillo-Rogez, J., Otto, K.A., Marchi, S., O'Brien, D., Bland, M., Hughson, K., Schmidt, B., Scully, J., Buczkowski, D., Krohn, K., Hoogenboom, T., Kramer, G., Bray, V., Neesemann, A., Hiesinger, H., Platz, T., De Sanctis, M.C., Schroeder, S., Le Corre, L., McFadden, L., Sykes, M., Raymond, C., Russell, C.T., 2021. Compositional control on impact crater formation on mid-sized planetary bodies: Dawn at Ceres and Vesta, Cassini at Saturn. Icarus 359, 114343. `doi:10.1016/j.icarus.2021.114343 <https://doi.org/10.1016/j.icarus.2021.114343>`_
"""
def __init__(
self,
target: Target | str | None = None,
projectile: Projectile | str | None = None,
material: str | None = None,
K1: FloatLike | None = None,
mu: FloatLike | None = None,
Ybar: FloatLike | None = None,
density: FloatLike | None = None,
monte_carlo_scaling: bool = True,
rng: Generator | None = None,
rng_seed: int | None = None,
rng_state: dict | None = None,
**kwargs,
):
super().__init__(
target=target,
projectile=projectile,
rng=rng,
rng_seed=rng_seed,
rng_state=rng_state,
**kwargs,
)
object.__setattr__(self, "_K1", None)
object.__setattr__(self, "_mu", None)
object.__setattr__(self, "_Ybar", None)
object.__setattr__(self, "_transition_diameter", None)
object.__setattr__(self, "_transition_nominal", None)
object.__setattr__(self, "_complex_enlargement_factor", None)
object.__setattr__(self, "_simple_enlargement_factor", None)
object.__setattr__(self, "_final_exp", None)
object.__setattr__(self, "_material_catalogue", None)
object.__setattr__(self, "_montecarlo_scaling", monte_carlo_scaling)
if material is None:
self.material = self.target.material
else:
self.material = material
_set_properties(
self,
material=self.material,
K1=K1,
mu=mu,
Ybar=Ybar,
catalogue=self.material_catalogue,
**kwargs,
)
if density is not None:
self.target.density = density
elif self.target.density is None:
self.target.density = self.material_catalogue[self.material]["density"]
if self.projectile.density is None:
self.projectile.density = self.target.density
arg_check = sum(x is None for x in [self.target.density, self.K1, self.mu, self.Ybar])
if arg_check > 0:
raise ValueError("Scaling model is missing required parameters. Please check the material name and target properties.")
# Initialize transition factors
self.recompute()
return
def __str__(self) -> str:
str_repr = super().__str__()
str_repr += f"Material: {self.material}\n"
str_repr += f"K1: {self.K1:.3f}\n"
str_repr += f"mu: {self.mu:.3f}\n"
str_repr += f"Ybar: {format_large_units(self.Ybar, quantity='pressure')}\n"
str_repr += f"Target density: {self.target.density:.0f} kg/m³\n"
str_repr += f"Projectile density: {self.projectile.density:.0f} kg/m³\n"
str_repr += (
f"Nominal simple-complex transition diameter: {format_large_units(self.transition_nominal, quantity='length')}\n"
)
str_repr += f"Monte Carlo Scaling: {self._montecarlo_scaling}\n"
return str_repr
def _get_morphology_type(self, diameter: FloatLike | None = None, **kwargs: Any) -> str:
"""
Computes and the morphology type of a crater and returns a string corresponding to its type.
Parameters
----------
diameter : float
The diameter of the crater to compute.
Returns
-------
str
The type of crater "simple", "complex", or "transitional"
"""
if not isinstance(diameter, FloatLike) or diameter <= 0 or not np.isfinite(diameter):
raise ValueError("diameter must be a positive finite number")
# Use the 1/2x to 2x the nominal value of the simple->complex transition diameter to get the range of the "transitional" morphology type. This is supported by: Schenk et al. (2004) and Pike (1980) in particular
transition_range = (0.5 * self.transition_nominal, 2 * self.transition_nominal)
if diameter < transition_range[0]:
morphology_type = "simple"
elif diameter > transition_range[1]:
morphology_type = "complex"
else:
if self._montecarlo_scaling:
# We'll uses the distance from the nominal transition diameter to set a probability of being either simple, complex, or transitional.
if diameter < self.transition_nominal:
p = (self.transition_nominal - diameter) / (self.transition_nominal - transition_range[0])
categories = ["simple", "transitional"]
prob = [p, 1.0 - p]
morphology_type = self.rng.choice(categories, p=prob).item()
else:
p = (diameter - self.transition_nominal) / (transition_range[1] - self.transition_nominal)
categories = ["complex", "transitional"]
prob = [p, 1.0 - p]
morphology_type = self.rng.choice(categories, p=prob).item()
else:
morphology_type = "transitional"
return morphology_type
[docs]
def final_to_transient(
self,
final_diameter: FloatLike | None = None,
morphology_type: str | None = None,
**kwargs,
) -> float:
"""
Computes the transient diameter of a crater based on its final diameter and morphology type.
This method first ensures that the morphology type of the crater is computed. It then calculates
the transient crater diameter based on the final diameter using scaling factors for simple or complex
crater morphologies.
Parameters
----------
final_diameter : float-like
The final crater diameter in meters for which to compute the transient diameter.
morphology_type : str, optional
The morphology type of the crater ("simple", "complex", "transitional")
Returns
-------
float
Returns the crater transient diameter in meters
"""
if not isinstance(final_diameter, FloatLike) or final_diameter <= 0 or not np.isfinite(final_diameter):
raise ValueError("final_diameter must be a positive finite number")
if not morphology_type:
morphology_type = self._get_morphology_type(final_diameter)
if morphology_type == "simple":
transient_diameter = self._f2t_simple(final_diameter)
else:
transient_diameter = self._f2t_complex(final_diameter)
transient_diameter = float(transient_diameter)
return transient_diameter, morphology_type
[docs]
def transient_to_final(self, transient_diameter: FloatLike, **kwargs: Any) -> tuple[float, str]:
"""
Computes the final diameter of a crater based on its transient diameter and morphology type.
This method first ensures that the morphology type of the crater is computed. It then calculates
the final crater diameter based on the transient diameter using scaling factors for simple or complex
crater morphologies. This is a bit more complicated than the final->transient calculation because In
the transition region, a particular transient crater diameter could be associate with simple, complex,
or transitional crater morphologies. Therefore we need to monte carlo our way into a solution to avoid
biasing in favor of one or another in the transient->final computation
Parameters
----------
transient_diameter : FloatLike
The transient diameter in meters of the crater to convert to final.
Returns
-------
float
The final crater diameter
str
The morphology type of the crater
"""
# validate that transient_diameter is number and that it is positive and finite
if not isinstance(transient_diameter, FloatLike) or transient_diameter <= 0 or not np.isfinite(transient_diameter):
raise ValueError("transient_diameter must be a positive finite number")
# Invert the final -> transient functions for each crater type
final_diameter_simple = transient_diameter * self.simple_enlargement_factor
def root_func(final_diameter, Dt, scaling):
return scaling._f2t_complex(final_diameter) - Dt
sol = root_scalar(
lambda x, *args: root_func(x, *args),
bracket=(0.1 * final_diameter_simple, 10 * final_diameter_simple),
args=(transient_diameter, self),
)
final_diameter_complex = sol.root
# Evaluate the potential morphology that this transient crater could be consistent with. If both potential diameter values are unambigusously simple or complex, go with that.
# If there is disagreement, then we'll draw the answer from a hat and just check to make sure that final_diameter > transient_diameter
morphology_options = [
self._get_morphology_type(final_diameter_simple),
self._get_morphology_type(final_diameter_complex),
]
if len(set(morphology_options)) == 1: # We have agreement!
morphology_type = morphology_options[0]
if morphology_type == "simple":
final_diameter = final_diameter_simple
else:
final_diameter = final_diameter_complex # this includes transitional types as well
else:
if "simple" in morphology_options: # The disagreement is between simple/complex or simple/transitional
if morphology_options[0] == "simple":
sind = 0
cind = 1
else:
sind = 1
cind = 0
if self._montecarlo_scaling:
# Randomly draw a morphology based on weighting by whichever option is closest to the transition
p = self.rng.random()
else:
p = 0.5
is_simple = p < abs(final_diameter_complex - self.transition_diameter) / abs(
final_diameter_simple - final_diameter_complex
)
if is_simple:
final_diameter = final_diameter_simple
morphology_type = morphology_options[sind]
else:
final_diameter = final_diameter_complex
morphology_type = morphology_options[cind]
else:
final_diameter = final_diameter_complex
if self._montecarlo_scaling:
morphology_type = self.rng.choice(morphology_options)
else:
morphology_type = "transitional"
final_diameter = float(final_diameter)
morphology_type = morphology_type
return final_diameter, morphology_type
[docs]
def projectile_to_transient(self, projectile_diameter: FloatLike, **kwargs: Any) -> float:
"""
Calculate the transient diameter of a crater based on the properties of the projectile and target.
Returns
-------
float
The calculated transient diameter of the crater resulting from the impact.
"""
if not isinstance(projectile_diameter, FloatLike) or projectile_diameter <= 0 or not np.isfinite(projectile_diameter):
raise ValueError("projectile_diameter must be a positive finite number")
# Compute some auxiliary quantites
projectile_radius = projectile_diameter / 2
projectile_mass = (4.0 / 3.0) * math.pi * (projectile_radius**3) * self.projectile.density
c1 = 1.0 + 0.5 * self.mu
c2 = (-3 * self.mu) / (2.0 + self.mu)
# Find dimensionless quantities
pitwo = (self.target.gravity * projectile_radius) / (self.projectile.vertical_velocity**2)
pithree = self.Ybar / (self.target.density * (self.projectile.vertical_velocity**2))
pifour = self.target.density / self.projectile.density
pivol = self.K1 * ((pitwo * (pifour ** (-1.0 / 3.0))) + (pithree**c1)) ** c2
pivolg = self.K1 * (pitwo * (pifour ** (-1.0 / 3.0))) ** c2
# find transient crater volume and radii (depth = 1/3 diameter)
cvol = pivol * (projectile_mass / self.target.density)
cvolg = pivolg * (projectile_mass / self.target.density)
transient_radius = (3 * cvol / math.pi) ** (1.0 / 3.0)
# TODO: transient_radius_gravscale = (3 * cvolg / math.pi)**(1.0/3.0)
transient_diameter = transient_radius * 2
if transient_diameter < projectile_diameter:
transient_diameter = projectile_diameter
return transient_diameter
[docs]
def transient_to_projectile(self, transient_diameter: FloatLike, **kwargs: Any) -> float:
"""
Estimate the characteristics of the projectile that could have created a given crater.
This method approximates the properties of a hypothetical projectile based on the characteristics
of a known crater.
Parameters
----------
transient_diameter : float
The diameter of the crater in meters.
**kwargs : Any
|kwargs|
Returns
-------
Crater
The computed projectile for the crater.
"""
if not isinstance(transient_diameter, FloatLike) or transient_diameter <= 0 or not np.isfinite(transient_diameter):
raise ValueError("transient_diameter must be a positive finite number")
def root_func(projectile_diameter) -> float:
value = self.projectile_to_transient(projectile_diameter)
return value - transient_diameter
sol = root_scalar(
lambda x, *args: root_func(x, *args),
bracket=(1e-5 * transient_diameter, 1.2 * transient_diameter),
)
return sol.root
@property
def material_catalogue(self):
"""
The material catalogue used to look up material properties. Material property values are from Holsapple (1993) and Kraus et al. (2011).
Returns
-------
dict
The material catalogue.
References
----------
- Holsapple, K.A., 1993. The scaling of impact processes in planetary sciences 21, 333-373. `doi:10.1146/annurev.ea.21.050193.002001 <https://doi.org/10.1146/annurev.ea.21.050193.002001>`_
- Kraus, R.G., Senft, L.E., Stewart, S.T., 2011. Impacts onto H2O ice: Scaling laws for melting, vaporization, excavation, and final crater size. Icarus 214, 724-738. `doi:10.1016/j.icarus.2011.05.016 <https://doi.org/10.1016/j.icarus.2011.05.016>`_
"""
def _create_material_catalogue():
# Define some built-in catalogue values for known solar system materials of interest
# Define some default crater scaling relationship terms (see Richardson 2009, Table 1, and Kraus et al. 2011 for Ice)
material_properties = ["name", "K1", "mu", "Ybar", "density"]
material_values = [
("Water", 2.30, 0.55, 0.0, 1000.0),
("Sand", 0.24, 0.41, 0.0, 1750.0),
("Dry Soil", 0.24, 0.41, 0.18e6, 1500.0),
("Wet Soil", 0.20, 0.55, 1.14e6, 2000.0),
("Soft Rock", 0.20, 0.55, 7.60e6, 2250.0),
("Hard Rock", 0.20, 0.55, 18.0e6, 2500.0),
("Ice", 15.625, 0.48, 0.0, 900.0),
]
return _create_catalogue(material_properties, material_values)
if self._material_catalogue is None:
self._material_catalogue = _create_material_catalogue()
return self._material_catalogue
[docs]
def recompute(self):
"""
Computes and sets the internal attributes for transition factors between simple and complex craters.
"""
# These terms are used to compute the ratio of the transient crater to simple crater size
simple_enlargement_mean = 0.84 # See Melosh (1989) pg. 129 just following eq. 8.2.1
simple_enlargement_std = 0.04 # Just using Pike (1980) fig. 9 the crater depth varies by about the same amount on either side of the transition so this is a reasonable assumption
# These terms are used in the exponent in the final rim radius/ simple crater radius vs final radius / transition radius relationship
# See Holsapple (1993) eq. 28
final_exp_mean = 0.079
final_exp_std = 0.0001 # We add noise because this is nature and nature messy
complex_enlargement_factor = 1.02
# These terms are used to compute the transition diameter as a function of gravity. They are based on fits to the plot given in Fig. 7 of Schenk et al. (2021)
if self.target.transition_scale_type == "silicate":
simple_complex_A = -0.5484575694575697
simple_complex_B = 4.201369948094472
simple_complex_sigma = 0.0699898822890725
elif self.target.transition_scale_type == "ice":
simple_complex_A = -0.7066985617230864
simple_complex_B = 3.52268633631802
simple_complex_sigma = 0.10337890091526512
def _sample_transition_diameter(g, A, B, sigma):
mu = A * np.log10(g) + B
return 10 ** self.rng.normal(mu, sigma)
# The nominal value will be used for determining the range of the "transitional" morphology type
self._transition_nominal = 10 ** (simple_complex_A * np.log10(self.target.gravity) + simple_complex_B)
# Draw from a truncated normal distribution for each component of the model
if self._montecarlo_scaling:
simple_enlargement_factor = 1.0 / mc.bounded_norm(simple_enlargement_mean, simple_enlargement_std, rng=self.rng)[0]
final_exp = mc.bounded_norm(final_exp_mean, final_exp_std, rng=self.rng)[0]
transition_diameter = _sample_transition_diameter(
self.target.gravity,
simple_complex_A,
simple_complex_B,
simple_complex_sigma,
)
else:
simple_enlargement_factor = 1.0 / simple_enlargement_mean
final_exp = final_exp_mean
transition_diameter = float(self.transition_nominal)
self._transition_diameter = float(transition_diameter)
self._simple_enlargement_factor = float(simple_enlargement_factor)
self._complex_enlargement_factor = float(complex_enlargement_factor)
self._final_exp = float(final_exp)
return
def _f2t_simple(self, Df):
return Df / self.simple_enlargement_factor
def _f2t_complex(self, Df):
return (
Df
/ (self.simple_enlargement_factor * self.complex_enlargement_factor)
* (Df / self.transition_diameter) ** -self.final_exp
)
@property
def transition_diameter(self) -> float:
"""The transition diameter between simple and complex craters in m."""
return self._transition_diameter
@property
def transition_nominal(self) -> float:
"""The nominal transition diameter between simple and complex craters in m."""
return self._transition_nominal
@property
def simple_enlargement_factor(self) -> float:
"""The enlargement factor for simple craters."""
return self._simple_enlargement_factor
@property
def complex_enlargement_factor(self) -> float:
"""The enlargement factor for complex craters."""
return self._complex_enlargement_factor
@property
def final_exp(self) -> float:
"""The exponent used in the final rim radius to simple crater radius relationship."""
return self._final_exp
@parameter
def K1(self):
"""The K₁ term in the crater scaling relationship."""
return self._K1
@K1.setter
def K1(self, value):
if not isinstance(value, FloatLike) and value is not None:
raise TypeError("K1 must be a numeric value or None")
if value is not None and value < 0:
raise ValueError("K1 must be a positive number")
self._K1 = float(value)
@parameter
def mu(self):
"""The μ term in the crater scaling relationship."""
return self._mu
@mu.setter
def mu(self, value):
if not isinstance(value, FloatLike) and value is not None:
raise TypeError("mu must be a numeric value or None")
if value is not None and value < 0:
raise ValueError("mu must be a positive number")
self._mu = float(value)
@parameter
def Ybar(self):
"""The Ȳ term in the crater scaling relationship, which is the strength of the material in Pa."""
return self._Ybar
@Ybar.setter
def Ybar(self, value):
if not isinstance(value, FloatLike) and value is not None:
raise TypeError("Ybar must be a numeric value or None")
if value is not None and value < 0:
raise ValueError("Ybar must be a positive number")
self._Ybar = float(value)
@property
def catalogue_key(self):
"""The key used to identify the property used as the key in a catalogue."""
return "material"
@parameter
def material(self):
"""The name of the material composition of the target body."""
return self._material
@material.setter
def material(self, value):
if value is None:
return
if not isinstance(value, str):
raise TypeError("name must be a string or None")
self._material = value.title()
if self._material in self.material_catalogue:
self.K1 = self.material_catalogue[self._material]["K1"]
self.mu = self.material_catalogue[self._material]["mu"]
self.Ybar = self.material_catalogue[self._material]["Ybar"]
self.target.density = self.material_catalogue[self._material]["density"]
@parameter
def monte_carlo_scaling(self):
"""If True, the scaling laws will be applied using monte carlo methods to account for the uncertainty in the scaling laws. If False, the scaling laws will be applied deterministically."""
return self._montecarlo_scaling
@monte_carlo_scaling.setter
def monte_carlo_scaling(self, value):
if not isinstance(value, bool):
raise TypeError("monte_carlo_scaling must be a boolean")
self._montecarlo_scaling = value