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::ShadowModel;
30use crate::dynamics::guidance::ObjectiveEfficiency;
31pub use crate::md::objective::Objective;
32pub use crate::md::StateParameter;
33use crate::State;
34use std::f64::consts::FRAC_PI_2 as half_pi;
35use std::fmt;
36use std::sync::Arc;
37
38/// Ruggiero defines the closed loop guidance law from IEPC 2011-102
39#[derive(Clone, Default, Serialize, Deserialize)]
40pub struct Ruggiero {
41    /// Stores the objectives, and their associated efficiency threshold (set to zero if not minimum efficiency).
42    pub objectives: Vec<ObjectiveEfficiency>,
43    /// If defined, coast until vehicle is out of the provided eclipse state.
44    pub max_eclipse_prct: Option<f64>,
45    pub 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 = Vec::with_capacity(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 (obj, ηthreshold) in objectives.iter().copied().zip(ηthresholds.iter().copied()) {
85            if [
86                StateParameter::Element(OrbitalElement::SemiMajorAxis),
87                StateParameter::Element(OrbitalElement::Eccentricity),
88                StateParameter::Element(OrbitalElement::Inclination),
89                StateParameter::Element(OrbitalElement::RAAN),
90                StateParameter::Element(OrbitalElement::AoP),
91            ]
92            .contains(&obj.parameter)
93            {
94                objs.push(ObjectiveEfficiency {
95                    objective: obj,
96                    efficiency: ηthreshold,
97                });
98            } else {
99                return Err(NyxError::GuidanceConfigError {
100                    msg: format!("Objective {} not supported in Ruggerio", obj.parameter),
101                });
102            }
103        }
104        Ok(Arc::new(Self {
105            objectives: objs,
106            init_state: initial,
107            max_eclipse_prct: None,
108        }))
109    }
110
111    /// Creates a new Ruggiero locally optimal control as an Arc
112    /// Note: this returns an Arc so it can be plugged into the Spacecraft dynamics directly.
113    pub fn from_max_eclipse(
114        objectives: &[Objective],
115        initial: Spacecraft,
116        max_eclipse: f64,
117    ) -> Result<Arc<Self>, NyxError> {
118        let mut objs = Vec::with_capacity(5);
119        if objectives.len() > 5 || objectives.is_empty() {
120            return Err(NyxError::GuidanceConfigError {
121                msg: format!(
122                    "Must provide between 1 and 5 objectives (included), provided {}",
123                    objectives.len()
124                ),
125            });
126        }
127        for obj in objectives {
128            if [
129                StateParameter::Element(OrbitalElement::SemiMajorAxis),
130                StateParameter::Element(OrbitalElement::Eccentricity),
131                StateParameter::Element(OrbitalElement::Inclination),
132                StateParameter::Element(OrbitalElement::RAAN),
133                StateParameter::Element(OrbitalElement::AoP),
134            ]
135            .contains(&obj.parameter)
136            {
137                objs.push(ObjectiveEfficiency {
138                    objective: *obj,
139                    efficiency: 0.0,
140                });
141            } else {
142                return Err(NyxError::GuidanceConfigError {
143                    msg: format!("Objective {} not supported in Ruggerio", obj.parameter),
144                });
145            }
146        }
147        Ok(Arc::new(Self {
148            objectives: objs,
149            init_state: initial,
150            max_eclipse_prct: Some(max_eclipse),
151        }))
152    }
153
154    /// Sets the maximum eclipse during which we can thrust.
155    pub fn set_max_eclipse(&mut self, max_eclipse: f64) {
156        self.max_eclipse_prct = Some(max_eclipse);
157    }
158
159    /// Returns the efficiency η ∈ [0; 1] of correcting a specific orbital element at the provided osculating orbit
160    pub fn efficiency(parameter: &StateParameter, osc_orbit: &Orbit) -> Result<f64, GuidanceError> {
161        let e = osc_orbit.ecc().context(GuidancePhysicsSnafu {
162            action: "computing Ruggiero efficiency",
163        })?;
164
165        let ν_ta = osc_orbit
166            .ta_deg()
167            .context(GuidancePhysicsSnafu {
168                action: "computing Ruggiero efficiency",
169            })?
170            .to_radians();
171
172        let ω = osc_orbit
173            .aop_deg()
174            .context(GuidancePhysicsSnafu {
175                action: "computing Ruggiero efficiency",
176            })?
177            .to_radians();
178
179        match parameter {
180            StateParameter::Element(OrbitalElement::SemiMajorAxis) => {
181                let a = osc_orbit.sma_km().context(GuidancePhysicsSnafu {
182                    action: "computing Ruggiero efficiency",
183                })?;
184
185                let μ = osc_orbit.frame.mu_km3_s2().context(GuidancePhysicsSnafu {
186                    action: "computing Ruggiero efficiency",
187                })?;
188                Ok(osc_orbit.vmag_km_s() * ((a * (1.0 - e)) / (μ * (1.0 + e))).sqrt())
189            }
190            StateParameter::Element(OrbitalElement::Eccentricity) => {
191                let num = 1.0 + 2.0 * e * ν_ta.cos() + ν_ta.cos().powi(2);
192                let denom = 1.0 + e * ν_ta.cos();
193                // NOTE: There is a typo in IEPC 2011 102: the max of this efficiency function is at ν=0
194                // where it is equal to 2*(2+2e) / (1+e). Therefore, I think the correct formulation should be
195                // _divided_ by two, not multiplied by two.
196                Ok(num / (2.0 * denom))
197            }
198            StateParameter::Element(OrbitalElement::Inclination) => {
199                let num = (ω + ν_ta).cos().abs()
200                    * ((1.0 - e.powi(2) * ω.sin().powi(2)).sqrt() - e * ω.cos().abs());
201                let denom = 1.0 + e * ν_ta.cos();
202                Ok(num / denom)
203            }
204            StateParameter::Element(OrbitalElement::RAAN) => {
205                let num = (ω + ν_ta).sin().abs()
206                    * ((1.0 - e.powi(2) * ω.cos().powi(2)).sqrt() - e * ω.sin().abs());
207                let denom = 1.0 + e * ν_ta.cos();
208                Ok(num / denom)
209            }
210            StateParameter::Element(OrbitalElement::AoP) => Ok(1.0),
211            _ => Err(GuidanceError::InvalidControl { param: *parameter }),
212        }
213    }
214
215    /// Computes the weight at which to correct this orbital element, will be zero if the current efficiency is below the threshold
216    fn weighting(&self, obj: &Objective, osc_sc: &Spacecraft, η_threshold: f64) -> f64 {
217        let init = self.init_state.value(obj.parameter).unwrap();
218        let osc = osc_sc.value(obj.parameter).unwrap();
219        let target = obj.desired_value;
220        let tol = obj.tolerance;
221
222        // Calculate the efficiency of correcting this specific orbital element
223        let η = Self::efficiency(&obj.parameter, &osc_sc.orbit).unwrap();
224
225        if (osc - target).abs() < tol || η < η_threshold {
226            0.0
227        } else {
228            // Let's add the tolerance to the initial value if we want to keep a parameter fixed (i.e. target and initial are equal)
229            (target - osc)
230                / (target
231                    - if (init - target).abs() < tol {
232                        init + tol
233                    } else {
234                        init
235                    })
236                .abs()
237        }
238    }
239
240    /// Returns whether the guidance law has achieved all goals
241    pub fn status(&self, state: &Spacecraft) -> Vec<String> {
242        self.objectives
243            .iter()
244            .map(|obj| {
245                let (ok, err) = obj.objective.assess(state).unwrap();
246                format!(
247                    "{} achieved: {}\t error = {:.5} {}",
248                    obj.objective,
249                    ok,
250                    err,
251                    obj.objective.parameter.unit()
252                )
253            })
254            .collect::<Vec<String>>()
255    }
256}
257
258impl fmt::Display for Ruggiero {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        let obj_msg = self
261            .objectives
262            .iter()
263            .map(|obj| format!("{}", obj.objective))
264            .collect::<Vec<String>>();
265        write!(
266            f,
267            "Ruggiero Controller (max eclipse: {}): \n {}",
268            match self.max_eclipse_prct {
269                Some(eclp) => format!("{eclp}"),
270                None => "None".to_string(),
271            },
272            obj_msg.join("\n")
273        )
274    }
275}
276
277impl GuidanceLaw for Ruggiero {
278    /// Returns whether the guidance law has achieved all goals
279    fn achieved(&self, state: &Spacecraft) -> Result<bool, GuidanceError> {
280        for obj in self.objectives.iter() {
281            if !obj
282                .objective
283                .assess_value(
284                    state
285                        .value(obj.objective.parameter)
286                        .context(GuidStateSnafu)?,
287                )
288                .0
289            {
290                return Ok(false);
291            }
292        }
293        Ok(true)
294    }
295
296    fn direction(&self, sc: &Spacecraft) -> Result<Vector3<f64>, GuidanceError> {
297        if sc.mode() == GuidanceMode::Thrust {
298            let osc = sc.orbit;
299            let mut steering = Vector3::zeros();
300            for objective in &self.objectives {
301                let obj = objective.objective;
302                let ηthreshold = objective.efficiency;
303                let weight = self.weighting(&obj, sc, ηthreshold);
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::Element(OrbitalElement::SemiMajorAxis) => {
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::Element(OrbitalElement::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::Element(OrbitalElement::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::Element(OrbitalElement::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::Element(OrbitalElement::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 = ShadowModel::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::analysis::prelude::OrbitalElement;
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(
470            StateParameter::Element(OrbitalElement::SemiMajorAxis),
471            42164.0,
472            1.0,
473        ),
474        Objective::within_tolerance(
475            StateParameter::Element(OrbitalElement::Eccentricity),
476            0.01,
477            5e-5,
478        ),
479    ];
480
481    let ruggiero = Ruggiero::simple(objectives, sc).unwrap();
482    // 7301.597157 201.699933 0.176016 -0.202974 7.421233 0.006476 298.999726
483    let osc = Orbit::new(
484        7_303.253_461_441_64f64,
485        127.478_714_816_381_75,
486        0.111_246_193_227_445_4,
487        -0.128_284_025_765_195_6,
488        7.422_889_151_816_439,
489        0.006_477_694_429_837_2,
490        start_time,
491        eme2k,
492    );
493
494    let mut osc_sc = Spacecraft::new(osc, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0);
495    // Must set the guidance mode to thrusting otherwise the direction will be set to zero.
496    osc_sc.mut_mode(GuidanceMode::Thrust);
497
498    let expected = Vector3::new(
499        -0.017_279_636_133_108_3,
500        0.999_850_315_226_803,
501        0.000_872_534_222_883_2,
502    );
503
504    let got = ruggiero.direction(&osc_sc).unwrap();
505
506    assert!(
507        dbg!(expected - got).norm() < 1e-12,
508        "incorrect direction computed"
509    );
510}