Source code for planetary_coverage.spice.fov

"""SPICE instrument module."""

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


[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.') 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, optional Number of points in the interpolated contour (default: `24`). Returns ------- numpy.ndarray Field of view contour rays, shape: (npt, 3). Note ---- The points will be distributed evenly and the final number of points may be slightly lower than the requested count. """ if self.shape == 'CIRCLE': r = self.extent[1] angle = np.linspace(0, 360, max(npt, 1), endpoint=False) xy = r * np.transpose(cs(angle)) return self.from_boresight(*xy) if self.shape == 'ELLIPSE': a, b = self.extent[1], self.extent[3] angle = np.linspace(0, 360, npt, endpoint=False) if npt > 2 else (0, 90) ct, st = cs(angle) e2 = 1 - (b / a) ** 2 r = b / np.sqrt(1 - e2 * ct ** 2) xy = np.transpose([r * ct, r * st]) return self.from_boresight(*xy) # Rectangular and Polygon nb = len(self.bounds) # Number of point on edges (with 1 bound) ne = max(npt - nb, 0) // nb + 1 corners = self.corners.T xy = np.vstack([ np.linspace(corners[i], corners[i + 1 if i + 1 < nb else 0], ne, endpoint=False) for i in range(nb) ]) return self.from_boresight(*xy)
[docs] def contour(self, npt=25): """FOV footprint contour. Parameters ---------- npt: int, optional Number of points in the interpolated contour (default: `25`). The minimum of points is 9. """ ray = self.rays(npt=max(npt - 1, 8)) 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'] = .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