Source code for planetary_coverage.spice.fov

"""SPICE instrument module."""

from itertools import cycle, repeat

import numpy as np

import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, Polygon

import spiceypy as sp
from spiceypy.utils.exceptions import SpiceFRAMEMISSING

from ..math.vectors import boresight_rot_m, cs, norm
from ..misc import cached_property


TWO_ELEMENTS = 2


[docs] class SpiceFieldOfView: """SPICE Instrument field of view object. Parameters ---------- code: int SPICE instrument code. See Also -------- spiceypy.spiceypy.getfov """ MARGIN = 1.1 # X and Y limit margins def __init__(self, code): try: self.shape, self._frame, self.boresight, _, self.bounds = sp.getfov( int(code), 100 ) except SpiceFRAMEMISSING: raise ValueError( f'Instrument `{int(code)}` does not have a valid FOV.' ) from None def __repr__(self): s = f'<{self.__class__.__name__}> ' s += f'Frame: {self.frame} | ' if self.frame else '' s += f'Boresight: {np.round(self.boresight, 3)} | ' s += f'Shape: {self.shape} | ' s += f'Bounds:\n{np.round(self.bounds, 3)}' return s @property def frame(self): """Field of view reference frame.""" return self._frame @cached_property def m(self): """Boresight rotation matrix.""" return boresight_rot_m(self.boresight)
[docs] def to_boresight(self, *vec): """Rotate a vector in the plane orthogonal to the boresight. The vector(s) is/are rotated to be in the plane orthogonal to the boresight with a unit length in the z-direction. Parameters ---------- *vec: tuple or numpy.ndarray Input vector(s) to rotate. """ x, y, z = self.m @ np.transpose(vec) invalid = z == 0 np.divide(x, z, out=x, where=~invalid) np.divide(y, z, out=y, where=~invalid) x[invalid] = np.nan y[invalid] = np.nan return np.array([x, y])
@cached_property def corners(self): """Boundaries corners in the plane orthogonal to the boresight.""" return self.to_boresight(*self.bounds) @cached_property(parent='corners') def extent(self): """Boundaries extent in the plane orthogonal to the boresight.""" if self.shape == 'CIRCLE': r = norm(self.corners.T)[0] return [-r, r, -r, r] if self.shape == 'ELLIPSE': a, b = np.max(np.abs(self.corners), axis=1) return [-a, a, -b, b] xmin, ymin = np.min(self.corners, axis=1) xmax, ymax = np.max(self.corners, axis=1) return [xmin, xmax, ymin, ymax] @property def xlim(self): """Boundary x-limits in the plane orthogonal to the boresight.""" return self.MARGIN * self.extent[0], self.MARGIN * self.extent[1] @property def ylim(self): """Boundary y-limits in the plane orthogonal to the boresight.""" return self.MARGIN * self.extent[2], self.MARGIN * self.extent[3]
[docs] def from_boresight(self, *xy): """Rotate a vector from the boresight plane to the instrument frame. Parameters ---------- *xy: tuple or numpy.ndarray Input boresight unitary boresight vector(s). """ vec = np.ones((3, len(xy))) vec[:2] = np.transpose(xy) return (self.m.T @ vec).T
[docs] def rays(self, npt=24): """Interpolated rays around the FOV in the instrument frame. The points are interpolated in the plane orthogonal to the boresight at a unitary distance to the center and re-aligned with the boresight. Parameters ---------- npt: int or tuple, optional Number of rays in the interpolated contour. If the FOV has a ``RECTANGULAR`` and ``POLYGON`` shape, it is possible to provide a tuple that will correspond to the number of points per edges (excluding the corners). If the tuple size is smaller than the number of edges, its values will be cycled. Returns ------- numpy.ndarray Field of view contour rays, shape: (npt, 3). If npt is a tuple, you need to compute how many points were added per edge to get to total number of points in the contour. Note ---- The points will be distributed evenly and the final number of points may be slightly lower than the requested count. """ match self.shape: case 'CIRCLE': xy = self.rays_circle(self.extent[1], npt) case 'ELLIPSE': xy = self.rays_ellipse(self.extent[1], self.extent[3], npt) case 'RECTANGLE' | 'POLYGON': xy = self.rays_edges(self.corners.T, npt) return self.from_boresight(*xy)
[docs] @staticmethod def rays_circle(r, npt): """Interpolated rays around a CIRCLE FOV. Parameters ---------- r: float, optional Circle radius. npt: int Number of points in the interpolated contour. Returns ------- numpy.ndarray Interpolated coordinates in the XY plane. """ angle = np.linspace(0, 360, max(npt, 1), endpoint=False) return r * np.transpose(cs(angle))
[docs] @staticmethod def rays_ellipse(a, b, npt): """Interpolated rays around a ELLIPSE FOV. Parameters ---------- a: float, optional Ellipse primary axis. b: float, optional Ellipse secondary axis. npt: int Number of points in the interpolated contour. Returns ------- numpy.ndarray Interpolated coordinates in the XY plane. """ match npt: case 0 | 2: angles = np.array([0, 90]) # Points on primary axes only case _: angles = np.linspace(0, 360, max(npt, 1), endpoint=False) ct, st = cs(angles) e2 = 1 - (b / a) ** 2 r = b / np.sqrt(1 - e2 * ct**2) return np.transpose([r * ct, r * st])
[docs] @staticmethod def rays_edges(corners, npt): """Interpolated rays around a RECTANGULAR or POLYGON FOV. Parameters ---------- corners: list List of FOV corners coordinates. npt: int or tuple Number of points around the contour **including the corners**. If npt <= len(corners), no interpolation will be performed. If npt > len(corners), they will be evenly distributed on each edge, then the final number of points on the contour may be smaller than npt. If npt is provided as a tuple, it will correspond to the number of **interpolation points** to be added on each edges between the corners. If len(npt) < len(corners) it will be cycled though each edges. Returns ------- numpy.ndarray Interpolated coordinates in the XY plane. """ n_corners = len(corners) if isinstance(npt, int): iter_npt = repeat((npt - n_corners) // n_corners) else: iter_npt = cycle(npt) return np.vstack([ np.linspace( corners[i], corners[i + 1 if i + 1 < n_corners else 0], max(n, 0) + 1, endpoint=False, ) for i, n in zip(range(n_corners), iter_npt, strict=False) ])
[docs] def contour(self, npt=25): """FOV footprint contour. Parameters ---------- npt: int or tuple, optional Number of points in the interpolated contour (default: `25`). The minimum of points is 9 to properly represent the CIRCLE and ELLIPSE shapes. If tuple is provided, it will be used to split the edges recursively. See Also -------- .rays """ ray = self.rays(npt=max(npt - 1, 8) if isinstance(npt, int) else npt) return np.transpose([*ray, ray[0]])
[docs] def patch(self, **kwargs): """FOV patch in the plane orthogonal to the boresight. Parameters ---------- **kwargs: any Matplotlib artist optional keywords. Returns ------- matplotlib.patches.Circle or matplotlib.patches.PathPatch Matplotlib Patch. """ if self.shape == 'CIRCLE': r = self.extent[0] return Circle((0, 0), r, **kwargs) if self.shape == 'ELLIPSE': a, b = self.extent[1], self.extent[3] return Ellipse((0, 0), 2 * a, 2 * b, **kwargs) return Polygon(self.corners.T, closed=False, **kwargs)
[docs] def view(self, ax=None, fig=None, **kwargs): """2D display field of view in the plane perpendicular to the boresight. Parameters ---------- ax: matplotlib.axes.Axes, optional Axis to incrust the plot. fig: matplotlib.figure.Figure, optional Main figure (if no :attr:`ax` is provided). **kwargs: any Matplotlib keyword attributes pass to :class:`matplotlib.patches.Patch`. Returns ------- matplotlib.axes FOV view """ if ax is None: if fig is None: fig = plt.figure(figsize=(10, 10)) ax = fig.add_subplot() if 'alpha' not in kwargs: kwargs['alpha'] = 0.5 ax.plot(0, 0, 'o', color='tab:red') ax.plot(*self.corners, 'o', color='tab:blue') ax.add_patch(self.patch(**kwargs)) ax.set_aspect(1) ax.set_xlim(*self.xlim) ax.set_ylim(*self.ylim) ax.set_axis_off() ax.set_title(getattr(self, 'name', '')) return ax
[docs] def display(self, ax=None, fig=None, npt=25, show_bounds=False, **kwargs): """3D display field of view with its the boresight. Parameters ---------- ax: matplotlib.axes.Axes, optional Axis to incrust the plot. fig: matplotlib.figure.Figure, optional Main figure (if no :attr:`ax` is provided). npt: int, optional Number of points in the interpolated contour (default: ``25``). Returns ------- matplotlib.axes FOV view """ if ax is None: if fig is None: fig = plt.figure(figsize=(8, 8)) ax = fig.add_subplot(projection='3d') if ax.name != '3d': raise KeyError('Axis must be a projection 3D axis.') ax.quiver(0, 0, 0, *self.boresight, color='tab:red', arrow_length_ratio=0) if show_bounds: for bound in self.bounds: ax.quiver( 0, 0, 0, *bound, color='tab:orange', ls='-', arrow_length_ratio=0 ) for ray in self.rays(0): ax.quiver( 0, 0, 0, *ray, color='tab:orange', lw=1, ls='--', arrow_length_ratio=0 ) ax.plot(*self.contour(npt=npt), '-', color='tab:blue') ax.set_title(getattr(self, 'name', '')) return ax