from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
import pandas as pd
import xarray as xr
from cratermaker._cratermaker import counting_bindings
from geopandas import GeoSeries
from matplotlib.axes import Axes
from numpy.typing import ArrayLike
from shapely.ops import transform
from tqdm import tqdm
from vtk import vtkPolyData
from cratermaker import __version__ as cratermaker_version
from cratermaker.components.crater import Crater
from cratermaker.components.morphology import Morphology, MorphologyCrater
from cratermaker.constants import VECTOR_DRIVER_TO_EXTENSION_MAP, FloatLike
from cratermaker.core.base import ComponentBase, import_components
DRIVER_TO_EXTENSION_MAP = {"NETCDF": "nc", "SCC": "scc", "VTK": "vtp", "VTP": "vtp", "CSV": "csv", **VECTOR_DRIVER_TO_EXTENSION_MAP}
if TYPE_CHECKING:
from cratermaker.components.surface import LocalSurface, Surface
_TALLY_LONG_NAME = "Unique crater identification number"
# The number of layers used for tagging faces with crater ids. This allows a single face to contain multiple crater ids
_N_LAYER = 8
# The minimum number of faces required in a region to perform crater counting, which corresponds to a roughly 6-8 pix diameter crater
_MIN_FACE_FOR_COUNTING = 100
[docs]
class Counting(ComponentBase):
"""
Base class for all crater counting models. It defines the interface for tallying the observable craters on a surface.
"""
_registry: dict[str, Counting] = {}
def __init__(
self,
surface: Surface | LocalSurface,
reset: bool = True,
**kwargs: Any,
):
"""
**Warning:** This object should not be instantiated directly. Instead, use the ``.maker()`` method.
Parameters
----------
surface : Surface | LocalSurface
The surface or local surface view to be counted.
Crater : type[Crater], optional
The Crater class associated with this counting model. This is used to ensure that the correct variable properties for from a specialized Crater class are available (such as one associated with a Morphology class) when importing craters from file. If not supplied, then the base Crater class is used.
reset : bool, optional
Flag to indicate whether to reset the count and delete any old output files. Default is True.
**kwargs : Any
|kwargs|
"""
from cratermaker.components.surface import Surface
super().__init__(reset=reset, **kwargs)
object.__setattr__(self, "_emplaced", [])
object.__setattr__(self, "_observed", {})
object.__setattr__(self, "_output_dir_name", "counting")
object.__setattr__(self, "_output_file_prefix", "craters")
object.__setattr__(self, "_output_file_extension", "nc")
object.__setattr__(self, "_surface", None)
object.__setattr__(self, "_morphology", None)
object.__setattr__(
self,
"_driver_to_extension_map",
{"SCC": "scc", "VTK": "vtk", "VTP": "vtp", "CSV": "csv", **VECTOR_DRIVER_TO_EXTENSION_MAP},
)
self._surface = Surface.maker(surface, reset=reset, **kwargs)
self._output_file_pattern += [
f"observed_{self._output_file_prefix}*.{self._output_file_extension}",
f"emplaced_{self._output_file_prefix}*.{self._output_file_extension}",
]
if not reset:
observed, emplaced = self.read_saved_output(interval=-1)
if observed:
interval = observed.interval.values[-1]
observed = self.from_xarray(observed, interval=interval)
for crater in observed:
self._observed[crater.id] = crater
if emplaced:
self._emplaced = self.from_xarray(emplaced, interval=interval)
return
[docs]
@classmethod
def maker(
cls,
counting: str | Counting | None = None,
surface: Surface | LocalSurface | None = None,
Crater: type[Crater] | None = None,
reset: bool = True,
**kwargs: Any,
) -> Counting:
"""
Initialize a Counting model with the given name or instance.
Parameters
----------
counting : str, Counting or None, default=None
The name of the counting model to initialize. If None, the default model is used.
surface : Surface | LocalSurface
The surface or local surface view to be counted.
Crater : type[Crater], optional
The Crater class associated with this counting model. This is used to ensure that the correct variable properties for from a specialized Crater class are available (such as one associated with a Morphology class) when importing craters from file. If not supplied, then the base Crater class is used.
reset : bool, optional
Flag to indicate whether to reset the count and delete any old output files. Default is True
**kwargs : Any
|kwargs|
Returns
-------
Counting
An instance of the specified counting model.
Raises
------
KeyError
If the specified counting model name is not found in the registry.
TypeError
If the specified counting model is not a string or a subclass of Scaling.
"""
if counting is None:
counting = "depthcount"
counting = super().maker(
component=counting,
surface=surface,
reset=reset,
Crater=Crater,
**kwargs,
)
return counting
def __str__(self) -> str:
base = super().__str__()
str_repr = f"{base}\n"
str_repr += f"Number of observed craters: {self.n_observed}\n"
str_repr += f"Number of emplaced craters: {self.n_emplaced}\n"
str_repr += f"\n{self.surface}\n"
return str_repr
[docs]
def reset(self, **kwargs: Any) -> None:
"""
Remove all craters count records from the surface and delete any output files.
Parameters
----------
**kwargs : Any
|kwargs|
"""
self.surface.add_tag(name="crater_id", long_name=_TALLY_LONG_NAME, tag=None, n_layer=self.n_layer)
self._emplaced = []
self._observed = {}
super().reset(**kwargs)
return
[docs]
def add(self, crater: MorphologyCrater, **kwargs: Any):
"""
Add a crater to the surface.
Parameters
----------
crater : Crater
The crater to be added to the surface.
**kwargs : Any
|kwargs|
"""
if not isinstance(crater, MorphologyCrater):
raise TypeError("crater must be an instance of MorphologyCrater")
if self.surface.uxds is None:
raise ValueError(
"Surface must have an associated Uxarray dataset to use for counting. This is commonly caused by using a HiResLocal surface type without setting the superdomain_scale_factor."
)
# Tag a region just outside crater rim with the id
crater_region = crater.crater_region
if crater_region and crater_region.n_face >= _MIN_FACE_FOR_COUNTING:
crater_region.add_tag(
name="crater_id",
long_name=_TALLY_LONG_NAME,
tag=crater.id,
**kwargs,
)
# Check to make sure the crater was recorded to the surface. HiResLocal surfaces may not record craters in the superdomain
if crater.id in self.surface.uxds.crater_id:
self.emplaced.append(crater)
self.observed[crater.id] = crater
else:
return
rim_interior_region = crater_region.extract_subregion(subregion_radius=crater.radius)
# Cookie cutting: remove any smaller craters that are overlapped by this new crater
unique_ids = np.unique(rim_interior_region.crater_id)
unique_ids = unique_ids[unique_ids > 0] # Remove the 0 id which corresponds to no crater
if len(unique_ids) > 0:
# Compute cookie cutting removes list
observed = self.observed.copy()
removes = [id for id, v in observed.items() if v.id in unique_ids and v.diameter < crater.diameter]
# For every id that appears in the removes list, set it to 0 in the data array
for remove_id in removes:
rim_interior_region.remove_tag(name="crater_id", tag=remove_id)
if not np.any(
self.surface.uxds.crater_id.data == remove_id
): # Check to see if this crater id still appears, and if not, it's gone man.
self.observed.pop(remove_id, None)
return
[docs]
def remove(self, crater_id: int) -> None:
"""
Remove all instances of a crater id from the surface.
Parameters
----------
crater_id : int
The ID of the crater to be removed.
"""
self.surface.remove_tag(name="crater_id", tag=crater_id)
self.observed.pop(crater_id, None)
return
[docs]
def fit_rim(
self, crater: Crater, tol=0.01, nloops=4, score_quantile=0.95, fit_center=False, fit_ellipse=False, **kwargs
) -> Crater:
"""
Find the rim region of a crater on the surface.
Parameters
----------
crater : Crater
The crater for which to find the rim region.
tol : float, optional
The tolerance for the rim fitting algorithm. Default is 0.01.
nloops : int, optional
The number of iterations for the rim fitting algorithm. Default is 4.
score_quantile : float, optional
The quantile of rim scores to consider. Default is 0.95.
fit_center : bool, optional
If True, fit the crater center as well. Default is False.
fit_ellipse : bool, optional
If True, fit an ellipse to the rim, otherwise fit a circle. Default is False.
**kwargs : Any
|kwargs|
Returns
-------
Crater
A new Crater object with updated measured rim parameters.
"""
if not isinstance(crater, Crater):
raise TypeError("crater must be an instance of Crater")
location, ap, bp, orientation = counting_bindings.fit_rim(
self.surface,
crater,
tol,
nloops,
score_quantile,
fit_center,
fit_ellipse,
)
crater.measured_semimajor_axis = ap
crater.measured_semiminor_axis = bp
crater.measured_orientation = np.degrees(orientation)
crater.measured_location = location
return crater
[docs]
def score_rim(self, crater: Crater, quantile=0.95, gradmult=1.0, curvmult=1.0, heightmult=1.0) -> None:
"""
Score the rim region of a crater on the surface.
Parameters
----------
crater : Crater
The crater for which to score the rim region.
quantile : float, optional
The quantile of rim scores to consider. Default is 0.95.
gradmult : float, optional
Gradient multiplier for scoring. Default is 1.0.
curvmult : float, optional
Curvature multiplier for scoring. Default is 1.0.
heightmult : float, optional
Height multiplier for scoring. Default is 1.0.
Returns
-------
Updates the attached surface object with the rim score for this crater.
"""
if not isinstance(crater, Crater):
raise TypeError("crater must be an instance of Crater")
region = crater.crater_region
region.compute_desloped_face_elevation()
region = counting_bindings.score_rim(region, crater, quantile, gradmult, curvmult, heightmult)
return region
[docs]
def tally(self, region: LocalSurface | None = None, measure_rim: bool = False, quiet: bool = False, **kwargs: Any) -> None:
"""
Tally the craters on the surface using the method of Minton et al. (2019) [#]_.
Parameters
----------
region : LocalSurface, optional
A LocalSurface region to count. If not supplied, then the associated surface property is used.
measure_rim : bool, optional
If True, measure the rim of each crater. Default is False.
quiet : bool, optional
If True, suppress progress output. Default is False.
**kwargs : Any
|kwargs|
References
----------
.. [#] Minton, D.A., Fassett, C.I., Hirabayashi, M., Howl, B.A., Richardson, J.E., (2019). The equilibrium size-frequency distribution of small craters reveals the effects of distal ejecta on lunar landscape morphology. Icarus 326, 63-87. https://doi.org/10.1016/j.icarus.2019.02.021
"""
if region is None:
region = self.surface
if hasattr(self.surface, "crater_id"):
id_array = self.surface.crater_id
else:
return
elif isinstance(region, LocalSurface):
id_array = region.crater_id
else:
raise TypeError(f"Expected a LocalSurface, but got {type(region).__name__}.")
unique_ids = np.unique(id_array[id_array > 0])
remove_ids = []
if quiet:
iterable = unique_ids
else:
iterable = tqdm(
unique_ids,
total=len(unique_ids),
desc="Counting craters",
unit="craters",
position=1,
leave=False,
)
for id in iterable:
# Check if we have orphaned crater ids for some reason and remove them
if id not in self.observed:
remove_ids.append(id)
continue
# Update the crater size measurement before computing the degradation and visibility functions
crater = self.observed[id]
# TODO: Make the fit_rim function more reliable before turning it on by default. It is currently very slow and not reliable enough.
if measure_rim:
fit_center = kwargs.pop("fit_center", False)
fit_ellipse = kwargs.pop("fit_ellipse", False)
crater = self.fit_rim(crater=crater, fit_center=fit_center, fit_ellipse=fit_ellipse, **kwargs)
Kd = self.measure_degradation_state(crater, **kwargs)
Kv = self.visibility_function(crater, **kwargs)
if Kd >= Kv:
remove_ids.append(id)
else:
self.observed[id] = crater # Save the updated measurements to the observed tally
if len(remove_ids) > 0:
if quiet:
iterable = remove_ids
else:
iterable = tqdm(
remove_ids,
total=len(remove_ids),
desc="Removing craters",
unit="craters",
position=2,
leave=False,
)
for id in iterable:
self.remove(id)
self.remove_complex_data()
return
[docs]
@abstractmethod
def measure_degradation_state(self, crater: Crater, **kwargs: Any) -> float: ...
[docs]
@abstractmethod
def visibility_function(self, crater: Crater, Kv1: float = 0.17, gamma: float = 2.0, **kwargs: Any) -> float: ...
[docs]
@staticmethod
def to_xarray(craters: dict[int, Crater] | list[Crater]) -> xr.Dataset:
"""
Convert a list or dictionary of Crater objects to an xarray Dataset.
Parameters
----------
craters : dict[int, Crater] | list[Crater]
A dictionary or list of Crater objects to convert.
Returns
-------
xr.Dataset
An xarray Dataset containing the crater data.
"""
if len(craters) == 0:
return xr.Dataset()
new_data = []
if isinstance(craters, dict):
craters = craters.values()
for c in craters:
d = c.as_dict(skip_complex_data=True)
if "location" in d:
lon, lat = d.pop("location")
d["longitude"] = lon
d["latitude"] = lat
if "measured_location" in d:
mlon, mlat = d.pop("measured_location")
d["measured_longitude"] = mlon
d["measured_latitude"] = mlat
d = xr.Dataset(data_vars=d).set_coords("id").expand_dims(dim="id")
d["id"].attrs["long_name"] = _TALLY_LONG_NAME
new_data.append(d)
return xr.concat(new_data, dim="id")
def _to_file(
self,
craters: dict[int, Crater] | list[Crater],
filename: Path | str,
interval: int = 0,
) -> xr.Dataset | None:
"""
Merge a list or dictionary of Crater objects with an existing file.
Parameters
----------
craters : dict[int, Crater] | list[Crater]
A dictionary or list of Crater objects to merge.
filename : Path | str
The path to the file to merge with.
interval : int, optional
The interval number. This is added to the coordinates of the dataset created from the craters before merging with the file. Default is 0.
Returns
-------
xr.Dataset | None
An xarray Dataset containing the merged crater data, or None if no data.
"""
# Convert into an xarray dataset
combined_data = self.to_xarray(craters)
combined_data = combined_data.expand_dims(dim="interval").assign_coords({"interval": [interval]})
# If the file already exists, read it and merge
if filename.exists():
filename.unlink()
# Write merged data back to file
if combined_data:
combined_data.to_netcdf(filename)
return combined_data
[docs]
def save(
self,
crater_type: Literal["observed", "emplaced", "both"] = "both",
interval: int = 0,
craters: list[Crater] | None = None,
**kwargs: Any,
) -> None:
"""
Dump the crater lists to a file and reset the emplaced crater list.
Parameters
----------
crater_type : str, optional
The type of craters to export. Options are "observed", "emplaced", and "both". Default is "observed".
interval : int, default=0
The interval number for the output file naming.
craters: list[Crater] | None, optional
An arbitrary list of craters to save. If None, then the current observed and/or emplaced tallies are used. Default is None.
**kwargs : Any
|kwargs|
"""
if craters is None:
self.remove_complex_data()
observed = self.observed
emplaced = self.emplaced
else:
if crater_type == "both":
raise ValueError(
"If supplying a custom list of craters to save, then crater_type must be either 'observed' or 'emplaced', not 'both'."
)
elif crater_type == "observed":
observed = {crater.id: crater for crater in craters}
emplaced = None
elif crater_type == "emplaced":
observed = None
emplaced = craters
if crater_type == "observed":
iter = zip([observed], ["observed"], strict=True)
elif crater_type == "emplaced":
iter = zip([emplaced], ["emplaced"], strict=True)
elif crater_type == "both":
iter = zip([observed, emplaced], ["observed", "emplaced"], strict=True)
else:
raise ValueError(f"Invalid crater_type: {crater_type}. Must be one of 'observed', 'emplaced', or 'both'.")
for craters, name in iter:
if craters:
filename = self.output_dir / f"{name}_{self.output_filename(interval)}"
self._to_file(craters, filename, interval)
save_args = {"interval": interval, **kwargs}
super().save(**save_args)
return
[docs]
def from_file(self, filename: str | Path, **kwargs: Any) -> None:
pass
[docs]
def export(
self,
crater_type: Literal["observed", "emplaced", "both"] = "observed",
interval: int | None = None,
driver: str = "SCC",
ask_overwrite: bool | None = None,
**kwargs: Any,
) -> None:
"""
Exports crater lists to a file.
Parameters
----------
crater_type : str, optional
The type of craters to export. Options are "observed", "emplaced", and "both". Default is "observed".
interval : int | None, optional
|interval_export|
driver : str, default='SCC'
The export driver used to save. The valid drivers are given by the `valid_drivers` attribute of this object.
ask_overwrite : bool, optional
|ask_overwrite_methods|
**kwargs : Any
|kwargs|
"""
# Check if the requested driver is a supported one for crater counts, and return silently if not.
if driver.upper() not in self.valid_drivers:
return
# 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
crater_names = ["observed", "emplaced"]
output_ds = self.read_saved_output(interval=interval)
for name, crater_ds in zip(crater_names, output_ds, strict=True):
if crater_type == "observed" and name != "observed":
continue
if crater_type == "emplaced" and name != "emplaced":
continue
if type(crater_ds) is dict:
interval_numbers = list(crater_ds.keys())
elif crater_ds is None or "interval" not in crater_ds:
if interval is None:
interval_numbers = [0]
else:
interval_numbers = [interval]
else:
interval_numbers = crater_ds.interval.values
for interval in interval_numbers:
if driver.upper() == "NETCDF" and crater_ds is not None:
self.save(crater_type=name, interval=interval, craters=self.from_xarray(crater_ds, interval=interval), **kwargs)
elif driver.upper() in ["VTK", "VTP"] and crater_ds is not None:
self.to_vtk_file(
crater_ds=crater_ds,
interval=interval,
name=name,
**kwargs,
)
elif driver.upper() == "CSV" and crater_ds is not None:
self.to_csv_file(
crater_ds=crater_ds,
interval=interval,
name=name,
**kwargs,
)
elif driver.upper() == "SCC":
if crater_ds is None:
crater_ds = [] # Allow for empty SCC files because they can still have the region polygon
self.to_scc_file(
crater_ds=crater_ds,
interval=interval,
name=name,
**kwargs,
)
elif driver.upper() in VECTOR_DRIVER_TO_EXTENSION_MAP and crater_ds is not None:
self.to_vector_file(
crater_ds=crater_ds,
interval=interval,
name=name,
driver=driver,
**kwargs,
)
self.ask_overwrite = ask_overwrite_orig
return
[docs]
def plot(
self,
interval: int | None = None,
observed_color: str | None = "white",
observed_original_color: str | None = None,
emplaced_color: str | None = None,
plot_style: Literal["map", "hillshade"] = "map",
variable_name: str | None = None,
cmap: str | None = None,
show=True,
save=True,
ax: Axes | None = None,
**kwargs: Any,
) -> Axes:
"""
Plot an image of the local region.
Parameters
----------
interval : int | None, optional
The interval number to load the emplaced crater data from. if None, then all emplaced data currently saved to file is used. Default is None.
observed_color : str | None, optional
The color to use for observed craters using their measured properties. If None, observed craters will not be plotted. Default is "white".
observed_original_color : str | None, optional
The color to use for observed craters using their original properties. If None, observed craters will not be plotted. Default is None.
emplaced_color : str | None, optional
The color to use for emplaced craters. If None, emplaced craters will not be plotted. Default is None.
plot_style : str, optional
The style of the plot. Options are "map" and "hillshade". In "map" mode, the variable is displayed as a colored map. In "hillshade" mode, a hillshade image is generated using "face_elevation" data. If a different variable is passed to `variable`, then the hillshade will be overlayed with that variable's data. Default is "map".
variable_name : str | None, optional
The variable to plot. If None is provided then "face_elevation" is used in "map" mode.
cmap : str, optional
The colormap to use for the plot. If None, a default colormap will be used ("cividis" by default and "grey" when plot_style=="hillshade" and variable=="face_elevation").
show : bool, optional
If True, the plot will be displayed. Default is True.
save : bool, optional
If True, the plot will be saved to the default plot directory. Default is True
ax : matplotlib.axes.Axes, optional
An existing Axes object to plot on. If None, a new figure and axes will be created.
**kwargs : Any
|kwargs|
Returns
-------
matplotlib.image.Axes object created by the surface plot method, with crater counts plotted on top if specified. If show is True, the plot will also be displayed.
The Axes object
"""
import matplotlib.pyplot as plt
from cratermaker.components.surface.hireslocal import HiResLocalSurface
crs = self.surface.crs
split_antimeridian = True
file_prefix = f"{self.surface.output_file_prefix}"
# Handle the HiResLocal surface case where we may or may not be plotting the global surface
if isinstance(self.surface, HiResLocalSurface):
superdomain = kwargs.pop("superdomain", False)
if not superdomain and self.surface.local is not None:
crs = self.surface.local.crs
split_antimeridian = False
file_prefix = f"{self.surface.local.output_file_prefix}"
file_prefix += f"_{self.output_file_prefix}"
if interval is not None:
observed, emplaced = self.read_saved_output(interval=interval)
if observed:
interval = observed.interval.values[-1]
observed = self.from_xarray(observed, interval=interval)
if emplaced:
emplaced_interval = emplaced.interval.values[-1]
if emplaced_interval == interval:
emplaced = self.from_xarray(emplaced, interval=interval)
filename = self.plot_dir / f"{file_prefix}{interval:06d}.{self.surface.output_image_file_extension}"
else:
observed = [c for _, c in self.observed.items()]
emplaced = self.emplaced
filename = self.plot_dir / f"{file_prefix}.{self.surface.output_image_file_extension}"
if ax is None:
W, H = self.surface.get_raster_dims()
_, ax = plt.subplots(figsize=(1, 1), dpi=W, frameon=False)
ax = self.surface.plot(
interval=interval,
show=False,
save=False,
ax=ax,
plot_style=plot_style,
variable_name=variable_name,
cmap=cmap,
**kwargs,
)
if emplaced_color is not None and emplaced is not None and len(emplaced) > 0:
gs = self.to_geoseries(
craters=emplaced, use_measured_properties=False, split_antimeridian=split_antimeridian, autolim=False
).to_crs(crs)
facecolor = kwargs.pop("facecolor", "none")
edgecolor = emplaced_color
linewidth = kwargs.pop("linewidth", 0.1)
linestyle = kwargs.pop("linestyle", "solid")
ax = gs.plot(ax=ax, facecolor=facecolor, edgecolor=edgecolor, linewidth=linewidth, linestyle=linestyle)
if observed_original_color is not None and observed is not None and len(observed) > 0:
gs = self.to_geoseries(
craters=observed, use_measured_properties=False, split_antimeridian=split_antimeridian, autolim=False
).to_crs(crs)
facecolor = kwargs.pop("facecolor", "none")
edgecolor = observed_original_color
linewidth = kwargs.pop("linewidth", 0.1)
linestyle = kwargs.pop("linestyle", "solid")
ax = gs.plot(ax=ax, facecolor=facecolor, edgecolor=edgecolor, linewidth=linewidth, linestyle=linestyle)
if observed_color is not None and observed is not None and len(observed) > 0:
gs = self.to_geoseries(
craters=observed, use_measured_properties=True, split_antimeridian=split_antimeridian, autolim=False
).to_crs(crs)
facecolor = kwargs.pop("facecolor", "none")
edgecolor = observed_color
linewidth = kwargs.pop("linewidth", 0.1)
linestyle = kwargs.pop("linestyle", "solid")
ax = gs.plot(ax=ax, facecolor=facecolor, edgecolor=edgecolor, linewidth=linewidth, linestyle=linestyle)
if save:
print(f"Saving crater count plot to {filename}...")
plt.savefig(filename, bbox_inches="tight", pad_inches=0, dpi=W)
if show:
plt.show()
else:
plt.close()
return ax
[docs]
def show_pyvista(
self,
surface: Surface | LocalSurface | None = None,
observed_color: str = "white",
emplaced_color: str = "red",
interval: int | None = None,
**kwargs: Any,
):
"""
Passes through to the surface show_pyvista method and adds crater counts to it.
Parameters
----------
surface : Surface | LocalSurface, optional
The surface or local surface view to be displayed. If None, uses the associated surface property
observed_color : str, optional
The color to use for observed craters. Default is "white".
emplaced_color : str, optional
The color to use for emplaced craters. Default is "red".
interval : int, optional
The interval number to load the emplaced crater data from. if None, then all emplaced data currently saved to file is used. Default is None.
**kwargs : Any
|kwargs|
Returns
-------
plotter : pyvista.Plotter
The pyvista plotter with the crater counts added.
"""
from cratermaker.constants import PYVISTA_ADD_MESH_KWARGS
if surface is None:
surface = self.surface
plotter = surface.show_pyvista(**kwargs)
from cratermaker.utils.general_utils import toggle_pyvista_actor, update_pyvista_help_message
if interval is None:
interval = -1
observed, emplaced = self.read_saved_output(interval=interval)
add_mesh_kwargs = {k: v for k, v in kwargs.items() if k in PYVISTA_ADD_MESH_KWARGS}
add_mesh_kwargs = {"line_width": 2, **add_mesh_kwargs}
if observed:
interval = observed.interval.values[-1]
observed = self.from_xarray(observed, interval=interval)
observed_kwargs = {"color": observed_color, **add_mesh_kwargs}
observed_count_actor = plotter.add_mesh(
self.to_vtk_mesh(observed, use_measured_properties=True), name="observed", **observed_kwargs
)
observed_count_actor.SetVisibility(False)
plotter.add_key_event("c", lambda: toggle_pyvista_actor(plotter, observed_count_actor))
plotter = update_pyvista_help_message(plotter, new_message="c: Toggle counted craters")
if emplaced:
emplaced_interval = emplaced.interval.values[-1]
if emplaced_interval == interval:
emplaced = self.from_xarray(emplaced, interval=interval)
emplaced_kwargs = {"color": emplaced_color, **add_mesh_kwargs}
emplaced_count_actor = plotter.add_mesh(
self.to_vtk_mesh(emplaced, use_measured_properties=False), name="emplaced", **emplaced_kwargs
)
emplaced_count_actor.SetVisibility(False)
plotter.add_key_event("t", lambda: toggle_pyvista_actor(plotter, emplaced_count_actor))
plotter = update_pyvista_help_message(plotter, new_message="t: Toggle emplaced craters")
return plotter
[docs]
def show3d(self, engine: str = "pyvista", observed_color: str = "white", emplaced_color: str = "red", **kwargs: Any) -> Any:
"""
Passes through to the surface show method and adds crater counts to it.
Parameters
----------
engine : str, optional
The engine to use for plotting. Currently, only "pyvista" is supported. Default is "pyvista".
observed_color : str, optional
The color to use for observed craters. Default is "white".
emplaced_color : str, optional
The color to use for emplaced craters. Default is "red".
**kwargs : Any
|kwargs|
Returns
-------
plotter : pyvista.Plotter or other engine-specific plotter object
"""
from cratermaker.constants import PYVISTA_SHOW_KWARGS
if engine.lower() == "pyvista":
plotter = self.show_pyvista(observed_color=observed_color, emplaced_color=emplaced_color, **kwargs)
plotter_kwargs = {k: v for k, v in kwargs.items() if k in PYVISTA_SHOW_KWARGS}
plotter.show(**plotter_kwargs)
else:
raise ValueError(f"Engine '{engine}' is not supported for crater counting visualization.")
return plotter
[docs]
def to_geoseries(
self,
craters: list[Crater] | None = None,
use_measured_properties: bool = True,
split_antimeridian: bool = False,
**kwargs: Any,
) -> GeoSeries:
if craters is None:
craters = [c for _, c in self.observed.items()]
surface = self.surface
crater_gs = []
for crater in tqdm(
craters,
total=len(craters),
desc="Converting craters to GeoSeries polygons",
unit="crater",
position=0,
leave=False,
):
crater_gs.append(
crater.to_geoseries(surface=surface, split_antimeridian=split_antimeridian, use_measured_properties=False)
)
return pd.concat(crater_gs)
def _validate_export_args(
self,
name: Literal["observed", "emplaced"] = "observed",
interval: int | None = None,
crater_ds: xr.Dataset | list[Crater] | dict[int, Crater] | None = None,
) -> list[Crater]:
if crater_ds is None:
crater_list = getattr(self, name)
if type(crater_ds) is dict:
if type(list(crater_ds.values())[0]) is xr.Dataset: # like multi-interval dicts keyed by interval
crater_ds = crater_ds.get(
interval, []
) # Get the dataset for the specified interval, or an empty dataset if not found
else:
return list(crater_ds.values()) # like observed dicts keyed by crater id
if type(crater_ds) is list:
crater_list = crater_ds
elif type(crater_ds) is dict:
crater_list = list(crater_ds.values())
elif type(crater_ds) is xr.Dataset:
crater_list = self.from_xarray(crater_ds, interval=interval)
else:
raise ValueError(f"Unrecognized type for crater_ds: {type(crater_ds)}")
return crater_list
[docs]
def to_vector_file(
self,
name: Literal["observed", "emplaced"] = "observed",
crater_ds: xr.Dataset | list[Crater] | dict[int, Crater] | None = None,
interval: int | None = None,
driver: str = "GPKG",
use_measured_properties: bool = True,
**kwargs,
) -> None:
"""
Export the crater data to a vector file and stores it in the default export directory.
Parameters
----------
name : Literal["observed", "emplaced"], optional
The name of the crater dataset to export, either "observed" or "emplaced
crater_ds : xr.Dataset | list[Crater] | dict[int, Crater] | None, optional
The crater data to export. Can be provided as an xarray Dataset, a list of Crater objects, or a dictionary mapping interval numbers to Crater objects. If None, the crater data will be the attribute of the class corresponding to the name parameter (self.observed or self.emplaced). Default is None.
interval : int | None, optional
|interval_export|
driver : str, optional
The file format to save. Supported formats are 'GPKG', 'ESRI Shapefile', etc. Default is 'GPKG'.
use_measured_properties : bool, optional
If True, use the current measured crater properties (semimajor_axis, semiminor_axis, location, orientation) instead of the initial ones, by default True.
**kwargs : Any
|kwargs|
"""
return
# TODO Fix this
# from cratermaker.constants import VECTOR_DRIVER_TO_EXTENSION_MAP
# crater_list = self._validate_export_args(name=name, interval=interval, crater_ds=crater_ds)
# def shp_key_fix(key: str) -> str:
# """
# ESRI Shapefile format limits field names to 10 characters, so this function substitues longer names with shorter alternatives, truncates the results, and sets them to upper case.
# """
# alt_names = {
# "projectile_": "proj",
# "morphology_": "morph",
# "diameter": "diam",
# "longitude": "lon",
# "latitude": "lat",
# "density": "dens",
# "velocity": "vel",
# "direction": "dir",
# "location": "loc",
# "angle": "ang",
# "transient_": "tr",
# "semimajor_axis": "a",
# "semiminor_axis": "b",
# "orientation": "orient",
# "measured_": "meas",
# "degradation_state": "kdeg",
# }
# for long, short in alt_names.items():
# if long in key:
# key = key.replace(long, short)
# return key[:10].upper()
# # Common alias for Shapefile
# if driver.upper() == "SHP":
# driver = "ESRI Shapefile"
# if driver.upper() in VECTOR_DRIVER_TO_EXTENSION_MAP:
# file_extension = VECTOR_DRIVER_TO_EXTENSION_MAP[driver.upper()]
# else:
# raise ValueError("Cannot infer file extension from driver {driver}.")
# if file_extension == "shp":
# format_has_layers = False
# else:
# format_has_layers = True
# surface = self.surface
# split_antimeridian = False
# geoms = []
# attrs = []
# for crater in tqdm(
# crater_list,
# total=len(crater_list),
# desc=f"Converting {name} craters to geometries for export",
# unit="craters",
# position=0,
# leave=False,
# ):
# poly = crater.to_geoseries(
# surface=surface, split_antimeridian=split_antimeridian, use_measured_properties=use_measured_properties
# ).item()
# df = crater_ds.sel(id=[crater.id]).to_dataframe()
# if isinstance(poly, GeometryCollection):
# for p in poly.geoms:
# geoms.append(p)
# attrs.append(df)
# else:
# geoms.append(poly)
# attrs.append(df)
# if len(geoms) > 0:
# attrs_df = pd.concat(attrs, ignore_index=True)
# else:
# attrs_df = pd.DataFrame()
# if driver.upper() == "ESRI SHAPEFILE":
# attrs_df.rename(mapper=shp_key_fix, axis=1, inplace=True)
# gdf = gpd.GeoDataFrame(data=attrs_df, geometry=geoms, crs=surface.crs)
# if format_has_layers:
# output_file = self.export_dir / f"{self.output_file_prefix}{interval:06d}.{file_extension}"
# print(f"Saving {name} layer to vector file: '{output_file}'...")
# else:
# output_file = self.export_dir / f"{name}_{self.output_file_prefix}{interval:06d}.{file_extension}"
# if not self._overwrite_check(output_file):
# return
# if driver.upper() == "ESRI SHAPEFILE" and hasattr(self.surface, "local"):
# # Create the _AREA file
# self.surface.local.export_region_polygon(driver=driver)
# try:
# if format_has_layers:
# gdf.to_file(output_file, layer=name)
# else:
# gdf.to_file(output_file)
# except Exception as e:
# raise RuntimeError(f"Error saving {output_file}: {e}") from e
# return
[docs]
def to_vtk_mesh(self, craters: list[Crater], use_measured_properties: bool = True, **kwargs: Any) -> vtkPolyData:
"""
Convert the crater data to a VTK PolyData mesh.
Parameters
----------
craters : list[Crater]
A list of Crater objects to convert.
use_measured_properties : bool, optional
If True, use the current measured crater properties (semimajor_axis, semiminor_axis, location, orientation) instead of the initial ones, by default True.
**kwargs : Any
|kwargs|
Returns
-------
vtkPolyData
A VTK PolyData object representing the crater geometries.
"""
from vtk import (
vtkCellArray,
vtkPoints,
vtkPolyLine,
vtkXMLPolyDataWriter,
)
def lonlat_to_xyz(R):
def _f(lon_deg, lat_deg, z=0.0):
lon = np.deg2rad(lon_deg)
lat = np.deg2rad(lat_deg)
X = (R + z) * np.cos(lat) * np.cos(lon)
Y = (R + z) * np.cos(lat) * np.sin(lon)
Z = (R + z) * np.sin(lat)
return X, Y, Z
return _f
def polygon_xyz_coords(geom, R):
"""Yield Nx3 arrays for each exterior ring (drop closing vertex)."""
g3d = transform(lonlat_to_xyz(R), geom)
def _rings(g):
if g.geom_type == "Polygon":
yield np.asarray(g.exterior.coords)[:-1] # (N, 3)
for i in g.interiors:
yield np.asarray(i.coords)[:-1]
elif g.geom_type in ("MultiPolygon", "GeometryCollection"):
for sub in g.geoms:
yield from _rings(sub)
else:
yield np.asarray(g.coords)
yield from _rings(g3d)
surface = self.surface
geoms = []
for crater in craters:
geoms.append(
crater.to_geoseries(
surface=surface, split_antimeridian=False, use_measured_properties=use_measured_properties, **kwargs
)
)
points = vtkPoints()
lines = vtkCellArray()
point_id = 0 # Keep track of the point ID across all circles
for g in geoms:
for ring_xyz in polygon_xyz_coords(g.item(), surface.radius):
x, y, z = ring_xyz.T # each is (N,)
for i in range(len(x)):
points.InsertNextPoint(float(x[i]), float(y[i]), float(z[i]))
polyline = vtkPolyLine()
polyline.GetPointIds().SetNumberOfIds(len(x))
for i in range(len(x)):
polyline.GetPointIds().SetId(i, point_id + i)
point_id += len(x)
lines.InsertNextCell(polyline)
# Create a poly_data object and add points and lines to it
poly_data = vtkPolyData()
poly_data.SetPoints(points)
poly_data.SetLines(lines)
return poly_data
[docs]
def to_vtk_file(
self,
name: Literal["observed", "emplaced"] = "observed",
crater_ds: xr.Dataset | list[Crater] | dict[int, Crater] | None = None,
interval: int | None = None,
**kwargs,
) -> None:
"""
Export the crater data to a VTK file and stores it in the default export directory.
Notes: In order for the crater and surface to be synced up when saving to VTK/VTP format, the initial conditions (no craters) must be saved. Otherwise, saving to file only occurs if there are craters to save.
Parameters
----------
name : Literal["observed", "emplaced"], optional
The name of the crater dataset to export, either "observed" or "emplaced
crater_ds : xr.Dataset | list[Crater] | dict[int, Crater] | None, optional
The crater data to export. Can be provided as an xarray Dataset, a list of Crater objects, or a dictionary mapping interval numbers to Crater objects. If None, the crater data will be the attribute of the class corresponding to the name parameter (self.observed or self.emplaced). Default is None.
interval : int | None, optional
|interval_export|
**kwargs : Any
|kwargs|
"""
from vtk import vtkXMLPolyDataWriter
crater_list = self._validate_export_args(name=name, interval=interval, crater_ds=crater_ds)
filename_base = self.output_filename(interval).replace(self.output_file_extension, "vtp")
output_file = self.export_dir / f"{name}_{filename_base}"
if not self._overwrite_check(output_file):
return
print(f"Saving crater data to VTK file: '{output_file}'...")
poly_data = self.to_vtk_mesh(craters=crater_list)
# Write the poly_data to a VTK file
writer = vtkXMLPolyDataWriter()
writer.SetFileName(output_file)
writer.SetInputData(poly_data)
# Optional: set the data mode to binary to save disk space
writer.SetDataModeToBinary()
writer.Write()
return
[docs]
def to_csv_file(
self,
name: Literal["observed", "emplaced"] = "observed",
crater_ds: xr.Dataset | list[Crater] | dict[int, Crater] | None = None,
interval: int | None = None,
**kwargs,
) -> None:
"""
Export the crater data to a CSV file and stores it in the default export directory.
Parameters
----------
name : Literal["observed", "emplaced"], optional
The name of the crater dataset to export, either "observed" or "emplaced
crater_ds : xr.Dataset | list[Crater] | dict[int, Crater] | None, optional
The crater data to export. Can be provided as an xarray Dataset, a list of Crater objects, or a dictionary mapping interval numbers to Crater objects. If None, the crater data will be the attribute of the class corresponding to the name parameter (self.observed or self.emplaced). Default is None.
interval : int | None, optional
|interval_export|
**kwargs : Any
|kwargs|
"""
import csv
crater_list = self._validate_export_args(name=name, interval=interval, crater_ds=crater_ds)
filename_base = self.output_filename(interval).replace(self.output_file_extension, "csv")
output_file = self.export_dir / f"{name}_{filename_base}"
if not self._overwrite_check(output_file):
return
print(f"Saving crater data to CSV file: '{output_file}'...")
with output_file.open(mode="w", newline="") as csvfile:
writer = csv.writer(csvfile)
header_written = False
for crater in crater_list:
crater_dict = crater.as_dict(skip_complex_data=True)
# Convert location fields from tuples into lon/lat
location = crater_dict.pop("location")
crater_dict["longitude"] = location[0]
crater_dict["latitude"] = location[1]
measured_location = crater_dict.pop("measured_location")
crater_dict["measured_longitude"] = measured_location[0]
crater_dict["measured_latitude"] = measured_location[1]
# Check if the crater is circular, and if so convert semimajor/semiminor to diameter
if (
"measured_semimajor_axis" in crater_dict
and "measured_semiminor_axis" in crater_dict
and crater_dict["measured_semimajor_axis"] == crater_dict["measured_semiminor_axis"]
):
measured_diameter = 2.0 * crater_dict.pop("measured_semimajor_axis")
crater_dict.pop("measured_semiminor_axis")
items = list(crater_dict.items())
crater_dict = {"id": crater_dict["id"], "measured_diameter": measured_diameter}
crater_dict.update(items[1:])
if crater_dict["semimajor_axis"] == crater_dict["semiminor_axis"]:
diameter = 2.0 * crater_dict.pop("semimajor_axis")
crater_dict.pop("semiminor_axis")
items = list(crater_dict.items())
crater_dict = {"id": crater_dict["id"], "diameter": diameter}
crater_dict.update(items[1:])
# Pop out any None values
crater_dict = {k: v for k, v in crater_dict.items() if v is not None}
if not header_written:
header = list(crater_dict.keys())
writer.writerow(header)
header_written = True
row = [crater_dict[key] for key in header]
writer.writerow(row)
return
[docs]
def from_csv_file(self, input_file: Path | str) -> list[Crater]:
"""
Import crater data from a CSV file.
Parameters
----------
input_file : Path | str
The path to the CSV file containing crater data.
Returns
-------
list[Crater]
A list of Crater objects imported from the CSV file.
"""
import csv
craters = []
input_file = Path(input_file)
if not input_file.exists():
raise FileNotFoundError(f"Input file '{input_file}' does not exist.")
with input_file.open(mode="r", newline="") as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
crater_data = {}
crater_data["location"] = [None, None]
crater_data["measured_location"] = [None, None]
for key, value in row.items():
if key in ["id"]:
crater_data[key] = int(value)
elif key == "longitude":
crater_data["location"][0] = float(value)
elif key == "latitude":
crater_data["location"][1] = float(value)
elif key == "measured_longitude":
crater_data["measured_location"][0] = float(value)
elif key == "measured_latitude":
crater_data["measured_location"][1] = float(value)
else:
try:
crater_data[key] = float(value)
except ValueError:
crater_data[key] = value
if None in crater_data["location"]:
crater_data.pop("location")
if None in crater_data["measured_location"]:
crater_data.pop("measured_location")
crater = self.Crater.maker(**crater_data, morphology=self.morphology, check_redundant_inputs=False)
craters.append(crater)
return craters
[docs]
def to_scc_file(
self,
name: Literal["observed", "emplaced"] = "observed",
crater_ds: xr.Dataset | list[Crater] | dict[int, Crater] | None = None,
interval: int | None = None,
**kwargs,
) -> None:
"""
Export the crater data to Craterstats and OpenCraterTools-compatible SCC file and stores it in the default export directory.
Parameters
----------
name : Literal["observed", "emplaced"], optional
The name of the crater dataset to export, either "observed" or "emplaced
crater_ds : xr.Dataset | list[Crater] | dict[int, Crater] | None, optional
The crater data to export. Can be provided as an xarray Dataset, a list of Crater objects, or a dictionary mapping interval numbers to Crater objects. If None, the crater data will be the attribute of the class corresponding to the name parameter (self.observed or self.emplaced). Default is None.
interval : int | None, optional
|interval_export|
**kwargs : Any
|kwargs|
"""
import datetime
crater_list = self._validate_export_args(name=name, interval=interval, crater_ds=crater_ds)
def overlap_fraction(crater, region_poly=None):
if region_poly is None:
return 1.0
distance = self.surface.compute_distances(reference_location=self.surface.local_location, locations=[crater.location])
if distance + crater.measured_radius > self.surface.local_radius:
crater_poly = crater.to_geoseries(
surface=self.surface, split_antimeridian=False, use_measured_properties=True
).to_crs(self.surface.crs)
overlap_area = crater_poly.intersection(region_poly).to_crs(self.surface.local.crs).area.item()
return overlap_area / crater_poly.to_crs(self.surface.local.crs).area.item()
else:
return 1.0
region_poly = None
output_file = self.export_dir / f"{name}{interval:06d}.scc"
if not self._overwrite_check(output_file):
return
print(f"Saving crater data to {output_file}")
with output_file.open(mode="w") as f:
f.write(f"# Spatial crater count Cratermaker version {cratermaker_version}\n")
f.write("#\n")
f.write(f"# Exported on {datetime.datetime.now().isoformat()}\n")
f.write("#\n")
f.write("# Ellipsoid axes\n")
f.write(f"a-axis radius = {self.surface.radius * 1e-3:.3f} <km>\n")
f.write(f"b-axis radius = {self.surface.radius * 1e-3:.3f} <km>\n")
f.write(f"c-axis radius = {self.surface.radius * 1e-3:.3f} <km>\n")
# Start with regional area
boundary_points = []
if hasattr(self.surface, "local_radius") and hasattr(self.surface, "local_location"):
f.write(f"coordinate_system_name = {self.surface.local.crs.name}\n")
if region_poly is None: # We only need to do this the first time through
region_circle = self.Crater.maker(
radius=self.surface.local_radius, location=self.surface.local_location
) # We can get away with using just the base class for Crater here
region_poly = (
region_circle.to_geoseries(surface=self.surface, split_antimeridian=False, use_measured_properties=False)
.to_crs(self.surface.crs)
.item()
)
boundary_points = list(region_poly.exterior.coords)
area = self.surface.local.area
else:
f.write(f"coordinate_system_name = {self.surface.crs.name}\n")
boundary_points = [(-180.0, -90.0), (180.0, -90.0), (180.0, 90.0), (-180.0, 90.0), (-180.0, -90.0)]
area = self.surface.area
region_poly = None
f.write("# area_shapes:\n")
f.write("unit_boundary = {vertex, sub_area, tag, lon, lat\n")
for i, p in enumerate(boundary_points):
f.write(f"{i}\t1\text\t{p[0]}\t{p[1]}\n")
f.write("}\n")
f.write("#\n")
f.write("# area_info:\n")
f.write(f"Total_area = {area * 1e-6} <km²>\n")
f.write("#\n")
f.write("# crater_diameters\n")
f.write("crater = {diam, fraction, lon, lat, topo_scale_factor\n")
for crater in crater_list:
f.write(
f"{crater.measured_diameter * 1e-3}\t{overlap_fraction(crater, region_poly)}\t{crater.measured_location[0]}\t{crater.measured_location[1]}\t 1\n"
)
f.write("}\n")
return
[docs]
def from_scc_file(self, input_file: Path | str) -> list[Crater]:
"""
Import crater data from a Spatial Crater Count file.
Parameters
----------
input_file : Path | str
The path to the SCC file containing crater data.
Returns
-------
list[Crater]
A list of Crater objects imported from the SCC file.
"""
from craterstats import Spatialcount
craters = []
input_file = Path(input_file)
if not input_file.exists():
raise FileNotFoundError(f"Input file '{input_file}' does not exist.")
if input_file.suffix != ".scc":
raise ValueError(f"Input file '{input_file}' is not a .scc file.")
scc = Spatialcount(filename=str(input_file))
for diam, lon, lat in zip(scc.diam, scc.lon, scc.lat, strict=True):
crater = self.Crater.maker(diameter=diam * 1e3, location=(lon, lat), morphology=self.morphology)
craters.append(crater)
return craters
[docs]
def from_xarray(self, dataset: xr.Dataset | dict, interval: int | None = None) -> list[Crater]:
"""
Import crater data from an xarray Dataset.
Parameters
----------
dataset : xr.Dataset | dict
The xarray Dataset containing crater data or a dictionary of xarray Datasets keyed by interval number.
Returns
-------
list[Crater]
A list of Crater objects imported from the xarray Dataset.
"""
craters = []
if type(dataset) is dict:
if interval is None:
dataset = dataset[-1]
elif interval in dataset:
dataset = dataset[interval]
else:
return craters
if "interval" in dataset.coords:
if interval is None:
dataset = dataset.isel(interval=-1)
elif interval in dataset.interval:
dataset = dataset.sel(interval=interval)
else:
return craters
dataset.load()
if len(dataset) == 0:
return craters
for id in tqdm(dataset.id.data, desc="Converting xarray Dataset to Crater objects", unit="crater", position=0, leave=False):
crater_data = dataset.sel(id=id).to_dict()["data_vars"]
crater_data = {k: v["data"] for k, v in crater_data.items()}
if np.isnan(crater_data["semimajor_axis"]):
continue
if "longitude" in crater_data and "latitude" in crater_data:
crater_data["location"] = (crater_data.pop("longitude"), crater_data.pop("latitude"))
if "measured_longitude" in crater_data and "measured_latitude" in crater_data:
crater_data["measured_location"] = (
crater_data.pop("measured_longitude"),
crater_data.pop("measured_latitude"),
)
for k, v in crater_data.items():
if v is not None and np.any(np.isreal(v)) and np.any(np.isnan(v)):
crater_data[k] = None
crater = self.Crater.maker(**crater_data, morphology=self.morphology, check_redundant_inputs=False)
craters.append(crater)
return craters
[docs]
def remove_complex_data(self):
"""
Remove complex data from all observed and emplaced craters to free up memory. This is typically called after the tally step to clear out things like the affect node and face index sets.
"""
for v in self.observed.values():
v.remove_complex_data()
for v in self.emplaced:
v.remove_complex_data()
return
@property
def surface(self):
"""The Surface object associated with this Counting object."""
return self._surface
@surface.setter
def surface(self, value):
from cratermaker.components.surface import LocalSurface, Surface
if not isinstance(value, (Surface | LocalSurface)):
raise TypeError("surface must be an instance of Surface or LocalSurface")
self._surface = value
@property
def n_layer(self) -> int:
"""Number of layers in the counting model."""
return _N_LAYER
@property
def observed(self) -> dict[int, Crater]:
"""Dictionary of observed craters on the surface keyed to the crater id."""
return self._observed
@property
def emplaced(self) -> list[Crater]:
"""List of craters that have been emplaced in the simulation in the current interval in chronological order."""
return self._emplaced
@property
def n_emplaced(self) -> int:
"""Number of craters that have been emplaced in the simulation in the current interval."""
return len(self._emplaced)
@property
def n_observed(self) -> int:
"""Number of craters that have been observed on the surface in the current interval."""
return len(self._observed)
@property
def Crater(self) -> type[Crater]:
"""The Crater class used for this counting component, which is determined by the morphology component."""
if self._morphology is None:
return Crater
else:
return self._morphology.Crater
@property
def morphology(self) -> Morphology:
"""The morphology component associated with this counting component, which determines the Crater class and the crater properties that are tracked in the simulation."""
return self._morphology
@morphology.setter
def morphology(self, value):
from cratermaker.components.morphology import Morphology
if not isinstance(value, Morphology):
raise TypeError("morphology must be an instance of the Morphology class.")
self._morphology = value
# Re-run any saved emplaced or observed craters through the new object's maker function so that we promote them to the new type
for i, crater in enumerate(self.emplaced):
self.emplaced[i] = self.Crater.maker(crater=crater)
for id, crater in self.observed.items():
self.observed[id] = self.Crater.maker(crater=crater)
return
def R_to_CSFD(
R: Callable[[FloatLike | ArrayLike], FloatLike | ArrayLike],
D: FloatLike | ArrayLike,
Dlim: FloatLike = 1e6,
*args: Any,
) -> FloatLike | ArrayLike:
"""
Convert R values to cumulative N values for a given D using the R-plot function.
Parameter
----------
R : R = f(D)
A function that computes R given D.
D : FloatLike or ArrayLike
diameter in units of km.
Dlim : FloatLike
Upper limit on the diameter over which to evaluate the integral
args : Any
Additional arguments to pass to the R function
Returns
-------
float or ArrayLike
The cumulative number of craters greater than D in diameter.
"""
def _R_to_CSFD_scalar(R, D, Dlim, *args):
# Helper function to integrate the R function
def integrand(D):
return R(D, *args) / D**3 # This is dN/dD
N = 0.0
D_i = D
while D_i < Dlim:
D_next = D_i * np.sqrt(2.0)
D_mid = (D_i + D_next) / 2 # Mid-point of the bin
bin_width = D_next - D_i
R_value = integrand(D_mid)
N += R_value * bin_width
D_i = D_next # Move to the next bin
return N
return _R_to_CSFD_scalar(R, D, Dlim, *args) if np.isscalar(D) else np.vectorize(_R_to_CSFD_scalar)(R, D, Dlim, *args)
def csfd_geometric_saturation(diameter: FloatLike | ArrayLike) -> FloatLike | ArrayLike:
"""
Calculate the cumulative number of craters at geometric saturation for a given diameter.
We use the definition of geomatric saturation from Melosh (1989) [#]_.
Parameter
----------
diameter : FloatLike or ArrayLike
The diameter(s) for which to calculate the cumulative number of craters at geometric saturation.
Returns
-------
FloatLike or ArrayLike
The cumulative number of craters at geometric saturation for the given diameter(s).
References
----------
.. [#] Melosh, H.J., 1989. Impact cratering: A geologic process. Oxford University Press, New York, New York.
"""
return 1.54 * diameter ** (-2)
def csfd_equilibrium(diameter: FloatLike | ArrayLike, f_geometric=0.0218) -> FloatLike | ArrayLike:
"""
Calculate the cumulative number of craters at equilibrium for a given diameter.
Parameter
----------
diameter : FloatLike or ArrayLike
The diameter(s) for which to calculate the cumulative number of craters at equilibrium.
f_geometric : float, optional
The fraction of geometric saturation at which equilibrium occurs. The default value is 0.0218, which is the value used in Minton et al. (2019) [#]_.
Returns
-------
FloatLike or ArrayLike
The cumulative number of craters at equilibrium for the given diameter(s).
References
----------
.. [#] Minton, D.A., Fassett, C.I., Hirabayashi, M., Howl, B.A., Richardson, J.E., (2019). The equilibrium size-frequency distribution of small craters reveals the effects of distal ejecta on lunar landscape morphology. Icarus 326, 63-87. https://doi.org/10.1016/j.icarus.2019.02.021
"""
return f_geometric * csfd_geometric_saturation(diameter)
import_components(__name__, __path__)