1use crate::orbit::physics::NBodyState;
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum JidokaResponse {
23 Continue,
25
26 Warning {
28 message: String,
29 body_index: Option<usize>,
30 metric: String,
31 current: f64,
32 threshold: f64,
33 },
34
35 Pause {
37 violation: OrbitJidokaViolation,
38 recoverable: bool,
39 suggestion: String,
40 },
41
42 Halt { violation: OrbitJidokaViolation },
44}
45
46impl JidokaResponse {
47 #[must_use]
49 pub fn can_continue(&self) -> bool {
50 matches!(self, Self::Continue | Self::Warning { .. })
51 }
52
53 #[must_use]
55 pub fn is_warning(&self) -> bool {
56 matches!(self, Self::Warning { .. })
57 }
58
59 #[must_use]
61 pub fn should_pause(&self) -> bool {
62 matches!(self, Self::Pause { .. })
63 }
64
65 #[must_use]
67 pub fn should_halt(&self) -> bool {
68 matches!(self, Self::Halt { .. })
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub enum OrbitJidokaViolation {
75 NonFinite {
77 body_index: usize,
78 field: String,
79 value: f64,
80 },
81
82 EnergyDrift {
84 initial: f64,
85 current: f64,
86 relative_error: f64,
87 tolerance: f64,
88 },
89
90 AngularMomentumDrift {
92 initial: f64,
93 current: f64,
94 relative_error: f64,
95 tolerance: f64,
96 },
97
98 CloseEncounter {
100 body_i: usize,
101 body_j: usize,
102 separation: f64,
103 threshold: f64,
104 },
105
106 EscapeVelocity {
108 body_index: usize,
109 velocity: f64,
110 escape_velocity: f64,
111 },
112}
113
114impl std::fmt::Display for OrbitJidokaViolation {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Self::NonFinite {
118 body_index,
119 field,
120 value,
121 } => {
122 write!(f, "Non-finite {field} at body {body_index}: {value}")
123 }
124 Self::EnergyDrift {
125 relative_error,
126 tolerance,
127 ..
128 } => {
129 write!(
130 f,
131 "Energy drift {relative_error:.2e} exceeds tolerance {tolerance:.2e}"
132 )
133 }
134 Self::AngularMomentumDrift {
135 relative_error,
136 tolerance,
137 ..
138 } => {
139 write!(
140 f,
141 "Angular momentum drift {relative_error:.2e} exceeds tolerance {tolerance:.2e}"
142 )
143 }
144 Self::CloseEncounter {
145 body_i,
146 body_j,
147 separation,
148 threshold,
149 } => {
150 write!(f, "Close encounter: bodies {body_i}-{body_j} at {separation:.2e}m (threshold: {threshold:.2e}m)")
151 }
152 Self::EscapeVelocity {
153 body_index,
154 velocity,
155 escape_velocity,
156 } => {
157 write!(f, "Body {body_index} at escape velocity: {velocity:.2e} > {escape_velocity:.2e} m/s")
158 }
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165#[allow(clippy::struct_excessive_bools)] pub struct OrbitJidokaConfig {
167 pub check_finite: bool,
169 pub check_energy: bool,
171 pub energy_tolerance: f64,
173 pub energy_warning_fraction: f64,
175 pub check_angular_momentum: bool,
177 pub angular_momentum_tolerance: f64,
179 pub check_close_encounters: bool,
181 pub close_encounter_threshold: f64,
183 pub max_warnings_before_pause: usize,
185}
186
187impl Default for OrbitJidokaConfig {
188 fn default() -> Self {
189 Self {
190 check_finite: true,
191 check_energy: true,
192 energy_tolerance: 1e-6,
193 energy_warning_fraction: 0.8,
194 check_angular_momentum: true,
195 angular_momentum_tolerance: 1e-9,
196 check_close_encounters: true,
197 close_encounter_threshold: 1e6, max_warnings_before_pause: 10,
199 }
200 }
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize)]
205#[allow(clippy::struct_excessive_bools)] pub struct JidokaStatus {
207 pub energy_error: f64,
209 pub energy_ok: bool,
211 pub angular_momentum_error: f64,
213 pub angular_momentum_ok: bool,
215 pub finite_ok: bool,
217 pub min_separation: f64,
219 pub close_encounter_warning: bool,
221 pub warning_count: usize,
223}
224
225#[derive(Debug, Clone)]
227pub struct OrbitJidokaGuard {
228 config: OrbitJidokaConfig,
229 initial_energy: Option<f64>,
230 initial_angular_momentum: Option<f64>,
231 warning_count: usize,
232 status: JidokaStatus,
233}
234
235impl OrbitJidokaGuard {
236 #[must_use]
238 pub fn new(config: OrbitJidokaConfig) -> Self {
239 Self {
240 config,
241 initial_energy: None,
242 initial_angular_momentum: None,
243 warning_count: 0,
244 status: JidokaStatus::default(),
245 }
246 }
247
248 pub fn initialize(&mut self, state: &NBodyState) {
250 self.initial_energy = Some(state.total_energy());
251 self.initial_angular_momentum = Some(state.angular_momentum_magnitude());
252 self.warning_count = 0;
253 }
254
255 pub fn reset_warnings(&mut self) {
257 self.warning_count = 0;
258 }
259
260 #[must_use]
262 pub fn status(&self) -> &JidokaStatus {
263 &self.status
264 }
265
266 pub fn check(&mut self, state: &NBodyState) -> JidokaResponse {
268 self.status = JidokaStatus::default();
270
271 if self.config.check_finite {
273 if let Some(response) = self.check_finite(state) {
274 return response;
275 }
276 }
277
278 if self.config.check_energy {
280 if let Some(response) = self.check_energy(state) {
281 return response;
282 }
283 }
284
285 if self.config.check_angular_momentum {
287 if let Some(response) = self.check_angular_momentum(state) {
288 return response;
289 }
290 }
291
292 if self.config.check_close_encounters {
294 if let Some(response) = self.check_close_encounters(state) {
295 return response;
296 }
297 }
298
299 JidokaResponse::Continue
301 }
302
303 fn check_finite(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
304 for (i, body) in state.bodies.iter().enumerate() {
305 if !body.position.is_finite() {
306 self.status.finite_ok = false;
307 return Some(JidokaResponse::Halt {
308 violation: OrbitJidokaViolation::NonFinite {
309 body_index: i,
310 field: "position".to_string(),
311 value: f64::NAN,
312 },
313 });
314 }
315 if !body.velocity.is_finite() {
316 self.status.finite_ok = false;
317 return Some(JidokaResponse::Halt {
318 violation: OrbitJidokaViolation::NonFinite {
319 body_index: i,
320 field: "velocity".to_string(),
321 value: f64::NAN,
322 },
323 });
324 }
325 }
326 self.status.finite_ok = true;
327 None
328 }
329
330 fn check_energy(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
331 let initial = self.initial_energy?;
332
333 let current = state.total_energy();
334 let relative_error = if initial.abs() > f64::EPSILON {
335 (current - initial).abs() / initial.abs()
336 } else {
337 (current - initial).abs()
338 };
339
340 self.status.energy_error = relative_error;
341 self.status.energy_ok = relative_error <= self.config.energy_tolerance;
342
343 if relative_error > self.config.energy_tolerance {
345 self.warning_count += 1;
346
347 if self.warning_count >= self.config.max_warnings_before_pause {
348 return Some(JidokaResponse::Pause {
349 violation: OrbitJidokaViolation::EnergyDrift {
350 initial,
351 current,
352 relative_error,
353 tolerance: self.config.energy_tolerance,
354 },
355 recoverable: true,
356 suggestion: "Consider reducing time step or using Yoshida integrator"
357 .to_string(),
358 });
359 }
360
361 return Some(JidokaResponse::Warning {
362 message: format!("Energy drift: {relative_error:.2e}"),
363 body_index: None,
364 metric: "energy".to_string(),
365 current: relative_error,
366 threshold: self.config.energy_tolerance,
367 });
368 }
369
370 let warning_threshold = self.config.energy_tolerance * self.config.energy_warning_fraction;
372 if relative_error > warning_threshold {
373 return Some(JidokaResponse::Warning {
374 message: format!("Energy approaching tolerance: {relative_error:.2e}"),
375 body_index: None,
376 metric: "energy".to_string(),
377 current: relative_error,
378 threshold: self.config.energy_tolerance,
379 });
380 }
381
382 None
383 }
384
385 fn check_angular_momentum(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
386 let initial = self.initial_angular_momentum?;
387
388 let current = state.angular_momentum_magnitude();
389 let relative_error = if initial.abs() > f64::EPSILON {
390 (current - initial).abs() / initial.abs()
391 } else {
392 (current - initial).abs()
393 };
394
395 self.status.angular_momentum_error = relative_error;
396 self.status.angular_momentum_ok = relative_error <= self.config.angular_momentum_tolerance;
397
398 if relative_error > self.config.angular_momentum_tolerance {
399 self.warning_count += 1;
400
401 if self.warning_count >= self.config.max_warnings_before_pause {
402 return Some(JidokaResponse::Pause {
403 violation: OrbitJidokaViolation::AngularMomentumDrift {
404 initial,
405 current,
406 relative_error,
407 tolerance: self.config.angular_momentum_tolerance,
408 },
409 recoverable: true,
410 suggestion:
411 "Angular momentum should be conserved - check for numerical instability"
412 .to_string(),
413 });
414 }
415 }
416
417 None
418 }
419
420 fn check_close_encounters(&mut self, state: &NBodyState) -> Option<JidokaResponse> {
421 let min_sep = state.min_separation();
422 self.status.min_separation = min_sep;
423 self.status.close_encounter_warning = min_sep < self.config.close_encounter_threshold;
424
425 if min_sep < self.config.close_encounter_threshold {
426 let n = state.bodies.len();
428 for i in 0..n {
429 for j in (i + 1)..n {
430 let r = state.bodies[i].position - state.bodies[j].position;
431 let sep = r.magnitude().get::<uom::si::length::meter>();
432
433 if sep < self.config.close_encounter_threshold {
434 self.warning_count += 1;
435
436 if self.warning_count >= self.config.max_warnings_before_pause {
437 return Some(JidokaResponse::Pause {
438 violation: OrbitJidokaViolation::CloseEncounter {
439 body_i: i,
440 body_j: j,
441 separation: sep,
442 threshold: self.config.close_encounter_threshold,
443 },
444 recoverable: true,
445 suggestion:
446 "Reduce time step or enable softening for close encounters"
447 .to_string(),
448 });
449 }
450
451 return Some(JidokaResponse::Warning {
452 message: format!("Close encounter: bodies {i}-{j} at {sep:.2e}m"),
453 body_index: Some(i),
454 metric: "separation".to_string(),
455 current: sep,
456 threshold: self.config.close_encounter_threshold,
457 });
458 }
459 }
460 }
461 }
462
463 None
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::orbit::physics::{NBodyState, OrbitBody};
471 use crate::orbit::units::{OrbitMass, Position3D, Velocity3D, AU, EARTH_MASS, G, SOLAR_MASS};
472
473 fn create_sun_earth_system() -> NBodyState {
474 let v_circular = (G * SOLAR_MASS / AU).sqrt();
475 let bodies = vec![
476 OrbitBody::new(
477 OrbitMass::from_kg(SOLAR_MASS),
478 Position3D::zero(),
479 Velocity3D::zero(),
480 ),
481 OrbitBody::new(
482 OrbitMass::from_kg(EARTH_MASS),
483 Position3D::from_au(1.0, 0.0, 0.0),
484 Velocity3D::from_mps(0.0, v_circular, 0.0),
485 ),
486 ];
487 NBodyState::new(bodies, 0.0)
488 }
489
490 #[test]
491 fn test_jidoka_response_can_continue() {
492 assert!(JidokaResponse::Continue.can_continue());
493 assert!(JidokaResponse::Warning {
494 message: "test".to_string(),
495 body_index: None,
496 metric: "test".to_string(),
497 current: 0.0,
498 threshold: 1.0,
499 }
500 .can_continue());
501
502 assert!(!JidokaResponse::Pause {
503 violation: OrbitJidokaViolation::EnergyDrift {
504 initial: 1.0,
505 current: 2.0,
506 relative_error: 1.0,
507 tolerance: 0.1,
508 },
509 recoverable: true,
510 suggestion: String::new(),
511 }
512 .can_continue());
513 }
514
515 #[test]
516 fn test_jidoka_config_default() {
517 let config = OrbitJidokaConfig::default();
518 assert!(config.check_finite);
519 assert!(config.check_energy);
520 assert!(config.check_angular_momentum);
521 assert!((config.energy_tolerance - 1e-6).abs() < 1e-10);
522 }
523
524 #[test]
525 fn test_jidoka_guard_initialize() {
526 let config = OrbitJidokaConfig::default();
527 let mut guard = OrbitJidokaGuard::new(config);
528 let state = create_sun_earth_system();
529
530 guard.initialize(&state);
531
532 assert!(guard.initial_energy.is_some());
533 assert!(guard.initial_angular_momentum.is_some());
534 }
535
536 #[test]
537 fn test_jidoka_guard_check_healthy_state() {
538 let config = OrbitJidokaConfig::default();
539 let mut guard = OrbitJidokaGuard::new(config);
540 let state = create_sun_earth_system();
541
542 guard.initialize(&state);
543 let response = guard.check(&state);
544
545 assert!(response.can_continue());
546 assert!(guard.status().energy_ok);
547 assert!(guard.status().angular_momentum_ok);
548 assert!(guard.status().finite_ok);
549 }
550
551 #[test]
552 fn test_jidoka_guard_energy_violation() {
553 let mut config = OrbitJidokaConfig::default();
554 config.energy_tolerance = 1e-20; config.max_warnings_before_pause = 1;
556
557 let mut guard = OrbitJidokaGuard::new(config);
558 let mut state = create_sun_earth_system();
559
560 guard.initialize(&state);
561
562 state.bodies[1].velocity = Velocity3D::from_mps(0.0, 30000.0, 0.0);
564
565 let response = guard.check(&state);
566
567 assert!(response.should_pause() || response.is_warning());
569 }
570
571 #[test]
572 fn test_jidoka_guard_close_encounter() {
573 let mut config = OrbitJidokaConfig::default();
574 config.close_encounter_threshold = 1e15; config.max_warnings_before_pause = 1;
576
577 let mut guard = OrbitJidokaGuard::new(config);
578 let state = create_sun_earth_system();
579
580 guard.initialize(&state);
581 let response = guard.check(&state);
582
583 assert!(response.is_warning() || response.should_pause());
585 }
586
587 #[test]
588 fn test_jidoka_violation_display() {
589 let violation = OrbitJidokaViolation::EnergyDrift {
590 initial: -1e33,
591 current: -1.1e33,
592 relative_error: 0.1,
593 tolerance: 1e-6,
594 };
595
596 let display = format!("{violation}");
597 assert!(display.contains("Energy drift"));
598 assert!(display.contains("1.00e-1"));
599 }
600
601 #[test]
602 fn test_jidoka_status_default() {
603 let status = JidokaStatus::default();
604 assert!(!status.energy_ok);
605 assert!(!status.angular_momentum_ok);
606 assert!(!status.finite_ok);
607 assert_eq!(status.warning_count, 0);
608 }
609
610 #[test]
611 fn test_jidoka_reset_warnings() {
612 let config = OrbitJidokaConfig::default();
613 let mut guard = OrbitJidokaGuard::new(config);
614
615 guard.warning_count = 5;
616 guard.reset_warnings();
617
618 assert_eq!(guard.warning_count, 0);
619 }
620
621 #[test]
622 fn test_jidoka_warning_accumulation() {
623 let mut config = OrbitJidokaConfig::default();
624 config.energy_tolerance = 1e-20; config.max_warnings_before_pause = 5;
626
627 let mut guard = OrbitJidokaGuard::new(config);
628 let mut state = create_sun_earth_system();
629
630 guard.initialize(&state);
631 state.bodies[1].velocity = Velocity3D::from_mps(0.0, 35000.0, 0.0);
632
633 for _ in 0..4 {
635 let response = guard.check(&state);
636 assert!(response.is_warning());
637 }
638
639 let response = guard.check(&state);
641 assert!(response.should_pause());
642 }
643
644 #[test]
645 fn test_jidoka_response_is_warning() {
646 assert!(!JidokaResponse::Continue.is_warning());
647 assert!(JidokaResponse::Warning {
648 message: "test".to_string(),
649 body_index: None,
650 metric: "test".to_string(),
651 current: 0.0,
652 threshold: 1.0,
653 }
654 .is_warning());
655 }
656
657 #[test]
658 fn test_jidoka_response_should_pause() {
659 assert!(!JidokaResponse::Continue.should_pause());
660 assert!(JidokaResponse::Pause {
661 violation: OrbitJidokaViolation::EnergyDrift {
662 initial: 1.0,
663 current: 2.0,
664 relative_error: 1.0,
665 tolerance: 0.1,
666 },
667 recoverable: true,
668 suggestion: String::new(),
669 }
670 .should_pause());
671 }
672
673 #[test]
674 fn test_jidoka_response_should_halt() {
675 assert!(!JidokaResponse::Continue.should_halt());
676 assert!(JidokaResponse::Halt {
677 violation: OrbitJidokaViolation::NonFinite {
678 body_index: 0,
679 field: "position".to_string(),
680 value: f64::NAN,
681 },
682 }
683 .should_halt());
684 }
685
686 #[test]
687 fn test_jidoka_violation_display_non_finite() {
688 let violation = OrbitJidokaViolation::NonFinite {
689 body_index: 1,
690 field: "velocity".to_string(),
691 value: f64::INFINITY,
692 };
693 let display = format!("{violation}");
694 assert!(display.contains("Non-finite"));
695 assert!(display.contains("velocity"));
696 assert!(display.contains("body 1"));
697 }
698
699 #[test]
700 fn test_jidoka_violation_display_angular_momentum() {
701 let violation = OrbitJidokaViolation::AngularMomentumDrift {
702 initial: 1e40,
703 current: 1.1e40,
704 relative_error: 0.1,
705 tolerance: 1e-9,
706 };
707 let display = format!("{violation}");
708 assert!(display.contains("Angular momentum drift"));
709 }
710
711 #[test]
712 fn test_jidoka_violation_display_close_encounter() {
713 let violation = OrbitJidokaViolation::CloseEncounter {
714 body_i: 0,
715 body_j: 1,
716 separation: 1e6,
717 threshold: 1e7,
718 };
719 let display = format!("{violation}");
720 assert!(display.contains("Close encounter"));
721 assert!(display.contains("0-1"));
722 }
723
724 #[test]
725 fn test_jidoka_violation_display_escape() {
726 let violation = OrbitJidokaViolation::EscapeVelocity {
727 body_index: 1,
728 velocity: 50000.0,
729 escape_velocity: 42000.0,
730 };
731 let display = format!("{violation}");
732 assert!(display.contains("escape velocity"));
733 assert!(display.contains("Body 1"));
734 }
735
736 #[test]
737 fn test_jidoka_guard_halt_on_non_finite_position() {
738 let config = OrbitJidokaConfig::default();
739 let mut guard = OrbitJidokaGuard::new(config);
740 let mut state = create_sun_earth_system();
741
742 guard.initialize(&state);
743
744 state.bodies[0].position = Position3D::from_meters(f64::NAN, 0.0, 0.0);
746
747 let response = guard.check(&state);
748 assert!(response.should_halt());
749 }
750
751 #[test]
752 fn test_jidoka_guard_halt_on_non_finite_velocity() {
753 let config = OrbitJidokaConfig::default();
754 let mut guard = OrbitJidokaGuard::new(config);
755 let mut state = create_sun_earth_system();
756
757 guard.initialize(&state);
758
759 state.bodies[1].velocity = Velocity3D::from_mps(f64::INFINITY, 0.0, 0.0);
761
762 let response = guard.check(&state);
763 assert!(response.should_halt());
764 }
765
766 #[test]
767 fn test_jidoka_guard_check_without_initialize() {
768 let config = OrbitJidokaConfig::default();
769 let mut guard = OrbitJidokaGuard::new(config);
770 let state = create_sun_earth_system();
771
772 let response = guard.check(&state);
774 assert!(response.can_continue());
775 }
776
777 #[test]
778 fn test_jidoka_guard_disabled_checks() {
779 let config = OrbitJidokaConfig {
780 check_finite: false,
781 check_energy: false,
782 check_angular_momentum: false,
783 check_close_encounters: false,
784 ..Default::default()
785 };
786 let mut guard = OrbitJidokaGuard::new(config);
787 let mut state = create_sun_earth_system();
788
789 guard.initialize(&state);
790
791 state.bodies[0].position = Position3D::from_meters(f64::NAN, 0.0, 0.0);
793
794 let response = guard.check(&state);
795 assert!(response.can_continue());
796 }
797
798 #[test]
799 fn test_jidoka_energy_warning_threshold() {
800 let mut config = OrbitJidokaConfig::default();
801 config.energy_tolerance = 1e-3;
802 config.energy_warning_fraction = 0.8; let mut guard = OrbitJidokaGuard::new(config);
805 let mut state = create_sun_earth_system();
806
807 guard.initialize(&state);
808
809 state.bodies[1].velocity = Velocity3D::from_mps(0.0, 29784.0, 0.0);
811
812 let response = guard.check(&state);
813 assert!(response.can_continue());
815 }
816
817 #[test]
818 fn test_jidoka_angular_momentum_violation() {
819 let mut config = OrbitJidokaConfig::default();
820 config.angular_momentum_tolerance = 1e-20; config.max_warnings_before_pause = 1;
822
823 let mut guard = OrbitJidokaGuard::new(config);
824 let mut state = create_sun_earth_system();
825
826 guard.initialize(&state);
827
828 state.bodies[1].velocity = Velocity3D::from_mps(29784.0, 0.0, 0.0);
830
831 let response = guard.check(&state);
832 assert!(response.should_pause());
834 }
835}