Skip to main content

pykep_core/dynamics/
zoh.rs

1// Copyright (c) 2023-2026 Dario Izzo (dario.izzo@gmail.com)
2//                          Advanced Concepts Team, European Space Agency (ESA)
3// Copyright (c) 2026 pykep-rust contributors
4// SPDX-License-Identifier: MPL-2.0
5
6//! Zero-order-hold Kepler, CR3BP, equinoctial, and solar-sail dynamics.
7//!
8//! This file adapts `src/ta/zoh_kep.cpp`, `zoh_cr3bp.cpp`, `zoh_eq.cpp`, and
9//! `zoh_ss.cpp` from the pinned pykep/kep3 source into evaluated models.
10//! The four built-in models evaluate sensitivity Jacobians with fixed-size
11//! centered differences using a relative step of `3e-6`. Integrator tolerances
12//! therefore do not imply the same accuracy for returned sensitivities.
13
14use crate::error::ensure_finite;
15use crate::integration::{
16    DifferentiableDynamicsModel, Dop853, DynamicsModel, InitialValueProblem, IntegrationStats,
17    IntegratorOptions, Propagation, SensitivityProblem, SensitivityPropagation, Termination,
18};
19use crate::{PykepError, Result};
20
21/// A validated piecewise-constant control history.
22///
23/// `controls[i]` owns `[boundaries[i], boundaries[i + 1])`; the final
24/// boundary is included in the final segment. Boundaries are strictly
25/// increasing. Backward propagation traverses the same intervals in reverse,
26/// using the interval to the left of each encountered switching time.
27#[derive(Clone, Debug, PartialEq)]
28pub struct ControlSchedule<const C: usize> {
29    boundaries: Vec<f64>,
30    controls: Vec<[f64; C]>,
31}
32
33impl<const C: usize> ControlSchedule<C> {
34    /// Constructs and validates a zero-order-hold control schedule.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error unless there is at least one non-empty segment,
39    /// `boundaries.len() == controls.len() + 1`, every value is finite, and
40    /// the boundaries are strictly increasing.
41    pub fn new(boundaries: Vec<f64>, controls: Vec<[f64; C]>) -> Result<Self> {
42        if C == 0 {
43            return Err(PykepError::InvalidInput {
44                parameter: "control_dimension",
45                reason: "must be greater than zero".into(),
46            });
47        }
48        if controls.is_empty() {
49            return Err(PykepError::InvalidInput {
50                parameter: "controls",
51                reason: "must contain at least one segment".into(),
52            });
53        }
54        if boundaries.len() != controls.len() + 1 {
55            return Err(PykepError::DimensionMismatch {
56                expected: controls.len() + 1,
57                actual: boundaries.len(),
58            });
59        }
60        for &boundary in &boundaries {
61            ensure_finite("boundaries", boundary)?;
62        }
63        if boundaries.windows(2).any(|pair| pair[0] >= pair[1]) {
64            return Err(PykepError::InvalidInput {
65                parameter: "boundaries",
66                reason: "must be strictly increasing".into(),
67            });
68        }
69        for control in &controls {
70            for &value in control {
71                ensure_finite("controls", value)?;
72            }
73        }
74        Ok(Self {
75            boundaries,
76            controls,
77        })
78    }
79
80    /// Returns the switching boundaries.
81    pub fn boundaries(&self) -> &[f64] {
82        &self.boundaries
83    }
84
85    /// Returns the controls in chronological segment order.
86    pub fn controls(&self) -> &[[f64; C]] {
87        &self.controls
88    }
89
90    /// Returns the number of control segments.
91    pub fn len(&self) -> usize {
92        self.controls.len()
93    }
94
95    /// Returns whether the schedule contains no segments.
96    ///
97    /// Valid schedules are never empty; this method is supplied alongside
98    /// [`Self::len`] for collection-like APIs.
99    pub fn is_empty(&self) -> bool {
100        self.controls.is_empty()
101    }
102
103    /// Returns the first boundary.
104    pub fn initial_time(&self) -> f64 {
105        self.boundaries[0]
106    }
107
108    /// Returns the final boundary.
109    pub fn final_time(&self) -> f64 {
110        self.boundaries[self.boundaries.len() - 1]
111    }
112
113    /// Returns the right-continuous control at a time inside the schedule.
114    ///
115    /// At an internal switching time, the later segment owns the boundary.
116    /// The final boundary returns the final segment's control.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error for non-finite times or times outside the closed
121    /// schedule interval.
122    pub fn control_at(&self, time: f64) -> Result<[f64; C]> {
123        ensure_finite("time", time)?;
124        if time < self.initial_time() || time > self.final_time() {
125            return Err(PykepError::InvalidInput {
126                parameter: "time",
127                reason: "must lie inside the control schedule".into(),
128            });
129        }
130        if time == self.final_time() {
131            return Ok(self.controls[self.controls.len() - 1]);
132        }
133        let index = self
134            .boundaries
135            .partition_point(|boundary| *boundary <= time)
136            - 1;
137        Ok(self.controls[index])
138    }
139}
140
141/// Seed directions carried through a segmented ZOH propagation.
142///
143/// State sensitivities remain continuous at switches. Each segment has its
144/// own control seed matrix, so a future segment's control columns are exactly
145/// zero before that segment becomes active.
146#[derive(Clone, Debug, PartialEq)]
147pub struct ZohSensitivitySeeds<const N: usize, const C: usize, const K: usize, const W: usize> {
148    /// Initial `dstate/dseed`.
149    pub initial_state: [[f64; W]; N],
150    /// One `dcontrol/dseed` matrix per schedule segment.
151    pub segment_controls: Vec<[[f64; W]; C]>,
152    /// Constant-model-parameter seed matrix.
153    pub constants: [[f64; W]; K],
154}
155
156/// Model contract used by the common segmented propagators.
157pub trait ZeroOrderHoldModel<const N: usize, const C: usize, const K: usize, const P: usize>:
158    DifferentiableDynamicsModel<N, P>
159{
160    /// Combines a segment control and model constants in upstream parameter
161    /// order.
162    fn parameters(control: [f64; C], constants: [f64; K]) -> [f64; P];
163
164    /// Combines control and constant seed matrices in upstream parameter
165    /// order.
166    fn parameter_seeds<const W: usize>(
167        control: [[f64; W]; C],
168        constants: [[f64; W]; K],
169    ) -> [[f64; W]; P];
170}
171
172/// Propagates a ZOH schedule from its first boundary to its last.
173///
174/// Each segment is integrated exactly once and the active control is copied
175/// into a fixed-size parameter array before integration. No control search or
176/// allocation occurs inside right-hand-side evaluation.
177///
178/// # Errors
179///
180/// Returns a model-domain or integration error.
181pub fn propagate_schedule<M, const N: usize, const C: usize, const K: usize, const P: usize>(
182    model: &M,
183    schedule: &ControlSchedule<C>,
184    initial_state: [f64; N],
185    constants: [f64; K],
186    options: IntegratorOptions,
187) -> Result<Propagation<N>>
188where
189    M: ZeroOrderHoldModel<N, C, K, P>,
190{
191    propagate_schedule_direction(model, schedule, initial_state, constants, options, false)
192}
193
194/// Propagates a ZOH schedule backward from its last boundary to its first.
195///
196/// # Errors
197///
198/// Returns a model-domain or integration error.
199pub fn propagate_schedule_backward<
200    M,
201    const N: usize,
202    const C: usize,
203    const K: usize,
204    const P: usize,
205>(
206    model: &M,
207    schedule: &ControlSchedule<C>,
208    final_state: [f64; N],
209    constants: [f64; K],
210    options: IntegratorOptions,
211) -> Result<Propagation<N>>
212where
213    M: ZeroOrderHoldModel<N, C, K, P>,
214{
215    propagate_schedule_direction(model, schedule, final_state, constants, options, true)
216}
217
218/// Propagates arbitrary seed directions through a complete ZOH schedule.
219///
220/// Runtime is linear in the number of segments for a fixed sensitivity width:
221/// each segment advances the current augmented state once.
222///
223/// # Errors
224///
225/// Returns an error for a seed/schedule length mismatch, invalid seeds, or a
226/// model/integration failure.
227pub fn propagate_schedule_with_sensitivities<
228    M,
229    const N: usize,
230    const C: usize,
231    const K: usize,
232    const P: usize,
233    const W: usize,
234>(
235    model: &M,
236    schedule: &ControlSchedule<C>,
237    initial_state: [f64; N],
238    constants: [f64; K],
239    seeds: &ZohSensitivitySeeds<N, C, K, W>,
240    options: IntegratorOptions,
241) -> Result<SensitivityPropagation<N, W>>
242where
243    M: ZeroOrderHoldModel<N, C, K, P>,
244{
245    propagate_schedule_sensitivities_direction(
246        model,
247        schedule,
248        initial_state,
249        constants,
250        seeds,
251        options,
252        false,
253    )
254}
255
256/// Propagates arbitrary seed directions backward through a ZOH schedule.
257///
258/// # Errors
259///
260/// Returns an error for a seed/schedule length mismatch, invalid seeds, or a
261/// model/integration failure.
262pub fn propagate_schedule_with_sensitivities_backward<
263    M,
264    const N: usize,
265    const C: usize,
266    const K: usize,
267    const P: usize,
268    const W: usize,
269>(
270    model: &M,
271    schedule: &ControlSchedule<C>,
272    final_state: [f64; N],
273    constants: [f64; K],
274    seeds: &ZohSensitivitySeeds<N, C, K, W>,
275    options: IntegratorOptions,
276) -> Result<SensitivityPropagation<N, W>>
277where
278    M: ZeroOrderHoldModel<N, C, K, P>,
279{
280    propagate_schedule_sensitivities_direction(
281        model,
282        schedule,
283        final_state,
284        constants,
285        seeds,
286        options,
287        true,
288    )
289}
290
291fn propagate_schedule_direction<M, const N: usize, const C: usize, const K: usize, const P: usize>(
292    model: &M,
293    schedule: &ControlSchedule<C>,
294    initial_state: [f64; N],
295    constants: [f64; K],
296    options: IntegratorOptions,
297    backward: bool,
298) -> Result<Propagation<N>>
299where
300    M: ZeroOrderHoldModel<N, C, K, P>,
301{
302    let mut state = initial_state;
303    let mut statistics = IntegrationStats::default();
304    if backward {
305        for index in (0..schedule.len()).rev() {
306            let result = Dop853.propagate(
307                model,
308                InitialValueProblem::new(
309                    schedule.boundaries[index + 1],
310                    state,
311                    schedule.boundaries[index],
312                    M::parameters(schedule.controls[index], constants),
313                ),
314                options,
315            )?;
316            state = result.state;
317            add_statistics(&mut statistics, result.stats);
318        }
319    } else {
320        for index in 0..schedule.len() {
321            let result = Dop853.propagate(
322                model,
323                InitialValueProblem::new(
324                    schedule.boundaries[index],
325                    state,
326                    schedule.boundaries[index + 1],
327                    M::parameters(schedule.controls[index], constants),
328                ),
329                options,
330            )?;
331            state = result.state;
332            add_statistics(&mut statistics, result.stats);
333        }
334    }
335    Ok(Propagation {
336        time: if backward {
337            schedule.initial_time()
338        } else {
339            schedule.final_time()
340        },
341        state,
342        stats: statistics,
343        termination: Termination::FinalTime,
344    })
345}
346
347#[allow(clippy::too_many_arguments)]
348fn propagate_schedule_sensitivities_direction<
349    M,
350    const N: usize,
351    const C: usize,
352    const K: usize,
353    const P: usize,
354    const W: usize,
355>(
356    model: &M,
357    schedule: &ControlSchedule<C>,
358    initial_state: [f64; N],
359    constants: [f64; K],
360    seeds: &ZohSensitivitySeeds<N, C, K, W>,
361    options: IntegratorOptions,
362    backward: bool,
363) -> Result<SensitivityPropagation<N, W>>
364where
365    M: ZeroOrderHoldModel<N, C, K, P>,
366{
367    if seeds.segment_controls.len() != schedule.len() {
368        return Err(PykepError::DimensionMismatch {
369            expected: schedule.len(),
370            actual: seeds.segment_controls.len(),
371        });
372    }
373    let mut state = initial_state;
374    let mut sensitivities = seeds.initial_state;
375    let mut statistics = IntegrationStats::default();
376    let mut run_segment = |index: usize, start: f64, end: f64| -> Result<()> {
377        let result = Dop853.propagate_with_sensitivities(
378            model,
379            SensitivityProblem {
380                nominal: InitialValueProblem::new(
381                    start,
382                    state,
383                    end,
384                    M::parameters(schedule.controls[index], constants),
385                ),
386                initial_sensitivities: sensitivities,
387                parameter_seeds: M::parameter_seeds(seeds.segment_controls[index], seeds.constants),
388            },
389            options,
390        )?;
391        state = result.state;
392        sensitivities = result.sensitivities;
393        add_statistics(&mut statistics, result.stats);
394        Ok(())
395    };
396    if backward {
397        for index in (0..schedule.len()).rev() {
398            run_segment(
399                index,
400                schedule.boundaries[index + 1],
401                schedule.boundaries[index],
402            )?;
403        }
404    } else {
405        for index in 0..schedule.len() {
406            run_segment(
407                index,
408                schedule.boundaries[index],
409                schedule.boundaries[index + 1],
410            )?;
411        }
412    }
413    Ok(SensitivityPropagation {
414        time: if backward {
415            schedule.initial_time()
416        } else {
417            schedule.final_time()
418        },
419        state,
420        sensitivities,
421        stats: statistics,
422    })
423}
424
425fn add_statistics(total: &mut IntegrationStats, segment: IntegrationStats) {
426    total.rhs_evaluations += segment.rhs_evaluations;
427    total.accepted_steps += segment.accepted_steps;
428    total.rejected_steps += segment.rejected_steps;
429}
430
431/// Seven-state Cartesian low-thrust Kepler dynamics with normalized `mu = 1`.
432///
433/// State order is `[x, y, z, vx, vy, vz, mass]`; parameters are
434/// `[thrust, ix, iy, iz, c]`. Its [`DifferentiableDynamicsModel`] Jacobians
435/// use the centered-difference contract documented by this module.
436#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
437pub struct ZohKeplerDynamics;
438
439impl DynamicsModel<7, 5> for ZohKeplerDynamics {
440    const NAME: &'static str = "ZOH Kepler dynamics";
441
442    fn validate(&self, time: f64, state: &[f64; 7], parameters: &[f64; 5]) -> Result<()> {
443        validate_finite(time, state, parameters)?;
444        validate_positive_state(state[6], "mass")?;
445        validate_radius(&state[..3], "ZOH Kepler radius")?;
446        Ok(())
447    }
448
449    fn rhs(
450        &self,
451        time: f64,
452        state: &[f64; 7],
453        parameters: &[f64; 5],
454        derivative: &mut [f64; 7],
455    ) -> Result<()> {
456        self.validate(time, state, parameters)?;
457        let radius_squared = squared_norm(&state[..3]);
458        let gravity = -1.0 / (radius_squared * radius_squared.sqrt());
459        let [thrust, ix, iy, iz, c] = *parameters;
460        *derivative = [
461            state[3],
462            state[4],
463            state[5],
464            gravity * state[0] + thrust * ix / state[6],
465            gravity * state[1] + thrust * iy / state[6],
466            gravity * state[2] + thrust * iz / state[6],
467            regularized_mass_flow(state[6], thrust, c),
468        ];
469        validate_rhs(Self::NAME, derivative)
470    }
471}
472
473impl DifferentiableDynamicsModel<7, 5> for ZohKeplerDynamics {
474    fn jacobians(
475        &self,
476        time: f64,
477        state: &[f64; 7],
478        parameters: &[f64; 5],
479        state_jacobian: &mut [[f64; 7]; 7],
480        parameter_jacobian: &mut [[f64; 5]; 7],
481    ) -> Result<()> {
482        numerical_jacobians(
483            self,
484            time,
485            state,
486            parameters,
487            state_jacobian,
488            parameter_jacobian,
489            &[6],
490        )
491    }
492}
493
494impl ZeroOrderHoldModel<7, 4, 1, 5> for ZohKeplerDynamics {
495    fn parameters(control: [f64; 4], constants: [f64; 1]) -> [f64; 5] {
496        [control[0], control[1], control[2], control[3], constants[0]]
497    }
498
499    fn parameter_seeds<const W: usize>(
500        control: [[f64; W]; 4],
501        constants: [[f64; W]; 1],
502    ) -> [[f64; W]; 5] {
503        [control[0], control[1], control[2], control[3], constants[0]]
504    }
505}
506
507/// Seven-state low-thrust CR3BP dynamics in the synodic frame.
508///
509/// Parameters are `[thrust, ix, iy, iz, c, mu]`. Its
510/// [`DifferentiableDynamicsModel`] Jacobians use the centered-difference
511/// contract documented by this module.
512#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
513pub struct ZohCr3bpDynamics;
514
515impl DynamicsModel<7, 6> for ZohCr3bpDynamics {
516    const NAME: &'static str = "ZOH CR3BP dynamics";
517
518    fn validate(&self, time: f64, state: &[f64; 7], parameters: &[f64; 6]) -> Result<()> {
519        validate_finite(time, state, parameters)?;
520        validate_positive_state(state[6], "mass")?;
521        validate_mass_fraction(parameters[5])?;
522        let mu = parameters[5];
523        validate_radius(
524            &[state[0] + mu, state[1], state[2]],
525            "ZOH CR3BP primary-one distance",
526        )?;
527        validate_radius(
528            &[state[0] + mu - 1.0, state[1], state[2]],
529            "ZOH CR3BP primary-two distance",
530        )?;
531        Ok(())
532    }
533
534    fn rhs(
535        &self,
536        time: f64,
537        state: &[f64; 7],
538        parameters: &[f64; 6],
539        derivative: &mut [f64; 7],
540    ) -> Result<()> {
541        self.validate(time, state, parameters)?;
542        let [thrust, ix, iy, iz, c, mu] = *parameters;
543        let d1 = [state[0] + mu, state[1], state[2]];
544        let d2 = [state[0] + mu - 1.0, state[1], state[2]];
545        let r1_squared = squared_norm(&d1);
546        let r2_squared = squared_norm(&d2);
547        let primary = (1.0 - mu) / (r1_squared * r1_squared.sqrt());
548        let secondary = mu / (r2_squared * r2_squared.sqrt());
549        *derivative = [
550            state[3],
551            state[4],
552            state[5],
553            2.0 * state[4] + state[0] - primary * d1[0] - secondary * d2[0]
554                + thrust * ix / state[6],
555            -2.0 * state[3] + state[1] - (primary + secondary) * state[1] + thrust * iy / state[6],
556            -(primary + secondary) * state[2] + thrust * iz / state[6],
557            regularized_mass_flow(state[6], thrust, c),
558        ];
559        validate_rhs(Self::NAME, derivative)
560    }
561}
562
563impl DifferentiableDynamicsModel<7, 6> for ZohCr3bpDynamics {
564    fn jacobians(
565        &self,
566        time: f64,
567        state: &[f64; 7],
568        parameters: &[f64; 6],
569        state_jacobian: &mut [[f64; 7]; 7],
570        parameter_jacobian: &mut [[f64; 6]; 7],
571    ) -> Result<()> {
572        numerical_jacobians(
573            self,
574            time,
575            state,
576            parameters,
577            state_jacobian,
578            parameter_jacobian,
579            &[6],
580        )
581    }
582}
583
584impl ZeroOrderHoldModel<7, 4, 2, 6> for ZohCr3bpDynamics {
585    fn parameters(control: [f64; 4], constants: [f64; 2]) -> [f64; 6] {
586        [
587            control[0],
588            control[1],
589            control[2],
590            control[3],
591            constants[0],
592            constants[1],
593        ]
594    }
595
596    fn parameter_seeds<const W: usize>(
597        control: [[f64; W]; 4],
598        constants: [[f64; W]; 2],
599    ) -> [[f64; W]; 6] {
600        [
601            control[0],
602            control[1],
603            control[2],
604            control[3],
605            constants[0],
606            constants[1],
607        ]
608    }
609}
610
611/// Seven-state modified-equinoctial low-thrust dynamics with normalized
612/// `mu = 1`.
613///
614/// State order is `[p, f, g, h, k, L, mass]`; control is
615/// `[thrust, i_r, i_t, i_n]` and the sole constant is mass-flow coefficient
616/// `c`. Its [`DifferentiableDynamicsModel`] Jacobians use the
617/// centered-difference contract documented by this module.
618#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
619pub struct ZohEquinoctialDynamics;
620
621impl DynamicsModel<7, 5> for ZohEquinoctialDynamics {
622    const NAME: &'static str = "ZOH equinoctial dynamics";
623
624    fn validate(&self, time: f64, state: &[f64; 7], parameters: &[f64; 5]) -> Result<()> {
625        validate_finite(time, state, parameters)?;
626        validate_positive_state(state[0], "semilatus_rectum")?;
627        validate_positive_state(state[6], "mass")?;
628        let w = 1.0 + state[1] * state[5].cos() + state[2] * state[5].sin();
629        if w == 0.0 {
630            return Err(PykepError::SingularGeometry {
631                operation: "ZOH equinoctial radial denominator",
632            });
633        }
634        Ok(())
635    }
636
637    fn rhs(
638        &self,
639        time: f64,
640        state: &[f64; 7],
641        parameters: &[f64; 5],
642        derivative: &mut [f64; 7],
643    ) -> Result<()> {
644        self.validate(time, state, parameters)?;
645        let [p, f, g, h, k, longitude, mass] = *state;
646        let [thrust, radial, transverse, normal, c] = *parameters;
647        let sine = longitude.sin();
648        let cosine = longitude.cos();
649        let w = 1.0 + f * cosine + g * sine;
650        let s2 = 1.0 + h * h + k * k;
651        let hsk = h * sine - k * cosine;
652        let sqrt_p = p.sqrt();
653        let radial_thrust = radial * thrust;
654        let transverse_thrust = transverse * thrust;
655        let normal_thrust = normal * thrust;
656        *derivative = [
657            sqrt_p * (2.0 * p / w) * transverse_thrust / mass,
658            sqrt_p
659                * (radial_thrust * sine + ((1.0 + w) * cosine + f) / w * transverse_thrust
660                    - g / w * hsk * normal_thrust)
661                / mass,
662            sqrt_p
663                * (-radial_thrust * cosine
664                    + ((1.0 + w) * sine + g) / w * transverse_thrust
665                    + f / w * hsk * normal_thrust)
666                / mass,
667            sqrt_p * (s2 / w / 2.0) * cosine * normal_thrust / mass,
668            sqrt_p * (s2 / w / 2.0) * sine * normal_thrust / mass,
669            sqrt_p * hsk / w * normal_thrust / mass + w * w / p.powf(1.5),
670            regularized_mass_flow(mass, thrust, c),
671        ];
672        validate_rhs(Self::NAME, derivative)
673    }
674}
675
676impl DifferentiableDynamicsModel<7, 5> for ZohEquinoctialDynamics {
677    fn jacobians(
678        &self,
679        time: f64,
680        state: &[f64; 7],
681        parameters: &[f64; 5],
682        state_jacobian: &mut [[f64; 7]; 7],
683        parameter_jacobian: &mut [[f64; 5]; 7],
684    ) -> Result<()> {
685        numerical_jacobians(
686            self,
687            time,
688            state,
689            parameters,
690            state_jacobian,
691            parameter_jacobian,
692            &[0, 6],
693        )
694    }
695}
696
697impl ZeroOrderHoldModel<7, 4, 1, 5> for ZohEquinoctialDynamics {
698    fn parameters(control: [f64; 4], constants: [f64; 1]) -> [f64; 5] {
699        [control[0], control[1], control[2], control[3], constants[0]]
700    }
701
702    fn parameter_seeds<const W: usize>(
703        control: [[f64; W]; 4],
704        constants: [[f64; W]; 1],
705    ) -> [[f64; W]; 5] {
706        [control[0], control[1], control[2], control[3], constants[0]]
707    }
708}
709
710/// Six-state ideal solar-sail dynamics with piecewise-constant cone and clock
711/// angles.
712///
713/// Parameters are `[alpha, beta, c]`, where `c/r²` scales sail acceleration
714/// in normalized heliocentric units. Its [`DifferentiableDynamicsModel`]
715/// Jacobians use the centered-difference contract documented by this module.
716#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
717pub struct ZohSolarSailDynamics;
718
719impl DynamicsModel<6, 3> for ZohSolarSailDynamics {
720    const NAME: &'static str = "ZOH solar-sail dynamics";
721
722    fn validate(&self, time: f64, state: &[f64; 6], parameters: &[f64; 3]) -> Result<()> {
723        validate_finite(time, state, parameters)?;
724        validate_radius(&state[..3], "ZOH solar-sail radius")?;
725        let angular_momentum = cross(&state[..3], &state[3..]);
726        validate_radius(&angular_momentum, "ZOH solar-sail angular momentum")?;
727        Ok(())
728    }
729
730    fn rhs(
731        &self,
732        time: f64,
733        state: &[f64; 6],
734        parameters: &[f64; 3],
735        derivative: &mut [f64; 6],
736    ) -> Result<()> {
737        self.validate(time, state, parameters)?;
738        let [alpha, beta, c] = *parameters;
739        let radius_squared = squared_norm(&state[..3]);
740        let radius = radius_squared.sqrt();
741        let angular_momentum = cross(&state[..3], &state[3..]);
742        let angular_norm = squared_norm(&angular_momentum).sqrt();
743        let radial = [state[0] / radius, state[1] / radius, state[2] / radius];
744        let normal = [
745            angular_momentum[0] / angular_norm,
746            angular_momentum[1] / angular_norm,
747            angular_momentum[2] / angular_norm,
748        ];
749        let transverse = cross(&normal, &radial);
750        let thrust = c / radius_squared * alpha.cos().powi(2);
751        let radial_acceleration = alpha.cos() * thrust;
752        let transverse_acceleration = alpha.sin() * beta.sin() * thrust;
753        let normal_acceleration = alpha.sin() * beta.cos() * thrust;
754        let gravity = -1.0 / (radius_squared * radius);
755        *derivative = [
756            state[3],
757            state[4],
758            state[5],
759            gravity * state[0]
760                + radial_acceleration * radial[0]
761                + transverse_acceleration * transverse[0]
762                + normal_acceleration * normal[0],
763            gravity * state[1]
764                + radial_acceleration * radial[1]
765                + transverse_acceleration * transverse[1]
766                + normal_acceleration * normal[1],
767            gravity * state[2]
768                + radial_acceleration * radial[2]
769                + transverse_acceleration * transverse[2]
770                + normal_acceleration * normal[2],
771        ];
772        validate_rhs(Self::NAME, derivative)
773    }
774}
775
776impl DifferentiableDynamicsModel<6, 3> for ZohSolarSailDynamics {
777    fn jacobians(
778        &self,
779        time: f64,
780        state: &[f64; 6],
781        parameters: &[f64; 3],
782        state_jacobian: &mut [[f64; 6]; 6],
783        parameter_jacobian: &mut [[f64; 3]; 6],
784    ) -> Result<()> {
785        numerical_jacobians(
786            self,
787            time,
788            state,
789            parameters,
790            state_jacobian,
791            parameter_jacobian,
792            &[],
793        )
794    }
795}
796
797impl ZeroOrderHoldModel<6, 2, 1, 3> for ZohSolarSailDynamics {
798    fn parameters(control: [f64; 2], constants: [f64; 1]) -> [f64; 3] {
799        [control[0], control[1], constants[0]]
800    }
801
802    fn parameter_seeds<const W: usize>(
803        control: [[f64; W]; 2],
804        constants: [[f64; W]; 1],
805    ) -> [[f64; W]; 3] {
806        [control[0], control[1], constants[0]]
807    }
808}
809
810fn validate_finite<const N: usize, const P: usize>(
811    time: f64,
812    state: &[f64; N],
813    parameters: &[f64; P],
814) -> Result<()> {
815    ensure_finite("time", time)?;
816    for &value in state {
817        ensure_finite("state", value)?;
818    }
819    for &value in parameters {
820        ensure_finite("parameters", value)?;
821    }
822    Ok(())
823}
824
825fn validate_positive_state(value: f64, parameter: &'static str) -> Result<()> {
826    if value > 0.0 {
827        Ok(())
828    } else {
829        Err(PykepError::InvalidInput {
830            parameter,
831            reason: "must be greater than zero".into(),
832        })
833    }
834}
835
836fn validate_mass_fraction(mu: f64) -> Result<()> {
837    if (0.0..=1.0).contains(&mu) {
838        Ok(())
839    } else {
840        Err(PykepError::InvalidInput {
841            parameter: "mu",
842            reason: "must lie in the closed interval [0, 1]".into(),
843        })
844    }
845}
846
847fn validate_radius(vector: &[f64], operation: &'static str) -> Result<()> {
848    let squared = squared_norm(vector);
849    if squared == 0.0 {
850        Err(PykepError::SingularGeometry { operation })
851    } else if squared.is_finite() {
852        Ok(())
853    } else {
854        Err(PykepError::NumericalOverflow { operation })
855    }
856}
857
858fn squared_norm(vector: &[f64]) -> f64 {
859    vector.iter().map(|value| value * value).sum()
860}
861
862fn cross(left: &[f64], right: &[f64]) -> [f64; 3] {
863    [
864        left[1] * right[2] - left[2] * right[1],
865        left[2] * right[0] - left[0] * right[2],
866        left[0] * right[1] - left[1] * right[0],
867    ]
868}
869
870fn regularized_mass_flow(mass: f64, thrust: f64, coefficient: f64) -> f64 {
871    -coefficient * thrust * (-1.0 / mass / 1e16).exp()
872}
873
874fn validate_rhs<const N: usize>(operation: &'static str, values: &[f64; N]) -> Result<()> {
875    if values.iter().all(|value| value.is_finite()) {
876        Ok(())
877    } else {
878        Err(PykepError::NumericalOverflow { operation })
879    }
880}
881
882fn numerical_jacobians<M, const N: usize, const P: usize>(
883    model: &M,
884    time: f64,
885    state: &[f64; N],
886    parameters: &[f64; P],
887    state_jacobian: &mut [[f64; N]; N],
888    parameter_jacobian: &mut [[f64; P]; N],
889    positive_state_indices: &[usize],
890) -> Result<()>
891where
892    M: DynamicsModel<N, P>,
893{
894    *state_jacobian = [[0.0; N]; N];
895    *parameter_jacobian = [[0.0; P]; N];
896    for column in 0..N {
897        let mut step = 3e-6 * state[column].abs().max(1.0);
898        if positive_state_indices.contains(&column) {
899            step = step.min(state[column] * 0.25);
900        }
901        let mut plus = *state;
902        let mut minus = *state;
903        plus[column] += step;
904        minus[column] -= step;
905        let mut rhs_plus = [0.0; N];
906        let mut rhs_minus = [0.0; N];
907        model.rhs(time, &plus, parameters, &mut rhs_plus)?;
908        model.rhs(time, &minus, parameters, &mut rhs_minus)?;
909        for row in 0..N {
910            state_jacobian[row][column] = (rhs_plus[row] - rhs_minus[row]) / (2.0 * step);
911        }
912    }
913    for column in 0..P {
914        let step = 3e-6 * parameters[column].abs().max(1.0);
915        let mut plus = *parameters;
916        let mut minus = *parameters;
917        plus[column] += step;
918        minus[column] -= step;
919        let mut rhs_plus = [0.0; N];
920        let mut rhs_minus = [0.0; N];
921        model.rhs(time, state, &plus, &mut rhs_plus)?;
922        model.rhs(time, state, &minus, &mut rhs_minus)?;
923        for row in 0..N {
924            parameter_jacobian[row][column] = (rhs_plus[row] - rhs_minus[row]) / (2.0 * step);
925        }
926    }
927    Ok(())
928}