from contextlib import suppress
from pathlib import Path
from typing import Any
import numpy as np
import yaml
from matplotlib.axes import Axes
from numpy.random import Generator
from numpy.typing import ArrayLike
from tqdm import tqdm
from cratermaker.components.counting import Counting
from cratermaker.components.crater import Crater
from cratermaker.components.morphology import Morphology
from cratermaker.components.production import Production
from cratermaker.components.projectile import Projectile
from cratermaker.components.scaling import Scaling
from cratermaker.components.surface import Surface
from cratermaker.components.surface.hireslocal import HiResLocalSurface
from cratermaker.components.target import Target
from cratermaker.constants import (
_COMPONENT_NAMES,
_CONFIG_FILE_NAME,
FloatLike,
PairOfFloats,
)
from cratermaker.core.base import CratermakerBase, _convert_for_yaml
from cratermaker.utils.general_utils import _set_properties, format_large_units, parameter
[docs]
class Simulation(CratermakerBase):
"""
Creates a simulation of a crater population on a target body. It allows for the generation of craters based on a variety of parameters, including the target body, scaling laws, production functions, and morphology models.
Parameters
----------
target: Target or str, optional, default "Moon"
Name target body for the simulation, default is "Moon".
scaling : Scaling or str, optional
The projectile->crater size scaling model to use from the components library. The default is "montecarlo".
production: Production or str, optional
The production function model to use from the components library that defines the production function used to populate the surface with craters. If none provided,
then the default will be based on the target body, with the NeukumProduction crater-based scaling law used if the target
body is the Moon or Mars, the NeukumProduction projectile-based scaling law if the target body is Mercury, Venus, or
Earth, and a simple power law model otherwise.
morphology : str, optional
The model used to generate the morphology of the crater. If none provided, then the default will "basicmoon", which is similar to the one used by CTEM.
projectile : str, optional
The projectile model to use from the components library, which is used to generate the projectile properties for the simulation, such as velocity and density. The default is "asteroids" when target is Mercury, Venus, Earth, Moon, Mars, Ceres, or Vesta, and "comets" otherwise.
surface : str, optional
The name of the surface used for the surface. Default is "icosphere".
counting : Counting or str, optional
The crater counting model to use from the components library. Default is "depthcount".
simdir : str | Path
|simdir|
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|
ask_overwrite : bool, optional
If True, the user will be prompted before overwriting any existing files. Default is True.
reset : bool, optional
Flag to indicate whether to reset the simulation or resume from an old simulation. If False, the simulation will attempt to load the previous state from the config file. Default is False if `ask_overwrite=False` and a config file is detected, otherwise default is True.
do_counting : bool, optional
If True, the counting component will keep track of emplaced craters during the simulation. Default is True.
save_actions: list[dict[str, dict]], optional
A dictionary of actions to perform when the save method is called. The keys are the names of the actions and the values are dictionaries of keyword arguments to pass to the corresponding component's save method. For example, if you want to automatically generate a hillshade plot every time the simulation is saved, you can pass `save_actions=[{"plot": {"plot_style": "hillshade", "cmap": "pink", "scalebar": True, "label": "Mars region simulation", "show": True, "save": True}}]`. This will call the surface's save method with the specified keyword arguments every time the simulation is saved. Default is to save a hillshade plot of the surface every time the simulation is saved.
**kwargs : Any
|kwargs|, including those for component function constructors. Refer to the documentation of each component module for details.
"""
def __init__(
self,
*, # Enforce keyword-only arguments
target: Target | str | None = None,
scaling: Scaling | str | None = None,
production: Production | str | None = None,
morphology: Morphology | str | None = None,
projectile: Projectile | str | None = None,
surface: Surface | str | None = None,
counting: Counting | str | None = None,
simdir: str | Path | None = None,
rng: Generator | None = None,
rng_seed: int | None = None,
rng_state: dict | None = None,
reset: bool = None,
ask_overwrite: bool = True,
do_counting: bool = True,
save_actions: list[dict[str, dict]] | None = None,
**kwargs: Any,
):
object.__setattr__(self, "_target", None)
object.__setattr__(self, "_scaling", None)
object.__setattr__(self, "_production", None)
object.__setattr__(self, "_morphology", None)
object.__setattr__(self, "_projectile", None)
object.__setattr__(self, "_surface", None)
object.__setattr__(self, "_counting", None)
object.__setattr__(self, "_interval", None)
object.__setattr__(self, "_elapsed_time", None)
object.__setattr__(self, "_time", None)
object.__setattr__(self, "_elapsed_n1", None)
object.__setattr__(self, "_smallest_crater", None)
object.__setattr__(self, "_smallest_projectile", None)
object.__setattr__(self, "_largest_crater", None)
object.__setattr__(self, "_largest_projectile", None)
object.__setattr__(self, "_config_readonly", True)
object.__setattr__(self, "_is_new", False)
super().__init__(simdir=simdir, rng=rng, rng_seed=rng_seed, rng_state=rng_state, ask_overwrite=ask_overwrite, **kwargs)
if reset is None:
if self.ask_overwrite and self.config_file.exists():
response = input(
"Old run detected. Enter y to reset the simulation and n to resume from the previous state. To disable this message and suppress all prompts about overwriting old files, pass `ask_overwrite=False` as an argument to Simulation() or enter 'a' to suppress prompts about overwriting files. (y/[N]/a): "
)
if response.lower() == "a":
print("All prompts about overwriting files will be suppressed for this and future runs.")
self.ask_overwrite = False
reset = True
elif response.lower() == "y":
print("Resetting simulation.")
reset = True
else:
reset = False
else:
reset = False
self.is_new = reset
object.__setattr__(self, "_config_readonly", not reset)
if not self.is_new and self.config_file.exists():
config_file = self.config_file
object.__setattr__(self, "_config_readonly", True)
else:
config_file = None
config_override = {}
for component in _COMPONENT_NAMES:
# Set to true if a local variable from the argument list with the component name is set to something other than None, otherwise false
config_override[component] = getattr(self, f"_{component}") is not None
_, unmatched = _set_properties(
self,
target=target,
scaling=scaling,
production=production,
morphology=morphology,
projectile=projectile,
surface=surface,
counting=counting,
config_file=config_file,
**vars(self.common_args),
)
for component in _COMPONENT_NAMES:
if config_override[component]:
# If the component is set to something other than None, then remove it from the unmatched dictionary
unmatched.pop(f"{component}_config", None)
production_config = unmatched.pop("production_config", {})
scaling_config = unmatched.pop("scaling_config", {})
surface_config = unmatched.pop("surface_config", {})
morphology_config = unmatched.pop("morphology_config", {})
target_config = unmatched.pop("target_config", {})
projectile_config = unmatched.pop("projectile_config", {})
if do_counting:
counting_config = unmatched.pop("counting_config", {})
kwargs.update(unmatched)
kwargs = {**kwargs, **vars(self.common_args)}
target_config = {**target_config, **kwargs}
self.target = Target.maker(self.target, **target_config)
production_config = {**production_config, **kwargs}
self.production = Production.maker(self.production, target=self.target, **production_config)
projectile_config = {**projectile_config, **kwargs}
self.projectile = Projectile.maker(self.projectile, target=self.target, **projectile_config)
scaling_config = {**scaling_config, **kwargs}
self.scaling = Scaling.maker(
self.scaling,
target=self.target,
projectile=self.projectile,
**scaling_config,
)
surface_config = {
**surface_config,
**kwargs,
}
if "superdomain_scale_factor" not in surface_config:
surface_config["superdomain_scale_factor"] = (
None # This will trigger setting of the superdomain after the Morphology and Scaling models are set
)
self.surface = Surface.maker(
self.surface,
target=self.target,
reset=self.is_new,
**surface_config,
)
# If the surface had to be regridded, then we will need to reset the simulation. HiResLocal surfaces are deferred, so won't have the flag set
if self.surface.is_new is not None:
self.is_new = self.surface.is_new
if do_counting:
counting_config = {**counting_config, **kwargs}
self.counting = Counting.maker(
self.counting,
surface=self.surface,
reset=self.is_new,
**counting_config,
)
morphology_config = {**morphology_config, **kwargs}
self.morphology = Morphology.maker(
self.morphology,
surface=self.surface,
production=self.production,
counting=self.counting,
**morphology_config,
)
# If this is a variant of the HiResLocalSurface we need to check to see if it has a grid yet.
# This is because when creating a new Surface object of this type, the grid generation is deferred until the Scaling and Morphology objects are initialized in order to set the superdomain properly.
if issubclass(self.surface.__class__, HiResLocalSurface) and self.surface.uxgrid is None:
self.surface.set_superdomain(
scaling=self.scaling,
morphology=self.morphology,
reset=self.is_new,
**surface_config,
)
# The surface should now hav its is_new attribute set and will be set to True if it had to be regridded
self.is_new = self.surface.is_new
if self.is_new:
object.__setattr__(self, "_config_readonly", False)
# The Surface has already had its reset method called.
skip_components = ["surface"]
if not do_counting:
skip_components.append("counting")
self.reset(skip_component=skip_components)
if save_actions is None:
self.save_actions = [{"plot": {"plot_style": "hillshade", "show": False, "save": True}}]
self.to_config()
return
def __str__(self) -> str:
"""
Returns a string representation of the Simulation object.
"""
output = (
f"<Simulation>\n\n"
f"simdir : {str(self.simdir)}\n"
f"{self.counting}\n\n"
f"{self.morphology}\n\n"
f"{self.production}\n\n"
f"{self.projectile}\n\n"
f"{self.scaling}\n\n"
f"{self.surface}\n\n"
f"{self.target}\n\n"
f"<Current state>\n"
f"Interval : {self.interval}\n"
)
if self.time is not None:
output += (
f"Current time : {format_large_units(self.time, quantity='time')} before present\n"
f"Elapsed time: {format_large_units(self.elapsed_time, quantity='time')}\n"
f"Elapsed N_1 : {self.elapsed_n1} #/m²\n"
)
return output
def __repr__(self) -> str:
config = self.to_config(save_to_file=False)
txt = f"{self.__class__.__name__}("
for k, v in config.items():
if isinstance(v, str):
v = f"'{v}'"
txt += f"\n {k}={v},"
txt += "\n)"
return txt
def __setattr__(self, name, value):
super().__setattr__(name, value)
if name not in self._user_defined:
return
# Avoid recursive calls during initialization or early access
if (
not self._config_readonly
and hasattr(self, "to_config")
and callable(getattr(self, "to_config", None))
and _convert_for_yaml(value) is not None
):
self.to_config()
[docs]
def run(
self,
age: FloatLike | None = None,
time_start: FloatLike | None = None,
time_end: FloatLike | None = None,
diameter_number: PairOfFloats | None = None,
time_interval: FloatLike | None = None,
ninterval: int | None = None,
**kwargs: Any,
) -> None:
"""
Run the simulation over a specified interval using the current production function.
Parameters
----------
age : FloatLike, optional
Start age in My relative to the present for the simulation, used to compute the starting point of the production function.
Default is None, which requires 'time_start' or `diameter_number` to be set.
time_start : Floatlike, optional
An alternative to `age` that specifies the starting time in My relative to the present for the simulation, used to compute the starting point of the production function. This is used in conjunction with `time_end` in order to allow for simulations that span a range of time rather than being of a specific age. Default is None, which requires either `age` or `diameter_number` to be set.
time_end : FloatLike, optional
Ending time in My relative to the present for the simulation, used to compute the ending point of the production function.
Default is 0 (present day) if not provided but `time_start` is provided.
diameter_number: PairOfFloats, optional
Cumulative number and diameter pair (D, N) that defines the total crater accumulation for the given production function. Default is None, which requires `age` or `time_start` and `time_end` to be set.
time_interval : FloatLike, optional
Interval in My for outputting intermediate results. If not provided, calculated as `age` / `ninterval` or (`time_start - time_end`) / `ninterval` if `ninterval` is provided, otherwise set to the total simulation duration (e.g. `ninterval=1`).
ninterval : int, optional
Number of intervals for outputting results. This has a special use case where one can specify age-based inputs but output are computed in equal cumulative number intervals and vice versa.
**kwargs : Any
|kwargs|
Notes
-----
This function allows defining the simulation parameters either in terms of time or crater frequency (cumulative number). The
arguments `age`, `time_start`, `time_end` are mutually exclusive with `diameter_number` and `time_interval` is mutually exclusive with `ninterval`.
The initial state of the simulation (before any craters are emplaced) is always saved automatically.
As a result, the total number of saved states will be `ninterval + 1`, where `ninterval` is the number
of simulation intervals requested.
Examples
--------
.. code-block:: python
# Create a simulation object with default parameters (Moon, NeukumProduction, etc.)
sim = cratermaker.Simulation()
# Run the simulation for 3.8 billion years, saving the results every 100 million years
sim.run(age=3.8e3, time_interval=100.0)
# Run the simulation for 3.8 billion years, saving 100 intervals with equal cumulative number intervals
sim.run(age=3.8e3, ninterval=100)
# Run the simulation to create 80 craters larger than 300 km and output 100 equal cumulative number intervals
sim.run(diameter_number=(300e3, 80), ninterval=100)
# Run the simulation from 3.8 billion years to 3.0 billion years, saving the results every 100 million years
sim.run(time_start=3.8e3, time_end=3.0e3, time_interval=100.0)
"""
def _validate_run_args(**kwargs: Any) -> dict:
"""
Validate all the input arguments to the sample method. This function will raise a ValueError if any of the arguments are invalid.
Parameters
----------
kwargs : Any
A dictionary of all the arguments passed to the run method, including age, time_start, time_end, diameter_number, time_interval, ninterval, and any additional arguments passed through **kwargs.
Returns
-------
A dict containing all arguments listed in Parameters above, as well as `is_time_interval`, which is a boolean flag indicating
whether or not the simulation is being run in equal age intervals or equal number intervals.
Raises
------
ValueError
If any of the following conditions are met:
- Neither the age nore the diameter_number argument is provided.
- Both the age and diameter_number arguments are provided.
- Both the age and either time_start or time_end arguments are provided.
- Both the time_start and diameter_number arguments are provided.
- The age, time_start, or time_end arguments are provided but are not a scalar.
- The time_interval is provided but is not a positive scalar.
- The time_interval provided is greater than age or time_start - time_end
- The diameter_number argument is not a pair of values, or any of them are less than 0
- The time_interval and nintervaql arguments are both provided.
- The ninterval is provided but is not an integer or is less than 1.
"""
# Determine whether we are going to do equal time intervals or equal number intervals
age = kwargs.pop("age", None)
time_start = kwargs.pop("time_start", None)
time_end = kwargs.pop("time_end", None)
diameter_number = kwargs.pop("diameter_number", None)
time_interval = kwargs.pop("time_interval", None)
ninterval = kwargs.pop("ninterval", None)
if age is not None:
if time_start is not None or time_end is not None:
raise ValueError("Cannot specify both age and time_start or time_end")
if diameter_number is not None:
raise ValueError("Cannot specify both age and diameter_number")
if not np.isscalar(age):
raise ValueError("age must be a scalar value")
# as age is just a convenience variable, we replace it with time_start = age and time_end = 0
time_start = age
time_end = 0.0
del age
if time_start is not None:
if time_end is None:
time_end = 0.0
if diameter_number is not None:
raise ValueError("Cannot specify both time_start and diameter_number")
if not np.isscalar(time_start):
raise ValueError("time_start must be a scalar value")
if not np.isscalar(time_end):
raise ValueError("time_end must be a scalar value")
elif time_end is not None:
if self._time is None:
raise ValueError("time_end cannot be used without time_start")
time_start = self.time
elif diameter_number is None:
raise ValueError("Must provide one of age, time_start, or diameter_number")
if ninterval is not None:
if not isinstance(ninterval, int):
raise TypeError("ninterval must be an integer")
if ninterval < 1:
raise ValueError("ninterval must be greater than zero")
if time_interval is not None:
raise ValueError("Cannot specify both ninterval and time_interval")
elif time_interval is None:
ninterval = 1
is_time_interval = time_interval is not None
# Validate arguments using the production function validator first, which will convert time-based values to diameter_number-based ones
kwargs["diameter_range"] = (
self._get_smallest_diameter(),
self._get_largest_diameter(),
)
kwargs["area"] = self.surface.area
kwargs["time_start"] = time_start
kwargs["time_end"] = time_end
kwargs["diameter_number"] = diameter_number
kwargs["ninterval"] = ninterval
kwargs["time_interval"] = time_interval
kwargs = self.production._validate_sample_args(**kwargs)
if is_time_interval:
if time_interval is None:
if ninterval is None:
ninterval = 1
time_interval = (time_start - time_end) / ninterval
else:
if time_interval > time_start - time_end:
raise ValueError("time_interval must be less than age or time_start - time_end")
elif time_interval <= 0:
raise ValueError("time_interval must be greater than zero")
ninterval = int(np.ceil((time_start - time_end) / time_interval))
kwargs["time_interval"] = time_interval
kwargs["ninterval"] = ninterval
else:
diameter_number = kwargs.get("diameter_number", None)
diameter_number_end = kwargs.get("diameter_number_end", None)
if diameter_number is None:
raise ValueError("Something went wrong! diameter_number should be set by self.production_validate_sample_args")
if diameter_number_end is None:
raise ValueError(
"Something went wrong! diameter_number_end should be set by self.production_validate_sample_args"
)
if ninterval is None:
ninterval = 1
diameter_number_interval = (
diameter_number[0],
(diameter_number[1] - diameter_number_end[1]) / ninterval,
)
kwargs["diameter_number_interval"] = diameter_number_interval
kwargs["ninterval"] = ninterval
kwargs["is_time_interval"] = is_time_interval
# Remove unecessary arguments that came out of the production._validate_sample_args method
kwargs.pop("diameter_range")
kwargs.pop("area")
kwargs.pop("return_age")
return kwargs
arguments = {
"age": age,
"time_start": time_start,
"time_end": time_end,
"time_interval": time_interval,
"diameter_number": diameter_number,
"ninterval": ninterval,
**kwargs,
}
arguments = _validate_run_args(**arguments)
time_start = arguments.pop("time_start", None)
time_end = arguments.pop("time_end", None)
time_interval = arguments.pop("time_interval", None)
diameter_number = arguments.pop("diameter_number", None)
diameter_number_interval = arguments.pop("diameter_number_interval", None)
ninterval = arguments.pop("ninterval", None)
is_time_interval = arguments.pop("is_time_interval", None)
validate_inputs = kwargs.pop("validate_inputs", False)
if ninterval == 1:
is_time_interval = True
time_interval = time_start - time_end
if not is_time_interval:
diameter_number_density_start = (
diameter_number[0],
diameter_number[1] / self.surface.area,
)
time_start = self.production.age_from_D_N(*diameter_number_density_start, validate_inputs=validate_inputs)
# If this is a fresh run, we need to set the value of the current time based on the requested starting condition.
if self._time is None:
self.time = time_start
self.interval = 0
# If this is a restarted run, we need to distinguish between a true restart and a continuation with different parameters
if time_start < self.time:
raise RuntimeError(
"Starting time cannot be later than the current time. Choose a starting time value equal to or larger than the current time, or reset this simulation."
)
if is_time_interval:
initial_interval = int((time_start - self.time) / time_interval)
else:
delta_n1_start = self.production.function(
diameter=1000.0,
time_start=time_start,
time_end=self.time,
validate_inputs=validate_inputs,
).item()
n1_interval = (
self.production.function(
diameter=1000.0,
time_start=time_start,
time_end=time_end,
validate_inputs=validate_inputs,
).item()
/ ninterval
)
initial_interval = int(delta_n1_start / n1_interval)
if self.is_new:
self.save(**kwargs)
with tqdm(
total=ninterval,
initial=initial_interval,
unit="interval",
position=3,
leave=True,
) as pbar:
for i in range(initial_interval, ninterval):
if self.do_counting:
self.counting._emplaced = []
if is_time_interval:
time = time_start - i * time_interval
current_time_end = time_start - (i + 1) * time_interval
if current_time_end < time_end:
current_time_end = time_end
time_str = format_large_units(time, quantity="time")
elapsed_str = format_large_units(self.elapsed_time, quantity="time")
pbar.set_description(f"Current: {time_str} bp; Elapsed: {elapsed_str}")
self.populate(time_start=time, time_end=current_time_end, **kwargs)
else:
current_diameter_number = (
diameter_number[0],
diameter_number[1] - i * diameter_number_interval[1],
)
current_diameter_number_end = (
diameter_number[0],
diameter_number[1] - (i + 1) * diameter_number_interval[1],
)
self.populate(
diameter_number=current_diameter_number, diameter_number_end=current_diameter_number_end, **kwargs
)
current_diameter_number_density = (
current_diameter_number[0],
current_diameter_number[1] / self.surface.area,
)
time = self.production.age_from_D_N(*current_diameter_number_density, validate_inputs=validate_inputs)
if current_diameter_number_end[1] > 0:
current_diameter_number_density_end = (
current_diameter_number_end[0],
current_diameter_number_end[1] / self.surface.area,
)
current_time_end = self.production.age_from_D_N(
*current_diameter_number_density_end,
validate_inputs=validate_inputs,
)
else:
current_time_end = 0.0
if current_time_end < 0.0:
current_time_end = 0.0
time_interval = time - current_time_end
self.elapsed_time += time_interval
self.elapsed_n1 += self.production.function(
diameter=1000.0,
time_start=time,
time_end=current_time_end,
validate_inputs=validate_inputs,
).item()
self.time = current_time_end
self.interval += 1
self.save(**kwargs)
pbar.update(1)
return
[docs]
def populate(
self,
age: FloatLike | None = None,
time_start: FloatLike | None = None,
time_end: FloatLike | None = None,
diameter_number: PairOfFloats | None = None,
diameter_number_end: PairOfFloats | None = None,
**kwargs: Any,
) -> None:
"""
Populate the surface with craters over a specified interval using the current production function.
Parameters
----------
age : FloatLike, optional
Age in the past in units of My relative to the present, which is used compute the cumulative SFD.
time_start : FloatLike, optional
An alternative to `age` that specifies the starting time in My relative to the present for the simulation, used to compute the starting point of the production function. This is used in conjunction with `time_end` in order to allow for simulations that span a range of time rather than being of a specific age. Default is None, which requires either `age` or `diameter_number`
time_end : FloatLike, optional
The ending time in My relative to the present for the simulation, used to compute the ending point of the production function. Default is 0 (present day).
diameter_number : PairOfFloats, optional
A pair of diameter and cumulative number values, in the form of a (D, N). If provided, the function will convert this value
to a corresponding age and use the production function for a given age.
diameter_number_end : PairOfFloats, optional
A pair of diameter and cumulative number values, in the form of a (D, N). If provided, the function will convert this
value to a corresponding reference age and use the production function for a given age.
"""
if not hasattr(self, "production"):
raise RuntimeError("No production function defined for this simulation")
elif not hasattr(self.production, "generator_type"):
raise RuntimeError("The production function is not properly defined. Missing 'generator_type' attribute")
elif self.production.generator_type not in ["crater", "projectile"]:
raise RuntimeError(f"Invalid production function type {self.production.generator_type}")
if age is not None:
if time_start is not None:
raise ValueError("Cannot specify both age and time_start")
time_start = age
del age
from_projectile = self.production.generator_type == "projectile"
diam_key = "projectile_diameter" if from_projectile else "diameter"
diam_max = self._get_largest_diameter(from_projectile=from_projectile)
diam_min = self._get_smallest_diameter(from_projectile=from_projectile)
# Loop over each face in the mesh to build up a population of craters in this interval. This is done because faces may
# not all have the same surface area, the range of crater sizes that can be formed on each face may be different.
impact_diameters = []
impact_times = []
impact_locations = []
# Process each bin
for i, face_indices in enumerate(self.surface.face_bin_indices):
total_bin_area = self.surface.face_bin_area[i]
area_ratio = total_bin_area / self.surface.area
diam_min = self._get_smallest_diameter(self.surface.face_bin_min_sizes[i], from_projectile=from_projectile)
diameter_number_local = (diameter_number[0], diameter_number[1] * area_ratio) if diameter_number is not None else None
if diameter_number_end is not None:
diameter_number_end_local = (
diameter_number_end[0],
diameter_number_end[1] * area_ratio,
)
else:
diameter_number_end_local = None
diameters, times = self.production.sample(
time_start=time_start,
time_end=time_end,
diameter_number=diameter_number_local,
diameter_number_end=diameter_number_end_local,
diameter_range=(diam_min, diam_max),
area=total_bin_area,
**kwargs,
)
if diameters.size > 0:
impact_diameters.extend(diameters.tolist())
impact_times.extend(times.tolist())
# Get the relative probability of impact onto any particular face then get the locations of the impacts
p = self.surface.face_area[face_indices] / total_bin_area
face_indices = self.rng.choice(face_indices, size=diameters.shape, p=p)
locations = self.surface.get_random_location_on_face(face_indices)
impact_locations.extend(np.array(locations).T.tolist())
if len(impact_diameters) > 0:
craterlist = []
# Sort the times, diameters, and locations so that they are in order of decreasing age
sort_indices = np.argsort(impact_times)[::-1]
impact_diameters = np.asarray(impact_diameters)[sort_indices]
impact_times = np.asarray(impact_times)[sort_indices]
impact_locations = np.array(impact_locations)[sort_indices]
for diameter, location, time in tqdm(
zip(impact_diameters, impact_locations, impact_times, strict=False),
total=len(impact_diameters),
desc="Generating crater population",
unit="crater",
position=0,
leave=False,
):
diam_arg = {diam_key: diameter}
craterlist.append(
self.Crater.maker(
location=location,
age=time,
scaling=self.scaling,
**diam_arg,
**vars(self.common_args),
**kwargs,
)
)
self.emplace(craterlist, **kwargs)
return
[docs]
def emplace(self, craters: list[Crater] | Crater | None = None, **kwargs: Any) -> list[Crater]:
"""
Emplace one or more craters in the simulation.
This method orchestrates the creation and placement of a crater in the
simulation. It can create a crater directly or based on the characteristics
of a projectile.
Parameters
----------
craters : Crater or list of Crater objects, optional
The Crater object(s) to be emplaced. If provided, this will be used directly. Otherwise, a single crat er will be generated based on the keyword arguments.
**kwargs : Any
|kwargs|
Returns
-------
list[Crater]
A list of the Crater objects that were emplaced in the simulation. Returns an empty list if no craters were emplaced.
Notes
-----
The keyword arguments provided are passed down to :py:meth:`Crater.maker`. Refer to its documentation for a detailed description of valid keyword arguments.
Examples
--------
.. code-block:: python
from cratermaker import Simulation, Crater
sim = Simulation()
# Create a crater with specific diameter
sim.emplace(diameter=10.0e3)
# Create a crater based on a projectile with given mass and projectile_velocity
sim.emplace(projectile_mass=1e15, projectile_velocity=20e3)
# Create a crater with a specific transient diameter and location
sim.emplace(transient_diameter=50e3, location=(43.43, -86.92))
# Create multiple craters
craters = [Crater.maker(diameter=20.0e3), Crater.maker(diameter=20.0e3)]
sim.emplace(craters)
"""
if craters is None and "scaling" not in kwargs:
kwargs["scaling"] = self.scaling
self.is_new = False
return self.morphology.emplace(craters=craters, **kwargs)
[docs]
def save(self, **kwargs: Any) -> None:
"""
Save the current simulation state to a file.
Parameters
----------
**kwargs : Any
Additional keyword argumments to pass to the component save methods.
"""
save_args = {"interval": self.interval, "time_variables": self.time_variables, **kwargs}
self.surface.save(**save_args)
if self.do_counting:
self.counting.save(**save_args)
self.to_config(**kwargs)
super().save(**save_args)
return
[docs]
def export(
self,
driver: str = "OpenCraterTool",
interval: int = -1,
ask_overwrite: bool | None = None,
**kwargs: Any,
) -> None:
"""
Export component output to a specified file format.
Parameters
----------
driver : str, optional
The driver to use export the data to. Supported formats are 'OpenCraterTool', 'VTK' or a driver supported by GeoPandas ('GPKG', 'ESRI Shapefile', etc.). This is overridden if either the filename or file_extension parameters are provided. Default is 'OpenCraterTool'.
interval : int, optional
The interval number to export. Default is -1 (the most current interval saved in the simulation).
ask_overwrite : bool, optional
|ask_overwrite_methods|
**kwargs : Any
|kwargs|
Notes
-----
The default driver is 'OpenCraterTool', which is designed to output data into a format that is relatively easy to import into QGIS with the OpenCraterTool plugin. This will create a GeoTIFF file representation of the surface, and a set of SCC files for the crater counting data if counting is enabled.
"""
# Temporarily set the ask_overwrite attribute for the duration of the export, but reset it to its original value afterwards.
ask_overwrite_orig = self.ask_overwrite
if ask_overwrite is not None:
self.ask_overwrite = ask_overwrite
if interval is not None and interval < 0:
interval = self.interval + 1 + interval
self.save(**kwargs, skip_actions=True, ask_overwrite=ask_overwrite)
if driver.lower() == "opencratertool":
surface_driver = "GeoTIFF"
counting_driver = "SCC"
else:
surface_driver = driver
counting_driver = driver
self.surface.export(
driver=surface_driver,
interval=interval,
ask_overwrite=ask_overwrite,
**kwargs,
)
if self.do_counting:
self.counting.export(
craters=self.counting.observed,
interval=interval,
driver=counting_driver,
ask_overwrite=ask_overwrite,
**kwargs,
)
self.ask_overwrite = ask_overwrite_orig
return
[docs]
def plot(
self,
include_counting: bool = False,
interval: int | None = None,
plot_style: str = "hillshade",
label="default",
show=False,
save=True,
ax: Axes | None = None,
**kwargs: Any,
) -> Axes:
"""
Plot the current state of the surface.
Parameters
----------
include_counting : bool, optional
If True, the counting data will be included in the plot if counting is enabled. Default is False
interval : int, optional
The interval number to plot. Default is None, which will plot the most current interval saved in the simulation.
plot_style : str, optional
The style to use for surface plots. See :py:meth:`Surface.plot` for more details. Default is 'hillshade'.
label : str, optional
The label to use for the plot. Default is None, which will use a label based on the current time and elapsed time of the simulation.
show : bool, optional
If True, the plot will be displayed. Default is False.
save : bool, optional
If True, the plot will be saved to a file. Default is True.
ax : matplotlib.axes.Axes, optional
An optional matplotlib Axes object to plot on. If not provided, a new figure and Axes will be created. Default is None.
**kwargs : Any
|kwargs|
Returns
-------
Axes
The matplotlib Axes object created by the surface plot method.
"""
if interval is None:
interval = self.interval
if label == "default":
if self.time is None:
label = f"Interval: {interval}"
else:
if issubclass(self.surface.__class__, HiResLocalSurface):
label = f"Time: {self.time:.0f} My bp\nAge : {self.elapsed_time:.0f} My" # The line break makes a more compact label that fits in the corner of the plot without overprinting the surface image for this style of plot.
else:
label = f"Time: {self.time:.0f} My bp Age : {self.elapsed_time:.0f} My" # Prevent the label from overprinting the surface image for this style of plot.
plot_args = {"interval": interval, "plot_style": plot_style, "label": label, "show": show, "save": save, "ax": ax, **kwargs}
if include_counting and self.do_counting:
ax = self.counting.plot(**plot_args)
else:
ax = self.surface.plot(**plot_args)
return ax
[docs]
def show3d(self, engine: str = "pyvista", **kwargs: Any) -> Any:
"""
Show the current state of the simulated surface.
Parameters
----------
engine : str, optional
The engine to use for plotting. Currently, only "pyvista" is supported. Default is "pyvista".
**kwargs : Any
|kwargs|
Returns
-------
plotter : pyvista.Plotter or other engine-specific plotter object
"""
self.save(**kwargs, skip_actions=True)
if "interval" not in kwargs:
kwargs["interval"] = self.interval
if self.do_counting:
return self.counting.show3d(engine=engine, **kwargs)
else:
return self.surface.show3d(engine=engine, **kwargs)
[docs]
def to_config(self, save_to_file: bool = True, **kwargs: Any) -> dict:
"""
Converts values to types that can be used in yaml.safe_dump.
This will convert various types into a format that can be saved in a human-readable YAML file. This will consolidate all of the configuration
parameters into a single dictionary that can be saved to a YAML file. This will also remove any common arguments from the individual configurations for each component model to avoid repeating them.
Parameters
----------
save_to_file : bool, optional
If True, the configuration will be saved to a file. Default is True.
**kwargs : Any
|kwargs|
Returns
-------
dict[str, Any]
A dictionary of the object's attributes that can be serialized to YAML.
Notes
-----
- The function will ignore any attributes that are not serializable to human-readable YAML. Therefore, it will ignore anything that cannot be converted into a str, int, float, or bool.
- The function will convert Numpy types to their native Python types.
"""
sim_config = super().to_config(remove_common_args=False)
for component_name in _COMPONENT_NAMES:
component_config = component_name + "_config"
component = getattr(self, component_name, None)
if component is not None:
sim_config[component_name] = component.name if hasattr(component, "name") else component
if hasattr(component, "to_config") and callable(getattr(component, "to_config", None)):
sim_config[component_config] = component.to_config(remove_common_args=True)
# drop any empty values or {} from either f"{config} or f"{config}_config" if when they are either None or empty
if component_name in sim_config and (sim_config[component_name] is None or sim_config[component_name] == {}):
sim_config.pop(component_name)
if component_config in sim_config and (sim_config[component_config] is None or sim_config[component_config] == {}):
sim_config.pop(component_config)
# Write the combined configuration to a YAML file
if save_to_file and not self._config_readonly:
with Path.open(self.config_file, "w") as f:
yaml.safe_dump(sim_config, f, indent=4)
return sim_config
[docs]
def reset(
self,
skip_component: str | list[str] | None = None,
) -> None:
"""
Reset the simulation by clearing all data and files associated with it.
Parameters
----------
skip_component : str or list of str, optional
List of component names to skip during the reset process. Default is an empty list, which means all components will be reset.
"""
if skip_component is None:
skip_component = []
elif isinstance(skip_component, str):
skip_component = [skip_component]
elif not isinstance(skip_component, list) or not all(isinstance(c, str) for c in skip_component):
raise TypeError("skip_component must be a string or a list of strings")
ask_overwrite_original = (
self.ask_overwrite
) # Saves the value of ask_overwrite in case the user selects 'a' for this operation
if self.ask_overwrite:
files_to_remove = []
for component in _COMPONENT_NAMES:
if component not in skip_component and hasattr(self, component):
files_to_remove += getattr(self, component).saved_output_files()
if len(files_to_remove) > 0:
print("The following files will be deleted:")
for f in files_to_remove:
print(f" {f}")
print("To disable this message, set `ask_overwrite=False` to this instance.")
response = input(f"Are you sure you want to delete {len(files_to_remove)} files? [y/N/a]: ")
if response.lower() == "a":
self.ask_overwrite = False
elif response.lower() != "y":
raise RuntimeError("User aborted the reset operation.")
for component in _COMPONENT_NAMES:
if component not in skip_component and hasattr(self, component):
getattr(self, component).reset()
self.ask_overwrite = ask_overwrite_original
self._interval = 0
self._elapsed_time = None
self._time = None
self._elapsed_n1 = None
self._smallest_crater = 0.0 # The smallest crater will be determined by the smallest face area
self._smallest_projectile = 0.0 # The smallest crater will be determined by the smallest face area
self._largest_crater = np.inf # The largest crater will be determined by the target body radius
self._largest_projectile = np.inf # The largest projectile will be determined by the target body radius
self.save(skip_actions=True)
self.is_new = True
return
[docs]
def update_elevation(self, *args: Any, **kwargs: Any) -> None:
"""
Set the elevation on the surface. Delegates to the Surface object.
Parameters
----------
*args: Variable length argument list to pass to self.surface.update_elevation.
|kwargs|
"""
return self.surface.update_elevation(*args, **kwargs)
def _get_smallest_diameter(self, face_size: ArrayLike | None = None, from_projectile: bool = False) -> float:
"""
Get the smallest possible crater or projectile be formed on a face.
Parameters
----------
face_size : FloatLike, optional
The effective size of the face to determine the smallest crater size that can be formed on it. If None, the size of the smallest face on the surface will be used.
from_projectile : bool, optional
If True, the smallest projectile diameter will be returned instead of the smallest crater diameter. Default is False.
Returns
-------
float
The smallest possible crater or projectile diameter that can be formed on the surface.
"""
if face_size is None:
face_size = np.min(self.surface.face_size)
if from_projectile:
crater = Crater.maker(
diameter=face_size,
angle=90.0,
projectile_velocity=self.scaling.projectile_mean_velocity * 10,
scaling=self.scaling,
**vars(self.common_args),
)
return crater.projectile_diameter
else:
return float(face_size)
def _get_largest_diameter(self, from_projectile: bool = False) -> float:
"""
Get the largest possible crater or projectile that can be formed on the surface.
"""
largest_crater = self.target.radius * 2
if from_projectile:
crater = Crater.maker(
diameter=largest_crater,
angle=1.0,
projectile_velocity=self.scaling.projectile_mean_velocity / 10.0,
scaling=self.scaling,
**vars(self.common_args),
)
return crater.projectile_diameter
else:
return largest_crater
@property
def target(self):
"""
The target body for the impact simulation. Set during initialization.
"""
return self._target
@target.setter
def target(self, value):
if not isinstance(value, (Target | str)):
raise TypeError("target must be an instance of Target or str")
self._target = value
@property
def surface(self):
"""
Surface mesh data for the simulation. Set during initialization.
"""
return self._surface
@surface.setter
def surface(self, value):
if not isinstance(value, (Surface | str | type)) or (isinstance(value, type) and not issubclass(value, Surface)):
raise TypeError("surface must be an instance of Surface, a subclass of Surface, or str")
self._surface = value
@property
def production(self):
"""
The Production class instance used for crater production. Set during initialization.
"""
return self._production
@production.setter
def production(self, value):
if not isinstance(value, (Production | str)):
raise TypeError("production must be a subclass of Production or str")
self._production = value
@property
def scaling(self):
"""
The Scaling object that defines the crater scaling relationships model. Set during initialization.
"""
return self._scaling
@scaling.setter
def scaling(self, value):
if not isinstance(value, (Scaling | str)):
raise TypeError("scaling must be of Scaling type or str")
self._scaling = value
@property
def morphology(self):
"""
The crater morphology model. Set during initialization.
"""
return self._morphology
@morphology.setter
def morphology(self, value):
if not isinstance(value, (Morphology | str)):
raise TypeError("morphology must be of Morphology type or str")
self._morphology = value
@property
def projectile(self):
"""
The crater projectile model. Set during initialization.
"""
return self._projectile
@projectile.setter
def projectile(self, value):
if not isinstance(value, (Projectile | str)):
raise TypeError("projectile must be of Projectile type or str")
self._projectile = value
@property
def counting(self):
"""
The crater counting model. Set during initialization.
"""
return self._counting
@counting.setter
def counting(self, value):
if not isinstance(value, (Counting | str)):
raise TypeError("counting must be of Counting type or str")
self._counting = value
@property
def n_node(self):
"""
Number of nodes in the simulation mesh. Dynamically set based on `surface` attribute.
"""
return self.surface.uxgrid.n_node
@property
def n_face(self):
"""
Number of faces in the simulation mesh. Dynamically set based on `surface` attribute.
"""
return self.surface.uxgrid.n_face
@parameter
def interval(self):
"""
The index of the current time step.
"""
if self._interval is None:
return 0
return self._interval
@interval.setter
def interval(self, value):
if not isinstance(value, int):
raise TypeError("interval must be an integer")
if value < 0:
raise ValueError("interval must be greater than or equal to zero")
self._interval = value
@parameter
def elapsed_time(self):
"""
The elapsed time in My since the start of the simulation.
"""
if self._elapsed_time is None:
return 0.0
return self._elapsed_time
@elapsed_time.setter
def elapsed_time(self, value):
self._elapsed_time = float(value)
@parameter
def time(self):
"""
The age of the current time step in My relative to the present from the chronology of the production function.
"""
return self._time
@time.setter
def time(self, value):
self._time = float(value)
@parameter
def elapsed_n1(self):
"""
The elapsed number of craters larger than 1 km in diameter.
"""
if self._elapsed_n1 is None:
return 0.0
return self._elapsed_n1
@elapsed_n1.setter
def elapsed_n1(self, value):
self._elapsed_n1 = float(value)
@parameter
def smallest_crater(self):
"""
The smallest crater diameter in meters. Set during initialization.
"""
return self._smallest_crater
@smallest_crater.setter
def smallest_crater(self, value):
if value is None:
self._smallest_crater = 0.0
return
elif not isinstance(value, FloatLike):
raise TypeError("smallest_crater must be a scalar value")
elif value < 0:
raise ValueError("smallest_crater must be greater than or equal to zero")
elif self._largest_crater is not None and value > self._largest_crater:
raise ValueError("smallest_crater must be less than or equal to largest_crater")
self._smallest_crater = float(value)
@parameter
def largest_crater(self):
"""
The largest crater diameter in meters. Set during initialization.
"""
return self._largest_crater
@largest_crater.setter
def largest_crater(self, value):
if value is None or np.isinf(value):
self._largest_crater = np.inf
return
elif not isinstance(value, FloatLike):
raise TypeError("largest_crater must be a scalar value")
elif value <= 0:
raise ValueError("largest_crater must be greater than zero")
elif self._smallest_crater is not None and value < self._smallest_crater:
raise ValueError("largest_crater must be greater than or equal to smallest_crater")
self._largest_crater = float(value)
@parameter
def smallest_projectile(self):
"""
The smallest projectile diameter in meters. Set during initialization.
"""
return self._smallest_projectile
@smallest_projectile.setter
def smallest_projectile(self, value):
if value is None:
self._smallest_projectile = 0.0
return
elif not isinstance(value, FloatLike):
raise TypeError("smallest_projectile must be a scalar value")
elif value < 0:
raise ValueError("smallest_projectile must be greater or equal to zero")
elif self._largest_projectile is not None and value > self._largest_projectile:
raise ValueError("smallest_projectile must be less than or equal to largest_projectile")
self._smallest_projectile = float(value)
@parameter
def largest_projectile(self):
"""
The largest projectile diameter in meters. Set during initialization.
"""
return self._largest_projectile
@largest_projectile.setter
def largest_projectile(self, value):
if value is None or np.isinf(value):
self._largest_projectile = np.inf
return
elif not isinstance(value, FloatLike):
raise TypeError("largest_projectile must be a scalar value")
elif value <= 0:
raise ValueError("largest_projectile must be greater than zero")
elif self._smallest_projectile is not None and value < self._smallest_projectile:
raise ValueError("largest_projectile must be greater than or equal to smallest_projectile")
self._largest_projectile = float(value)
@property
def name(self):
"""
The name of the simulation.
"""
return "Cratermaker Simulation object"
@property
def config_file(self):
"""
The path to the configuration file for the simulation.
"""
return self.simdir / _CONFIG_FILE_NAME
@property
def config_readonly(self) -> bool:
"""
Flag indicating whether the configuration is read-only.
"""
return self._config_readonly
@property
def time_variables(self) -> dict[str, float]:
"""
A dictionary of time-related variables for the simulation.
Returns
-------
dict[str, float]
A dictionary containing the following keys:
- "time": The current age in My relative to the present.
- "elapsed_time": The elapsed time in My since the start of the simulation.
- "elapsed_n1": The elapsed number of craters larger than 1 km in diameter.
"""
if self.time is None:
return {}
else:
return {
"time": self.time,
"elapsed_time": self.elapsed_time,
"elapsed_n1": self.elapsed_n1,
}
@property
def do_counting(self) -> bool:
"""
A boolean flag indicating whether or not counting is enabled for the simulation. This is determined by whether or not a counting model is present and has counting enabled.
Returns
-------
bool
True if counting is enabled, False otherwise.
"""
return self.counting is not None and self.morphology.do_counting if self.morphology is not None else False
@property
def observed(self) -> dict[Crater] | None:
"""
Pass-through to retrieve the current observed craters from the counting model, if it is enabled.
"""
if self.do_counting:
return self.counting.observed
else:
return None
@property
def emplaced(self) -> list[Crater] | None:
"""
Pass-through to retrieve the current emplaced craters from the morphology model, if it is enabled.
"""
if self.morphology is not None:
return self.counting.emplaced
else:
return None
@property
def n_observed(self) -> int | None:
"""
Pass-through to retrieve the current number of observed craters from the counting model, if it is enabled.
"""
if self.do_counting:
return self.counting.n_observed
else:
return None
@property
def n_emplaced(self) -> int | None:
"""
Pass-through to retrieve the current number of emplaced craters from the counting model, if it is enabled.
"""
if self.do_counting:
return self.counting.n_emplaced
else:
return None
[docs]
def output_filename(self, interval=None, **kwargs):
return None
@property
def is_new(self) -> bool:
"""
A boolean flag indicating that this is a new simulation run, which will trigger a reset action when run is called.
"""
return self._is_new
@is_new.setter
def is_new(self, value: bool):
if not isinstance(value, bool):
raise TypeError("is_new must be a boolean value")
self._is_new = value
@property
def Crater(self):
"""
The Crater class used for crater generation in the simulation. Set during initialization.
"""
return self.morphology.Crater if self.morphology is not None else None