solar_line_core/
attitude.rs

1//! Attitude control analysis for SOLAR LINE.
2//!
3//! Analyzes pointing accuracy requirements, flip maneuver dynamics,
4//! and miss-distance sensitivity for brachistochrone trajectories.
5
6/// Miss distance (km) from pointing error during a constant-thrust burn.
7///
8/// For a burn of duration `t` seconds at acceleration `a` m/s²,
9/// a pointing error of `theta` radians produces a lateral displacement:
10///   miss = 0.5 * a * t² * sin(theta)
11/// For small angles: miss ≈ 0.5 * a * t² * theta
12///
13/// Returns miss distance in km.
14pub fn miss_distance_km(accel_m_s2: f64, burn_time_s: f64, pointing_error_rad: f64) -> f64 {
15    let lateral_accel = accel_m_s2 * pointing_error_rad.sin();
16    let miss_m = 0.5 * lateral_accel * burn_time_s * burn_time_s;
17    miss_m / 1000.0 // m → km
18}
19
20/// Required pointing accuracy (radians) to achieve a given miss distance.
21///
22/// Inverse of miss_distance_km:
23///   theta = arcsin(2 * miss_km * 1000 / (a * t²))
24/// For small angles: theta ≈ 2 * miss_km * 1000 / (a * t²)
25pub fn required_pointing_rad(accel_m_s2: f64, burn_time_s: f64, max_miss_km: f64) -> f64 {
26    let sin_theta = 2.0 * max_miss_km * 1000.0 / (accel_m_s2 * burn_time_s * burn_time_s);
27    if sin_theta.abs() > 1.0 {
28        // Any pointing accuracy is sufficient (miss is already small enough)
29        std::f64::consts::FRAC_PI_2
30    } else {
31        sin_theta.asin()
32    }
33}
34
35/// Angular rate for a flip maneuver (rad/s).
36///
37/// A brachistochrone flip involves rotating the ship 180° at the midpoint.
38/// Given a desired flip duration in seconds, returns the angular rate.
39/// Assumes constant angular velocity (instantaneous start/stop).
40pub fn flip_angular_rate(flip_duration_s: f64) -> f64 {
41    std::f64::consts::PI / flip_duration_s
42}
43
44/// Angular momentum for a flip maneuver (kg·m²/s).
45///
46/// Given ship mass (kg), characteristic radius (m, distance from
47/// center of mass to thrust point), and flip angular rate (rad/s),
48/// returns the angular momentum about the rotation axis.
49///
50/// Approximates the ship as a cylinder: I ≈ 0.5 * m * r²
51pub fn flip_angular_momentum(mass_kg: f64, radius_m: f64, angular_rate_rad_s: f64) -> f64 {
52    let moment_of_inertia = 0.5 * mass_kg * radius_m * radius_m;
53    moment_of_inertia * angular_rate_rad_s
54}
55
56/// RCS torque required for a flip maneuver (N·m).
57///
58/// Given moment of inertia (I = 0.5 * m * r²), flip duration, and
59/// desired angular acceleration/deceleration time (ramp time).
60/// Assumes trapezoidal angular velocity profile:
61///   accelerate for ramp_s, coast, decelerate for ramp_s.
62pub fn flip_rcs_torque(mass_kg: f64, radius_m: f64, flip_duration_s: f64, ramp_time_s: f64) -> f64 {
63    let moment_of_inertia = 0.5 * mass_kg * radius_m * radius_m;
64    // Peak angular velocity: ω = π / (flip_duration - ramp_time)
65    // (approximately, for trapezoidal profile)
66    let coast_duration = flip_duration_s - 2.0 * ramp_time_s;
67    if coast_duration <= 0.0 {
68        // Pure triangular profile
69        let omega_peak = 2.0 * std::f64::consts::PI / flip_duration_s;
70        let angular_accel = omega_peak / (flip_duration_s / 2.0);
71        return moment_of_inertia * angular_accel;
72    }
73    let omega_peak = std::f64::consts::PI / (coast_duration + ramp_time_s);
74    let angular_accel = omega_peak / ramp_time_s;
75    moment_of_inertia * angular_accel
76}
77
78/// Pointing error from thrust vector misalignment over a burn.
79///
80/// Returns the velocity error (km/s) from a constant pointing offset.
81///   v_error = a * t * sin(theta)
82pub fn velocity_error_from_pointing(
83    accel_m_s2: f64,
84    burn_time_s: f64,
85    pointing_error_rad: f64,
86) -> f64 {
87    let v_error_m_s = accel_m_s2 * burn_time_s * pointing_error_rad.sin();
88    v_error_m_s / 1000.0 // m/s → km/s
89}
90
91/// Navigation accuracy as angular precision.
92///
93/// Given "accuracy X%" where X represents the fraction of velocity
94/// that is correctly aligned, convert to pointing error in radians.
95///   accuracy = cos(theta)  →  theta = arccos(accuracy)
96/// For 99.8% accuracy: theta ≈ 0.063 rad ≈ 3.6°
97pub fn accuracy_to_pointing_error_rad(accuracy_fraction: f64) -> f64 {
98    accuracy_fraction.clamp(-1.0, 1.0).acos()
99}
100
101/// Gravity gradient torque on an elongated spacecraft (N·m).
102///
103/// In a gravity field, a non-spherical body experiences torque that
104/// tends to align its long axis radially. For a cylinder of length L:
105///   τ ≈ 3 * μ * (I_zz - I_xx) * sin(2θ) / (2 * r³)
106/// where θ is the angle between the long axis and the local vertical.
107///
108/// Returns torque in N·m. Inputs: GM (m³/s²), distance (m),
109/// mass (kg), length (m), angle from vertical (rad).
110pub fn gravity_gradient_torque(
111    gm_m3_s2: f64,
112    distance_m: f64,
113    mass_kg: f64,
114    length_m: f64,
115    angle_rad: f64,
116) -> f64 {
117    // For a uniform cylinder: I_zz - I_xx ≈ m * L² / 12
118    let delta_i = mass_kg * length_m * length_m / 12.0;
119    let r3 = distance_m * distance_m * distance_m;
120    3.0 * gm_m3_s2 * delta_i * (2.0 * angle_rad).sin() / (2.0 * r3)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_miss_distance_small_angle() {
129        // 20 m/s² accel, 1 hour burn, 0.001 rad (~0.057°) error
130        let miss = miss_distance_km(20.0, 3600.0, 0.001);
131        // 0.5 * 20 * 3600² * 0.001 / 1000 = 0.5 * 20 * 12960000 * 0.001 / 1000 = 129.6 km
132        assert!((miss - 129.6).abs() < 0.1, "miss = {miss}");
133    }
134
135    #[test]
136    fn test_miss_distance_zero_error() {
137        assert_eq!(miss_distance_km(20.0, 3600.0, 0.0), 0.0);
138    }
139
140    #[test]
141    fn test_required_pointing_inverse() {
142        let accel = 20.0;
143        let burn_time = 3600.0;
144        let target_miss = 10.0; // 10 km
145        let theta = required_pointing_rad(accel, burn_time, target_miss);
146        let actual_miss = miss_distance_km(accel, burn_time, theta);
147        assert!(
148            (actual_miss - target_miss).abs() < 0.01,
149            "actual = {actual_miss}"
150        );
151    }
152
153    #[test]
154    fn test_flip_angular_rate() {
155        // 60 second flip → π/60 rad/s ≈ 0.0524 rad/s ≈ 3°/s
156        let rate = flip_angular_rate(60.0);
157        assert!((rate - std::f64::consts::PI / 60.0).abs() < 1e-10);
158    }
159
160    #[test]
161    fn test_flip_angular_momentum() {
162        // 300,000 kg ship, 5 m radius, 0.05 rad/s
163        let h = flip_angular_momentum(300_000.0, 5.0, 0.05);
164        // I = 0.5 * 300000 * 25 = 3,750,000 kg·m²
165        // H = 3,750,000 * 0.05 = 187,500 kg·m²/s
166        assert!((h - 187_500.0).abs() < 1.0, "h = {h}");
167    }
168
169    #[test]
170    fn test_flip_rcs_torque() {
171        // 300,000 kg, 5 m radius, 60 s flip, 10 s ramp
172        let torque = flip_rcs_torque(300_000.0, 5.0, 60.0, 10.0);
173        // I = 3,750,000 kg·m²
174        // coast = 60 - 20 = 40 s
175        // ω_peak = π / (40 + 10) = π/50 ≈ 0.0628 rad/s
176        // α = ω_peak / 10 ≈ 0.00628 rad/s²
177        // τ = I * α ≈ 3,750,000 * 0.00628 ≈ 23,562 N·m
178        assert!((torque - 23_562.0).abs() < 100.0, "torque = {torque}");
179    }
180
181    #[test]
182    fn test_velocity_error() {
183        // 20 m/s², 3600 s, 0.001 rad
184        let v_err = velocity_error_from_pointing(20.0, 3600.0, 0.001);
185        // 20 * 3600 * sin(0.001) / 1000 ≈ 72 * 0.001 = 0.072 km/s
186        assert!((v_err - 0.072).abs() < 0.001, "v_err = {v_err}");
187    }
188
189    #[test]
190    fn test_accuracy_to_pointing() {
191        // 99.8% accuracy → cos(θ) = 0.998
192        let theta = accuracy_to_pointing_error_rad(0.998);
193        // arccos(0.998) ≈ 0.0632 rad ≈ 3.62°
194        assert!((theta - 0.0632).abs() < 0.001, "theta = {theta}");
195    }
196
197    #[test]
198    fn test_gravity_gradient_torque_zero_angle() {
199        // At zero angle (aligned with vertical), torque is zero
200        let torque = gravity_gradient_torque(3.986e14, 7_000_000.0, 300_000.0, 100.0, 0.0);
201        assert!(torque.abs() < 1e-6, "torque = {torque}");
202    }
203
204    #[test]
205    fn test_gravity_gradient_torque_nonzero() {
206        // At 45°, torque is maximum
207        let torque = gravity_gradient_torque(
208            3.986e14,    // Earth GM in m³/s²
209            7_000_000.0, // 7000 km orbit
210            300_000.0,   // 300 t
211            100.0,       // 100 m length
212            std::f64::consts::FRAC_PI_4,
213        );
214        // Should be positive and significant
215        assert!(torque > 0.0, "torque = {torque}");
216        // τ ≈ 3 * 3.986e14 * (300000 * 10000 / 12) * sin(π/2) / (2 * 3.43e20)
217        // ≈ 3 * 3.986e14 * 2.5e8 * 1 / 6.86e20
218        // ≈ 2.99e23 / 6.86e20 ≈ 436 N·m
219        assert!((torque - 436.0).abs() < 10.0, "torque = {torque}");
220    }
221
222    // --- Edge case tests ---
223
224    #[test]
225    fn required_pointing_clamp_to_frac_pi_2() {
226        // When miss tolerance exceeds what's achievable, sin_theta > 1 → clamp
227        let theta = required_pointing_rad(0.001, 1.0, 1_000_000.0);
228        assert_eq!(theta, std::f64::consts::FRAC_PI_2);
229    }
230
231    #[test]
232    fn flip_rcs_torque_triangular_profile() {
233        // When ramp_time >= flip_duration/2, coast_duration <= 0 → triangular
234        let torque_tri = flip_rcs_torque(300_000.0, 5.0, 60.0, 30.0);
235        // coast = 60 - 60 = 0, exactly boundary
236        // ω_peak = 2π / 60; α = ω_peak / 30
237        // I = 0.5 * 300000 * 25 = 3,750,000
238        // torque = I * 2π/60 / 30 = 3750000 * 2π / 1800
239        let expected = 3_750_000.0 * 2.0 * std::f64::consts::PI / 1800.0;
240        assert!((torque_tri - expected).abs() < 1.0, "torque = {torque_tri}");
241    }
242
243    #[test]
244    fn flip_rcs_torque_overlong_ramp() {
245        // Ramp longer than half the flip duration → still triangular
246        let torque = flip_rcs_torque(300_000.0, 5.0, 60.0, 40.0);
247        assert!(torque > 0.0, "torque should be positive: {torque}");
248    }
249
250    #[test]
251    fn gravity_gradient_max_at_45_degrees() {
252        let gm = 3.986e14;
253        let r = 7_000_000.0;
254        let m = 300_000.0;
255        let l = 100.0;
256        let torque_45 = gravity_gradient_torque(gm, r, m, l, std::f64::consts::FRAC_PI_4);
257        let torque_30 = gravity_gradient_torque(gm, r, m, l, std::f64::consts::FRAC_PI_6);
258        let torque_60 = gravity_gradient_torque(gm, r, m, l, std::f64::consts::FRAC_PI_3);
259        // sin(2*45°) = sin(90°) = 1, which is the maximum
260        assert!(torque_45 > torque_30, "45° > 30°");
261        assert!(torque_45 > torque_60, "45° > 60°");
262    }
263
264    #[test]
265    fn velocity_error_zero_burn_time() {
266        assert_eq!(velocity_error_from_pointing(20.0, 0.0, 0.1), 0.0);
267    }
268
269    #[test]
270    fn velocity_error_zero_accel() {
271        assert_eq!(velocity_error_from_pointing(0.0, 3600.0, 0.1), 0.0);
272    }
273
274    #[test]
275    fn miss_distance_large_angle() {
276        // At exactly π/2 rad (90°), sin(π/2) = 1 → full lateral
277        let miss = miss_distance_km(20.0, 3600.0, std::f64::consts::FRAC_PI_2);
278        // 0.5 * 20 * 3600² * 1.0 / 1000 = 129600 km
279        assert!((miss - 129_600.0).abs() < 1.0, "miss = {miss}");
280    }
281
282    #[test]
283    fn accuracy_100_percent_is_zero_error() {
284        let theta = accuracy_to_pointing_error_rad(1.0);
285        assert!(theta.abs() < 1e-15, "theta = {theta}");
286    }
287
288    #[test]
289    fn accuracy_0_percent_is_pi_over_2() {
290        let theta = accuracy_to_pointing_error_rad(0.0);
291        assert!(
292            (theta - std::f64::consts::FRAC_PI_2).abs() < 1e-15,
293            "theta = {theta}"
294        );
295    }
296}