1use 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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SimulationConfig {
101 #[serde(rename = "type")]
102 pub sim_type: String,
103 pub name: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ReproducibilityConfig {
109 pub seed: u64,
110 #[serde(default)]
111 pub ieee_strict: bool,
112}
113
114#[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#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct FalsificationConfig {
129 #[serde(default)]
130 pub criteria: Vec<YamlCriterion>,
131}
132
133#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct OrbitalState {
170 pub position: [f64; 2],
172 pub velocity: [f64; 2],
174 pub time: f64,
176 pub energy: f64,
178 pub angular_momentum: f64,
180 pub step_count: u64,
182}
183
184impl OrbitalState {
185 #[must_use]
187 pub fn compute_hash(&self) -> u64 {
188 let mut hasher = DefaultHasher::new();
189 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#[derive(Debug, Clone)]
202pub struct OrbitalStepResult {
203 pub position: [f64; 2],
205 pub energy_drift: f64,
207 pub angular_momentum_drift: f64,
209}
210
211#[derive(Debug, Clone)]
223pub struct OrbitalEngine {
224 config: OrbitConfig,
226 position: Vec2,
228 velocity: Vec2,
230 mu: f64,
232 time: f64,
234 initial_energy: f64,
236 initial_angular_momentum: f64,
238 step_count: u64,
240 dt: f64,
242 rng: SimRng,
244 seed: u64,
246}
247
248impl OrbitalEngine {
249 #[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 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 fn angular_momentum(&self) -> f64 {
277 self.position.cross(&self.velocity).abs()
278 }
279
280 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 fn step_stormer_verlet(&mut self) {
291 let dt = self.dt;
292
293 let a_n = self.acceleration(&self.position);
295 let v_half = self.velocity + a_n * (0.5 * dt);
296
297 self.position = self.position + v_half * dt;
299
300 let a_n1 = self.acceleration(&self.position);
302 self.velocity = v_half + a_n1 * (0.5 * dt);
303 }
304
305 fn step_yoshida4(&mut self) {
307 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 self.position = self.position + self.velocity * (c[i] * dt);
319
320 if i < 3 {
322 let a = self.acceleration(&self.position);
323 self.velocity = self.velocity + a * (d[i] * dt);
324 }
325 }
326 }
327
328 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 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 fn initialize_from_config(config: &OrbitConfig) -> (Vec2, Vec2, f64) {
350 let scenario = &config.scenario;
351
352 let g = 6.674_30e-11; let mu = g * scenario.central_body.mass_kg;
355
356 let a = scenario.orbiter.semi_major_axis_m;
358 let e = scenario.orbiter.eccentricity;
359
360 let r = a * (1.0 - e); 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 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 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 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 match mr.id.as_str() {
550 "MR-TIME-REVERSAL" => {
551 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#[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 for _ in 0..10 {
658 engine.step();
659 }
660 let state1 = engine.state();
661
662 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 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 for _ in 0..5 {
757 engine.step();
758 }
759 let saved_state = engine.state();
760
761 for _ in 0..5 {
763 engine.step();
764 }
765
766 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 let drift = engine.energy_drift();
812 assert!(
813 drift < 1e-10,
814 "Yoshida4 energy drift {drift:.2e} should be < 1e-10"
815 );
816 }
817}