Source code for missiontools.spacecraft

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

from .orbit.constants import EARTH_MU, EARTH_J2, EARTH_SEMI_MAJOR_AXIS
from .cache import cached_propagate_analytical
from .orbit.propagation import (
    sun_synchronous_orbit,
    geostationary_orbit,
    highly_elliptical_orbit,
)
from .attitude import AbstractAttitudeLaw, FixedAttitudeLaw

_VALID_PROPAGATORS = frozenset({"twobody", "j2"})


[docs] @dataclass class Spacecraft: """A spacecraft defined by its Keplerian orbital elements and propagator. All angles in radians; distances in metres; times as ``datetime64[us]``. Parameters ---------- a : float Semi-major axis (m). e : float Eccentricity (dimensionless). i : float Inclination (rad). raan : float Right ascension of the ascending node (rad). arg_p : float Argument of perigee (rad). ma : float Mean anomaly at epoch (rad). epoch : np.datetime64 Epoch at which the elements are defined. propagator_type : str, optional ``'twobody'`` (default) or ``'j2'``. central_body_mu : float, optional Gravitational parameter (m³ s⁻²). Defaults to Earth. central_body_j2 : float, optional J2 perturbation coefficient (m⁵ s⁻²). Defaults to Earth. central_body_radius : float, optional Equatorial radius (m). Defaults to Earth WGS84. Notes ----- The dataclass-generated ``__eq__`` compares only the declared fields (orbital elements, epoch, propagator type, and central body parameters). Two ``Spacecraft`` instances with identical orbital elements but different attached sensors or antennas will still compare as equal. Examples -------- Construct directly:: import numpy as np from missiontools import Spacecraft sc = Spacecraft( a=6_771_000.0, e=0.0006, i=np.radians(51.6), raan=np.radians(120.0), arg_p=np.radians(30.0), ma=0.0, epoch=np.datetime64('2025-01-01T00:00:00', 'us'), propagator_type='j2', ) Construct from :func:`~missiontools.orbit.sun_synchronous_orbit`:: from missiontools import Spacecraft from missiontools.orbit import sun_synchronous_orbit params = sun_synchronous_orbit(altitude=550_000.0, local_time_at_node='10:30') sc = Spacecraft.from_dict(params, propagator_type='j2') """ a: float e: float i: float raan: float arg_p: float ma: float epoch: np.datetime64 propagator_type: str = "twobody" central_body_mu: float = EARTH_MU central_body_j2: float = EARTH_J2 central_body_radius: float = EARTH_SEMI_MAJOR_AXIS def __post_init__(self): if self.propagator_type not in _VALID_PROPAGATORS: raise ValueError( f"propagator_type must be one of {sorted(_VALID_PROPAGATORS)}, " f"got {self.propagator_type!r}" ) self.epoch = np.asarray(self.epoch, dtype="datetime64[us]").item() self._attitude_law: AbstractAttitudeLaw = FixedAttitudeLaw.nadir() self._sensors: list = [] self._solar_configs: list = [] self._thermal_configs: list = [] self._antennas: list = [] @property def attitude_law(self) -> AbstractAttitudeLaw: """Pointing law for this spacecraft. Defaults to nadir pointing.""" return self._attitude_law @attitude_law.setter def attitude_law(self, value: AbstractAttitudeLaw) -> None: if not isinstance(value, AbstractAttitudeLaw): raise TypeError( f"attitude_law must be an AbstractAttitudeLaw instance, " f"got {type(value).__name__!r}" ) self._attitude_law = value @property def sensors(self) -> list: """Sensors attached to this spacecraft (read-only copy).""" return list(self._sensors) @property def solar_configs(self) -> list: """Solar configs attached to this spacecraft (read-only copy).""" return list(self._solar_configs)
[docs] def add_solar_config(self, config) -> None: """Attach a solar config to this spacecraft. Sets the config's back-reference to this spacecraft and appends it to the internal solar configs list. Parameters ---------- config : AbstractSolarConfig The solar config to attach. Raises ------ TypeError If ``config`` is not an :class:`~missiontools.power.AbstractSolarConfig` instance. """ from .power.solar_config import ( AbstractSolarConfig, ) # local import avoids circular dep if not isinstance(config, AbstractSolarConfig): raise TypeError( f"config must be an AbstractSolarConfig instance, " f"got {type(config).__name__!r}" ) if config._spacecraft is not None: raise ValueError("Solar config is already attached to a spacecraft.") config._spacecraft = self self._solar_configs.append(config)
@property def thermal_configs(self) -> list: """Thermal configs attached to this spacecraft (read-only copy).""" return list(self._thermal_configs)
[docs] def add_thermal_config(self, config) -> None: """Attach a thermal config to this spacecraft. Sets the config's back-reference to this spacecraft and appends it to the internal thermal configs list. Parameters ---------- config : AbstractThermalConfig The thermal config to attach. Raises ------ TypeError If ``config`` is not an :class:`~missiontools.thermal.AbstractThermalConfig` instance. """ from .thermal.thermal_config import AbstractThermalConfig if not isinstance(config, AbstractThermalConfig): raise TypeError( f"config must be an AbstractThermalConfig instance, " f"got {type(config).__name__!r}" ) if config._spacecraft is not None: raise ValueError("Thermal config is already attached to a spacecraft.") config._spacecraft = self self._thermal_configs.append(config)
@property def antennas(self) -> list: """Antennas attached to this spacecraft (read-only copy).""" return list(self._antennas)
[docs] def add_antenna(self, antenna) -> None: """Attach an antenna to this spacecraft. Sets the antenna's back-reference to this spacecraft and appends it to the internal antennas list. Parameters ---------- antenna : AbstractAntenna The antenna to attach. Raises ------ TypeError If *antenna* is not an :class:`~missiontools.comm.AbstractAntenna`. ValueError If the antenna is already attached to a GroundStation. """ from .comm.antenna import AbstractAntenna if not isinstance(antenna, AbstractAntenna): raise TypeError( f"antenna must be an AbstractAntenna instance, " f"got {type(antenna).__name__!r}" ) if antenna._ground_station is not None: raise ValueError("Antenna is already attached to a GroundStation.") antenna._spacecraft = self self._antennas.append(antenna)
[docs] def add_sensor(self, sensor) -> None: """Attach a sensor to this spacecraft. Sets the sensor's back-reference to this spacecraft and appends it to the internal sensors list. Parameters ---------- sensor : AbstractSensor The sensor to attach. Raises ------ TypeError If ``sensor`` is not an :class:`~missiontools.AbstractSensor` instance. """ from .sensor import AbstractSensor # local import avoids circular dep if not isinstance(sensor, AbstractSensor): raise TypeError( f"sensor must be an AbstractSensor instance, " f"got {type(sensor).__name__!r}" ) if sensor._spacecraft is not None: raise ValueError("Sensor is already attached to a spacecraft.") sensor._spacecraft = self self._sensors.append(sensor)
@property def keplerian_params(self) -> dict: """Orbital elements as a dict, compatible with :func:`~missiontools.orbit.propagate_analytical`. Use this to pass the spacecraft's orbit to the functional API:: r, v = propagate_analytical(t, **sc.keplerian_params) coverage_fraction(lat, lon, sc.keplerian_params, t_start, t_end, propagator_type=sc.propagator_type) """ return { "epoch": self.epoch, "a": self.a, "e": self.e, "i": self.i, "raan": self.raan, "arg_p": self.arg_p, "ma": self.ma, "central_body_mu": self.central_body_mu, "central_body_j2": self.central_body_j2, "central_body_radius": self.central_body_radius, }
[docs] @classmethod def from_dict(cls, params: dict, propagator_type: str = "twobody") -> Spacecraft: """Construct from a ``keplerian_params`` dict. Accepts dicts produced by :func:`~missiontools.orbit.sun_synchronous_orbit` and similar helpers. Optional central-body keys fall back to Earth defaults if absent. Parameters ---------- params : dict Must contain ``'a'``, ``'e'``, ``'i'``, ``'raan'``, ``'arg_p'``, ``'ma'``, ``'epoch'``. May optionally contain ``'central_body_mu'``, ``'central_body_j2'``, ``'central_body_radius'``. propagator_type : str, optional ``'twobody'`` (default) or ``'j2'``. """ return cls( a=params["a"], e=params["e"], i=params["i"], raan=params["raan"], arg_p=params["arg_p"], ma=params["ma"], epoch=params["epoch"], propagator_type=propagator_type, central_body_mu=params.get("central_body_mu", EARTH_MU), central_body_j2=params.get("central_body_j2", EARTH_J2), central_body_radius=params.get( "central_body_radius", EARTH_SEMI_MAJOR_AXIS ), )
[docs] def propagate( self, t_start: np.datetime64, t_end: np.datetime64, step: np.timedelta64, ) -> dict: """Propagate the orbit and return ECI state vectors. Parameters ---------- t_start : np.datetime64 Start of the propagation window. t_end : np.datetime64 End of the propagation window (inclusive). step : np.timedelta64 Time step between samples. Returns ------- dict ``t`` : ``(N,)`` ``datetime64[us]`` — sample timestamps. ``r`` : ``(N, 3)`` float — ECI position vectors (m). ``v`` : ``(N, 3)`` float — ECI velocity vectors (m s⁻¹). """ t_start = np.asarray(t_start, dtype="datetime64[us]") t_end = np.asarray(t_end, dtype="datetime64[us]") total_us = int((t_end - t_start) / np.timedelta64(1, "us")) step_us = int(step / np.timedelta64(1, "us")) if total_us <= 0 or step_us <= 0: return { "t": np.array([], dtype="datetime64[us]"), "r": np.empty((0, 3), dtype=np.float64), "v": np.empty((0, 3), dtype=np.float64), } offs = np.arange(0, total_us + 1, step_us, dtype=np.int64) if offs[-1] != total_us: offs = np.append(offs, np.int64(total_us)) t = t_start + offs.astype("timedelta64[us]") r, v = cached_propagate_analytical( t, **self.keplerian_params, propagator_type=self.propagator_type ) return {"t": t, "r": r, "v": v}
# ------------------------------------------------------------------ # Orbit-type factory classmethods # ------------------------------------------------------------------
[docs] @classmethod def sunsync( cls, altitude_km: float, node_solar_time: str, node_type: str = "ascending", epoch: np.datetime64 | None = None, ma_deg: float = 0.0, ) -> "Spacecraft": """Create a circular sun-synchronous orbit spacecraft. Delegates to :func:`~missiontools.orbit.sun_synchronous_orbit` for element computation and always uses the ``'j2'`` propagator, since J2 is what drives the RAAN precession that maintains sun-synchronicity. Parameters ---------- altitude_km : float Orbit altitude above the WGS84 equatorial surface (km). node_solar_time : str Local solar time at the node crossing (``'HH:MM'`` or ``'HH:MM:SS'``, 24-hour clock). node_type : str, optional ``'ascending'`` (default) or ``'descending'``. epoch : np.datetime64 | None, optional Reference epoch. Defaults to J2000.0. ma_deg : float, optional Mean anomaly at epoch (deg). Defaults to 0 (spacecraft at the ascending or descending node at epoch). Returns ------- Spacecraft With ``propagator_type='j2'``. """ params = sun_synchronous_orbit( altitude=altitude_km * 1000.0, local_time_at_node=node_solar_time, node_type=node_type, epoch=epoch, ) params["ma"] = np.radians(ma_deg) return cls.from_dict(params, propagator_type="j2")
[docs] @classmethod def geostationary( cls, longitude_deg: float, epoch: np.datetime64 | None = None, propagator: str = "twobody", ) -> "Spacecraft": """Create a geostationary orbit spacecraft. Delegates to :func:`~missiontools.orbit.geostationary_orbit`. The satellite is placed at ``longitude_deg`` geographic longitude exactly at the epoch. Parameters ---------- longitude_deg : float Sub-satellite longitude at epoch (deg). Any value is accepted; values outside ``[-180, 180]`` are wrapped automatically. epoch : np.datetime64 | None, optional Reference epoch. Defaults to J2000.0. propagator : str, optional ``'twobody'`` (default) or ``'j2'``. Returns ------- Spacecraft Equatorial, circular orbit with ``i=0``, ``e=0``. """ return cls.from_dict( geostationary_orbit(longitude_deg, epoch=epoch), propagator_type=propagator, )
[docs] @classmethod def heo( cls, period_s: float, e: float, epoch: np.datetime64, apogee_solar_time: str, apogee_longitude_deg: float, arg_p_deg: float = 270.0, propagator: str = "twobody", ) -> "Spacecraft": """Create a critically inclined highly elliptical orbit spacecraft. Delegates to :func:`~missiontools.orbit.highly_elliptical_orbit`. The inclination is set automatically to the critical inclination (63.435° for northern-hemisphere apogee, 116.565° for southern) so the apsidal line does not drift under J2. Parameters ---------- period_s : float Orbital period (s). e : float Eccentricity (0 < e < 1). epoch : np.datetime64 Reference epoch for the orbital elements. apogee_solar_time : str Local mean solar time at the apogee sub-satellite point (``'HH:MM'`` or ``'HH:MM:SS'``, 24-hour clock). apogee_longitude_deg : float Geographic longitude of the apogee sub-satellite point (deg). arg_p_deg : float, optional Argument of perigee (deg). 270° (default) places the apogee in the northern hemisphere; 90° places it in the southern hemisphere. propagator : str, optional ``'twobody'`` (default) or ``'j2'``. Returns ------- Spacecraft """ return cls.from_dict( highly_elliptical_orbit( period_s=period_s, e=e, epoch=epoch, apogee_solar_time=apogee_solar_time, apogee_longitude_deg=apogee_longitude_deg, arg_p_deg=arg_p_deg, ), propagator_type=propagator, )