Source code for planetary_coverage.spice.references

"""SPICE reference module."""

import re

import numpy as np

import spiceypy as sp

from .fov import SpiceFieldOfView
from .kernel import get_item
from .times import sclk
from ..misc import cached_property


# Spice frame classes
FRAME_CLASS_TYPES = {
    1: 'Inertial frame',
    2: 'PCK body-fixed frame',
    3: 'CK frame',
    4: 'Fixed offset frame',
    5: 'Dynamic frame',
    6: 'Switch frame',
}


[docs] class SpiceRefRegex: r"""SPICE regular expression for references. Tip --- To check the pattern you need to use the ``in`` keyword: >>> 399 in SpiceRefRegex(r'[1-9]99') True Warning ------- The match is only performed on the whole pattern, and the regular expression pattern is always prefixed with ``^`` and suffixed by ``$`` internally (if not already present). Note ---- SpiceRefRegex patterns can be concatenated with the *OR* operator (``|``): >>> INNER_MOONS = SpiceRefRegex(r'301|401|402') >>> OUTER_MOONS = SpiceRefRegex(r'[5-9](?!00|99)\d{2}') >>> 501 in INNER_MOONS | OUTER_MOONS True """ def __init__(self, re_exp): if not re_exp.startswith('^'): re_exp = rf'^{re_exp}' if not re_exp.endswith('$'): re_exp = rf'{re_exp}$' self.re_exp = re_exp def __str__(self): return self.re_exp def __contains__(self, ref): """Match SPICE code to a regular expression""" return bool(re.match(self.re_exp, str(int(ref)))) def __or__(self, other): if isinstance(other, type(self)): return self.__class__(f'{self}|{other}') raise NotImplementedError
# Spice body code regular expressions RE_SUN = SpiceRefRegex('10') # Planets: 3 digits ending with 99 RE_PLANETS = SpiceRefRegex(r'[1-9]99') # Satellites: Inner moons in solar system: Moon (301) / Phobos (401) / Deimos (402) RE_INNER_MOONS = SpiceRefRegex(r'301|401|402') # Satellites: Outer moons in solar system (501-598, 601-698, …) RE_OUTER_MOONS = SpiceRefRegex(r'[5-9](?!00|99)\d{2}') # Comets: 1,000,000 + JPL's Solar System Dynamics Group number RE_COMETS = SpiceRefRegex(r'1(?!0{6})\d{6}') # Comet fragments (from Shoemaker Levy-9): 50,000,001 to 50,000,023 RE_COMET_FRAGMENTS = SpiceRefRegex(r'50{5}(0[1-9]|1\d|2[0-3])') # Asteroids original schema (permanently and provisional numbered) RE_ASTEROIDS_ORIGINAL_SCHEMA = SpiceRefRegex(r'[2-3](?!0{6})\d{6}') # Asteroids extended schema (permanently and provisional numbered) RE_ASTEROIDS_EXTENDED_SCHEMA = SpiceRefRegex(r'(?![25]0{7})[2-9]\d{7}') # Asteroid systems with two or more bodies (9-digits) primary body with 9 prefix RE_ASTEROID_PRIMARIES = SpiceRefRegex(r'9(?![25]0{7})[2-9]\d{7}') # Asteroid systems with two or more bodies (9-digits) excluding primary body with 9 prefix RE_ASTEROID_SECONDARIES = SpiceRefRegex(r'[1-8](?![25]0{7})[2-9]\d{7}') # Asteroid exceptions (Gaspra) RE_ASTEROID_EXCEPTIONS = SpiceRefRegex('9511010') # Spice DSN spacecraft (-1 to -999) RE_SC_DSN = SpiceRefRegex(r'-(?!0{1,3})\d{1,3}') # Spice NORAD spacecraft (-100,001 to -119,999) RE_SC_NORAD = SpiceRefRegex(r'-1(?!0{5})[01]\d{4}') # Spice instrument CODE_INST_MAX = -1_000 CODE_INST_MIN = -1_000_000 def spice_name_code(ref): """Get name and code from a reference. Parameters ---------- ref: str or int Reference name or code id. Returns ------- str, int Reference name and code id. Raises ------ ValueError If this reference is not known in the kernel pool. """ try: code = sp.bods2c(str(ref).upper()) name = sp.bodc2n(code) except sp.stypes.NotFoundError: if re.match(r'-?\d+', str(ref)): code, name = int(ref), sp.frmnam(int(ref)) else: code, name = sp.namfrm(ref), str(ref) if code == 0 or not name: raise ValueError(f'Unknown reference: `{ref}`') from None return str(name), int(code)
[docs] class AbstractSpiceRef: """SPICE reference helper. Parameters ---------- ref: str or int Reference name or code id. Raises ------ KeyError If this reference is not known in the kernel pool. """ def __init__(self, ref): self.name, self.id = spice_name_code(ref) if not self.is_valid(): raise KeyError(f'{self.__class__.__name__} invalid id: `{int(self)}`') def __str__(self): return self.name def __repr__(self): return f'<{self.__class__.__name__}> {self} ({int(self):_})' def __int__(self): return self.id def __hash__(self): return hash(str(self)) def __eq__(self, other): return str(self) == other or int(self) == other def __getitem__(self, item): return get_item(item) @property def code(self): """SPICE reference ID as string.""" return str(self.id)
[docs] def encode(self, encoding='utf-8'): """Reference name encoded.""" return str(self).encode(encoding=encoding)
[docs] def is_valid(self): """Generic SPICE reference. Returns ------- bool Generic SPICE reference should always ``True``. """ return isinstance(int(self), int)
@cached_property def frame(self): """Reference frame.""" if hasattr(self, '_frame'): return SpiceFrame(self._frame) try: return SpiceFrame(sp.cidfrm(int(self))[1]) except sp.stypes.NotFoundError: return SpiceFrame(get_item(f'FRAME_{int(self)}_NAME'))
[docs] class SpiceFrame(AbstractSpiceRef): """SPICE reference frame. Parameters ---------- name: str or int Reference frame name or code id. """
[docs] def is_valid(self): """Check if the code is a frame code.""" return bool(sp.namfrm(str(self)))
@property def class_type(self): """Frame class type.""" _, _class, _ = sp.frinfo(int(self)) return FRAME_CLASS_TYPES[_class] @property def center(self): """Frame center reference.""" return SpiceRef(int(get_item(f'FRAME_{int(self)}_CENTER'))) @property def sclk(self): """Frame SCLK reference.""" return SpiceRef(int(get_item(f'CK_{int(self)}_SCLK'))) @property def spk(self): """Frame SPK reference.""" return SpiceRef(int(get_item(f'CK_{int(self)}_SPK'))) @cached_property def frame(self): """Reference frame. Not implemented for a :class:`SpiceFrame`. """ raise NotImplementedError
[docs] class SpiceBody(AbstractSpiceRef): """SPICE body reference. It can be either: - Sun - Planet - Satellite - Comet (including fragments) - Asteroid (permanently or provisional numbered) Parameters ---------- name: str or int Body name or code id. """ def __getitem__(self, item): return get_item(f'BODY{int(self)}_{item.upper()}')
[docs] def is_valid(self): """Check if the code is valid for a SPICE body. Refer to the `NAIF Integer ID codes <https://naif.jpl.nasa.gov/pub/naif/toolkit_docs/FORTRAN/req/naif_ids.html>`_ in section `Planets and Satellites`/`Comets`/`Asteroids` for more details. - Sun: ``10`` - Planets: ``N99`` - Satellites: ``NMM`` with ``MM`` not ``00`` or ``99`` (*) (*): No satellites for Mercury nor Venus, only the Moon (``301``) for the Earth and Phobos (``401``) and Deimos (``402``) for Mars. - Commets: ``1,000,000 + JPL's Solar System Dynamics Group number`` - Asteroid original schema (7-digits): - Permanently numbered: ``2,000,000 + Permanent Asteroid Number`` (2,000,001 to 2,999,999) - Provisional numbered: ``3,000,000 + Provisional Asteroid Number`` (3,000,001 to 3,999,999) - Asteroid extended schema (8 or 9-digits): - Permanently numbered: ``20,000,000 + Permanent Asteroid Number`` (20,000,001 to 49,999,999) - Provisional numbered: ``50,000,000 + Provisional Asteroid Number`` (20,000,001 to 49,999,999) Warning ------- For asteroid systems with two or more bodies the 8-digit NAIF ID code represents the barycenter. Individual satellites have a prepended number ``1`` through ``8``, while the primary body uses the *last available* prefix ``9``, resulting in 9-digit NAIF ID codes. For example, asteroids Didymos (``65,803``) and its satellite Dimorphos can be accommodated only using the extended schema with IDs ``920,065,803`` and ``120,065,803``, and Didymos system barycenter with ID ``20,065,803``. Note ---- Asteroid barycenter will not be considered as ``SpiceBody`` but as a regular ``SpiceRef``. Returns ------- bool Valid body ids are listed above. """ if 'BARYCENTER' in str(self): return False return ( self in RE_SUN | RE_PLANETS | RE_INNER_MOONS | RE_OUTER_MOONS | RE_COMETS | RE_COMET_FRAGMENTS | RE_ASTEROIDS_ORIGINAL_SCHEMA | RE_ASTEROIDS_EXTENDED_SCHEMA | RE_ASTEROID_PRIMARIES | RE_ASTEROID_SECONDARIES | RE_ASTEROID_EXCEPTIONS )
@property def is_planet(self): """Check if the body is a planet.""" return self in RE_PLANETS @property def is_satellite(self): """Check if the body is a planet, asteroid or commet's satellite.""" return ( self in RE_INNER_MOONS | RE_OUTER_MOONS | RE_COMET_FRAGMENTS | RE_ASTEROID_SECONDARIES ) @property def is_comet(self): """Check if the body is a comet.""" return self in RE_COMETS | RE_COMET_FRAGMENTS @property def is_asteroid(self): """Check if the body is an asteroid.""" return (self not in RE_COMET_FRAGMENTS) and ( self in RE_ASTEROIDS_ORIGINAL_SCHEMA | RE_ASTEROIDS_EXTENDED_SCHEMA | RE_ASTEROID_PRIMARIES | RE_ASTEROID_SECONDARIES | RE_ASTEROID_EXCEPTIONS ) @cached_property def parent(self): """Parent body.""" if self in RE_INNER_MOONS | RE_OUTER_MOONS: parent = f'{self.code[0]}99' elif self in RE_COMET_FRAGMENTS: parent = 'JUPITER' elif self in RE_ASTEROID_SECONDARIES: parent = f'9{self.code[1:]}' else: parent = 'SUN' return SpiceBody(parent) @cached_property def barycenter(self): """Body barycenter.""" if self in RE_SUN | RE_PLANETS: return SpiceRef(int(self) // 100) if self in RE_ASTEROID_PRIMARIES: return SpiceRef(f'{self.code[1:]}') return self.parent.barycenter @cached_property def radii(self): """Body radii, if available (km).""" return self['RADII'] @property def radius(self): """Body mean radius, if available (km).""" return np.cbrt(np.prod(self.radii)) @property def r(self): """Body mean radius alias.""" return self.radius @property def re(self): """Body equatorial radius, if available (km).""" return self.radii[0] @property def rp(self): """Body polar radius, if available (km).""" return self.radii[2] @property def f(self): """Body flattening coefficient, if available (km).""" re, _, rp = self.radii return (re - rp) / re @cached_property def mu(self): """Gravitational parameter (GM, km³/sec²).""" return self['GM']
[docs] class SpiceObserver(AbstractSpiceRef): """SPICE observer reference. Parameters ---------- ref: str or int Reference name or code id. Raises ------ KeyError If the provided key is neither spacecraft nor an instrument. """ def __init__(self, ref): super().__init__(ref) # Spacecraft object promotion if SpiceSpacecraft.is_valid(self): self.__class__ = SpiceSpacecraft # Instrument object promotion elif SpiceInstrument.is_valid(self): self.__class__ = SpiceInstrument else: raise KeyError('A SPICE observer must be a valid Spacecraft or Instrument')
[docs] class SpiceSpacecraft(SpiceObserver): """SPICE spacecraft reference. Parameters ---------- name: str or int Spacecraft name or code id. """ BORESIGHT = [0, 0, 1]
[docs] def is_valid(self): """Check if the code is valid for a SPICE spacecraft. Refer to the `NAIF Integer ID codes <https://naif.jpl.nasa.gov/pub/naif/toolkit_docs/FORTRAN/req/naif_ids.html>`_ in sections `Spacecraft` and `Earth Orbiting Spacecraft` for more details. - Interplanetary spacecraft is normally the negative of the code assigned to the same spacecraft by JPL's Deep Space Network (DSN) as determined the NASA control authority at Goddard Space Flight Center. - Earth orbiting spacecraft are defined as: ``-100000 - NORAD ID code`` Returns ------- bool Valid spacecraft ids are between -999 and -1 and between -119,999 and -100,001. """ return self in RE_SC_DSN | RE_SC_NORAD
@cached_property def instruments(self): """SPICE instruments in the pool associated with the spacecraft.""" keys = sp.gnpool(f'INS{int(self)}*_FOV_FRAME', 0, 1_000) codes = sorted([int(key[3:-10]) for key in keys], reverse=True) return list(map(SpiceInstrument, codes))
[docs] def instr(self, name): """SPICE instrument from the spacecraft.""" try: return SpiceInstrument(f'{self}_{name}') except ValueError: return SpiceInstrument(name)
@property def spacecraft(self): """Spacecraft SPICE reference.""" return self
[docs] def sclk(self, *time): """Continuous encoded spacecraft clock ticks. Parameters ---------- *time: float or str Ephemeris time (ET) or UTC time inputs. """ return sclk(int(self), *time)
@cached_property def frame(self): """Spacecraft frame (if available).""" try: return super().frame except (ValueError, KeyError): return SpiceFrame(self[f'FRAME_{int(self) * 1_000}_NAME']) @property def boresight(self): """Spacecraft z-axis boresight. For an orbiting spacecraft, the Z-axis points from the spacecraft to the closest point on the target body. The component of inertially referenced spacecraft velocity vector orthogonal to Z is aligned with the -X axis. The Y axis is the cross product of the Z axis and the X axis. You can change the :attr:`SpiceSpacecraft.BORESIGHT` value manually. """ return np.array(self.BORESIGHT)
[docs] class SpiceInstrument(SpiceObserver, SpiceFieldOfView): """SPICE instrument reference. Parameters ---------- name: str or int Instrument name or code id. """ def __getitem__(self, item): return get_item(f'INS{int(self)}_{item.upper()}')
[docs] def is_valid(self): """Check if the code is valid for a SPICE instrument. Refer to the `NAIF Integer ID codes <https://naif.jpl.nasa.gov/pub/naif/toolkit_docs/FORTRAN/req/naif_ids.html>`_ in section `Instruments` for more details. .. code-block:: text NAIF instrument code = (s/c code)*(1000) - instrument number This allows for 1000 instrument assignments on board a spacecraft. Danger ------ Based on the SPICE documentation, and instrument should have a NAIF code between -1,000,000. For some mission (Juice, EnVision), this rule is not enforced because some instrument requires more ids. For examples: - ``juice_pep_v15.ti`` has ``JUICE_PEP_JDC_PIXEL_000 (ID: -2_851_000)`` - ``envision_vensar_v00.ti`` has ``ENVISION_VENSAR_SAR_20_220 (ID: -668_110_001)`` Returns ------- bool Valid instrument ids are below -1,000 and have a valid field of view definition. """ if ( int(self) >= CODE_INST_MAX ): # MIN value (-1,000,000) is not enforced (see above) return False try: # Check if the FOV is valid and init the value if not an `int`. if isinstance(self, int): _ = SpiceFieldOfView(self) else: SpiceFieldOfView.__init__(self, int(self)) # noqa: PLC2801 (init on self) return True except ValueError: return False
@cached_property def spacecraft(self): """Parent spacecraft. Warning ------- Some mission don't enforce NAIF rule on instrument id definition. Therefore, the preferred method to guess the parent of the spacecraft name is to extract the first part of the instrument name. If this method fails, the instrument id is divided by 1,000 to get the space id. Raises ------ ValueError If the SpiceSpacecraft is not found. """ try: return SpiceSpacecraft(str(self).split('_', 1)[0]) except ValueError: return SpiceSpacecraft(-(-int(self) // 1_000))
[docs] def sclk(self, *time): """Continuous encoded parent spacecraft clock ticks. Parameters ---------- *time: float or str Ephemeris time (ET) or UTC time inputs. """ return sclk(int(self.spacecraft), *time)
@property def ns(self): """Instrument number of samples.""" try: return int(self['PIXEL_SAMPLES']) except KeyError: return 1 @property def nl(self): """Instrument number of lines.""" try: return int(self['PIXEL_LINES']) except KeyError: return 1 def _rad_fov(self, key): """Get FOV angle value in radians""" angle = self[f'FOV_{key}'] return angle if self['FOV_ANGLE_UNITS'] == 'RADIANS' else np.radians(angle) @property def fov_along_track(self): """Instrument field of view along-track angle (radians).""" if self.shape == 'POLYGON': return np.nan return 2 * self._rad_fov('REF_ANGLE') @property def fov_cross_track(self): """Instrument field of view cross-track angle (radians).""" if self.shape in {'CIRCLE', 'POLYGON'}: return self.fov_along_track return 2 * self._rad_fov('CROSS_ANGLE') @property def ifov(self): """Instrument instantaneous field of view angle (radians). Danger ------ This calculation expect that the sample direction is aligned with the cross-track direction (ie. 1-line acquisition in push-broom mode should be in the direction of flight). Warning ------- ``JUICE_JANUS`` instrument in ``v06`` does not follow this convention. We manually manage this exception for the moment. See `MR !27 <https://gitlab.esa.int/juice-soc-public/python/planetary-coverage/-/merge_requests/27>`_ for more details. """ if self != 'JUICE_JANUS': along_track = self.fov_along_track / self.nl cross_track = self.fov_cross_track / self.ns else: along_track = self.fov_along_track / self.ns cross_track = self.fov_cross_track / self.nl return along_track, cross_track @property def ifov_along_track(self): """Instrument instantaneous along-track field of view angle (radians).""" return self.ifov[0] @property def ifov_cross_track(self): """Instrument instantaneous cross-track field of view angle (radians).""" return self.ifov[1]
[docs] class SpiceRef(AbstractSpiceRef): """SPICE reference generic helper. Parameters ---------- ref: str or int Reference name or code id. """ def __init__(self, ref): super().__init__(ref) # Body object promotion if SpiceBody.is_valid(self): self.__class__ = SpiceBody # Spacecraft object promotion elif SpiceSpacecraft.is_valid(self): self.__class__ = SpiceSpacecraft # Instrument object promotion elif SpiceInstrument.is_valid(self): self.__class__ = SpiceInstrument # Frame object promotion elif SpiceFrame.is_valid(self): self.__class__ = SpiceFrame @property def spacecraft(self): """Spacecraft SPICE reference. Not implemented for a :class:`SpiceRef`. """ raise NotImplementedError
[docs] def sclk(self, *time): """Continuous encoded parent spacecraft clock ticks. Not implemented for a :class:`SpiceRef`. """ raise NotImplementedError