Skip to main content

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