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