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, warn


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


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}`')

    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(getattr(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 planet/satellite body reference. 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` for more details. Returns ------- bool Valid bodies are 10 (SUN) and any value between 101 and 999. """ return int(self) == 10 or 100 < int(self) < 1_000
@property def is_planet(self): """Check if the body is a planet.""" return self.code[-2:] == '99' @cached_property def parent(self): """Parent body.""" return SpiceBody('SUN' if self.is_planet else self.code[0] + '99') @cached_property def barycenter(self): """Body barycenter.""" if self.is_planet or int(self) == 10: return SpiceRef(int(self) // 100) 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 # pylint: disable=unbalanced-tuple-unpacking 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 -1_000 < int(self) < 0 or -120_000 < int(self) < -100_000
@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 Returns ------- bool Valid instrument ids is below -1,000 and have a valid field of view definition. Warning ------- Based on the SPICE documentation, the min value of the NAIF code should be -1,000,000. This rule is not enforced because some instrument of Juice have value below -2,800,000 (cf. ``JUICE_PEP_JDC_PIXEL_000 (ID: -2_851_000)`` in `juice_pep_v09.ti`). """ if int(self) >= -1_000: 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)) return True except ValueError: return False
@cached_property def spacecraft(self): """Parent spacecraft. Warning ------- The current definition of Juice PEP instruments IDs (in `juice_pep_v09.ti`) are out-of-range NAIF code rules. This special case is expected to be an exception and manually fixed here with a ``DepreciationWarning``. See `issue #12 <https://juigitlab.esac.esa.int/python/planetary-coverage/-/issues/12>`_ to get more details. """ try: return SpiceSpacecraft(-(-int(self) // 1_000)) except ValueError as err: if str(self).startswith('JUICE_PEP_'): warn.warning( 'Invalid Juice/PEP instrument NAIF IDs (%i) for `%s`. ' 'The parent spacecraft ID is manually set to `JUICE` (-28). ' 'See issue #12 ' '(https://juigitlab.esac.esa.int/python/planetary-coverage/-/' 'issues/12) ' 'for more details.', int(self), str(self) ) return SpiceSpacecraft('JUICE') raise err
[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://juigitlab.esac.esa.int/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