Skip to main content

nyx_space/dynamics/guidance/
kluever.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    GuidStateSnafu, GuidanceError, GuidanceLaw, GuidanceMode, GuidancePhysicsSnafu, Spacecraft,
27    Vector3,
28};
29use crate::cosmic::eclipse::EclipseLocator;
30pub use crate::md::objective::Objective;
31pub use crate::md::StateParameter;
32use crate::State;
33use std::fmt;
34use std::sync::Arc;
35
36/// Kluever defines a blended closed loop guidance law for low-thrust orbit transfers.
37#[derive(Clone, Serialize, Deserialize)]
38pub struct Kluever {
39    /// Stores the objectives
40    pub objectives: Vec<Option<Objective>>,
41    /// Stores the weights for each objective
42    pub weights: Vec<f64>,
43    /// If defined, coast until vehicle is out of the provided eclipse state.
44    pub max_eclipse_prct: Option<f64>,
45}
46
47impl Kluever {
48    /// Creates a new Kluever blended control law.
49    pub fn new(objectives: &[Objective], weights: &[f64]) -> Arc<Self> {
50        let objs: Vec<Option<Objective>> = objectives.iter().copied().map(Some).collect();
51
52        Arc::new(Self {
53            objectives: objs,
54            weights: weights.to_vec(),
55            max_eclipse_prct: None,
56        })
57    }
58
59    /// Sets the maximum eclipse during which we can thrust.
60    pub fn set_max_eclipse(&mut self, max_eclipse: f64) {
61        self.max_eclipse_prct = Some(max_eclipse);
62    }
63}
64
65impl fmt::Display for Kluever {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        let obj_msg = self
68            .objectives
69            .iter()
70            .flatten()
71            .enumerate()
72            .map(|(i, obj)| format!("{obj} (weight: {:.3})", self.weights[i]))
73            .collect::<Vec<String>>();
74        write!(
75            f,
76            "Kluever Controller (max eclipse: {}): \n {}",
77            match self.max_eclipse_prct {
78                Some(eclp) => format!("{eclp}"),
79                None => "None".to_string(),
80            },
81            obj_msg.join("\n")
82        )
83    }
84}
85
86impl GuidanceLaw for Kluever {
87    /// Returns whether the guidance law has achieved all goals
88    fn achieved(&self, state: &Spacecraft) -> Result<bool, GuidanceError> {
89        for obj in self.objectives.iter().flatten() {
90            if !obj
91                .assess_value(state.value(obj.parameter).context(GuidStateSnafu)?)
92                .0
93            {
94                return Ok(false);
95            }
96        }
97        Ok(true)
98    }
99
100    fn direction(&self, sc: &Spacecraft) -> Result<Vector3<f64>, GuidanceError> {
101        if sc.mode() != GuidanceMode::Thrust {
102            return Ok(Vector3::zeros());
103        }
104
105        let osc = sc.orbit;
106        let mut sum_weighted_num_alpha = 0.0;
107        let mut sum_weighted_den_alpha = 0.0;
108        let mut sum_weighted_num_beta = 0.0;
109
110        // Elements for calculations
111        let ecc = osc.ecc().context(GuidancePhysicsSnafu {
112            action: "Kluever guidance",
113        })?;
114        let ta_rad = osc
115            .ta_deg()
116            .context(GuidancePhysicsSnafu {
117                action: "Kluever guidance",
118            })?
119            .to_radians();
120        let aop_rad = osc
121            .aop_deg()
122            .context(GuidancePhysicsSnafu {
123                action: "Kluever guidance",
124            })?
125            .to_radians();
126        let raan_rad = osc
127            .raan_deg()
128            .context(GuidancePhysicsSnafu {
129                action: "Kluever guidance",
130            })?
131            .to_radians();
132
133        let u_rad = ta_rad + aop_rad;
134        let l_rad = u_rad + raan_rad;
135        let (sin_l, cos_l) = l_rad.sin_cos();
136
137        for (i, obj_opt) in self.objectives.iter().enumerate() {
138            if let Some(obj) = obj_opt {
139                let weight = self.weights[i];
140                if weight == 0.0 {
141                    continue;
142                }
143
144                let osc_val = sc.value(obj.parameter).context(GuidStateSnafu)?;
145                let error = obj.desired_value - osc_val;
146                if error.abs() < obj.tolerance {
147                    continue;
148                }
149                let weight = weight * error.signum();
150
151                match obj.parameter {
152                    StateParameter::Element(OrbitalElement::SemiMajorAxis) => {
153                        // Maximize rate of change of energy
154                        sum_weighted_num_alpha += weight * (ecc * ta_rad.sin());
155                        sum_weighted_den_alpha += weight * (1.0 + ecc * ta_rad.cos());
156                    }
157                    StateParameter::Element(OrbitalElement::Eccentricity) => {
158                        // Optimal alpha for eccentricity
159                        let (sin_ta, cos_ta) = ta_rad.sin_cos();
160                        sum_weighted_num_alpha += weight * sin_ta;
161                        sum_weighted_den_alpha +=
162                            weight * (cos_ta + (ecc + cos_ta) / (1.0 + ecc * cos_ta));
163                    }
164                    StateParameter::Element(OrbitalElement::Inclination) => {
165                        // Purely out-of-plane requirement
166                        let beta_opt = if u_rad.cos() >= 0.0 { 1.0 } else { -1.0 };
167                        sum_weighted_num_beta += weight * beta_opt;
168                    }
169                    StateParameter::Element(OrbitalElement::RAAN) => {
170                        let beta_opt = if u_rad.sin() >= 0.0 { 1.0 } else { -1.0 };
171                        sum_weighted_num_beta += weight * beta_opt;
172                    }
173
174                    StateParameter::Element(OrbitalElement::EquinoctialH) => {
175                        // H = e * sin(omega + RAAN)
176                        let h = sc
177                            .value(StateParameter::Element(OrbitalElement::EquinoctialH))
178                            .context(GuidStateSnafu)?;
179                        let k = sc
180                            .value(StateParameter::Element(OrbitalElement::EquinoctialK))
181                            .context(GuidStateSnafu)?;
182                        sum_weighted_num_alpha += weight * cos_l;
183                        sum_weighted_den_alpha +=
184                            weight * (sin_l + (h + sin_l) / (1.0 + h * sin_l + k * cos_l));
185                    }
186
187                    StateParameter::Element(OrbitalElement::EquinoctialK) => {
188                        // K = e * cos(omega + RAAN)
189                        let h = sc
190                            .value(StateParameter::Element(OrbitalElement::EquinoctialH))
191                            .context(GuidStateSnafu)?;
192                        let k = sc
193                            .value(StateParameter::Element(OrbitalElement::EquinoctialK))
194                            .context(GuidStateSnafu)?;
195                        sum_weighted_num_alpha += weight * (-sin_l);
196                        sum_weighted_den_alpha +=
197                            weight * (cos_l + (k + cos_l) / (1.0 + h * sin_l + k * cos_l));
198                    }
199
200                    StateParameter::Element(OrbitalElement::EquinoctialP) => {
201                        // P = tan(i/2) * sin(RAAN)
202                        let beta_opt = if sin_l >= 0.0 { 1.0 } else { -1.0 };
203                        sum_weighted_num_beta += weight * beta_opt;
204                    }
205
206                    StateParameter::Element(OrbitalElement::EquinoctialQ) => {
207                        // Q = tan(i/2) * cos(RAAN)
208                        let beta_opt = if cos_l >= 0.0 { 1.0 } else { -1.0 };
209                        sum_weighted_num_beta += weight * beta_opt;
210                    }
211
212                    StateParameter::Element(OrbitalElement::EquinoctialLambda) => {
213                        // Phasing / True Longitude
214                        sum_weighted_den_alpha += weight * 1.0;
215                    }
216
217                    _ => {
218                        return Err(GuidanceError::InvalidControl {
219                            param: obj.parameter,
220                        })
221                    }
222                }
223            }
224        }
225
226        // Calculate blended steering angles
227        let alpha = sum_weighted_num_alpha.atan2(sum_weighted_den_alpha);
228
229        // Beta blending (simplified Kluever approach)
230        let denom_beta = (sum_weighted_num_alpha.powi(2) + sum_weighted_den_alpha.powi(2)).sqrt();
231        let beta = sum_weighted_num_beta.atan2(denom_beta);
232
233        // Construct unit vector in RCN (Radial, Circum-normal, Normal) frame
234        let (s_a, c_a) = alpha.sin_cos();
235        let (s_b, c_b) = beta.sin_cos();
236        let steering_rcn = Vector3::new(s_a * c_b, c_a * c_b, s_b);
237
238        // Convert RCN to Inertial frame
239        Ok(osc
240            .dcm_from_rcn_to_inertial()
241            .context(GuidancePhysicsSnafu {
242                action: "RCN to Inertial",
243            })?
244            * steering_rcn)
245    }
246
247    /// Either thrust full power or not at all
248    fn throttle(&self, sc: &Spacecraft) -> Result<f64, GuidanceError> {
249        if sc.mode() == GuidanceMode::Thrust {
250            Ok(1.0)
251        } else {
252            Ok(0.0)
253        }
254    }
255
256    /// Update the state for the next iteration
257    fn next(&self, sc: &mut Spacecraft, almanac: Arc<Almanac>) {
258        if sc.mode() != GuidanceMode::Inhibit {
259            if !self.achieved(sc).unwrap() {
260                // Check eclipse state if applicable.
261                if let Some(max_eclipse) = self.max_eclipse_prct {
262                    let locator = EclipseLocator::cislunar(almanac.clone());
263                    if locator
264                        .compute(sc.orbit, almanac)
265                        .expect("cannot compute eclipse")
266                        .percentage
267                        > max_eclipse
268                    {
269                        // Coast in eclipse
270                        sc.mut_mode(GuidanceMode::Coast);
271                    } else {
272                        sc.mut_mode(GuidanceMode::Thrust);
273                    }
274                } else {
275                    if sc.mode() == GuidanceMode::Coast {
276                        debug!("enabling steering: {:x}", sc.orbit);
277                    }
278                    sc.mut_mode(GuidanceMode::Thrust);
279                }
280            } else {
281                if sc.mode() == GuidanceMode::Thrust {
282                    debug!("disabling steering: {:x}", sc.orbit);
283                }
284                sc.mut_mode(GuidanceMode::Coast);
285            }
286        }
287    }
288}
289
290#[test]
291fn kluever_direction() {
292    use crate::time::Epoch;
293    use anise::analysis::prelude::OrbitalElement;
294    use anise::constants::frames::EARTH_J2000;
295
296    let eme2k = EARTH_J2000.with_mu_km3_s2(398_600.433);
297    let start_time = Epoch::from_gregorian_tai_at_midnight(2020, 1, 1);
298
299    // Define the objectives
300    let objectives = &[
301        Objective::within_tolerance(
302            StateParameter::Element(OrbitalElement::SemiMajorAxis),
303            42164.0,
304            1.0,
305        ),
306        Objective::within_tolerance(
307            StateParameter::Element(OrbitalElement::Eccentricity),
308            0.01,
309            5e-5,
310        ),
311    ];
312    let weights = &[1.0, 1.0];
313
314    let kluever = Kluever::new(objectives, weights);
315    // 7301.597157 201.699933 0.176016 -0.202974 7.421233 0.006476 298.999726
316    let osc = crate::Orbit::new(
317        7_303.253_461_441_64f64,
318        127.478_714_816_381_75,
319        0.111_246_193_227_445_4,
320        -0.128_284_025_765_195_6,
321        7.422_889_151_816_439,
322        0.006_477_694_429_837_2,
323        start_time,
324        eme2k,
325    );
326
327    let mut osc_sc = Spacecraft::new(osc, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0);
328    // Must set the guidance mode to thrusting otherwise the direction will be set to zero.
329    osc_sc.mut_mode(GuidanceMode::Thrust);
330
331    let got = kluever.direction(&osc_sc).unwrap();
332
333    // Verification of the exact value might be tricky without a reference,
334    // but we can check it's normalized and non-zero.
335    assert!(got.norm() > 0.0);
336    assert!((got.norm() - 1.0).abs() < 1e-12);
337
338    println!("Kluever direction: {got}");
339}