nyx_space/dynamics/guidance/
ruggiero.rs

1/*
2    Nyx, blazing fast astrodynamics
3    Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU Affero General Public License as published
7    by the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9
10    This program is distributed in the hope that it will be useful,
11    but WITHOUT ANY WARRANTY; without even the implied warranty of
12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13    GNU Affero General Public License for more details.
14
15    You should have received a copy of the GNU Affero General Public License
16    along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18
19use anise::prelude::Almanac;
20use log::debug;
21use serde::{Deserialize, Serialize};
22use snafu::ResultExt;
23
24use super::{
25    unit_vector_from_plane_angles, GuidStateSnafu, GuidanceError, GuidanceLaw, GuidanceMode,
26    GuidancePhysicsSnafu, NyxError, Orbit, Spacecraft, Vector3,
27};
28use crate::cosmic::eclipse::EclipseLocator;
29pub use crate::md::objective::Objective;
30pub use crate::md::StateParameter;
31use crate::State;
32use std::f64::consts::FRAC_PI_2 as half_pi;
33use std::fmt;
34use std::sync::Arc;
35
36/// Ruggiero defines the closed loop guidance law from IEPC 2011-102
37#[derive(Copy, Clone, Default, Serialize, Deserialize)]
38pub struct Ruggiero {
39    /// Stores the objectives
40    pub objectives: [Option<Objective>; 5],
41    /// Stores the minimum efficiency to correct a given orbital element, defaults to zero (i.e. always correct)
42    pub ηthresholds: [f64; 5],
43    /// If define, coast until vehicle is out of the provided eclipse state.
44    pub max_eclipse_prct: Option<f64>,
45    init_state: Spacecraft,
46}
47
48/// The Ruggiero is a locally optimal guidance law of a state for specific osculating elements.
49/// NOTE: The efficiency parameters for AoP is NOT implemented: the paper's formulation is broken.
50/// WARNING: Objectives must be in degrees!
51impl Ruggiero {
52    /// Creates a new Ruggiero locally optimal control as an Arc
53    /// Note: this returns an Arc so it can be plugged into the Spacecraft dynamics directly.
54    pub fn simple(objectives: &[Objective], initial: Spacecraft) -> Result<Arc<Self>, NyxError> {
55        Self::from_ηthresholds(objectives, &[0.0; 5], initial)
56    }
57
58    /// Creates a new Ruggiero locally optimal control with the provided efficiency threshold.
59    /// If the efficiency to correct the mapped orbital element is greater than the threshold, then the control law will be applied to this orbital element.
60    /// Note: this returns an Arc so it can be plugged into the Spacecraft dynamics directly.
61    pub fn from_ηthresholds(
62        objectives: &[Objective],
63        ηthresholds: &[f64],
64        initial: Spacecraft,
65    ) -> Result<Arc<Self>, NyxError> {
66        let mut objs: [Option<Objective>; 5] = [None, None, None, None, None];
67        let mut eff: [f64; 5] = [0.0; 5];
68        if objectives.len() > 5 || objectives.is_empty() {
69            return Err(NyxError::GuidanceConfigError {
70                msg: format!(
71                    "Must provide between 1 and 5 objectives (included), provided {}",
72                    objectives.len()
73                ),
74            });
75        } else if objectives.len() > ηthresholds.len() {
76            return Err(NyxError::GuidanceConfigError {
77                msg: format!(
78                    "Must provide at least {} efficiency threshold values, provided {}",
79                    objectives.len(),
80                    ηthresholds.len()
81                ),
82            });
83        }
84
85        for (i, obj) in objectives.iter().enumerate() {
86            if [
87                StateParameter::SMA,
88                StateParameter::Eccentricity,
89                StateParameter::Inclination,
90                StateParameter::RAAN,
91                StateParameter::AoP,
92            ]
93            .contains(&obj.parameter)
94            {
95                objs[i] = Some(*obj);
96            } else {
97                return Err(NyxError::GuidanceConfigError {
98                    msg: format!("Objective {} not supported in Ruggerio", obj.parameter),
99                });
100            }
101        }
102        for i in 0..objectives.len() {
103            objs[i] = Some(objectives[i]);
104            eff[i] = ηthresholds[i];
105        }
106        Ok(Arc::new(Self {
107            objectives: objs,
108            init_state: initial,
109            ηthresholds: eff,
110            max_eclipse_prct: None,
111        }))
112    }
113
114    /// Creates a new Ruggiero locally optimal control as an Arc
115    /// Note: this returns an Arc so it can be plugged into the Spacecraft dynamics directly.
116    pub fn from_max_eclipse(
117        objectives: &[Objective],
118        initial: Spacecraft,
119        max_eclipse: f64,
120    ) -> Result<Arc<Self>, NyxError> {
121        let mut objs: [Option<Objective>; 5] = [None, None, None, None, None];
122        let eff: [f64; 5] = [0.0; 5];
123        if objectives.len() > 5 || objectives.is_empty() {
124            return Err(NyxError::GuidanceConfigError {
125                msg: format!(
126                    "Must provide between 1 and 5 objectives (included), provided {}",
127                    objectives.len()
128                ),
129            });
130        }
131
132        for (i, obj) in objectives.iter().enumerate() {
133            if [
134                StateParameter::SMA,
135                StateParameter::Eccentricity,
136                StateParameter::Inclination,
137                StateParameter::RAAN,
138                StateParameter::AoP,
139            ]
140            .contains(&obj.parameter)
141            {
142                objs[i] = Some(*obj);
143            } else {
144                return Err(NyxError::GuidanceConfigError {
145                    msg: format!("Objective {} not supported in Ruggerio", obj.parameter),
146                });
147            }
148        }
149        for i in 0..objectives.len() {
150            objs[i] = Some(objectives[i]);
151        }
152        Ok(Arc::new(Self {
153            objectives: objs,
154            init_state: initial,
155            ηthresholds: eff,
156            max_eclipse_prct: Some(max_eclipse),
157        }))
158    }
159
160    /// Sets the maximum eclipse during which we can thrust.
161    pub fn set_max_eclipse(&mut self, max_eclipse: f64) {
162        self.max_eclipse_prct = Some(max_eclipse);
163    }
164
165    /// Returns the efficiency η ∈ [0; 1] of correcting a specific orbital element at the provided osculating orbit
166    pub fn efficency(parameter: &StateParameter, osc_orbit: &Orbit) -> Result<f64, GuidanceError> {
167        let e = osc_orbit.ecc().context(GuidancePhysicsSnafu {
168            action: "computing Ruggiero efficency",
169        })?;
170
171        let ν_ta = osc_orbit
172            .ta_deg()
173            .context(GuidancePhysicsSnafu {
174                action: "computing Ruggiero efficency",
175            })?
176            .to_radians();
177
178        let ω = osc_orbit
179            .aop_deg()
180            .context(GuidancePhysicsSnafu {
181                action: "computing Ruggiero efficency",
182            })?
183            .to_radians();
184
185        match parameter {
186            StateParameter::SMA => {
187                let a = osc_orbit.sma_km().context(GuidancePhysicsSnafu {
188                    action: "computing Ruggiero efficency",
189                })?;
190
191                let μ = osc_orbit.frame.mu_km3_s2().context(GuidancePhysicsSnafu {
192                    action: "computing Ruggiero efficency",
193                })?;
194                Ok(osc_orbit.vmag_km_s() * ((a * (1.0 - e)) / (μ * (1.0 + e))).sqrt())
195            }
196            StateParameter::Eccentricity => {
197                let num = 1.0 + 2.0 * e * ν_ta.cos() + ν_ta.cos().powi(2);
198                let denom = 1.0 + e * ν_ta.cos();
199                // NOTE: There is a typo in IEPC 2011 102: the max of this efficiency function is at ν=0
200                // where it is equal to 2*(2+2e) / (1+e). Therefore, I think the correct formulation should be
201                // _divided_ by two, not multiplied by two.
202                Ok(num / (2.0 * denom))
203            }
204            StateParameter::Inclination => {
205                let num = (ω + ν_ta).cos().abs()
206                    * ((1.0 - e.powi(2) * ω.sin().powi(2)).sqrt() - e * ω.cos().abs());
207                let denom = 1.0 + e * ν_ta.cos();
208                Ok(num / denom)
209            }
210            StateParameter::RAAN => {
211                let num = (ω + ν_ta).sin().abs()
212                    * ((1.0 - e.powi(2) * ω.cos().powi(2)).sqrt() - e * ω.sin().abs());
213                let denom = 1.0 + e * ν_ta.cos();
214                Ok(num / denom)
215            }
216            StateParameter::AoP => Ok(1.0),
217            _ => Err(GuidanceError::InvalidControl { param: *parameter }),
218        }
219    }
220
221    /// Computes the weight at which to correct this orbital element, will be zero if the current efficiency is below the threshold
222    fn weighting(&self, obj: &Objective, osc_sc: &Spacecraft, η_threshold: f64) -> f64 {
223        let init = self.init_state.value(obj.parameter).unwrap();
224        let osc = osc_sc.value(obj.parameter).unwrap();
225        let target = obj.desired_value;
226        let tol = obj.tolerance;
227
228        // Calculate the efficiency of correcting this specific orbital element
229        let η = Self::efficency(&obj.parameter, &osc_sc.orbit).unwrap();
230
231        if (osc - target).abs() < tol || η < η_threshold {
232            0.0
233        } else {
234            // Let's add the tolerance to the initial value if we want to keep a parameter fixed (i.e. target and initial are equal)
235            (target - osc)
236                / (target
237                    - if (init - target).abs() < tol {
238                        init + tol
239                    } else {
240                        init
241                    })
242                .abs()
243        }
244    }
245
246    /// Returns whether the guidance law has achieved all goals
247    pub fn status(&self, state: &Spacecraft) -> Vec<String> {
248        self.objectives
249            .iter()
250            .flatten()
251            .map(|obj| {
252                let (ok, err) = obj.assess(state).unwrap();
253                format!(
254                    "{} achieved: {}\t error = {:.5} {}",
255                    obj,
256                    ok,
257                    err,
258                    obj.parameter.unit()
259                )
260            })
261            .collect::<Vec<String>>()
262    }
263}
264
265impl fmt::Display for Ruggiero {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        let obj_msg = self
268            .objectives
269            .iter()
270            .flatten()
271            .map(|obj| format!("{obj}"))
272            .collect::<Vec<String>>();
273        write!(
274            f,
275            "Ruggiero Controller (max eclipse: {}): \n {}",
276            match self.max_eclipse_prct {
277                Some(eclp) => format!("{eclp}"),
278                None => "None".to_string(),
279            },
280            obj_msg.join("\n")
281        )
282    }
283}
284
285impl GuidanceLaw for Ruggiero {
286    /// Returns whether the guidance law has achieved all goals
287    fn achieved(&self, state: &Spacecraft) -> Result<bool, GuidanceError> {
288        for obj in self.objectives.iter().flatten() {
289            if !obj
290                .assess_value(state.value(obj.parameter).context(GuidStateSnafu)?)
291                .0
292            {
293                return Ok(false);
294            }
295        }
296        Ok(true)
297    }
298
299    fn direction(&self, sc: &Spacecraft) -> Result<Vector3<f64>, GuidanceError> {
300        if sc.mode() == GuidanceMode::Thrust {
301            let osc = sc.orbit;
302            let mut steering = Vector3::zeros();
303            for (i, obj) in self.objectives.iter().flatten().enumerate() {
304                let weight = self.weighting(obj, sc, self.ηthresholds[i]);
305                if weight.abs() <= 0.0 {
306                    continue;
307                }
308
309                // Compute all of the orbital elements here to unclutter the algorithm
310                let ecc = osc.ecc().context(GuidancePhysicsSnafu {
311                    action: "computing Ruggiero guidance",
312                })?;
313
314                let ta_rad = osc
315                    .ta_deg()
316                    .context(GuidancePhysicsSnafu {
317                        action: "computing Ruggiero guidance",
318                    })?
319                    .to_radians();
320
321                let inc_rad = osc
322                    .inc_deg()
323                    .context(GuidancePhysicsSnafu {
324                        action: "computing Ruggiero guidance",
325                    })?
326                    .to_radians();
327
328                let aop_rad = osc
329                    .aop_deg()
330                    .context(GuidancePhysicsSnafu {
331                        action: "computing Ruggiero guidance",
332                    })?
333                    .to_radians();
334
335                let ea_rad = osc
336                    .ea_deg()
337                    .context(GuidancePhysicsSnafu {
338                        action: "computing Ruggiero guidance",
339                    })?
340                    .to_radians();
341
342                match obj.parameter {
343                    StateParameter::SMA => {
344                        let num = ecc * ta_rad.sin();
345                        let denom = 1.0 + ecc * ta_rad.cos();
346                        let alpha = num.atan2(denom);
347                        steering += unit_vector_from_plane_angles(alpha, 0.0) * weight;
348                    }
349                    StateParameter::Eccentricity => {
350                        let num = ta_rad.sin();
351                        let denom = ta_rad.cos() + ea_rad.cos();
352                        let alpha = num.atan2(denom);
353                        steering += unit_vector_from_plane_angles(alpha, 0.0) * weight;
354                    }
355                    StateParameter::Inclination => {
356                        let beta = half_pi.copysign((ta_rad + aop_rad).cos());
357                        steering += unit_vector_from_plane_angles(0.0, beta) * weight;
358                    }
359                    StateParameter::RAAN => {
360                        let beta = half_pi.copysign((ta_rad + aop_rad).sin());
361                        steering += unit_vector_from_plane_angles(0.0, beta) * weight;
362                    }
363                    StateParameter::AoP => {
364                        let oe2 = 1.0 - ecc.powi(2);
365                        let e3 = ecc.powi(3);
366                        // Compute the optimal true anomaly for in-plane thrusting
367                        let sqrt_val = (0.25 * (oe2 / e3).powi(2) + 1.0 / 27.0).sqrt();
368                        let opti_ta_alpha = ((oe2 / (2.0 * e3) + sqrt_val).powf(1.0 / 3.0)
369                            - (-oe2 / (2.0 * e3) + sqrt_val).powf(1.0 / 3.0)
370                            - 1.0 / ecc)
371                            .acos();
372                        // Compute the optimal true anomaly for out of plane thrusting
373                        let opti_ta_beta = (-ecc * aop_rad.cos()).acos() - aop_rad;
374                        // And choose whether to do an in-plane or out of plane thrust
375                        if (ta_rad - opti_ta_alpha).abs() < (ta_rad - opti_ta_beta).abs() {
376                            // In plane
377                            let p = osc.semi_parameter_km().context(GuidancePhysicsSnafu {
378                                action: "computing Ruggiero guidance",
379                            })?;
380                            let (sin_ta, cos_ta) = ta_rad.sin_cos();
381                            let alpha = (-p * cos_ta).atan2((p + osc.rmag_km()) * sin_ta);
382                            steering += unit_vector_from_plane_angles(alpha, 0.0) * weight;
383                        } else {
384                            // Out of plane
385                            let beta = half_pi.copysign(-(ta_rad + aop_rad).sin()) * inc_rad.cos();
386                            steering += unit_vector_from_plane_angles(0.0, beta) * weight;
387                        };
388                    }
389                    _ => unreachable!(),
390                }
391            }
392
393            // Return a normalized vector
394            steering = if steering.norm() > 0.0 {
395                steering / steering.norm()
396            } else {
397                steering
398            };
399
400            // Convert to inertial -- this whole guidance law is computed in the RCN frame
401            Ok(osc
402                .dcm_from_rcn_to_inertial()
403                .context(GuidancePhysicsSnafu {
404                    action: "computing RCN frame",
405                })?
406                * steering)
407        } else {
408            Ok(Vector3::zeros())
409        }
410    }
411
412    // Either thrust full power or not at all
413    fn throttle(&self, sc: &Spacecraft) -> Result<f64, GuidanceError> {
414        if sc.mode() == GuidanceMode::Thrust {
415            if self.direction(sc)?.norm() > 0.0 {
416                Ok(1.0)
417            } else {
418                Ok(0.0)
419            }
420        } else {
421            Ok(0.0)
422        }
423    }
424
425    /// Update the state for the next iteration
426    fn next(&self, sc: &mut Spacecraft, almanac: Arc<Almanac>) {
427        if sc.mode() != GuidanceMode::Inhibit {
428            if !self.achieved(sc).unwrap() {
429                // Check eclipse state if applicable.
430                if let Some(max_eclipse) = self.max_eclipse_prct {
431                    let locator = EclipseLocator::cislunar(almanac.clone());
432                    if locator
433                        .compute(sc.orbit, almanac)
434                        .expect("cannot compute eclipse")
435                        .percentage
436                        > max_eclipse
437                    {
438                        // Coast in eclipse
439                        sc.mode = GuidanceMode::Coast;
440                    } else {
441                        sc.mode = GuidanceMode::Thrust;
442                    }
443                } else if sc.mode() == GuidanceMode::Coast {
444                    debug!("enabling steering: {:x}", sc.orbit);
445                }
446                sc.mut_mode(GuidanceMode::Thrust);
447            } else {
448                if sc.mode() == GuidanceMode::Thrust {
449                    debug!("disabling steering: {:x}", sc.orbit);
450                }
451                sc.mut_mode(GuidanceMode::Coast);
452            }
453        }
454    }
455}
456
457#[test]
458fn ruggiero_weight() {
459    use crate::time::Epoch;
460    use anise::constants::frames::EARTH_J2000;
461
462    let eme2k = EARTH_J2000.with_mu_km3_s2(398_600.433);
463    let start_time = Epoch::from_gregorian_tai_at_midnight(2020, 1, 1);
464    let orbit = Orbit::keplerian(7378.1363, 0.01, 0.05, 0.0, 0.0, 1.0, start_time, eme2k);
465    let sc = Spacecraft::new(orbit, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
466
467    // Define the objectives
468    let objectives = &[
469        Objective::within_tolerance(StateParameter::SMA, 42164.0, 1.0),
470        Objective::within_tolerance(StateParameter::Eccentricity, 0.01, 5e-5),
471    ];
472
473    let ruggiero = Ruggiero::simple(objectives, sc).unwrap();
474    // 7301.597157 201.699933 0.176016 -0.202974 7.421233 0.006476 298.999726
475    let osc = Orbit::new(
476        7_303.253_461_441_64f64,
477        127.478_714_816_381_75,
478        0.111_246_193_227_445_4,
479        -0.128_284_025_765_195_6,
480        7.422_889_151_816_439,
481        0.006_477_694_429_837_2,
482        start_time,
483        eme2k,
484    );
485
486    let mut osc_sc = Spacecraft::new(osc, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0);
487    // Must set the guidance mode to thrusting otherwise the direction will be set to zero.
488    osc_sc.mut_mode(GuidanceMode::Thrust);
489
490    let expected = Vector3::new(
491        -0.017_279_636_133_108_3,
492        0.999_850_315_226_803,
493        0.000_872_534_222_883_2,
494    );
495
496    let got = ruggiero.direction(&osc_sc).unwrap();
497
498    assert!(
499        dbg!(expected - got).norm() < 1e-12,
500        "incorrect direction computed"
501    );
502}