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