space_dust/
state.rs

1//! State vectors and coordinate frame transformations.
2//!
3//! This module provides representations for orbital state vectors in various
4//! coordinate frames, along with transformations between them:
5//!
6//! - **ECIState** - Earth-Centered Inertial J2000 frame
7//! - **TEMEState** - True Equator Mean Equinox frame (SGP4 output)
8//! - **ECEFState** - Earth-Centered Earth-Fixed frame
9//! - **GeodeticState** - Geodetic coordinates (latitude, longitude, altitude)
10//! - **KeplerianElements** - Classical orbital elements
11
12use crate::bodies::Earth;
13use crate::constants::Constants;
14use crate::math::{Matrix3, Vector3};
15use chrono::{DateTime, Utc};
16use std::f64::consts::PI;
17
18// ============================================================================
19// ECIState - Earth-Centered Inertial J2000 Frame
20// ============================================================================
21
22/// State vector in Earth-Centered Inertial J2000 (ECI) reference frame.
23///
24/// ECI J2000 is an inertial reference frame with:
25/// - Origin at Earth's center of mass
26/// - X-axis pointing toward the mean vernal equinox at J2000.0
27/// - Z-axis pointing toward the mean celestial pole at J2000.0
28/// - Y-axis completing the right-handed system
29///
30/// Position is in kilometers and velocity is in km/s.
31#[derive(Debug, Clone)]
32pub struct ECIState {
33    /// UTC epoch of this state
34    pub epoch: DateTime<Utc>,
35    /// Position vector in km
36    pub position: Vector3,
37    /// Velocity vector in km/s
38    pub velocity: Vector3,
39}
40
41impl ECIState {
42    /// Create a new ECI state.
43    pub fn new(epoch: DateTime<Utc>, position: Vector3, velocity: Vector3) -> Self {
44        Self {
45            epoch,
46            position,
47            velocity,
48        }
49    }
50
51    /// Create from position and velocity tuples.
52    pub fn from_tuples(
53        epoch: DateTime<Utc>,
54        position: (f64, f64, f64),
55        velocity: (f64, f64, f64),
56    ) -> Self {
57        Self::new(
58            epoch,
59            Vector3::from_tuple(position),
60            Vector3::from_tuple(velocity),
61        )
62    }
63
64    /// Get the position magnitude (distance from Earth center) in km.
65    pub fn radius(&self) -> f64 {
66        self.position.magnitude()
67    }
68
69    /// Get the velocity magnitude (speed) in km/s.
70    pub fn speed(&self) -> f64 {
71        self.velocity.magnitude()
72    }
73
74    /// Get the specific angular momentum vector (h = r × v) in km²/s.
75    pub fn angular_momentum(&self) -> Vector3 {
76        self.position.cross(&self.velocity)
77    }
78
79    /// Get the specific orbital energy in km²/s².
80    pub fn specific_energy(&self) -> f64 {
81        let v_sq = self.velocity.magnitude_squared();
82        let r = self.radius();
83        v_sq / 2.0 - Constants::EARTH_MU_KM / r
84    }
85
86    /// Convert to TEME frame.
87    pub fn to_teme(&self) -> TEMEState {
88        StateTransforms::eci_to_teme(self)
89    }
90
91    /// Convert to ECEF frame.
92    pub fn to_ecef(&self) -> ECEFState {
93        StateTransforms::eci_to_ecef(self)
94    }
95
96    /// Convert to Keplerian elements.
97    pub fn to_keplerian(&self) -> KeplerianElements {
98        StateTransforms::cartesian_to_keplerian(self)
99    }
100}
101
102// ============================================================================
103// TEMEState - True Equator Mean Equinox Frame
104// ============================================================================
105
106/// State vector in True Equator Mean Equinox (TEME) reference frame.
107///
108/// TEME is the reference frame used by SGP4/SDP4 propagators.
109/// Position is in kilometers and velocity is in km/s.
110#[derive(Debug, Clone)]
111pub struct TEMEState {
112    /// UTC epoch of this state
113    pub epoch: DateTime<Utc>,
114    /// Position vector in km
115    pub position: Vector3,
116    /// Velocity vector in km/s
117    pub velocity: Vector3,
118}
119
120impl TEMEState {
121    /// Create a new TEME state.
122    pub fn new(epoch: DateTime<Utc>, position: Vector3, velocity: Vector3) -> Self {
123        Self {
124            epoch,
125            position,
126            velocity,
127        }
128    }
129
130    /// Create from position and velocity tuples.
131    pub fn from_tuples(
132        epoch: DateTime<Utc>,
133        position: (f64, f64, f64),
134        velocity: (f64, f64, f64),
135    ) -> Self {
136        Self::new(
137            epoch,
138            Vector3::from_tuple(position),
139            Vector3::from_tuple(velocity),
140        )
141    }
142
143    /// Get the position magnitude (distance from Earth center) in km.
144    pub fn radius(&self) -> f64 {
145        self.position.magnitude()
146    }
147
148    /// Get the velocity magnitude (speed) in km/s.
149    pub fn speed(&self) -> f64 {
150        self.velocity.magnitude()
151    }
152
153    /// Convert to ECI J2000 frame.
154    pub fn to_eci(&self) -> ECIState {
155        StateTransforms::teme_to_eci(self)
156    }
157
158    /// Convert to ECEF frame.
159    pub fn to_ecef(&self) -> ECEFState {
160        StateTransforms::teme_to_ecef(self)
161    }
162}
163
164// ============================================================================
165// ECEFState - Earth-Centered Earth-Fixed Frame
166// ============================================================================
167
168/// State vector in Earth-Centered Earth-Fixed (ECEF) reference frame.
169///
170/// ECEF is a rotating reference frame fixed to the Earth with:
171/// - Origin at Earth's center of mass
172/// - X-axis pointing toward the intersection of the prime meridian and equator
173/// - Z-axis pointing toward the North Pole
174/// - Y-axis completing the right-handed system (90° East longitude)
175///
176/// Position is in kilometers and velocity is in km/s.
177/// Note: Velocity in ECEF includes the Earth's rotation.
178#[derive(Debug, Clone)]
179pub struct ECEFState {
180    /// UTC epoch of this state
181    pub epoch: DateTime<Utc>,
182    /// Position vector in km
183    pub position: Vector3,
184    /// Velocity vector in km/s
185    pub velocity: Vector3,
186}
187
188impl ECEFState {
189    /// Create a new ECEF state.
190    pub fn new(epoch: DateTime<Utc>, position: Vector3, velocity: Vector3) -> Self {
191        Self {
192            epoch,
193            position,
194            velocity,
195        }
196    }
197
198    /// Create from position and velocity tuples.
199    pub fn from_tuples(
200        epoch: DateTime<Utc>,
201        position: (f64, f64, f64),
202        velocity: (f64, f64, f64),
203    ) -> Self {
204        Self::new(
205            epoch,
206            Vector3::from_tuple(position),
207            Vector3::from_tuple(velocity),
208        )
209    }
210
211    /// Get the position magnitude (distance from Earth center) in km.
212    pub fn radius(&self) -> f64 {
213        self.position.magnitude()
214    }
215
216    /// Convert to ECI J2000 frame.
217    pub fn to_eci(&self) -> ECIState {
218        StateTransforms::ecef_to_eci(self)
219    }
220
221    /// Convert to geodetic coordinates.
222    pub fn to_geodetic(&self) -> GeodeticState {
223        GeodeticState::from_ecef(self)
224    }
225}
226
227// ============================================================================
228// GeodeticState - Geodetic Coordinates
229// ============================================================================
230
231/// State in geodetic coordinates (latitude, longitude, altitude).
232///
233/// Uses WGS84 ellipsoid for Earth reference. This is the standard
234/// representation for ground-based observer locations.
235///
236/// - Latitude: Geodetic latitude in degrees (-90 to +90, positive North)
237/// - Longitude: Geodetic longitude in degrees (-180 to +180, positive East)
238/// - Altitude: Height above WGS84 ellipsoid in kilometers
239#[derive(Debug, Clone, Copy)]
240pub struct GeodeticState {
241    /// Geodetic latitude in degrees (-90 to +90)
242    pub latitude: f64,
243    /// Geodetic longitude in degrees (-180 to +180)
244    pub longitude: f64,
245    /// Altitude above WGS84 ellipsoid in kilometers
246    pub altitude: f64,
247}
248
249impl GeodeticState {
250    /// Create a new geodetic state.
251    ///
252    /// # Arguments
253    /// * `latitude` - Geodetic latitude in degrees (-90 to +90)
254    /// * `longitude` - Geodetic longitude in degrees (-180 to +180)
255    /// * `altitude` - Height above WGS84 ellipsoid in kilometers
256    pub fn new(latitude: f64, longitude: f64, altitude: f64) -> Self {
257        Self {
258            latitude,
259            longitude,
260            altitude,
261        }
262    }
263
264    /// Get latitude in radians.
265    pub fn latitude_rad(&self) -> f64 {
266        self.latitude * Constants::DEG_TO_RAD
267    }
268
269    /// Get longitude in radians.
270    pub fn longitude_rad(&self) -> f64 {
271        self.longitude * Constants::DEG_TO_RAD
272    }
273
274    /// Convert to ECEF position at a given epoch.
275    ///
276    /// Velocity is set to zero (stationary observer).
277    pub fn to_ecef(&self, epoch: DateTime<Utc>) -> ECEFState {
278        let lat_rad = self.latitude_rad();
279        let lon_rad = self.longitude_rad();
280
281        let sin_lat = lat_rad.sin();
282        let cos_lat = lat_rad.cos();
283        let sin_lon = lon_rad.sin();
284        let cos_lon = lon_rad.cos();
285
286        // Radius of curvature in the prime vertical
287        let n =
288            Constants::EARTH_RADIUS_EQ_KM / (1.0 - Constants::EARTH_E2 * sin_lat * sin_lat).sqrt();
289
290        // ECEF position
291        let x = (n + self.altitude) * cos_lat * cos_lon;
292        let y = (n + self.altitude) * cos_lat * sin_lon;
293        let z = (n * (1.0 - Constants::EARTH_E2) + self.altitude) * sin_lat;
294
295        // Stationary observer - velocity is zero in ECEF
296        ECEFState::new(epoch, Vector3::new(x, y, z), Vector3::zero())
297    }
298
299    /// Convert to ECI position at a given epoch.
300    pub fn to_eci(&self, epoch: DateTime<Utc>) -> ECIState {
301        let ecef = self.to_ecef(epoch);
302        ecef.to_eci()
303    }
304
305    /// Create geodetic state from ECEF position.
306    ///
307    /// Uses iterative algorithm for accurate conversion.
308    pub fn from_ecef(ecef: &ECEFState) -> Self {
309        let x = ecef.position.x;
310        let y = ecef.position.y;
311        let z = ecef.position.z;
312
313        // Longitude is straightforward
314        let lon = y.atan2(x) * Constants::RAD_TO_DEG;
315
316        // Iterative calculation for latitude
317        let p = (x * x + y * y).sqrt();
318        let mut lat = (z / (p * (1.0 - Constants::EARTH_E2))).atan();
319
320        // Iterate to convergence
321        for _ in 0..10 {
322            let sin_lat = lat.sin();
323            let n = Constants::EARTH_RADIUS_EQ_KM
324                / (1.0 - Constants::EARTH_E2 * sin_lat * sin_lat).sqrt();
325            let new_lat = (z + Constants::EARTH_E2 * n * sin_lat).atan2(p);
326
327            if (new_lat - lat).abs() < 1e-12 {
328                break;
329            }
330            lat = new_lat;
331        }
332
333        // Calculate altitude
334        let sin_lat = lat.sin();
335        let cos_lat = lat.cos();
336        let n =
337            Constants::EARTH_RADIUS_EQ_KM / (1.0 - Constants::EARTH_E2 * sin_lat * sin_lat).sqrt();
338
339        let alt = if cos_lat.abs() > 1e-10 {
340            p / cos_lat - n
341        } else {
342            z.abs() / sin_lat.abs() - n * (1.0 - Constants::EARTH_E2)
343        };
344
345        Self::new(lat * Constants::RAD_TO_DEG, lon, alt)
346    }
347
348    /// Get local sidereal time at this location.
349    ///
350    /// Returns LST in radians.
351    pub fn local_sidereal_time(&self, epoch: &DateTime<Utc>) -> f64 {
352        let nutation = Earth::nutation_angles(epoch);
353        let gast = nutation.gast;
354        let lon_rad = self.longitude_rad();
355
356        // Local sidereal time = GAST + observer longitude
357        let lst = gast + lon_rad;
358
359        // Normalize to [0, 2π]
360        let mut normalized = lst % Constants::TWO_PI;
361        if normalized < 0.0 {
362            normalized += Constants::TWO_PI;
363        }
364        normalized
365    }
366}
367
368impl Default for GeodeticState {
369    fn default() -> Self {
370        Self::new(0.0, 0.0, 0.0)
371    }
372}
373
374// ============================================================================
375// KeplerianElements - Classical Orbital Elements
376// ============================================================================
377
378/// Classical Keplerian orbital elements.
379///
380/// Represents an orbit using the six classical orbital elements:
381/// - Semi-major axis (a): Size of the orbit in km
382/// - Eccentricity (e): Shape of the orbit (0 = circular, 0-1 = elliptical)
383/// - Inclination (i): Tilt relative to the equatorial plane in radians
384/// - RAAN (Ω): Right Ascension of the Ascending Node in radians
385/// - Argument of perigee (ω): Orientation of the orbit in its plane in radians
386/// - True anomaly (ν): Position along the orbit in radians
387#[derive(Debug, Clone, Copy)]
388pub struct KeplerianElements {
389    /// UTC epoch of these elements
390    pub epoch: Option<DateTime<Utc>>,
391    /// Semi-major axis in kilometers
392    pub semi_major_axis: f64,
393    /// Orbital eccentricity (0 to < 1 for elliptical)
394    pub eccentricity: f64,
395    /// Inclination in radians
396    pub inclination: f64,
397    /// Right Ascension of Ascending Node in radians
398    pub raan: f64,
399    /// Argument of perigee in radians
400    pub arg_perigee: f64,
401    /// True anomaly in radians
402    pub true_anomaly: f64,
403    /// Gravitational parameter (defaults to Earth)
404    pub mu: f64,
405}
406
407impl KeplerianElements {
408    /// Create new Keplerian elements.
409    ///
410    /// # Arguments
411    /// * `semi_major_axis` - Semi-major axis in kilometers
412    /// * `eccentricity` - Orbital eccentricity (0 to < 1 for elliptical)
413    /// * `inclination` - Inclination in radians
414    /// * `raan` - Right Ascension of Ascending Node in radians
415    /// * `arg_perigee` - Argument of perigee in radians
416    /// * `true_anomaly` - True anomaly in radians
417    #[allow(clippy::too_many_arguments)]
418    pub fn new(
419        semi_major_axis: f64,
420        eccentricity: f64,
421        inclination: f64,
422        raan: f64,
423        arg_perigee: f64,
424        true_anomaly: f64,
425    ) -> Self {
426        Self {
427            epoch: None,
428            semi_major_axis,
429            eccentricity,
430            inclination,
431            raan,
432            arg_perigee,
433            true_anomaly,
434            mu: Constants::EARTH_MU_KM,
435        }
436    }
437
438    /// Create with a specific epoch.
439    pub fn with_epoch(mut self, epoch: DateTime<Utc>) -> Self {
440        self.epoch = Some(epoch);
441        self
442    }
443
444    /// Create with a specific gravitational parameter.
445    pub fn with_mu(mut self, mu: f64) -> Self {
446        self.mu = mu;
447        self
448    }
449
450    /// Calculate orbital period in seconds.
451    pub fn period(&self) -> f64 {
452        2.0 * PI * (self.semi_major_axis.powi(3) / self.mu).sqrt()
453    }
454
455    /// Calculate mean motion in radians per second.
456    pub fn mean_motion(&self) -> f64 {
457        (self.mu / self.semi_major_axis.powi(3)).sqrt()
458    }
459
460    /// Calculate apoapsis radius in kilometers.
461    pub fn apoapsis(&self) -> f64 {
462        self.semi_major_axis * (1.0 + self.eccentricity)
463    }
464
465    /// Calculate periapsis radius in kilometers.
466    pub fn periapsis(&self) -> f64 {
467        self.semi_major_axis * (1.0 - self.eccentricity)
468    }
469
470    /// Calculate apoapsis altitude above Earth's surface in kilometers.
471    pub fn apoapsis_altitude(&self) -> f64 {
472        self.apoapsis() - Constants::EARTH_RADIUS_EQ_KM
473    }
474
475    /// Calculate periapsis altitude above Earth's surface in kilometers.
476    pub fn periapsis_altitude(&self) -> f64 {
477        self.periapsis() - Constants::EARTH_RADIUS_EQ_KM
478    }
479
480    /// Calculate specific orbital energy in km²/s².
481    pub fn specific_energy(&self) -> f64 {
482        -self.mu / (2.0 * self.semi_major_axis)
483    }
484
485    /// Calculate specific angular momentum magnitude in km²/s.
486    pub fn angular_momentum(&self) -> f64 {
487        (self.mu * self.semi_major_axis * (1.0 - self.eccentricity * self.eccentricity)).sqrt()
488    }
489
490    /// Calculate semi-latus rectum (p) in km.
491    pub fn semi_latus_rectum(&self) -> f64 {
492        self.semi_major_axis * (1.0 - self.eccentricity * self.eccentricity)
493    }
494
495    /// Convert true anomaly to eccentric anomaly.
496    pub fn eccentric_anomaly(&self) -> f64 {
497        let e = self.eccentricity;
498        let nu = self.true_anomaly;
499        ((1.0 - e * e).sqrt() * nu.sin()).atan2(e + nu.cos())
500    }
501
502    /// Convert true anomaly to mean anomaly.
503    pub fn mean_anomaly(&self) -> f64 {
504        let ea = self.eccentric_anomaly();
505        ea - self.eccentricity * ea.sin()
506    }
507
508    /// Get inclination in degrees.
509    pub fn inclination_deg(&self) -> f64 {
510        self.inclination * Constants::RAD_TO_DEG
511    }
512
513    /// Get RAAN in degrees.
514    pub fn raan_deg(&self) -> f64 {
515        self.raan * Constants::RAD_TO_DEG
516    }
517
518    /// Get argument of perigee in degrees.
519    pub fn arg_perigee_deg(&self) -> f64 {
520        self.arg_perigee * Constants::RAD_TO_DEG
521    }
522
523    /// Get true anomaly in degrees.
524    pub fn true_anomaly_deg(&self) -> f64 {
525        self.true_anomaly * Constants::RAD_TO_DEG
526    }
527
528    /// Convert to ECI state vector.
529    pub fn to_eci(&self) -> ECIState {
530        StateTransforms::keplerian_to_cartesian(self)
531    }
532}
533
534// ============================================================================
535// StateTransforms - Coordinate Frame Transformations
536// ============================================================================
537
538/// Coordinate frame transformations.
539///
540/// Provides transformations between different reference frames and
541/// conversions between Cartesian and Keplerian representations.
542pub struct StateTransforms;
543
544impl StateTransforms {
545    // ========================================================================
546    // TEME <-> ECI J2000 Transformations
547    // ========================================================================
548
549    /// Convert TEME state to ECI J2000.
550    ///
551    /// This transformation accounts for precession, nutation, and the
552    /// equation of equinoxes.
553    pub fn teme_to_eci(teme: &TEMEState) -> ECIState {
554        let precession = Earth::precession_angles(&teme.epoch);
555        let nutation = Earth::nutation_angles(&teme.epoch);
556
557        // Build transformation matrices
558        let eq_mat = Self::equinox_matrix(nutation.d_psi, nutation.eps);
559        let nut_mat = Self::nutation_matrix(nutation.m_eps, nutation.d_psi, nutation.d_eps);
560        let prec_mat = Self::precession_matrix(precession.zeta, precession.theta, precession.z);
561
562        // Combined transformation: P * N * E
563        let combined = prec_mat.mul_mat(&nut_mat.mul_mat(&eq_mat));
564
565        // Apply to position and velocity
566        let pos_eci = combined.mul_vec(&teme.position);
567        let vel_eci = combined.mul_vec(&teme.velocity);
568
569        ECIState::new(teme.epoch, pos_eci, vel_eci)
570    }
571
572    /// Convert ECI J2000 state to TEME.
573    pub fn eci_to_teme(eci: &ECIState) -> TEMEState {
574        let precession = Earth::precession_angles(&eci.epoch);
575        let nutation = Earth::nutation_angles(&eci.epoch);
576
577        // Build transformation matrices (transposed for inverse)
578        let eq_mat = Self::equinox_matrix(nutation.d_psi, nutation.eps).transpose();
579        let nut_mat =
580            Self::nutation_matrix(nutation.m_eps, nutation.d_psi, nutation.d_eps).transpose();
581        let prec_mat =
582            Self::precession_matrix(precession.zeta, precession.theta, precession.z).transpose();
583
584        // Combined transformation: E^T * N^T * P^T
585        let combined = eq_mat.mul_mat(&nut_mat.mul_mat(&prec_mat));
586
587        // Apply to position and velocity
588        let pos_teme = combined.mul_vec(&eci.position);
589        let vel_teme = combined.mul_vec(&eci.velocity);
590
591        TEMEState::new(eci.epoch, pos_teme, vel_teme)
592    }
593
594    // ========================================================================
595    // ECI <-> ECEF Transformations
596    // ========================================================================
597
598    /// Convert ECI J2000 state to ECEF.
599    pub fn eci_to_ecef(eci: &ECIState) -> ECEFState {
600        let nutation = Earth::nutation_angles(&eci.epoch);
601        let gast = nutation.gast;
602
603        // Rotation matrix from ECI to ECEF
604        let rot = Matrix3::rotation_z(gast);
605
606        // Position transformation
607        let pos_ecef = rot.mul_vec(&eci.position);
608
609        // Velocity needs correction for Earth's rotation
610        // v_ecef = R * v_eci - omega x r_ecef
611        let vel_rotated = rot.mul_vec(&eci.velocity);
612
613        let omega = Constants::EARTH_ROTATION_RATE;
614        let omega_cross_r = Vector3::new(-omega * pos_ecef.y, omega * pos_ecef.x, 0.0);
615
616        let vel_ecef = vel_rotated - omega_cross_r;
617
618        ECEFState::new(eci.epoch, pos_ecef, vel_ecef)
619    }
620
621    /// Convert ECEF state to ECI J2000.
622    pub fn ecef_to_eci(ecef: &ECEFState) -> ECIState {
623        let nutation = Earth::nutation_angles(&ecef.epoch);
624        let gast = nutation.gast;
625
626        // Inverse rotation matrix
627        let rot_inv = Matrix3::rotation_z(-gast);
628
629        let omega = Constants::EARTH_ROTATION_RATE;
630
631        // First correct velocity for Earth's rotation
632        // v_eci = R^T * (v_ecef + omega x r_ecef)
633        let omega_cross_r = Vector3::new(-omega * ecef.position.y, omega * ecef.position.x, 0.0);
634        let vel_corrected = ecef.velocity + omega_cross_r;
635
636        // Apply rotation
637        let pos_eci = rot_inv.mul_vec(&ecef.position);
638        let vel_eci = rot_inv.mul_vec(&vel_corrected);
639
640        ECIState::new(ecef.epoch, pos_eci, vel_eci)
641    }
642
643    // ========================================================================
644    // TEME <-> ECEF (via ECI)
645    // ========================================================================
646
647    /// Convert TEME state to ECEF.
648    pub fn teme_to_ecef(teme: &TEMEState) -> ECEFState {
649        let eci = Self::teme_to_eci(teme);
650        Self::eci_to_ecef(&eci)
651    }
652
653    /// Convert ECEF state to TEME.
654    pub fn ecef_to_teme(ecef: &ECEFState) -> TEMEState {
655        let eci = Self::ecef_to_eci(ecef);
656        Self::eci_to_teme(&eci)
657    }
658
659    // ========================================================================
660    // Cartesian <-> Keplerian Conversions
661    // ========================================================================
662
663    /// Convert Cartesian state to Keplerian elements.
664    pub fn cartesian_to_keplerian(state: &ECIState) -> KeplerianElements {
665        let mu = Constants::EARTH_MU_KM;
666        let pos = &state.position;
667        let vel = &state.velocity;
668
669        let r = pos.magnitude();
670        let v = vel.magnitude();
671
672        // Specific angular momentum h = r x v
673        let h = pos.cross(vel);
674        let h_mag = h.magnitude();
675
676        // Node vector n = k x h (k is z-unit vector)
677        let n = Vector3::new(-h.y, h.x, 0.0);
678        let n_mag = n.magnitude();
679
680        // Eccentricity vector
681        let r_dot_v = pos.dot(vel);
682        let e_vec = (*pos * (v * v - mu / r) - *vel * r_dot_v) / mu;
683        let e = e_vec.magnitude();
684
685        // Semi-major axis
686        let energy = v * v / 2.0 - mu / r;
687        let a = -mu / (2.0 * energy);
688
689        // Inclination
690        let i = (h.z / h_mag).clamp(-1.0, 1.0).acos();
691
692        // RAAN
693        let raan = if n_mag > 1e-10 {
694            let raan_cos = (n.x / n_mag).clamp(-1.0, 1.0);
695            if n.y >= 0.0 {
696                raan_cos.acos()
697            } else {
698                Constants::TWO_PI - raan_cos.acos()
699            }
700        } else {
701            0.0
702        };
703
704        // Argument of perigee
705        let w = if n_mag > 1e-10 && e > 1e-10 {
706            let n_dot_e = n.dot(&e_vec);
707            let w_cos = (n_dot_e / (n_mag * e)).clamp(-1.0, 1.0);
708            if e_vec.z >= 0.0 {
709                w_cos.acos()
710            } else {
711                Constants::TWO_PI - w_cos.acos()
712            }
713        } else {
714            0.0
715        };
716
717        // True anomaly
718        let nu = if e > 1e-10 {
719            let e_dot_r = e_vec.dot(pos);
720            let nu_cos = (e_dot_r / (e * r)).clamp(-1.0, 1.0);
721            if r_dot_v >= 0.0 {
722                nu_cos.acos()
723            } else {
724                Constants::TWO_PI - nu_cos.acos()
725            }
726        } else {
727            0.0
728        };
729
730        KeplerianElements::new(a, e, i, raan, w, nu)
731            .with_epoch(state.epoch)
732            .with_mu(mu)
733    }
734
735    /// Convert Keplerian elements to Cartesian state.
736    pub fn keplerian_to_cartesian(elements: &KeplerianElements) -> ECIState {
737        let a = elements.semi_major_axis;
738        let e = elements.eccentricity;
739        let i = elements.inclination;
740        let raan = elements.raan;
741        let w = elements.arg_perigee;
742        let nu = elements.true_anomaly;
743        let mu = elements.mu;
744
745        // Semi-latus rectum
746        let p = a * (1.0 - e * e);
747
748        // Position and velocity in perifocal frame
749        let cos_nu = nu.cos();
750        let sin_nu = nu.sin();
751
752        let r_mag = p / (1.0 + e * cos_nu);
753
754        // Position in perifocal frame
755        let r_pf = Vector3::new(r_mag * cos_nu, r_mag * sin_nu, 0.0);
756
757        // Velocity in perifocal frame
758        let sqrt_mu_p = (mu / p).sqrt();
759        let v_pf = Vector3::new(-sqrt_mu_p * sin_nu, sqrt_mu_p * (e + cos_nu), 0.0);
760
761        // Rotation from perifocal to ECI
762        let cos_raan = raan.cos();
763        let sin_raan = raan.sin();
764        let cos_w = w.cos();
765        let sin_w = w.sin();
766        let cos_i = i.cos();
767        let sin_i = i.sin();
768
769        // Combined rotation matrix from perifocal to inertial
770        let r11 = cos_raan * cos_w - sin_raan * sin_w * cos_i;
771        let r12 = -cos_raan * sin_w - sin_raan * cos_w * cos_i;
772        let r13 = sin_raan * sin_i;
773        let r21 = sin_raan * cos_w + cos_raan * sin_w * cos_i;
774        let r22 = -sin_raan * sin_w + cos_raan * cos_w * cos_i;
775        let r23 = -cos_raan * sin_i;
776        let r31 = sin_w * sin_i;
777        let r32 = cos_w * sin_i;
778        let r33 = cos_i;
779
780        let rot = Matrix3::new(r11, r12, r13, r21, r22, r23, r31, r32, r33);
781
782        let pos = rot.mul_vec(&r_pf);
783        let vel = rot.mul_vec(&v_pf);
784
785        let epoch = elements.epoch.unwrap_or_else(Utc::now);
786        ECIState::new(epoch, pos, vel)
787    }
788
789    // ========================================================================
790    // Helper Matrix Functions
791    // ========================================================================
792
793    /// Build precession matrix from precession angles.
794    fn precession_matrix(zeta: f64, theta: f64, z: f64) -> Matrix3 {
795        // P = Rz(-z) * Ry(theta) * Rz(-zeta)
796        let rz_neg_zeta = Matrix3::rotation_z(-zeta);
797        let ry_theta = Matrix3::rotation_y(theta);
798        let rz_neg_z = Matrix3::rotation_z(-z);
799
800        rz_neg_z.mul_mat(&ry_theta.mul_mat(&rz_neg_zeta))
801    }
802
803    /// Build nutation matrix from nutation angles.
804    fn nutation_matrix(mean_eps: f64, delta_psi: f64, delta_eps: f64) -> Matrix3 {
805        let eps = mean_eps + delta_eps;
806
807        // N = Rx(mean_eps) * Rz(delta_psi) * Rx(-eps)
808        let rx_mean_eps = Matrix3::rotation_x(mean_eps);
809        let rz_delta_psi = Matrix3::rotation_z(delta_psi);
810        let rx_neg_eps = Matrix3::rotation_x(-eps);
811
812        rx_mean_eps.mul_mat(&rz_delta_psi.mul_mat(&rx_neg_eps))
813    }
814
815    /// Build equation of equinoxes rotation matrix.
816    fn equinox_matrix(delta_psi: f64, eps: f64) -> Matrix3 {
817        // The equation of equinoxes term: delta_psi * cos(eps)
818        let eq_eq = delta_psi * eps.cos();
819        Matrix3::rotation_z(-eq_eq)
820    }
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use chrono::TimeZone;
827
828    const EPSILON: f64 = 1e-6;
829
830    fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
831        (a - b).abs() < eps
832    }
833
834    #[test]
835    fn test_eci_state_creation() {
836        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
837        let pos = Vector3::new(7000.0, 0.0, 0.0);
838        let vel = Vector3::new(0.0, 7.5, 0.0);
839
840        let state = ECIState::new(epoch, pos, vel);
841
842        assert!(approx_eq(state.radius(), 7000.0, EPSILON));
843        assert!(approx_eq(state.speed(), 7.5, EPSILON));
844    }
845
846    #[test]
847    fn test_geodetic_to_ecef() {
848        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
849
850        // Test at equator, prime meridian
851        let geo = GeodeticState::new(0.0, 0.0, 0.0);
852        let ecef = geo.to_ecef(epoch);
853
854        // Should be on the positive X axis at Earth's equatorial radius
855        assert!(approx_eq(
856            ecef.position.x,
857            Constants::EARTH_RADIUS_EQ_KM,
858            1e-3
859        ));
860        assert!(approx_eq(ecef.position.y, 0.0, 1e-3));
861        assert!(approx_eq(ecef.position.z, 0.0, 1e-3));
862    }
863
864    #[test]
865    fn test_geodetic_roundtrip() {
866        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
867
868        let geo_original = GeodeticState::new(40.0, -105.0, 1.6);
869        let ecef = geo_original.to_ecef(epoch);
870        let geo_back = GeodeticState::from_ecef(&ecef);
871
872        assert!(approx_eq(geo_original.latitude, geo_back.latitude, 1e-6));
873        assert!(approx_eq(geo_original.longitude, geo_back.longitude, 1e-6));
874        assert!(approx_eq(geo_original.altitude, geo_back.altitude, 1e-6));
875    }
876
877    #[test]
878    fn test_keplerian_period() {
879        // Create a circular orbit at ~400km altitude (ISS-like)
880        let a = Constants::EARTH_RADIUS_EQ_KM + 400.0;
881        let elements = KeplerianElements::new(a, 0.0, 0.9, 0.0, 0.0, 0.0);
882
883        // Period should be approximately 92 minutes = 5520 seconds
884        let period = elements.period();
885        assert!(period > 5400.0 && period < 5700.0);
886    }
887
888    #[test]
889    fn test_keplerian_apoapsis_periapsis() {
890        let a = 10000.0;
891        let e = 0.1;
892        let elements = KeplerianElements::new(a, e, 0.0, 0.0, 0.0, 0.0);
893
894        assert!(approx_eq(elements.apoapsis(), a * (1.0 + e), EPSILON));
895        assert!(approx_eq(elements.periapsis(), a * (1.0 - e), EPSILON));
896    }
897
898    #[test]
899    fn test_cartesian_keplerian_roundtrip() {
900        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
901
902        // Create a state in ECI - use a non-degenerate orbit (not in equatorial plane)
903        let pos = Vector3::new(6500.0, 1500.0, 1000.0);
904        let vel = Vector3::new(-1.0, 7.0, 0.5);
905        let eci = ECIState::new(epoch, pos, vel);
906
907        // Convert to Keplerian and back
908        let kep = StateTransforms::cartesian_to_keplerian(&eci);
909        let eci_back = StateTransforms::keplerian_to_cartesian(&kep);
910
911        // Check roundtrip accuracy (relaxed tolerance for numerical precision)
912        assert!(approx_eq(eci.position.x, eci_back.position.x, 1.0));
913        assert!(approx_eq(eci.position.y, eci_back.position.y, 1.0));
914        assert!(approx_eq(eci.position.z, eci_back.position.z, 1.0));
915        assert!(approx_eq(eci.velocity.x, eci_back.velocity.x, 1e-3));
916        assert!(approx_eq(eci.velocity.y, eci_back.velocity.y, 1e-3));
917        assert!(approx_eq(eci.velocity.z, eci_back.velocity.z, 1e-3));
918    }
919
920    #[test]
921    fn test_ecef_eci_roundtrip() {
922        let epoch = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
923
924        let pos = Vector3::new(7000.0, 1000.0, 500.0);
925        let vel = Vector3::new(-1.0, 7.0, 0.5);
926        let eci = ECIState::new(epoch, pos, vel);
927
928        // Convert to ECEF and back
929        let ecef = StateTransforms::eci_to_ecef(&eci);
930        let eci_back = StateTransforms::ecef_to_eci(&ecef);
931
932        // Check roundtrip accuracy
933        assert!(approx_eq(eci.position.x, eci_back.position.x, 1e-6));
934        assert!(approx_eq(eci.position.y, eci_back.position.y, 1e-6));
935        assert!(approx_eq(eci.position.z, eci_back.position.z, 1e-6));
936        assert!(approx_eq(eci.velocity.x, eci_back.velocity.x, 1e-6));
937        assert!(approx_eq(eci.velocity.y, eci_back.velocity.y, 1e-6));
938        assert!(approx_eq(eci.velocity.z, eci_back.velocity.z, 1e-6));
939    }
940
941    #[test]
942    fn test_keplerian_mean_anomaly() {
943        // Circular orbit (e=0), mean anomaly should equal true anomaly
944        let kep = KeplerianElements::new(7000.0, 0.0, 0.5, 0.0, 0.0, 1.0);
945        assert!(approx_eq(kep.mean_anomaly(), 1.0, EPSILON));
946    }
947
948    #[test]
949    fn test_keplerian_angular_momentum() {
950        let a = 10000.0;
951        let e = 0.1;
952        let kep = KeplerianElements::new(a, e, 0.0, 0.0, 0.0, 0.0);
953
954        // h = sqrt(mu * a * (1 - e²))
955        let expected = (Constants::EARTH_MU_KM * a * (1.0 - e * e)).sqrt();
956        assert!(approx_eq(kep.angular_momentum(), expected, EPSILON));
957    }
958}