Source code for missiontools.sensor

"""
missiontools.sensor
===================
Sensor class for instruments attached to a spacecraft.
"""
from __future__ import annotations

import numpy as np
import numpy.typing as npt

from .orbit.frames import eci_to_lvlh, eci_to_ecef


def _euler_zyx_to_boresight(
        yaw_deg: float,
        pitch_deg: float,
        roll_deg: float,
) -> npt.NDArray[np.floating]:
    """Sensor boresight in spacecraft body frame from ZYX Euler angles.

    The ZYX intrinsic rotation sequence (yaw → pitch → roll) defines the
    rotation from the spacecraft body frame to the sensor frame:
    ``R = Rx(roll) @ Ry(pitch) @ Rz(yaw)``.

    The sensor boresight (sensor frame z-axis = ``[0, 0, 1]``) expressed
    in the spacecraft body frame is ``R.T @ [0, 0, 1]``.

    Parameters
    ----------
    yaw_deg : float
        Yaw angle (deg), rotation about body-Z.
    pitch_deg : float
        Pitch angle (deg), rotation about new Y after yaw.
    roll_deg : float
        Roll angle (deg), rotation about new X after pitch.

    Returns
    -------
    npt.NDArray[np.floating], shape (3,)
        Unit boresight vector in spacecraft body frame.
    """
    yaw, pitch, roll = np.radians([yaw_deg, pitch_deg, roll_deg])
    cy, sy = np.cos(yaw),   np.sin(yaw)
    cp, sp = np.cos(pitch), np.sin(pitch)
    cr, sr = np.cos(roll),  np.sin(roll)
    Rz = np.array([[cy, -sy, 0.], [sy,  cy, 0.], [0., 0., 1.]])
    Ry = np.array([[cp,  0., sp], [0.,  1., 0.], [-sp, 0., cp]])
    Rx = np.array([[1.,  0., 0.], [0.,  cr, -sr], [0., sr, cr]])
    R  = Rx @ Ry @ Rz                         # body → sensor
    return R.T @ np.array([0., 0., 1.])       # sensor-z in body frame


[docs] class Sensor: """An instrument attached to a spacecraft with a cone-shaped field of view. Prefer the keyword arguments to select the pointing mode (see below). The constructor is public and may be called directly when needed. Parameters ---------- half_angle_deg : float Half-angle of the sensor's conical field of view (degrees). Must satisfy ``0 < half_angle_deg <= 90``. attitude_law : AttitudeLaw, optional Independent :class:`~missiontools.AttitudeLaw` for this sensor, decoupled from the host spacecraft's attitude. Mutually exclusive with ``body_vector`` and ``body_euler_deg``. body_vector : array_like, shape (3,), optional Boresight direction expressed in the **spacecraft body frame**. Normalised on input. Mutually exclusive with ``attitude_law`` and ``body_euler_deg``. body_euler_deg : (yaw, pitch, roll) tuple of float, optional ZYX intrinsic Euler angles (degrees) defining the sensor frame relative to the spacecraft body frame. The boresight is the sensor frame's z-axis expressed in body-frame coordinates. Mutually exclusive with ``attitude_law`` and ``body_vector``. Notes ----- Body-mounted sensors (``body_vector`` or ``body_euler_deg``) require the sensor to be attached to a spacecraft via :meth:`~missiontools.Spacecraft.add_sensor` before their pointing methods can be called. Examples -------- Nadir-pointing sensor, 10° half-angle:: from missiontools import Sensor, AttitudeLaw sensor = Sensor(10.0, attitude_law=AttitudeLaw.nadir()) Sensor body-mounted along spacecraft body-z (boresight = nadir for a nadir spacecraft), 5° half-angle:: sensor = Sensor(5.0, body_vector=[0, 0, 1]) Sensor tilted 30° in pitch from body-z:: sensor = Sensor(15.0, body_euler_deg=(0, 30, 0)) """ def __init__( self, half_angle_deg: float, *, attitude_law=None, body_vector: npt.ArrayLike | None = None, body_euler_deg: tuple[float, float, float] | None = None, ): # --- validate half-angle ------------------------------------------- half_angle_deg = float(half_angle_deg) if not (0.0 < half_angle_deg <= 90.0): raise ValueError( f"half_angle_deg must be in (0, 90], got {half_angle_deg}" ) self._half_angle_rad: float = np.radians(half_angle_deg) # --- validate mode (exactly one) ------------------------------------ n_modes = sum(x is not None for x in (attitude_law, body_vector, body_euler_deg)) if n_modes == 0: raise ValueError( "Exactly one of 'attitude_law', 'body_vector', or " "'body_euler_deg' must be provided." ) if n_modes > 1: raise ValueError( "Only one of 'attitude_law', 'body_vector', or " "'body_euler_deg' may be provided." ) # --- store mode-specific state -------------------------------------- self._spacecraft = None # set by Spacecraft.add_sensor if attitude_law is not None: from .attitude import AttitudeLaw if not isinstance(attitude_law, AttitudeLaw): raise TypeError( f"attitude_law must be an AttitudeLaw instance, " f"got {type(attitude_law).__name__!r}" ) self._mode = 'independent' self._attitude_law = attitude_law self._body_vector = None elif body_vector is not None: vec = np.asarray(body_vector, dtype=np.float64) if vec.shape != (3,): raise ValueError( f"body_vector must have shape (3,), got {vec.shape}" ) norm = np.linalg.norm(vec) if norm == 0.0: raise ValueError("body_vector must not be the zero vector") self._mode = 'body' self._attitude_law = None self._body_vector = vec / norm else: # body_euler_deg yaw, pitch, roll = (float(a) for a in body_euler_deg) boresight = _euler_zyx_to_boresight(yaw, pitch, roll) self._mode = 'body' self._attitude_law = None self._body_vector = boresight / np.linalg.norm(boresight) # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def half_angle_rad(self) -> float: """FOV cone half-angle in radians.""" return self._half_angle_rad @property def half_angle_deg(self) -> float: """FOV cone half-angle in degrees.""" return float(np.degrees(self._half_angle_rad)) @property def spacecraft(self): """Host spacecraft, or ``None`` if not yet attached.""" return self._spacecraft # ------------------------------------------------------------------ # Pointing methods # ------------------------------------------------------------------
[docs] def pointing_eci(self, r_eci: npt.ArrayLike, v_eci: npt.ArrayLike, t: npt.ArrayLike, ) -> npt.NDArray[np.floating]: """Boresight unit vector(s) in the ECI frame. For ``body_vector`` / ``body_euler_deg`` sensors the host spacecraft's :attr:`~missiontools.Spacecraft.attitude_law` is used to transform the body-frame boresight to ECI. Parameters ---------- r_eci : array_like, shape ``(N, 3)`` or ``(3,)`` Host spacecraft ECI position(s) (m). v_eci : array_like, shape ``(N, 3)`` or ``(3,)`` Host spacecraft ECI velocity(s) (m s⁻¹). t : array_like of datetime64, shape ``(N,)`` or scalar Observation epoch(s). Returns ------- npt.NDArray[np.floating] Unit boresight vector(s) in ECI, shape ``(N, 3)`` for array inputs or ``(3,)`` for scalar inputs. Raises ------ RuntimeError If the sensor is in body mode and has not been attached to a spacecraft via :meth:`~missiontools.Spacecraft.add_sensor`. """ if self._mode == 'independent': return self._attitude_law.pointing_eci(r_eci, v_eci, t) # body mode if self._spacecraft is None: raise RuntimeError( "Sensor must be attached to a Spacecraft via add_sensor() " "before pointing methods can be called in body mode." ) return self._spacecraft.attitude_law.rotate_from_body( self._body_vector, r_eci, v_eci, t, )
[docs] def pointing_lvlh(self, r_eci: npt.ArrayLike, v_eci: npt.ArrayLike, t: npt.ArrayLike, ) -> npt.NDArray[np.floating]: """Boresight unit vector(s) in the LVLH frame. Parameters ---------- r_eci : array_like, shape ``(N, 3)`` or ``(3,)`` Host spacecraft ECI position(s) (m). v_eci : array_like, shape ``(N, 3)`` or ``(3,)`` Host spacecraft ECI velocity(s) (m s⁻¹). t : array_like of datetime64, shape ``(N,)`` or scalar Observation epoch(s). Returns ------- npt.NDArray[np.floating] Unit boresight vector(s) in LVLH, shape ``(N, 3)`` for array inputs or ``(3,)`` for scalar inputs. """ if self._mode == 'independent': return self._attitude_law.pointing_lvlh(r_eci, v_eci, t) r = np.asarray(r_eci, dtype=np.float64) scalar = r.ndim == 1 r_2d = np.atleast_2d(r) v_2d = np.atleast_2d(np.asarray(v_eci, dtype=np.float64)) eci = np.atleast_2d(self.pointing_eci(r_eci, v_eci, t)) result = eci_to_lvlh(eci, r_2d, v_2d) return result[0] if scalar else result
[docs] def pointing_ecef(self, r_eci: npt.ArrayLike, v_eci: npt.ArrayLike, t: npt.ArrayLike, ) -> npt.NDArray[np.floating]: """Boresight unit vector(s) in the ECEF frame. Parameters ---------- r_eci : array_like, shape ``(N, 3)`` or ``(3,)`` Host spacecraft ECI position(s) (m). v_eci : array_like, shape ``(N, 3)`` or ``(3,)`` Host spacecraft ECI velocity(s) (m s⁻¹). t : array_like of datetime64, shape ``(N,)`` or scalar Observation epoch(s). Returns ------- npt.NDArray[np.floating] Unit boresight vector(s) in ECEF, shape ``(N, 3)`` for array inputs or ``(3,)`` for scalar inputs. """ if self._mode == 'independent': return self._attitude_law.pointing_ecef(r_eci, v_eci, t) r = np.asarray(r_eci, dtype=np.float64) scalar = r.ndim == 1 t_arr = np.atleast_1d(np.asarray(t, dtype='datetime64[us]')) eci = np.atleast_2d(self.pointing_eci(r_eci, v_eci, t)) result = eci_to_ecef(eci, t_arr) return result[0] if scalar else result