Source code for planetary_coverage.trajectory.config

"""Tour configuration module."""

from pathlib import Path

import numpy as np

from .trajectory import AltitudeTooHighError, Flyby, Trajectory
from ..esa import ESA_MK
from ..events.event import Event, EventsDict, EventsList, EventWindow
from ..misc import cached_property, getenv
from ..spice import MetaKernel, SpiceAbCorr, SpicePool, check_kernels
from ..spice.metakernel import MissingKernel, MissingKernelsRemote


[docs]class TourConfig: """Orbital tour configuration object. Prepare the kernels configuration based on the selected spacecraft, target and metakernel setup. By default the SPICE kernel pool is purge and automatically loaded with the selected kernels. Parameters ---------- mk: MetaKernel or str, optional Metakernel filename (``*.tm``) or ESA metakernel identifier key. You can provide your own or use the one provided by ESA based on the spacecraft selected (default: `None`). For example: ``'5.0'`` with the spacecraft ``JUICE`` will load ``juice_crema_5_0.tm`` metakernel. kernels: str or pathlib.Path, optional Kernel filename or list of kernel filenames that will be loaded into the kernel pool. This could be use alone or complementary to a metakernel. If used with a metakernel, the kernels will be loaded at the end and will have the priority over the kernels in the metakernel. spacecraft: str, optional Name of the spacecraft selected (default: `JUICE`). instrument: str, optional Name of the instrument selected (default: `None`). target: str or SpiceBody, optional Name of the target selected (default: `Ganymede`). version: str, optional ESA metakernel SKD version / tag (default:`latest`). This parameter is only available for metakernel defined with a key/shortcut and available in :attr:`ESA_MK`. kernels_dir: str or pathlib.Path, optional Kernels directory location. This parameter is only used to substitute the ``$KERNELS`` symbol value at runtime in the provided metakernel. This parameter has no effect on the :attr:`kernels` parameter. If no explicit value is provided (default), the tool will try to pull the kernel location from your environment variables configuration, i.e. it will use the `KERNELS_XXXX` env variable if you defined it on your system (with `XXXX` the name spacecraft). download_kernels: bool, optional Try to download the missing kernels in the metakernel if they are missing (default: `False`). This parameter has no effect on the :attr:`kernels` parameter. remote_kernels: str or int, optional Remote kernel source. If none is provided (default), the content of the file will be parsed to search for a remote base value (with ``://``). If multiple remotes are present, the first one will be used by default. You can provide an integer to choose which one you want to use. This value is not required if all the kernel are present locally (it is only used to download the missing kernels). load_kernels: bool, optional Explicitly force the load to the kernels in the SPICE pool (default: False). If forced, the content of the SPICE pool is checked, flushed and reloaded if needed. This can also be achieved with the `.load_kernels()` function. Any SPICE related calculation (decorated with `@check_kernels`) will performed this check. default_time_step: str, optional Default time step if a temporal slice is provided without a defined temporal step (default: ``1 minute``). abcorr: str, optional Aberration corrections to be applied when computing the target's position and orientation. Only the SPICE keys are accepted. exclude: EventWindow, EventsDict or EventsList Event window, dict or list of events to exclude from the analysis. Raises ------ ValueError If the metakernel is provided as a key/shortcut but the associated spacecraft is not available in :attr:`ESA_MK`. KernelsDirectoryNotFoundError If not kernels directory is supplied and the kernels were not found in the metakernel location. KernelNotFoundError If some kernels are missing and :attr:`download_kernels` is set to ``False``. KernelRemoteNotFoundError If the source of the kernels in the metakernel is unknown. KeyError If the target name is unknown. Tip --- If you need to replace a custom ``PATH_SYMBOLS`` different from `KERNELS` you could provide a :class:`.MetaKernel` object with the substituted values in the initial call: >>> TourConfig(mk=MetaKernel('foo.tm', custom_symbol='CUSTOM_VALUE')) """ kernels = () def __init__(self, mk=None, kernels=None, spacecraft='JUICE', instrument='', target='Ganymede', version='latest', kernels_dir=None, download_kernels=False, remote_kernels=0, load_kernels=False, default_time_step='1 minute', abcorr='NONE', exclude=None): # Properties self.spacecraft = spacecraft.upper() self.target = target.upper() self.instrument = instrument.upper() self.exclude = exclude # Kernel setup self.mk = mk, version, kernels_dir, download_kernels, remote_kernels self._add(kernels) if load_kernels: self.load_kernels() # Trajectory/Flyby default parameters self.default_time_step = default_time_step self.abcorr = SpiceAbCorr(abcorr) def __repr__(self): return f'<{self.__class__.__name__}> ' + ' | '.join( filter( lambda x: x is not None, [ f'Spacecraft: {self.spacecraft}', f'Instrument: {self.instrument}' if self.instrument else None, f'Target: {self.target}', f'Metakernel: {self.mk_identifier}' if self.mk else None, f'SKD version: {self.skd_version}' if self.skd_version else None, ] )) def __getitem__(self, times): traj = Trajectory( self.kernels, self.spacecraft, self.target, self._parse(times), abcorr=self.abcorr, exclude=self.exclude, ) if self.instrument: return traj.new_traj(instrument=self.instrument) return traj def __hash__(self): """Kernels hash.""" return self._kernels_hash @cached_property def _kernels_hash(self): """Expected Spice Pool kernels hash.""" return SpicePool.hash(self.kernels) @property def mk(self) -> MetaKernel: """Selected metakernel.""" return self.__mk @mk.setter def mk(self, args): """Metakernel setter. Parameters ---------- mk, kernels_dir, download: str or MetaKernel, str, bool Metakernel key or filename. The kernels directory that with be used to substitute the ``$KERNELS`` symbol value in the metakernel at runtime. Raises ------ ValueError If the metakernel is provided as a key/shortcut but the associated spacecraft is not available in :attr:`ESA_MK`. KernelsDirectoryNotFoundError If not kernels directory is supplied and the kernels were not found in the metakernel location. KernelNotFoundError If some kernels are missing and :attr:`download_kernels` is set to ``False``. KernelRemoteNotFoundError If the source of the kernels in the metakernel is unknown. """ mk, version, kernels_dir, download_kernels, remote_kernels = args # Store input mk parameters self._version, self._kernels_dir, self._download_kernels, self._remote_kernels = \ version, kernels_dir, download_kernels, remote_kernels if not isinstance(mk, (type(None), MetaKernel)): # Defined metakernels keyword arguments kwargs = {'download': download_kernels, 'remote': remote_kernels} if kernels_dir is None: spacecraft = str(self.spacecraft).replace(' ', '_') kernels_dir = getenv(f'KERNELS_{spacecraft}') if kernels_dir: kwargs['kernels'] = kernels_dir # Load ESA metakernel from key/shortcut (if available) if not str(mk).lower().endswith('.tm'): if self.spacecraft not in ESA_MK: raise ValueError( f'The spacecraft provided (`{self.spacecraft}`) does not support ' 'ESA metakernel shortcuts. ' 'Please provide an explicit `metakernel.tm` file.') mk = ESA_MK[self.spacecraft, mk, version] # Load the metakernel content and catch errors try: mk = MetaKernel(mk, **kwargs) except MissingKernel: if not kernels_dir: raise KernelsDirectoryNotFoundError( 'You need to provide an explicit `kernels_dir` attribute ' f'or add an environment variable `KERNELS_{self.spacecraft}`' ' with the path to your kernels directory.' ) from None raise KernelNotFoundError( 'Some kernels are missing, use `download_kernels=True` ' 'to download them.' ) from None except MissingKernelsRemote: raise KernelRemoteNotFoundError( 'The source of the kernels in the metakernel is unknown. ' 'You can provide directly a `MetaKernel` object ' 'with an explicit `remote` attribute to fix this issue.') # Cache the metakernel self.__mk = mk self.kernels = (mk,) if mk is not None else () @property def metakernel(self) -> Path: """Metakernel filename.""" return self.mk.fname if self.mk else None # pylint: disable=no-member def _add(self, kernel): """Add custom kernels to the configuration. The additional kernels are loaded after the metakernel (if present) and will have the priority in the Spice Pool. """ if kernel is not None: if hasattr(kernel, '__iter__') and not isinstance(kernel, str): for _kernel in kernel: self._add(_kernel) else: self.kernels += (str(Path(kernel).expanduser()),) @property @check_kernels def skd_version(self) -> str: """ESA metakernel SKD version value.""" try: return SpicePool['SKD_VERSION'] except KeyError: return None @property @check_kernels def mk_identifier(self) -> str: """ESA metakernel ID identifier value.""" try: return SpicePool['MK_IDENTIFIER'] except KeyError: return self.mk def _parse(self, times): """Parse input times. Method used in :func:`__getitem__` to query a trajectory on a temporal grid. Parameters ---------- times: str, int, float, slice, list, Event or EventWindow Different input times by types: - ``None`` or ``'all'`` for the whole tour (use the full coverage of the observer and the target loaded in the SPICE pool) with a regular temporal step of 30 minutes. If you need the full coverage with another time step, please consider using a slice with empty ``start`` and ``stop`` values (``[::'1 year']``). - ``'2033-01-01T12:34:56'`` time string with or without explicit time. All the SPICE time formats are supported. No change is performed in this method (conversion to ET is done in the :class:`Trajectory` initialization). - ``numpy.datetime64`` can be used (it will be converted as an ISO time string later, no change here). - ``int`` or ``float`` is considered to be an ephemeris time (ET). No change in this method. - ``slice(start, stop, step)`` times. All of them are optional. ``start`` can be replaced by ``None|'start'|'beg'|'begin'|'beginning'`` to use the coverage first point. ``stop`` can be replaced by ``None|'stop'|'end'`` to use the coverage last point. If no explicit ``step`` is provided, the :attr:`default_time_step` (provided in :func:`__init__`) will be used; if an ``int`` is provided, it will correspond to the total number of points (evenly space in time). - ``list``, ``tuple``, ``numpy.array`` are only modified when an :class:`Event` or an :class:`EventWindow` is provided (converted to ET later). - :class:`Event` will extract the event time (single value). - :class:`EvenWindow` will extract the start and stop time of the event window (the :attr:`default_time_step`) - ``'event-name'`` if a ``fk`` event kernel is provided, events can be loaded directly with their names. Returns ------- int, float, str, slice, list, tuple or numpy.datetime64 Parsed input time when necessary. """ if isinstance(times, (tuple, list, np.ndarray, EventsDict, EventsList)): return [self._parse(t) for t in times] if times in [None, 'all']: return slice(*self.coverage, '30 minutes') if isinstance(times, (Event, EventWindow)): return self._parse_event(times) if isinstance(times, slice): return self._parse_slice(times) if event := self._get_event(times): return self._parse(event) return times def _parse_event(self, event): """Parse event times.""" if isinstance(event, Event): return event.start # EventWindow return slice(event.start, event.stop, self.default_time_step) def _parse_slice(self, t_slice): """Parse slice times.""" start, stop, step = t_slice.start, t_slice.stop, t_slice.step if start in [None, 'start', 'beg', 'begin', 'beginning']: start = self.coverage[0] elif isinstance(start, Event): start = start.start if not isinstance(stop, Event): return start elif isinstance(start, EventWindow): # EventWindow:'1h' -> the step is moved from `stop` to `step` start, stop, step = start.start, start.stop, stop elif isinstance(start, (EventsDict, EventsList)): # Events:'1h' -> the step is moved from `stop` to `step` events, step = start, stop return [self._parse_slice(slice(event, step)) for event in events] elif event := self._get_event(start): step = stop return self._parse_slice(slice(event, step)) if stop in [None, 'stop', 'end']: stop = self.coverage[1] elif isinstance(stop, Event): stop = stop.stop if step is None: step = self.default_time_step return slice(start, stop, step)
[docs] @check_kernels def load_kernels(self): """Load the required kernels into the SPICE pool. Note ---- If the SPICE pool already contains the required kernels, nothing will append. If not, the pool is flushed and only the required kernels are loaded. """
@property @check_kernels def coverage(self): """Observer and Target intersection coverage. Overlapping windows of coverage from the observer and target data loaded in the SPICE pool. """ start, stop = SpicePool.coverage(self.spacecraft, self.target) # Fix rounding issues start += np.timedelta64(1, 'ms') stop -= np.timedelta64(1, 'ms') return start, stop
[docs] @check_kernels def gaps(self, *refs): """Get temporal coverage gaps intervals by reference(s). Parameters ---------- refs: str, int or SpiceRef Body(ies) reference(s). Returns ------- EventsList Events list of gaps. """ gaps = SpicePool.gaps(*refs, fmt='UTC') return EventsList([ EventWindow('coverage-gap', t_start=start, t_end=stop) for start, stop in gaps ])
@cached_property @check_kernels def phases(self): """Mission phases events list. The following properties needs to be present in the pool: - <SPACECRAFT>_MISSION_PHASE_NAME - <SPACECRAFT>_MISSION_PHASE_DESC - <SPACECRAFT>_MISSION_PHASE_STRT - <SPACECRAFT>_MISSION_PHASE_STOP """ spacecraft = self.spacecraft.upper() # Load mission phases try: phases = np.transpose([ SpicePool[f'{spacecraft}_MISSION_PHASE_NAME'], SpicePool[f'{spacecraft}_MISSION_PHASE_DESC'], SpicePool[f'{spacecraft}_MISSION_PHASE_STRT'], SpicePool[f'{spacecraft}_MISSION_PHASE_STOP'], ]) except KeyError: return {} # Parse mission phases return EventsDict([ EventWindow(desc, name=name, desc=desc, t_start=start, t_end=end) for name, desc, start, end in phases ]) @cached_property @check_kernels def timeline(self): """Mission timeline events list. The following properties needs to be present in the pool: - <SPACECRAFT>_TIMELINE_EVENT_TYPE - <SPACECRAFT>_TIMELINE_EVENT_NAME - <SPACECRAFT>_TIMELINE_EVENT_TIME """ spacecraft = self.spacecraft.upper() # Load mission phases try: timeline = np.transpose([ SpicePool[f'{spacecraft}_TIMELINE_EVENT_TYPE'], SpicePool[f'{spacecraft}_TIMELINE_EVENT_NAME'], SpicePool[f'{spacecraft}_TIMELINE_EVENT_TIME'], ]) except KeyError: return {} # Parse mission phases return EventsDict([ Event(key, **{'Crema name': name, 'time': time}) for key, name, time in timeline ])
[docs] def get_event(self, name): """Get event by name from mission phases or timeline. Parameters ---------- name: str Event name. Returns ------- Event or EventWindow Queried event or event window from the mission phases or timeline, if present. Raises ------ KeyError If the provided event name is invalid. """ if event := self._get_event(name): return event raise KeyError(f'Unknown event `{name}`')
def _get_event(self, name): """Internal get event by name from mission phases or timeline. See also -------- get_event """ if name in self.phases.keys(): # pylint: disable=C0201 return self.phases[name] if name in self.timeline.keys(): # pylint: disable=C0201 return self.timeline[name] for event in self.timeline: if isinstance(event, EventsList): if name in event.crema_names: return event[name] elif name == event.get('Crema name'): return event return None
[docs] def add_kernel(self, *kernels): """Create a new tour with additional kernels. Parameters ---------- *kernels: str or pathlib.Path Kernel(s) to append. Returns ------- TourConfig New tour configuration with a new set of kernels. """ return TourConfig( mk=self.mk, kernels=self.kernels[slice(1 if self.mk else 0, None)] + tuple(kernels), spacecraft=self.spacecraft, instrument=self.instrument, target=self.target, version=self._version, kernels_dir=self._kernels_dir, download_kernels=self._download_kernels, remote_kernels=self._remote_kernels, default_time_step=self.default_time_step, abcorr=self.abcorr, exclude=self.exclude, )
[docs] def new_tour(self, *, spacecraft=None, instrument=None, target=None): """Create a new tour configuration for a different set of target/observer. You can provide either one or multiple parameters as once. Parameters ---------- spacecraft: str or SpiceSpacecraft, optional New spacecraft name. instrument: str or SpiceInstrument, optional New instrument name (see note below). target: str or SpiceBody, optional New target name. Returns ------- TourConfig New tour configuration with new parameters. Raises ------ ValueError If no new parameter is provided. Note ---- If a ``spacecraft`` is provided without an ``instrument``, the ``instrument`` will be reset to ``''``. """ if not spacecraft and not instrument and not target: raise ValueError('You need to provide at least a `spacecraft`, ' 'an `instrument` or a `target` parameter.') inst = instrument if instrument else '' if spacecraft else self.instrument return TourConfig( mk=self.mk, kernels=self.kernels[slice(1 if self.mk else 0, None)], spacecraft=spacecraft if spacecraft else self.spacecraft, instrument=inst, target=target if target else self.target, version=self._version, kernels_dir=self._kernels_dir, download_kernels=self._download_kernels, remote_kernels=self._remote_kernels, default_time_step=self.default_time_step, abcorr=self.abcorr, exclude=self.exclude, )
@property def flybys(self): """List of all the flybys on the target below 150,000 km. See Also -------- :func:`get_flybys` if you need a different minimal altitude. """ return self.get_flybys()
[docs] def get_flybys(self, event=None, alt_min=150_000): """List of all the flybys on the target below a given altitude. Parameters ---------- event: EventWindow, EventsDict or EventsList, optional Optional event. If none is provided (default), the full coverage window will be used with a time step of 30 minutes. default_time_step: str, optional Default time step grid to search the location of the minimum of altitude in the flyby (default: ``'30 mins'``). alt_min: float, optional Minimal altitude at closest approach (default: ``150,000`` km). Returns ------- [Flyby, …] List of flybys below the required altitude. """ if event is None: if events := self._get_event(f'FLYBY_{self.target.upper()}'): flybys = {} for ev in events: try: name = ev.get('Crema name') flybys[name] = self.flyby(ev.start, alt_min=alt_min) except AltitudeTooHighError: pass return flybys # Take the whole tour if no event is provided event = 'all' return self[event].get_flybys(alt_min=alt_min)
[docs] def flyby(self, approx_ca_date, *dt, alt_min=150_000): """Select a single flyby with an approximate date. Parameters ---------- approx_ca_date: float, str or numpy.datetime64 Approximate CA datetime (at day level). This value will be re-computed (at the second level). :class:`.Event`, :class:`.EventWindow`, :class:`.EventsDict` and :class:`.EventsList` can be used as well. *dt: tuple(s), optional Temporal sequence around closest approach: .. code-block:: text (duration, numpy.datetime unit, step value and unit) See :func:`.et_ca_range` for more details. alt_min: float, optional Minimal altitude at closest approach (default: 150,000 km). Returns ------- SpacecraftFlyby or InstrumentFlyby Flyby trajectory. Note ---- If an :class:`.EventWindow`, :class:`.EventsDict` or an :class:`.EventsList` is provided, the output will be a list of the flybys found in these intervals. """ if isinstance(approx_ca_date, Event): approx_ca_date = approx_ca_date.start elif isinstance(approx_ca_date, EventWindow): return self.get_flybys(event=approx_ca_date, alt_min=alt_min) elif isinstance(approx_ca_date, (list, EventsDict, EventsList)): return [ self.flyby(event, *dt, alt_min=alt_min) for event in approx_ca_date ] elif event := self._get_event(approx_ca_date): return self.flyby(event, *dt, alt_min=alt_min) flyby = Flyby( self.kernels, self.spacecraft, self.target, approx_ca_date, *dt, abcorr=self.abcorr, exclude=self.exclude, alt_min=alt_min, ) if self.instrument: return flyby.new_traj(instrument=self.instrument) return flyby
class KernelsDirectoryNotFoundError(FileNotFoundError): """Kernels directory not found error.""" class KernelNotFoundError(FileNotFoundError): """Kernels not found error.""" class KernelRemoteNotFoundError(KernelNotFoundError): """Kernels remote not found error."""