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