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 pyvista
import xarray as xr
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.bindings import counting_bindings
from cratermaker.components.crater import _TALLY_LONG_NAME, 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 = {"SCC": "scc", "VTK": "vtp", "VTP": "vtp", "CSV": "csv", **VECTOR_DRIVER_TO_EXTENSION_MAP}
if TYPE_CHECKING:
from cratermaker.components.surface import LocalSurface, Surface
# 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.
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.Crater.from_xarray(observed, interval=interval)
for crater in observed:
self._observed[crater.id] = crater
if emplaced:
self._emplaced = self.Crater.from_xarray(emplaced, interval=interval)
return
[docs]
@classmethod
def maker(
cls,
counting: str | Counting | None = None,
surface: Surface | LocalSurface | 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.
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,
**kwargs,
)
return counting
def __str__(self) -> str:
str_repr = super().__str__()
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"Surface: <{self.surface.component_name}>\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) -> MorphologyCrater:
"""
Add a crater to the surface.
Parameters
----------
crater : Crater
The crater to be added to the surface.
**kwargs : Any
|kwargs|
Returns
-------
MorphologyCrater:
The emplacec crater
"""
if not isinstance(crater, Crater):
raise TypeError("crater must be an instance of Crater")
if not isinstance(crater, MorphologyCrater):
crater = self.morphology.Crater.maker(crater=crater)
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.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 crater
[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: float = 0.95,
gradmult: float = 1.0,
curvmult: float = 1.0,
heightmult: float = 1.0,
**kwargs: Any,
) -> 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.
**kwargs : Any
|kwargs|
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. `doi: 10.1016/j.icarus.2019.02.021 <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.
"""
from cratermaker.components.crater import _convert_tuple_vars
if len(craters) == 0:
return xr.Dataset()
data = []
if isinstance(craters, dict):
craters = craters.values()
for c in craters:
d = c.as_dict(skip_complex_data=True)
d = _convert_tuple_vars(input_dict=d, inverse=False)
d = xr.Dataset(data_vars=d).set_coords("id").expand_dims(dim="id")
d["id"].attrs["long_name"] = _TALLY_LONG_NAME
data.append(d)
return xr.concat(data, dim="id").sortby("id")
[docs]
def save(
self,
crater_type: Literal["observed", "emplaced", "both"] = "both",
interval: int = 0,
time_variables: dict | 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.
time_variables: dict | None, optional
A dictionary of time variables to include in the output file.
**kwargs : Any
|kwargs|
"""
if crater_type not in ["observed", "emplaced", "both"]:
raise ValueError(f"Invalid crater_type: {crater_type}. Must be one of 'observed', 'emplaced', or 'both'.")
if time_variables is not None and not isinstance(time_variables, dict):
raise TypeError("time_variables must be a dictionary")
self.remove_complex_data()
if crater_type == "observed":
iterator = zip([self.observed], ["observed"], strict=True)
elif crater_type == "emplaced":
iterator = zip([self.emplaced], ["emplaced"], strict=True)
elif crater_type == "both":
iterator = zip([self.observed, self.emplaced], ["observed", "emplaced"], strict=True)
for craters, crater_type in iterator:
if craters:
filename = self.output_dir / f"{crater_type}_{self.output_filename(interval)}"
ds = self.to_xarray(craters)
ds = ds.expand_dims(dim="interval").assign_coords({"interval": [interval]})
if time_variables is not None:
for k, v in time_variables.items():
ds[k] = xr.DataArray(data=[v], name=k, dims=["interval"], coords={"interval": [interval]})
if filename.exists():
filename.unlink()
ds.to_netcdf(filename)
save_args = {"interval": interval, **kwargs}
super().save(**save_args)
return
[docs]
def export(
self,
crater_type: Literal["observed", "emplaced", "both"] = "both",
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
if crater_type not in ["observed", "emplaced", "both"]:
raise ValueError("crater_type must be 'observed', 'emplaced', or 'both")
observed, emplaced = self.read_saved_output(interval=interval)
if crater_type == "both":
crater_types = ["observed", "emplaced"]
crater_ds_list = [observed, emplaced]
else:
crater_types = [crater_type]
if crater_type == "observed":
crater_ds_list = [observed]
elif crater_type == "emplaced":
crater_ds_list = [emplaced]
for crater_type, crater_ds in zip(crater_types, crater_ds_list, strict=True):
if isinstance(crater_ds, dict):
interval_numbers = list(crater_ds.keys())
elif isinstance(crater_ds, xr.Dataset) and "interval" in crater_ds.coords:
interval_numbers = crater_ds.interval.values
else:
if interval is None:
interval_numbers = [0]
else:
interval_numbers = [interval]
for interval in interval_numbers:
craters = self.Crater.from_xarray(crater_ds, interval=interval) if crater_ds is not None else []
if driver.upper() in ["VTK", "VTP"] and crater_ds is not None:
self.to_vtk_file(
craters=craters,
interval=interval,
crater_type=crater_type,
**kwargs,
)
elif driver.upper() == "CSV" and crater_ds is not None:
self.to_csv_file(
craters=craters,
interval=interval,
crater_type=crater_type,
**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(
craters=craters,
interval=interval,
crater_type=crater_type,
**kwargs,
)
elif driver.upper() in VECTOR_DRIVER_TO_EXTENSION_MAP and crater_ds is not None:
self.to_vector_file(
craters=craters,
interval=interval,
crater_type=crater_type,
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,
label: str | None = None,
scalebar: bool | None = None,
colorbar: bool = True,
show: bool = False,
save: bool = True,
ax: Axes | None = None,
close_when_done: bool = True,
minimum_plot_width: float | None = 800,
**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").
label : str | None, optional
A label for the plot. If None, no label will be added.
scalebar : bool, optional
If True, a scalebar will be added to the plot. Default is True.
colorbar : bool, optional
If True, a colorbar will be added to the plot when using "map" plot_style or "hillshade" with a variable overlay. Default is True.
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.
close_when_done : bool, optional
If True, the figure will be closed after plotting. Default is True when save is True and show is False, and False otherwise.
minimum_plot_width : float, optional
Because the width of the plot is determined by the number of faces, small regions will generate small plots with labels that are hard to read. This parameter sets a lower limit to the width of the image that is generated by the plot. By default it is 800. Set to None to turn it off.
**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
if close_when_done is None:
close_when_done = save and not show
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 variable_name is not None:
file_prefix += f"_{variable_name}"
if interval is not None:
observed_intervals, emplaced_intervals = self.get_saved_interval_numbers()
# Check if we are in the current interval, otherwise we have to read data from file.
trigger_read = False
if observed_intervals is not None and (len(observed_intervals) == 0 or observed_intervals[-1] == interval):
observed = list(self.observed.values())
else:
observed = None
trigger_read = True
if emplaced_intervals is not None and (len(emplaced_intervals) == 0 or emplaced_intervals[-1] == interval):
emplaced = self.emplaced
else:
emplaced = None
trigger_read = True
if trigger_read:
observed_ds, emplaced_ds = self.read_saved_output(interval=interval)
if observed is None and observed_ds:
interval = observed_ds.interval.values[-1]
observed = self.Crater.from_xarray(observed_ds, interval=interval)
if emplaced is None and emplaced_ds:
emplaced_interval = emplaced.interval.values[-1]
if emplaced_interval == interval:
emplaced = self.Crater.from_xarray(emplaced_ds, interval=interval)
filename = self.plot_dir / f"{file_prefix}{interval:06d}.{self.surface.output_image_file_extension}"
else:
observed = list(self.observed.values())
emplaced = self.emplaced
filename = self.plot_dir / f"{file_prefix}.{self.surface.output_image_file_extension}"
ax = self.surface.plot(
plot_style=plot_style,
variable_name=variable_name,
interval=interval,
cmap=cmap,
label=label,
scalebar=scalebar,
colorbar=colorbar,
show=False,
save=False,
ax=ax,
minimum_plot_width=minimum_plot_width,
close_when_done=False,
**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", ":")
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:
plt.savefig(filename, dpi=ax.figure.get_dpi())
if show:
plt.show()
if close_when_done and (save or show):
plt.close()
return ax
[docs]
def pyvista_plotter(
self,
crater_type: Literal["observed", "emplaced", "both"] = "both",
interval: int | None = None,
enable_interactive: bool = True,
crater_color: str | tuple[str] = ("white", "red"),
crater_style: Literal["rings", "points", "impacts", "spheres"] = "rings",
surface: Surface | LocalSurface | None = None,
plotter: pyvista.Plotter | None = None,
crater_size_scale_factor: FloatLike = 1.0,
**kwargs: Any,
) -> pyvista.Plotter:
"""
Passes through to the surface pyvista_plotter method and adds crater counts to it.
When enable_interactive is True (the default), this will add all saved crater data (observed and emplaced) with all plot styles (rings, points, and impacts) and allow them to be activated by key presses. If False, this will only plot one set of data with one style.
Parameters
----------
crater_type: Literal["observed","emplaced","both"] | list[Crater].
This is only used if enable_interactive is False, in which case it controls which crater data will be plotted. Default is "both".
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.
enable_interactive : bool, optional
If True, enables key events to toggle the visibility of all saved crater count types (observed and emplaced) with all styles (rings, points, and impacts), which are activated by keypresses ("c" for observed craters, and "t" for emplaced craters), and all are not visible on initialization. If False, only one set of data is plotted, and is visible on initialization.
crater_color: str | tuple[str] = ("white", "yellow")
The color to use for the plots. When used with enable_interactive or when craters is set to "both", this is a tuple where the first element is the color of observed craters and the second is the color of emplaced craters. Default is ("white,"red").
crater_style : Literal["rings", "points"], optional
Only used when enable_interactive is False. Sets the style of the mesh. Options are "rings", which creates polyline circles over the rim of each crater or "points" which creates a point at the center.
surface : Surface | LocalSurface, optional
The surface or local surface view to be displayed. If None, uses the associated surface property
plotter : pyvista.Plotter, optional
An existing pyvista Plotter to add the crater counts to. If None, a new Plotter will be created by the surface pyvista_plotter method. Default is None.
crater_size_scale_factor : FloatLike, optional
A factor to scale the size of the craters in "point", "impacts", or "spheres" styles. Default is 1.0.
**kwargs : Any
|kwargs|
Returns
-------
plotter : pyvista.Plotter
The pyvista plotter with the crater counts added.
"""
from cratermaker.constants import PYVISTA_ADD_MESH_KWARGS
from cratermaker.utils.general_utils import toggle_pyvista_actor, update_pyvista_help_message
def _draw_craters(craters, crater_color, name, crater_style, use_measured_properties, **kwargs):
add_mesh_kwargs = {k: v for k, v in kwargs.items() if k in PYVISTA_ADD_MESH_KWARGS}
add_mesh_kwargs = {"color": crater_color, **add_mesh_kwargs}
if crater_style == "rings":
add_mesh_kwargs = {**add_mesh_kwargs, "line_width": 2}
point_size = 1
elif crater_style == "points":
add_mesh_kwargs["render_points_as_spheres"] = True
add_mesh_kwargs["style"] = "points_gaussian"
add_mesh_kwargs["emissive"] = True
point_size = 1
size_scale = np.array([crater_size_scale_factor * surface.face_size[c.face_index] for c in craters])
elif crater_style == "impacts":
add_mesh_kwargs["render_points_as_spheres"] = False
add_mesh_kwargs["style"] = "points_gaussian"
add_mesh_kwargs["emissive"] = True
add_mesh_kwargs["pbr"] = True
size_scale = np.array([crater_size_scale_factor * c.floor_diameter for c in craters])
point_size = 1
elif crater_style == "spheres":
add_mesh_kwargs["render_points_as_spheres"] = True
add_mesh_kwargs["style"] = "points_gaussian"
add_mesh_kwargs["pbr"] = True
point_size = 1
size_scale = np.array([crater_size_scale_factor * c.projectile_radius for c in craters])
mesh = self.to_vtk_mesh(
craters=craters,
use_measured_properties=use_measured_properties,
crater_style=crater_style,
crater_size_scale_factor=crater_size_scale_factor,
**kwargs,
)
pdata = pyvista.PolyData(mesh)
if crater_style in ["points", "impacts", "spheres"]:
pdata["size_scale"] = size_scale
actor_name = f"{crater_type}_{crater_style}"
actor = plotter.add_mesh(pdata, name=actor_name, point_size=point_size, **add_mesh_kwargs)
if crater_style in ["points", "impacts", "spheres"]:
actor.mapper.scale_array = "size_scale"
return actor
def update_crater_style(plotter, actor_list):
inext = 0
for i, actor in enumerate(actor_list):
if actor.GetVisibility():
actor.SetVisibility(False)
inext = i + 1
break
if inext < len(actor_list):
actor_list[inext].SetVisibility(True)
plotter.update()
return
new_plotter = plotter is None
surface = self.surface
plotter = surface.pyvista_plotter(enable_interactive=enable_interactive, plotter=plotter, interval=interval, **kwargs)
valid_crater_styles = ["rings", "points", "impacts", "spheres"]
if not enable_interactive:
crater_style = crater_style.lower()
if crater_style not in valid_crater_styles:
raise ValueError(f"Invalid crater_style: {crater_style}. Must be one of {valid_crater_styles}.")
if interval is None:
interval = -1
if isinstance(crater_color, str):
crater_color = [crater_color]
elif not isinstance(crater_color, (tuple, list)) or (crater_type == "both" and len(crater_color) != 2):
raise ValueError(
"crater_color must be a tuple with the first element the color of observed craters and the second the color of emplaced craters."
)
if isinstance(crater_type, str):
if crater_type not in ["observed", "emplaced", "both"]:
raise ValueError("crater_type must be 'observed', 'emplaced', 'both', or a list of Crater objects")
if interval == -1:
observed = list(self.observed.values())
emplaced = self.emplaced
else:
observed, emplaced = self.read_saved_output(interval=interval)
if observed is not None and len(observed) > 0:
observed = self.Crater.from_xarray(observed)
else:
observed = None
if emplaced is not None and len(emplaced) > 0:
emplaced = self.Crater.from_xarray(emplaced)
else:
emplaced = None
if crater_type == "both":
craterlistlist = [observed, emplaced]
namelist = ["observed", "emplaced"]
measured = [True, False]
toggle = ["c", "t"]
elif crater_type == "observed":
craterlistlist = [observed]
namelist = ["observed"]
measured = [True]
toggle = ["c"]
crater_color = [crater_color[0]]
elif crater_type == "emplaced":
craterlistlist = [emplaced]
namelist = ["emplaced"]
measured = [False]
toggle = ["t"]
crater_color = [crater_color[-1]]
if enable_interactive:
styles = valid_crater_styles
else:
styles = [crater_style]
for name, craters, use_measured_properties, col, key in zip(
namelist, craterlistlist, measured, crater_color, toggle, strict=True
):
if craters is None:
continue
actor_list = []
for crater_style in styles:
actor = _draw_craters(craters, col, name, crater_style, use_measured_properties, **kwargs)
if enable_interactive:
actor.SetVisibility(False)
actor_list.append(actor)
if enable_interactive and new_plotter:
new_message = f"{key} Toggle {name} craters"
plotter = update_pyvista_help_message(plotter, new_message=new_message)
plotter.add_key_event(key, lambda plotter=plotter, actor_list=actor_list: update_crater_style(plotter, actor_list))
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=use_measured_properties
)
)
return pd.concat(crater_gs)
def _validate_export_args(
self,
crater_type: Literal["observed", "emplaced"] = "observed",
interval: int | None = None,
craters: list[Crater] | None = None,
) -> list[Crater]:
if crater_type not in ["observed", "emplaced"]:
raise ValueError("crater_type must be 'observed' or 'emplaced'")
if craters is None:
craters = getattr(self, crater_type)
if type(craters) is dict:
if type(list(craters.values())[0]) is xr.Dataset: # like multi-interval dicts keyed by interval
crater_ds = craters.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 isinstance(craters, dict):
craters = list(craters.values())
elif isinstance(craters, xr.Dataset):
craters = self.Crater.from_xarray(craters, interval=interval)
elif isinstance(craters, list):
if not all(isinstance(c, Crater) for c in craters):
raise TypeError("All elements of the craters list must be Crater objects.")
else:
raise TypeError(f"Unrecognized type for craters: {type(craters)}")
return craters
[docs]
def to_vector_file(
self,
crater_type: Literal["observed", "emplaced"] = "observed",
craters: 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
----------
crater_type : 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
# craters = 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(
# craters,
# total=len(craters),
# desc=f"Converting {crater_type} 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 {crater_type} layer to vector file: '{output_file}'...")
# else:
# output_file = self.export_dir / f"{crater_type}_{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,
crater_type: Literal["observed", "emplaced"] | None = None,
interval: int | None = None,
craters: list[Crater] | None = None,
use_measured_properties: bool = True,
crater_style: Literal["rings", "points", "impacts", "spheres"] = "rings",
crater_size_scale_factor: FloatLike = 1.0,
**kwargs: Any,
) -> vtkPolyData:
"""
Convert the crater data to a VTK PolyData mesh.
Parameters
----------
crater_type : Literal["observed", "emplaced"], optional
The name of the crater dataset to convert, either "observed" or "emplaced. If None is provided, then a list of Crater objects must be provided directly to the `craters` parameter. Default is None.
interval : int | None, optional
The interval number to load if craters is not provided directly. If None, then the most recent interval will be used. Default is None.
craters : list[Crater], optional
A list of Crater objects to convert. If None is provided, then the crater dataset will be determined by the `name` parameter. Default is None.
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.
crater_style : Literal["rings", "points", "impacts", "spheres"], optional
Sets the style of the mesh. Options are "rings", which creates polyline circles over the rim of each crater, "points" which creates a small sphere at the center of each crater, and "impacts" which places a point above the floor of the center of the crater and "spheres" which creates spheres with radius equal to the projectile radius at the location of each crater. Default is "rings".
crater_size_scale_factor : FloatLike, optional
A factor to scale the size of the craters in "point", "impacts", or "spheres" styles, which places the center point of the actor above the surface by its radius. Default is 1.0.
**kwargs : Any
|kwargs|
Returns
-------
vtkPolyData
A VTK PolyData object representing the crater geometries.
"""
from vtk import (
vtkCellArray,
vtkPoints,
vtkPolyLine,
)
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 (keep closing vertex)."""
g3d = transform(lonlat_to_xyz(R), geom)
def _rings(g):
if g.geom_type == "Polygon":
yield np.asarray(g.exterior.coords)
for i in g.interiors:
yield np.asarray(i.coords)
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)
def draw_rings(craters, surface):
R = surface.radius
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(), R):
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
def draw_points(craters, surface, crater_style):
from shapely.geometry import Point
from vtk import VTK_FLOAT
from vtkmodules.util.numpy_support import numpy_to_vtk
geoms = []
for crater in craters:
z = surface.face_elevation[crater.face_index]
if crater_style == "points":
z += crater_size_scale_factor * surface.face_size[crater.face_index] / 2
elif crater_style == "spheres":
z += crater_size_scale_factor * crater.projectile_radius
elif crater_style == "impacts":
z += np.sqrt(crater_size_scale_factor) * crater.floor_diameter / 2
geoms.append(Point(crater.location[0], crater.location[1], z))
gs = GeoSeries(geoms, crs=surface.crs)
# Convert to Cartesian coordinates using transform
R = surface.radius
points = vtkPoints()
for geom in gs:
x, y, z = lonlat_to_xyz(R)(geom.x, geom.y, geom.z)
points.InsertNextPoint(float(x), float(y), float(z))
poly_data = vtkPolyData()
poly_data.SetPoints(points)
return poly_data
valid_crater_styles = ["rings", "points", "impacts", "spheres"]
crater_style = crater_style.lower()
if crater_style not in valid_crater_styles:
vtxt = ", ".join(f'"{s}"' for s in valid_crater_styles)
raise ValueError(f"{crater_style} is not a recognized crater_style. Valid options are {vtxt}.")
surface = self.surface
if craters is None:
if crater_type is None:
raise ValueError(
"If craters is not provided directly, then name must be provided to determine which crater dataset to use."
)
elif crater_type not in ["observed", "emplaced"]:
raise ValueError("name must be either 'observed' or 'emplaced'")
if interval is None:
if crater_type == "observed":
craters = list(self.observed.values())
else:
craters = self.emplaced
else:
observed, emplaced = self.read_saved_output(interval=interval)
if crater_type == "observed":
if observed is None or len(observed) == 0:
return None
interval = observed.interval.values[-1]
craters = self.Crater.from_xarray(observed, interval=interval)
else:
if emplaced is None or len(emplaced) == 0:
return None
interval = emplaced.interval.values[-1]
craters = self.Crater.from_xarray(emplaced, interval=interval)
if craters is None:
return None
if crater_style == "rings":
return draw_rings(craters, surface)
else:
return draw_points(craters, surface, crater_style)
[docs]
def to_vtk_file(
self,
crater_type: Literal["observed", "emplaced"] | None = None,
interval: int | None = None,
craters: list[Crater] | None = None,
use_measured_properties: bool = True,
crater_style: Literal["rings", "points", "impacts", "spheres"] = "rings",
crater_size_scale_factor: FloatLike = 1.0,
**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
----------
crater_type : Literal["observed", "emplaced"], optional
The name of the crater dataset to convert, either "observed" or "emplaced. If None is provided, then a list of Crater objects must be provided directly to the `craters` parameter. Default is None.
interval : int | None, optional
|interval_export|
craters : list[Crater], optional
A list of Crater objects to convert. If None is provided, then the crater dataset will be determined by the `crater_type` parameter. Default is None.
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.
crater_style : Literal["rings", "points", "impacts", "spheres"], optional
Sets the style of the mesh. Options are "rings", which creates polyline circles over the rim of each crater, "points" which creates a small sphere at the center of each crater, and "impacts" which places a point above the floor of the center of the crater and "spheres" which creates spheres with radius equal to the projectile radius at the location of each crater. Default is "rings".
crater_size_scale_factor : FloatLike, optional
A factor to scale the size of the craters in "point", "impacts", or "spheres" styles, which places the center point of the actor above the surface by its radius. Default is 1.0.
**kwargs : Any
|kwargs|
"""
from vtk import vtkXMLPolyDataWriter
craters = self._validate_export_args(crater_type=crater_type, interval=interval, craters=craters)
filename_base = self.output_filename(interval).replace(self.output_file_extension, "vtp")
output_file = self.export_dir / f"{crater_type}_{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=craters)
# 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,
crater_type: Literal["observed", "emplaced"] = "observed",
craters: 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
----------
crater_type : Literal["observed", "emplaced"], optional
The type 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 crater_type parameter (self.observed or self.emplaced). Default is None.
interval : int | None, optional
|interval_export|
**kwargs : Any
|kwargs|
"""
import csv
from cratermaker.components.crater import _convert_tuple_vars
craters = self._validate_export_args(crater_type=crater_type, interval=interval, craters=craters)
filename_base = self.output_filename(interval).replace(self.output_file_extension, "csv")
output_file = self.export_dir / f"{crater_type}_{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 craters:
crater_dict = crater.as_dict(skip_complex_data=True)
crater_dict = _convert_tuple_vars(input_dict=crater_dict, inverse=False)
# 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
for k, v in crater_dict.items():
if v is None:
crater_dict[k] = ""
if not header_written:
header = list(crater_dict.keys())
writer.writerow(header)
header_written = True
row = [crater_dict.get(key, "") for key in header]
writer.writerow(row)
return
[docs]
def to_scc_file(
self,
crater_type: Literal["observed", "emplaced"] = "observed",
craters: 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
----------
crater_type : Literal["observed", "emplaced"], optional
The type of the crater dataset to export, either "observed" or "emplaced
craters : 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 crater_type parameter (self.observed or self.emplaced). Default is None.
interval : int | None, optional
|interval_export|
**kwargs : Any
|kwargs|
"""
import datetime
craters = self._validate_export_args(crater_type=crater_type, interval=interval, craters=craters)
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"{crater_type}{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 craters:
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 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.
Note
----
The Morphology component requires a Countinng component, so this property is set by the Morphology component on initialization, rather than by the Counting component itself.
"""
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. `doi: 10.1016/j.icarus.2019.02.021 <https://doi.org/10.1016/j.icarus.2019.02.021>`_
"""
return f_geometric * csfd_geometric_saturation(diameter)
import_components(__name__, __path__)