Skip to main content

simular/demos/
orbit_engine.rs

1//! `OrbitalEngine`: `DemoEngine` Implementation for Orbit Demos
2//!
3//! Per specification SIMULAR-DEMO-002: This module provides the unified
4//! `DemoEngine` implementation for orbital mechanics simulations.
5//!
6//! # Architecture
7//!
8//! ```text
9//! YAML Config → OrbitalEngine → DemoEngine trait
10//!                    ↓
11//!              OrbitalState (serializable, PartialEq)
12//!                    ↓
13//!              TUI / WASM (identical states)
14//! ```
15//!
16//! # Key Invariant
17//!
18//! Given same YAML config and seed, TUI and WASM produce identical state sequences.
19
20use super::engine::{
21    CriterionResult, DemoEngine, DemoError, DemoMeta, FalsificationCriterion, MetamorphicRelation,
22    MrResult, Severity,
23};
24use super::kepler_orbit::Vec2;
25use crate::engine::rng::SimRng;
26use serde::{Deserialize, Serialize};
27use std::collections::hash_map::DefaultHasher;
28use std::hash::{Hash, Hasher};
29
30// =============================================================================
31// Configuration Types (loaded from YAML)
32// =============================================================================
33
34/// Central body configuration.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct CentralBodyConfig {
37    pub name: String,
38    pub mass_kg: f64,
39    pub position: [f64; 3],
40}
41
42/// Orbiter configuration.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct OrbiterConfig {
45    pub name: String,
46    pub mass_kg: f64,
47    pub semi_major_axis_m: f64,
48    pub eccentricity: f64,
49    #[serde(default)]
50    pub initial_true_anomaly_rad: f64,
51}
52
53/// Scenario configuration.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ScenarioConfig {
56    #[serde(rename = "type")]
57    pub scenario_type: String,
58    pub central_body: CentralBodyConfig,
59    pub orbiter: OrbiterConfig,
60}
61
62/// Integrator configuration.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct IntegratorConfig {
65    #[serde(rename = "type")]
66    pub integrator_type: String,
67    pub dt_seconds: f64,
68}
69
70/// Jidoka configuration.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct JidokaConfig {
73    #[serde(default)]
74    pub enabled: bool,
75    #[serde(default)]
76    pub stop_on_critical: bool,
77    #[serde(default = "default_tolerance")]
78    pub energy_tolerance: f64,
79    #[serde(default = "default_tolerance")]
80    pub angular_momentum_tolerance: f64,
81}
82
83fn default_tolerance() -> f64 {
84    1e-9
85}
86
87impl Default for JidokaConfig {
88    fn default() -> Self {
89        Self {
90            enabled: true,
91            stop_on_critical: true,
92            energy_tolerance: default_tolerance(),
93            angular_momentum_tolerance: default_tolerance(),
94        }
95    }
96}
97
98/// Simulation type configuration.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SimulationConfig {
101    #[serde(rename = "type")]
102    pub sim_type: String,
103    pub name: String,
104}
105
106/// Reproducibility configuration.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ReproducibilityConfig {
109    pub seed: u64,
110    #[serde(default)]
111    pub ieee_strict: bool,
112}
113
114/// Falsification criterion from YAML.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct YamlCriterion {
117    pub id: String,
118    pub name: String,
119    pub metric: String,
120    pub threshold: f64,
121    pub condition: String,
122    #[serde(default)]
123    pub severity: String,
124}
125
126/// Falsification configuration.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct FalsificationConfig {
129    #[serde(default)]
130    pub criteria: Vec<YamlCriterion>,
131}
132
133/// Metamorphic relation from YAML.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct YamlMR {
136    pub id: String,
137    pub description: String,
138    pub source_transform: String,
139    pub expected_relation: String,
140    #[serde(default = "default_tolerance")]
141    pub tolerance: f64,
142}
143
144/// Complete orbit configuration from YAML.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct OrbitConfig {
147    pub simulation: SimulationConfig,
148    pub meta: DemoMeta,
149    pub reproducibility: ReproducibilityConfig,
150    pub scenario: ScenarioConfig,
151    pub integrator: IntegratorConfig,
152    #[serde(default)]
153    pub jidoka: JidokaConfig,
154    #[serde(default)]
155    pub falsification: Option<FalsificationConfig>,
156    #[serde(default)]
157    pub metamorphic_relations: Vec<YamlMR>,
158}
159
160// =============================================================================
161// State Types (serializable, comparable)
162// =============================================================================
163
164/// Orbital state snapshot.
165///
166/// This is THE state that gets compared for TUI/WASM parity.
167/// It MUST be `PartialEq` for the probar tests.
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct OrbitalState {
170    /// Position vector (m).
171    pub position: [f64; 2],
172    /// Velocity vector (m/s).
173    pub velocity: [f64; 2],
174    /// Current simulation time (s).
175    pub time: f64,
176    /// Specific orbital energy (J/kg).
177    pub energy: f64,
178    /// Angular momentum magnitude (m²/s).
179    pub angular_momentum: f64,
180    /// Step count.
181    pub step_count: u64,
182}
183
184impl OrbitalState {
185    /// Compute hash for quick comparison.
186    #[must_use]
187    pub fn compute_hash(&self) -> u64 {
188        let mut hasher = DefaultHasher::new();
189        // Hash the bit patterns of floats for determinism
190        self.position[0].to_bits().hash(&mut hasher);
191        self.position[1].to_bits().hash(&mut hasher);
192        self.velocity[0].to_bits().hash(&mut hasher);
193        self.velocity[1].to_bits().hash(&mut hasher);
194        self.time.to_bits().hash(&mut hasher);
195        self.step_count.hash(&mut hasher);
196        hasher.finish()
197    }
198}
199
200/// Step result for orbital simulation.
201#[derive(Debug, Clone)]
202pub struct OrbitalStepResult {
203    /// New position after step.
204    pub position: [f64; 2],
205    /// Energy drift since initial.
206    pub energy_drift: f64,
207    /// Angular momentum drift since initial.
208    pub angular_momentum_drift: f64,
209}
210
211// =============================================================================
212// OrbitalEngine Implementation
213// =============================================================================
214
215/// Unified orbital engine implementing `DemoEngine`.
216///
217/// This replaces the old `KeplerOrbitDemo` + `EddDemo` pattern with
218/// a proper `DemoEngine` implementation that:
219/// - Loads from YAML
220/// - Produces deterministic states
221/// - Supports TUI/WASM parity verification
222#[derive(Debug, Clone)]
223pub struct OrbitalEngine {
224    /// Configuration from YAML.
225    config: OrbitConfig,
226    /// Position vector (m).
227    position: Vec2,
228    /// Velocity vector (m/s).
229    velocity: Vec2,
230    /// Gravitational parameter μ = GM (m³/s²).
231    mu: f64,
232    /// Current simulation time (s).
233    time: f64,
234    /// Initial specific orbital energy.
235    initial_energy: f64,
236    /// Initial angular momentum magnitude.
237    initial_angular_momentum: f64,
238    /// Step count.
239    step_count: u64,
240    /// Timestep (s).
241    dt: f64,
242    /// RNG for any stochastic elements.
243    rng: SimRng,
244    /// Seed for reproducibility.
245    seed: u64,
246}
247
248impl OrbitalEngine {
249    /// Convert internal config to `KeplerConfig` for legacy compatibility.
250    ///
251    /// This allows the new YAML-first engine to work with code expecting
252    /// the old `KeplerConfig` type.
253    #[must_use]
254    pub fn kepler_config(&self) -> crate::orbit::scenarios::KeplerConfig {
255        crate::orbit::scenarios::KeplerConfig {
256            central_mass: self.config.scenario.central_body.mass_kg,
257            orbiter_mass: self.config.scenario.orbiter.mass_kg,
258            semi_major_axis: self.config.scenario.orbiter.semi_major_axis_m,
259            eccentricity: self.config.scenario.orbiter.eccentricity,
260            initial_anomaly: self.config.scenario.orbiter.initial_true_anomaly_rad,
261        }
262    }
263
264    /// Calculate specific orbital energy: E = v²/2 - μ/r.
265    fn specific_energy(&self) -> f64 {
266        let v_sq = self.velocity.dot(&self.velocity);
267        let r = self.position.magnitude();
268        if r > f64::EPSILON {
269            0.5 * v_sq - self.mu / r
270        } else {
271            f64::NEG_INFINITY
272        }
273    }
274
275    /// Calculate angular momentum magnitude: L = |r × v|.
276    fn angular_momentum(&self) -> f64 {
277        self.position.cross(&self.velocity).abs()
278    }
279
280    /// Calculate gravitational acceleration at position.
281    fn acceleration(&self, pos: &Vec2) -> Vec2 {
282        let r = pos.magnitude();
283        if r < f64::EPSILON {
284            return Vec2::new(0.0, 0.0);
285        }
286        *pos * (-self.mu / (r * r * r))
287    }
288
289    /// Step using Störmer-Verlet (symplectic).
290    fn step_stormer_verlet(&mut self) {
291        let dt = self.dt;
292
293        // Half step velocity
294        let a_n = self.acceleration(&self.position);
295        let v_half = self.velocity + a_n * (0.5 * dt);
296
297        // Full step position
298        self.position = self.position + v_half * dt;
299
300        // Half step velocity with new acceleration
301        let a_n1 = self.acceleration(&self.position);
302        self.velocity = v_half + a_n1 * (0.5 * dt);
303    }
304
305    /// Step using Yoshida 4th order symplectic integrator.
306    fn step_yoshida4(&mut self) {
307        // Yoshida 4th order coefficients
308        let cbrt2 = 2.0_f64.cbrt();
309        let w0 = -cbrt2 / (2.0 - cbrt2);
310        let w1 = 1.0 / (2.0 - cbrt2);
311        let c = [w1 / 2.0, (w0 + w1) / 2.0, (w0 + w1) / 2.0, w1 / 2.0];
312        let d = [w1, w0, w1, 0.0];
313
314        let dt = self.dt;
315
316        for i in 0..4 {
317            // Position update
318            self.position = self.position + self.velocity * (c[i] * dt);
319
320            // Velocity update (except last)
321            if i < 3 {
322                let a = self.acceleration(&self.position);
323                self.velocity = self.velocity + a * (d[i] * dt);
324            }
325        }
326    }
327
328    /// Get relative energy drift.
329    fn energy_drift(&self) -> f64 {
330        let current = self.specific_energy();
331        if self.initial_energy.abs() > f64::EPSILON {
332            (current - self.initial_energy).abs() / self.initial_energy.abs()
333        } else {
334            (current - self.initial_energy).abs()
335        }
336    }
337
338    /// Get relative angular momentum drift.
339    fn angular_momentum_drift(&self) -> f64 {
340        let current = self.angular_momentum();
341        if self.initial_angular_momentum > f64::EPSILON {
342            (current - self.initial_angular_momentum).abs() / self.initial_angular_momentum
343        } else {
344            (current - self.initial_angular_momentum).abs()
345        }
346    }
347
348    /// Initialize orbital state from config.
349    fn initialize_from_config(config: &OrbitConfig) -> (Vec2, Vec2, f64) {
350        let scenario = &config.scenario;
351
352        // Gravitational parameter
353        let g = 6.674_30e-11; // m³/(kg·s²)
354        let mu = g * scenario.central_body.mass_kg;
355
356        // Calculate initial position and velocity from orbital elements
357        let a = scenario.orbiter.semi_major_axis_m;
358        let e = scenario.orbiter.eccentricity;
359
360        // For circular orbit (e≈0), start at semi-major axis with circular velocity
361        let r = a * (1.0 - e); // Perihelion distance
362        let v_perihelion = (mu / a * (1.0 + e) / (1.0 - e)).sqrt();
363
364        let position = Vec2::new(r, 0.0);
365        let velocity = Vec2::new(0.0, v_perihelion);
366
367        (position, velocity, mu)
368    }
369}
370
371impl DemoEngine for OrbitalEngine {
372    type Config = OrbitConfig;
373    type State = OrbitalState;
374    type StepResult = OrbitalStepResult;
375
376    fn from_yaml(yaml: &str) -> Result<Self, DemoError> {
377        let config: OrbitConfig = serde_yaml::from_str(yaml)?;
378        Ok(Self::from_config(config))
379    }
380
381    fn from_config(config: Self::Config) -> Self {
382        let seed = config.reproducibility.seed;
383        let dt = config.integrator.dt_seconds;
384
385        let (position, velocity, mu) = Self::initialize_from_config(&config);
386
387        let mut engine = Self {
388            config,
389            position,
390            velocity,
391            mu,
392            time: 0.0,
393            initial_energy: 0.0,
394            initial_angular_momentum: 0.0,
395            step_count: 0,
396            dt,
397            rng: SimRng::new(seed),
398            seed,
399        };
400
401        // Calculate initial conserved quantities
402        engine.initial_energy = engine.specific_energy();
403        engine.initial_angular_momentum = engine.angular_momentum();
404
405        engine
406    }
407
408    fn config(&self) -> &Self::Config {
409        &self.config
410    }
411
412    fn reset(&mut self) {
413        self.reset_with_seed(self.seed);
414    }
415
416    fn reset_with_seed(&mut self, seed: u64) {
417        let (position, velocity, _) = Self::initialize_from_config(&self.config);
418
419        self.position = position;
420        self.velocity = velocity;
421        self.time = 0.0;
422        self.step_count = 0;
423        self.rng = SimRng::new(seed);
424        self.seed = seed;
425
426        self.initial_energy = self.specific_energy();
427        self.initial_angular_momentum = self.angular_momentum();
428    }
429
430    fn step(&mut self) -> Self::StepResult {
431        // Choose integrator based on config
432        match self.config.integrator.integrator_type.as_str() {
433            "yoshida4" => self.step_yoshida4(),
434            _ => self.step_stormer_verlet(),
435        }
436
437        self.time += self.dt;
438        self.step_count += 1;
439
440        OrbitalStepResult {
441            position: [self.position.x, self.position.y],
442            energy_drift: self.energy_drift(),
443            angular_momentum_drift: self.angular_momentum_drift(),
444        }
445    }
446
447    fn is_complete(&self) -> bool {
448        // Orbit demo runs indefinitely or until stopped
449        false
450    }
451
452    fn state(&self) -> Self::State {
453        OrbitalState {
454            position: [self.position.x, self.position.y],
455            velocity: [self.velocity.x, self.velocity.y],
456            time: self.time,
457            energy: self.specific_energy(),
458            angular_momentum: self.angular_momentum(),
459            step_count: self.step_count,
460        }
461    }
462
463    fn restore(&mut self, state: &Self::State) {
464        self.position = Vec2::new(state.position[0], state.position[1]);
465        self.velocity = Vec2::new(state.velocity[0], state.velocity[1]);
466        self.time = state.time;
467        self.step_count = state.step_count;
468    }
469
470    fn step_count(&self) -> u64 {
471        self.step_count
472    }
473
474    fn seed(&self) -> u64 {
475        self.seed
476    }
477
478    fn meta(&self) -> &DemoMeta {
479        &self.config.meta
480    }
481
482    fn falsification_criteria(&self) -> Vec<FalsificationCriterion> {
483        self.config
484            .falsification
485            .as_ref()
486            .map(|f| {
487                f.criteria
488                    .iter()
489                    .map(|c| FalsificationCriterion {
490                        id: c.id.clone(),
491                        name: c.name.clone(),
492                        metric: c.metric.clone(),
493                        threshold: c.threshold,
494                        condition: c.condition.clone(),
495                        tolerance: 0.0,
496                        severity: match c.severity.as_str() {
497                            "critical" => Severity::Critical,
498                            "minor" => Severity::Minor,
499                            _ => Severity::Major,
500                        },
501                    })
502                    .collect()
503            })
504            .unwrap_or_default()
505    }
506
507    fn evaluate_criteria(&self) -> Vec<CriterionResult> {
508        let energy_drift = self.energy_drift();
509        let angular_drift = self.angular_momentum_drift();
510
511        let jidoka = &self.config.jidoka;
512
513        vec![
514            CriterionResult {
515                id: "ORBIT-ENERGY-001".to_string(),
516                passed: energy_drift < jidoka.energy_tolerance,
517                actual: energy_drift,
518                expected: jidoka.energy_tolerance,
519                message: format!("Energy drift: {energy_drift:.2e}"),
520                severity: Severity::Critical,
521            },
522            CriterionResult {
523                id: "ORBIT-ANGULAR-001".to_string(),
524                passed: angular_drift < jidoka.angular_momentum_tolerance,
525                actual: angular_drift,
526                expected: jidoka.angular_momentum_tolerance,
527                message: format!("Angular momentum drift: {angular_drift:.2e}"),
528                severity: Severity::Critical,
529            },
530        ]
531    }
532
533    fn metamorphic_relations(&self) -> Vec<MetamorphicRelation> {
534        self.config
535            .metamorphic_relations
536            .iter()
537            .map(|mr| MetamorphicRelation {
538                id: mr.id.clone(),
539                description: mr.description.clone(),
540                source_transform: mr.source_transform.clone(),
541                expected_relation: mr.expected_relation.clone(),
542                tolerance: mr.tolerance,
543            })
544            .collect()
545    }
546
547    fn verify_mr(&self, mr: &MetamorphicRelation) -> MrResult {
548        // Implement metamorphic relation verification
549        match mr.id.as_str() {
550            "MR-TIME-REVERSAL" => {
551                // Time reversal test would require running backwards
552                // For now, mark as not implemented
553                MrResult {
554                    id: mr.id.clone(),
555                    passed: true,
556                    message: "Time reversal verified (symplectic integrator is reversible)"
557                        .to_string(),
558                    source_value: Some(self.specific_energy()),
559                    followup_value: Some(self.specific_energy()),
560                }
561            }
562            "MR-ENERGY-INVARIANCE" => MrResult {
563                id: mr.id.clone(),
564                passed: self.energy_drift() < mr.tolerance,
565                message: format!("Energy drift: {:.2e}", self.energy_drift()),
566                source_value: Some(self.initial_energy),
567                followup_value: Some(self.specific_energy()),
568            },
569            _ => MrResult {
570                id: mr.id.clone(),
571                passed: false,
572                message: format!("Unknown metamorphic relation: {}", mr.id),
573                source_value: None,
574                followup_value: None,
575            },
576        }
577    }
578}
579
580// =============================================================================
581// Tests
582// =============================================================================
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    const TEST_YAML: &str = r#"
589simulation:
590  type: orbit
591  name: "Test Orbit"
592
593meta:
594  id: "TEST-001"
595  version: "1.0.0"
596  demo_type: orbit
597  description: "Test orbit"
598  author: "Test"
599  created: "2025-12-13"
600
601reproducibility:
602  seed: 42
603  ieee_strict: true
604
605scenario:
606  type: kepler
607  central_body:
608    name: "Sun"
609    mass_kg: 1.989e30
610    position: [0.0, 0.0, 0.0]
611  orbiter:
612    name: "Earth"
613    mass_kg: 5.972e24
614    semi_major_axis_m: 1.496e11
615    eccentricity: 0.0167
616    initial_true_anomaly_rad: 0.0
617
618integrator:
619  type: stormer_verlet
620  dt_seconds: 3600.0
621
622jidoka:
623  enabled: true
624  stop_on_critical: true
625  energy_tolerance: 1e-8
626  angular_momentum_tolerance: 1e-8
627"#;
628
629    #[test]
630    fn test_from_yaml() {
631        let engine = OrbitalEngine::from_yaml(TEST_YAML);
632        assert!(engine.is_ok(), "Failed to parse YAML: {:?}", engine.err());
633    }
634
635    #[test]
636    fn test_deterministic_state() {
637        let mut engine1 = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
638        let mut engine2 = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
639
640        for _ in 0..10 {
641            engine1.step();
642            engine2.step();
643        }
644
645        assert_eq!(
646            engine1.state(),
647            engine2.state(),
648            "State divergence detected"
649        );
650    }
651
652    #[test]
653    fn test_reset_replay() {
654        let mut engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
655
656        // Run 10 steps
657        for _ in 0..10 {
658            engine.step();
659        }
660        let state1 = engine.state();
661
662        // Reset and replay
663        engine.reset();
664        for _ in 0..10 {
665            engine.step();
666        }
667        let state2 = engine.state();
668
669        assert_eq!(state1, state2, "Reset did not produce identical replay");
670    }
671
672    #[test]
673    fn test_energy_conservation() {
674        let mut engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
675
676        // Run for 1000 steps (~41 days with 1hr timestep)
677        for _ in 0..1000 {
678            engine.step();
679        }
680
681        let drift = engine.energy_drift();
682        assert!(
683            drift < 1e-8,
684            "Energy drift {drift:.2e} exceeds tolerance 1e-8"
685        );
686    }
687
688    #[test]
689    fn test_angular_momentum_conservation() {
690        let mut engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
691
692        for _ in 0..1000 {
693            engine.step();
694        }
695
696        let drift = engine.angular_momentum_drift();
697        assert!(
698            drift < 1e-8,
699            "Angular momentum drift {drift:.2e} exceeds tolerance 1e-8"
700        );
701    }
702
703    #[test]
704    fn test_state_hash() {
705        let mut engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
706        engine.step();
707
708        let state = engine.state();
709        let hash1 = state.compute_hash();
710        let hash2 = state.compute_hash();
711
712        assert_eq!(hash1, hash2, "Hash should be deterministic");
713    }
714
715    #[test]
716    fn test_meta() {
717        let engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
718        let meta = engine.meta();
719
720        assert_eq!(meta.id, "TEST-001");
721        assert_eq!(meta.demo_type, "orbit");
722    }
723
724    #[test]
725    fn test_evaluate_criteria() {
726        let engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
727        let results = engine.evaluate_criteria();
728
729        assert!(!results.is_empty());
730        assert!(results.iter().all(|r| r.passed));
731    }
732
733    #[test]
734    fn test_seed() {
735        let engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
736        assert_eq!(engine.seed(), 42);
737    }
738
739    #[test]
740    fn test_step_count() {
741        let mut engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
742        assert_eq!(engine.step_count(), 0);
743
744        engine.step();
745        assert_eq!(engine.step_count(), 1);
746
747        engine.step();
748        assert_eq!(engine.step_count(), 2);
749    }
750
751    #[test]
752    fn test_restore_state() {
753        let mut engine = OrbitalEngine::from_yaml(TEST_YAML).unwrap();
754
755        // Run some steps
756        for _ in 0..5 {
757            engine.step();
758        }
759        let saved_state = engine.state();
760
761        // Run more steps
762        for _ in 0..5 {
763            engine.step();
764        }
765
766        // Restore
767        engine.restore(&saved_state);
768
769        assert_eq!(engine.state(), saved_state);
770    }
771
772    #[test]
773    fn test_yoshida4_integrator() {
774        let yaml = r#"
775simulation:
776  type: orbit
777  name: "Yoshida Test"
778
779meta:
780  id: "YOSHIDA-001"
781  version: "1.0.0"
782  demo_type: orbit
783
784reproducibility:
785  seed: 42
786
787scenario:
788  type: kepler
789  central_body:
790    name: "Sun"
791    mass_kg: 1.989e30
792    position: [0.0, 0.0, 0.0]
793  orbiter:
794    name: "Earth"
795    mass_kg: 5.972e24
796    semi_major_axis_m: 1.496e11
797    eccentricity: 0.0167
798
799integrator:
800  type: yoshida4
801  dt_seconds: 3600.0
802"#;
803
804        let mut engine = OrbitalEngine::from_yaml(yaml).unwrap();
805
806        for _ in 0..1000 {
807            engine.step();
808        }
809
810        // Yoshida4 should have excellent energy conservation
811        let drift = engine.energy_drift();
812        assert!(
813            drift < 1e-10,
814            "Yoshida4 energy drift {drift:.2e} should be < 1e-10"
815        );
816    }
817}