from __future__ import annotations
import sys
from typing import (
TYPE_CHECKING,
Any,
)
try:
if sys.version_info >= (3, 11):
from typing import Self, TypeAlias
else:
from typing import TypeAlias
from typing_extensions import Self
except ImportError:
if TYPE_CHECKING:
raise
else:
Self: Any = None
import math
from typing import TYPE_CHECKING
import numpy as np
from numpy.random import Generator
from cratermaker.constants import FloatLike
from cratermaker.core.base import ComponentBase, import_components
from cratermaker.utils import montecarlo_utils as mc
from cratermaker.utils.general_utils import (
format_large_units,
parameter,
validate_and_normalize_location,
)
if TYPE_CHECKING:
from cratermaker.components.target import Target
[docs]
class Projectile(ComponentBase):
"""
An abstract base class for all projectile models. It defines the interface for generating projectile velocities, angles, and densities for a given target body.
"""
_registry: dict[str, Projectile] = {}
_catalogue = None
def __init__(
self,
sample: bool = True,
mean_velocity: FloatLike | None = None,
velocity: FloatLike | None = None,
density: FloatLike | None = None,
angle: FloatLike | None = None,
direction: FloatLike | None = None,
location: tuple[float, float] | None = None,
target: Target | str | None = None,
rng: Generator | None = None,
rng_seed: int | None = None,
rng_state: dict | None = None,
**kwargs,
):
"""
**Warning:** This object should not be instantiated directly. Instead, use the ``.maker()`` method.
Parameters
----------
sample : bool
Flag that determines whether to sample impact velocities, angles, and directions from distributions. If set to True, the `mean_velocity` argument is required. If set to False, the `velocity` argument is required.
mean_velocity : float, optional
The mean velocity of the projectile in m/s. Required if `sample` is True, ignored if `sample` is False.
velocity : float | None
The impact velocity in m/s. If `sample` is True, this value is ignored. If `sample` is False, this value is required.
density : float, optional
The density of the projectile in kg/m³.
angle : float, optional
The impact angle in degrees. Default is 90.0 degrees (vertical impact) if `sample` is False. If `sample` is True, this value is ignored.
direction : float | None
The impact direction in degrees. Default is 0.0 degrees (due North) if `sample` is False. If `sample` is True, this value is ignored.`
location : tuple[float, float] | None
The location of the projectile on the target body in (lon, lat) coordinates. If None, the location will be sampled from a distribution.
target : Target or str.
The name of the target body for the impact. Default is "Moon"
rng : numpy.random.Generator | None
|rng|
rng_seed : Any type allowed by the rng_seed argument of numpy.random.Generator, optional
|rng_seed|
rng_state : dict, optional
|rng_state|
**kwargs : Any
|kwargs|
"""
from cratermaker.components.target import Target
super().__init__(rng=rng, rng_seed=rng_seed, rng_state=rng_state, **kwargs)
object.__setattr__(self, "_sample", sample)
object.__setattr__(self, "_mean_velocity", mean_velocity)
object.__setattr__(self, "_velocity", velocity)
object.__setattr__(self, "_density", density)
object.__setattr__(self, "_angle", angle)
object.__setattr__(self, "_direction", direction)
object.__setattr__(self, "_location", location)
object.__setattr__(self, "_target", Target.maker(target, **kwargs))
if self.sample:
if self.mean_velocity is None:
raise ValueError("mean_velocity must be provided when sample is True")
else:
if self.velocity is None:
raise ValueError("velocity must be provided when sample is False")
if self.angle is None:
raise ValueError("angle must be provided when sample is False")
if self.direction is None:
raise ValueError("direction must be provided when sample is False")
if self.location is None:
raise ValueError("location must be provided when sample is False")
if self.density is None:
raise ValueError("density must be provided")
return
def __str__(self) -> str:
str_repr = super().__str__()
if self.sample:
str_repr += f"Sample from distributions: {self.sample}\n"
str_repr += f"Mean Velocity: {format_large_units(self.mean_velocity, quantity='velocity')}\n"
else:
str_repr += f"Velocity: {format_large_units(self.velocity, quantity='velocity')}\n"
str_repr += f"Angle: {self.angle:.1f}°\n"
str_repr += f"Direction: {self.direction:.1f}°\n"
str_repr += f"Density: {self.density:.1f} kg/m³\n"
return str_repr
def _copy(self, deep: bool = True, memo: dict[int, Any] | None = None) -> Self:
import copy
import inspect
copier = copy.deepcopy if deep else copy.copy
memo = {} if memo is None else memo
# Get all init parameters except 'self'
cls = self.__class__
sig = inspect.signature(cls.__init__)
init_keys = sig.parameters.keys() - {"self"}
# Extract values for those keys
init_kwargs = {}
for k in init_keys:
if k == "target":
init_kwargs[k] = self.target
elif k == "rng":
init_kwargs[k] = self.rng
else:
attr = getattr(self, k, None)
init_kwargs[k] = copier(attr, memo) if deep else copier(attr)
return cls(**init_kwargs)
def __copy__(self) -> Self:
return self._copy(deep=False)
def __deepcopy__(self, memo: dict[int, Any] | None = None) -> Self:
return self._copy(deep=True, memo=memo)
[docs]
@classmethod
def maker(
cls,
projectile: Projectile | str | None = None,
mean_velocity: FloatLike | None = None,
density: FloatLike | None = None,
sample: bool = True,
angle: FloatLike | None = None,
velocity: FloatLike | None = None,
direction: FloatLike | None = None,
location: tuple[float, float] | None = None,
target: Target | str | None = None,
rng: Generator | None = None,
rng_seed: int | None = None,
rng_state: dict | None = None,
**kwargs: Any,
) -> Projectile:
"""
Initialize a Projectile model with the given name or instance.
Parameters
----------
projectile : Projectile or str
The projectile model to initialize. Can be a class or a string representing the model name.
mean_velocity : float
The mean velocity of the projectile in m/s.
density : float
The density of the projectile in kg/m³.
sample : bool
Flag that determines whether to sample impact velocities, angles, and directions from distributions. If set to False, impact velocities will be set to the mean velocity, impact angles will be set to 90 degrees (vertical impact), and directions will be 0.
angle : float
The impact angle in degrees.
velocity : float | None
The impact velocity in m/s. If None, the velocity will be sampled from a distribution.
direction : float | None
The impact direction in degrees. If None, the direction will be sampled from a distribution.
location : tuple[float, float]
The location of the projectile on the target body in (lon, lat) coordinates. If None, the location willb e sampled from a distribution
target : Target or str.
The name of the target body for the impact. Default is "Moon"
rng : numpy.random.Generator | None
|rng|
rng_seed : Any type allowed by the rng_seed argument of numpy.random.Generator, optional
|rng_seed|
rng_state : dict, optional
|rng_state|
**kwargs : Any
|kwargs|
Returns
-------
Projectile
The initialized projectile model.
Raises
------
KeyError
If the specified projectile model is not found in the registry.
TypeError
If the specified projectile model is not a string or a subclass of Projectile.
"""
from cratermaker.components.projectile.asteroids import AsteroidProjectiles
from cratermaker.components.projectile.comets import CometProjectiles
from cratermaker.components.target import Target
target = Target.maker(target, **kwargs)
if projectile is None:
if target.name in AsteroidProjectiles._catalogue:
projectile = "asteroids"
elif target.name in CometProjectiles._catalogue:
projectile = "comets"
else:
projectile = "generic"
mean_velocity = 20.0e3 if mean_velocity is None else mean_velocity
# if this is a brand new uninstantiated projectile, we need to flag it so that it its properties can be set propertly. Otherwise, it should just pass through as is.
isfresh = (
isinstance(projectile, str)
or velocity is not None
or angle is not None
or direction is not None
or location is not None
or density is not None
)
projectile = super().maker(
component=projectile,
mean_velocity=mean_velocity,
density=density,
sample=sample,
angle=angle,
velocity=velocity,
direction=direction,
location=location,
target=target,
rng=rng,
rng_seed=rng_seed,
rng_state=rng_state,
**kwargs,
)
if isfresh:
return projectile.new_projectile(
velocity=velocity,
angle=angle,
direction=direction,
location=location,
density=density,
**kwargs,
)
else:
return projectile
[docs]
def new_projectile(
self,
velocity: FloatLike | None = None,
angle: FloatLike | None = None,
direction: FloatLike | None = None,
location: tuple[float, float] | None = None,
density: FloatLike | None = None,
**kwargs: Any,
) -> Self:
"""
Returns a new projectile instance with updated sampled or default values, based on the original instance.
Parameters
----------
velocity : float | None
The impact velocity in m/s. If None, the velocity will be sampled from a distribution if `sample` is True, otherwise it will be set to the value of `mean_velocity`.
angle : float, optional
The impact angle in degrees. Default is 90.0 degrees (vertical impact) if `sample` is False.
direction : float | None
The impact direction in degrees. Default is 0.0 degrees (due North) if `sample` is False.
location : tuple[float, float] | None
The location of the projectile on the target body in (lon, lat) coordinates. If None, the location will be sampled from a distribution.
density : float | None
The density of the projectile in kg/m³. If None, the density will be the default for this Projectile type.
**kwargs : Any
|kwargs|
Returns
-------
Projectile
A new Projectile instance with updated properties.
"""
import copy
new_obj = copy.copy(self)
if velocity is not None:
new_obj.velocity = velocity
elif new_obj.sample:
new_obj._velocity = float(
mc.get_random_velocity(
vmean=new_obj.mean_velocity,
vescape=new_obj.target.escape_velocity,
rng=new_obj.rng,
)[0]
)
elif new_obj._velocity is None:
new_obj._velocity = new_obj.mean_velocity
if angle is not None:
new_obj.angle = angle
elif new_obj.sample:
new_obj._angle = float(mc.get_random_impact_angle(rng=new_obj.rng)[0])
elif new_obj._angle is None:
new_obj._angle = 90.0
if direction is not None:
new_obj.direction = direction
elif new_obj.sample:
new_obj._direction = float(mc.get_random_impact_direction(rng=new_obj.rng)[0])
elif new_obj._direction is None:
new_obj._direction = 0.0
if location is not None:
new_obj.location = location
elif new_obj.sample:
new_obj._location = mc.get_random_location(rng=new_obj.rng)[0]
elif new_obj._location is None:
new_obj._location = (0, 0)
if density is not None:
new_obj.density = density
return new_obj
@parameter
def sample(self):
"""
Flag that determines whether to sample velocities, angles, and directions from distributions. If set to False, impact velocities will be set to the mean velocity, impact angles will be set to 90 degrees (vertical impact), and directions will be 0.
Returns
-------
bool
"""
return self._sample
@sample.setter
def sample(self, value):
if not isinstance(value, bool):
raise TypeError("sample must be a boolean value")
self._sample = value
return
@parameter
def mean_velocity(self):
"""
The mean velocity of the projectile in m/s.
Returns
-------
float
"""
return self._mean_velocity
@mean_velocity.setter
def mean_velocity(self, value):
if isinstance(value, np.ndarray):
value = value.item()
if isinstance(value, FloatLike):
if value < 0:
raise ValueError("mean_velocity must be a positive number")
self._mean_velocity = float(value)
else:
raise TypeError("mean_velocity must be a numeric value")
return
@property
def angle(self):
"""The impact angle relative to horizontal in degrees."""
return self._angle
@angle.setter
def angle(self, value):
if isinstance(value, np.ndarray):
value = value.item()
if not isinstance(value, FloatLike):
raise TypeError("angle must be a numeric value")
if value < 0:
raise ValueError("angle must be a positive number")
self._angle = float(value)
@property
def direction(self):
"""The impact direction measured clockwise from North in degrees."""
return self._direction
@direction.setter
def direction(self, value):
if isinstance(value, np.ndarray):
value = value.item()
if not isinstance(value, FloatLike):
raise TypeError("direction must be a numeric value")
if value < 0:
raise ValueError("direction must be a positive number")
self._direction = float(value)
@property
def location(self) -> tuple[float, float]:
"""The location of the projectile (longitude, latitude) in degrees on the target body."""
return self._location
@location.setter
def location(self, value: tuple[float, float]):
self._location = validate_and_normalize_location(value)
@property
def velocity(self):
"""The impact velocity in m/s."""
return self._velocity
@velocity.setter
def velocity(self, value):
if value is not None:
if not isinstance(value, FloatLike):
raise TypeError("velocity must be a numeric value")
if value < 0:
raise ValueError("velocity must be a positive number")
self._velocity = float(value)
else:
self._velocity = None
return
@parameter
def density(self):
"""The bulk density of the projectile in kg/m³."""
return self._density
@density.setter
def density(self, value):
if not isinstance(value, FloatLike):
raise TypeError("density must be a numeric value")
if value < 0:
raise ValueError("density must be a positive number")
self._density = float(value)
@property
def vertical_velocity(self):
"""The vertical component of the impact velocity in m/s."""
return self.velocity * math.sin(math.radians(self.angle))
@property
def population(self):
"""The name of the population of the projectile model."""
return self._component_name
@property
def target(self):
"""The Target object associated with theprojectile model."""
return self._target
@target.setter
def target(self, value):
from cratermaker.components.target import Target
self._target = Target.maker(value)
@property
def catalogue(self) -> str:
"""A string representation of the projectile catalogue for the target body."""
from cratermaker.utils.general_utils import format_large_units
if self.__class__._catalogue is None:
return "This Projectile component does not have a catalogue."
lines = []
header1 = "Target"
lines.append(f"\n{header1:<11}| Mean Velocity")
lines.append(26 * "-")
for name, velocity in self.__class__._catalogue.items():
formatted_velocity = format_large_units(velocity, quantity="velocity")
lines.append(f"{name:<11}| {formatted_velocity}")
return "\n".join(lines)
import_components(__name__, __path__)