1use nalgebra::{Vector3, Vector6};
10use std::collections::HashMap;
11
12use crate::derivatives::compute_derivatives;
13use crate::wind::WindSegment;
14use crate::BallisticInputs;
15use crate::DragModel;
16
17fn rk4_step(
19 state: &Vector6<f64>,
20 t: f64,
21 dt: f64,
22 params: &TrajectoryParams,
23 inputs: &BallisticInputs,
24) -> Vector6<f64> {
25 let k1 = compute_derivatives_vec(state, t, params, inputs);
27 let k2 = compute_derivatives_vec(&(state + dt * 0.5 * k1), t + dt * 0.5, params, inputs);
28 let k3 = compute_derivatives_vec(&(state + dt * 0.5 * k2), t + dt * 0.5, params, inputs);
29 let k4 = compute_derivatives_vec(&(state + dt * k3), t + dt, params, inputs);
30
31 state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
32}
33
34fn rk45_step(
36 state: &Vector6<f64>,
37 t: f64,
38 dt: f64,
39 params: &TrajectoryParams,
40 inputs: &BallisticInputs,
41 tol: f64,
42) -> (Vector6<f64>, f64, f64) {
43 const A21: f64 = 1.0 / 5.0;
45 const A31: f64 = 3.0 / 40.0;
46 const A32: f64 = 9.0 / 40.0;
47 const A41: f64 = 44.0 / 45.0;
48 const A42: f64 = -56.0 / 15.0;
49 const A43: f64 = 32.0 / 9.0;
50 const A51: f64 = 19372.0 / 6561.0;
51 const A52: f64 = -25360.0 / 2187.0;
52 const A53: f64 = 64448.0 / 6561.0;
53 const A54: f64 = -212.0 / 729.0;
54 const A61: f64 = 9017.0 / 3168.0;
55 const A62: f64 = -355.0 / 33.0;
56 const A63: f64 = 46732.0 / 5247.0;
57 const A64: f64 = 49.0 / 176.0;
58 const A65: f64 = -5103.0 / 18656.0;
59 const A71: f64 = 35.0 / 384.0;
60 const A73: f64 = 500.0 / 1113.0;
61 const A74: f64 = 125.0 / 192.0;
62 const A75: f64 = -2187.0 / 6784.0;
63 const A76: f64 = 11.0 / 84.0;
64
65 const B1: f64 = 35.0 / 384.0;
67 const B3: f64 = 500.0 / 1113.0;
68 const B4: f64 = 125.0 / 192.0;
69 const B5: f64 = -2187.0 / 6784.0;
70 const B6: f64 = 11.0 / 84.0;
71
72 const B1_ERR: f64 = 5179.0 / 57600.0;
74 const B3_ERR: f64 = 7571.0 / 16695.0;
75 const B4_ERR: f64 = 393.0 / 640.0;
76 const B5_ERR: f64 = -92097.0 / 339200.0;
77 const B6_ERR: f64 = 187.0 / 2100.0;
78 const B7_ERR: f64 = 1.0 / 40.0;
79
80 let k1 = compute_derivatives_vec(state, t, params, inputs);
82 let k2 = compute_derivatives_vec(&(state + dt * A21 * k1), t + dt * 0.2, params, inputs);
83 let k3 =
84 compute_derivatives_vec(&(state + dt * (A31 * k1 + A32 * k2)), t + dt * 0.3, params, inputs);
85 let k4 = compute_derivatives_vec(
86 &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
87 t + dt * 0.8,
88 params,
89 inputs,
90 );
91 let k5 = compute_derivatives_vec(
92 &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
93 t + dt * 8.0 / 9.0,
94 params,
95 inputs,
96 );
97 let k6 = compute_derivatives_vec(
98 &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
99 t + dt,
100 params,
101 inputs,
102 );
103 let k7 = compute_derivatives_vec(
104 &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
105 t + dt,
106 params,
107 inputs,
108 );
109
110 let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
112
113 let y_err = state
115 + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
116
117 let error = (y_new - y_err).norm() / (1.0 + state.norm());
119
120 let safety = 0.9;
122 let dt_new = if error < tol {
123 dt * safety * (tol / error).powf(0.2).min(2.0)
124 } else {
125 dt * safety * (tol / error).powf(0.25).max(0.1)
126 };
127
128 (y_new, dt_new, error)
129}
130
131pub struct TrajectoryParams {
133 pub mass_kg: f64,
134 pub bc: f64,
135 pub drag_model: DragModel,
136 pub wind_segments: Vec<WindSegment>,
137 pub atmos_params: (f64, f64, f64, f64),
143 pub omega_vector: Option<Vector3<f64>>,
144 pub enable_spin_drift: bool,
145 pub enable_magnus: bool,
146 pub enable_coriolis: bool,
147 pub target_distance_m: f64, pub enable_wind_shear: bool,
149 pub wind_shear_model: String,
150 pub shooter_altitude_m: f64,
151 pub is_twist_right: bool, pub bullet_diameter: f64, pub bullet_length: f64, pub twist_rate: f64, pub custom_drag_table: Option<crate::drag::DragTable>, pub bc_segments: Option<Vec<(f64, f64)>>, pub use_bc_segments: bool, pub ground_threshold: f64,
163}
164
165fn build_inputs(params: &TrajectoryParams) -> BallisticInputs {
171 let mut inputs = BallisticInputs {
172 bc_value: params.bc,
173 bc_type: params.drag_model,
174 bullet_mass: params.mass_kg, muzzle_velocity: 0.0, bullet_diameter: params.bullet_diameter, bullet_length: params.bullet_length,
178 twist_rate: params.twist_rate,
179 is_twist_right: params.is_twist_right,
180 enable_advanced_effects: params.enable_spin_drift
181 || params.enable_magnus
182 || params.enable_coriolis,
183 enable_magnus: params.enable_magnus,
184 enable_coriolis: params.enable_coriolis,
185 altitude: params.atmos_params.0,
186 temperature: params.atmos_params.1,
187 pressure: params.atmos_params.2,
188 humidity: params.atmos_params.3,
189 tipoff_yaw: 0.0,
190 target_distance: 1000.0, muzzle_angle: 0.0,
192 wind_speed: if !params.wind_segments.is_empty() {
193 params.wind_segments[0].0 * 0.2777778 } else {
195 0.0
196 },
197 wind_angle: if !params.wind_segments.is_empty() {
198 params.wind_segments[0].1.to_radians() } else {
200 0.0
201 },
202 latitude: None,
203 shooting_angle: 0.0,
204 azimuth_angle: 0.0,
205 shot_azimuth: 0.0, use_powder_sensitivity: false,
207 powder_temp_sensitivity: 0.0,
208 powder_temp: 59.0,
209 tipoff_decay_distance: 0.0,
210 ground_threshold: params.ground_threshold, bc_segments: params.bc_segments.clone(),
212 caliber_inches: params.bullet_diameter / 0.0254, weight_grains: params.mass_kg / 0.00006479891,
214 use_bc_segments: params.use_bc_segments,
215 bullet_id: None,
216 bc_segments_data: None,
217 use_enhanced_spin_drift: params.enable_spin_drift,
218 use_form_factor: false,
219 manufacturer: None,
220 bullet_model: None,
221 enable_wind_shear: false,
222 wind_shear_model: "none".to_string(),
223 use_cluster_bc: false,
224 bullet_cluster: None,
225 custom_drag_table: params.custom_drag_table.clone(),
226 bc_type_str: None,
227 enable_pitch_damping: false,
228 enable_precession_nutation: false,
229 enable_aerodynamic_jump: false,
237 use_rk4: true,
238 use_adaptive_rk45: false,
239 enable_trajectory_sampling: false,
240 sample_interval: 10.0,
241 sight_height: 0.0,
242 muzzle_height: 0.0,
243 target_height: 0.0,
244 };
245
246 if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
253 inputs.bc_segments_data =
254 crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
255 }
256 inputs
257}
258
259fn compute_derivatives_vec(
261 state: &Vector6<f64>,
262 t: f64,
263 params: &TrajectoryParams,
264 inputs: &BallisticInputs,
265) -> Vector6<f64> {
266 let pos = Vector3::new(state[0], state[1], state[2]);
267 let vel = Vector3::new(state[3], state[4], state[5]);
268
269 let wind_vector = if !params.wind_segments.is_empty() {
271 if params.enable_wind_shear && params.wind_shear_model != "none" {
272 crate::wind_shear::get_wind_at_position(
273 &pos,
274 ¶ms.wind_segments,
275 params.enable_wind_shear,
276 ¶ms.wind_shear_model,
277 params.shooter_altitude_m,
278 )
279 } else {
280 let seg = ¶ms.wind_segments[0];
282 let wind_speed_mps = seg.0 * 0.2777778; let wind_angle_rad = seg.1.to_radians();
284 Vector3::new(
286 -wind_speed_mps * wind_angle_rad.cos(), 0.0, -wind_speed_mps * wind_angle_rad.sin(), )
290 }
291 } else {
292 Vector3::zeros()
293 };
294
295 let deriv_result = compute_derivatives(
298 pos,
299 vel,
300 inputs,
301 wind_vector,
302 params.atmos_params,
303 params.bc,
304 params.omega_vector,
305 t,
306 );
307
308 Vector6::new(
309 deriv_result[0],
310 deriv_result[1],
311 deriv_result[2],
312 deriv_result[3],
313 deriv_result[4],
314 deriv_result[5],
315 )
316}
317
318pub fn integrate_trajectory(
320 initial_state: [f64; 6],
321 t_span: (f64, f64),
322 params: TrajectoryParams,
323 method: &str,
324 tolerance: f64,
325 max_step: f64,
326) -> Vec<(f64, Vector6<f64>)> {
327 let mut state = Vector6::new(
328 initial_state[0],
329 initial_state[1],
330 initial_state[2],
331 initial_state[3],
332 initial_state[4],
333 initial_state[5],
334 );
335
336 let mut t = t_span.0;
337 let t_end = t_span.1;
338 let mut dt = (t_end - t) / 1000.0; let mut trajectory = Vec::with_capacity(10000);
341 trajectory.push((t, state));
342
343 let inputs = build_inputs(¶ms);
346
347 match method {
348 "RK4" => {
349 dt = dt.min(max_step).min(0.001); while t < t_end {
353 if t + dt > t_end {
354 dt = t_end - t;
355 }
356
357 let new_state = rk4_step(&state, t, dt, ¶ms, &inputs);
358
359 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
361 let alpha = (params.target_distance_m - state[0]) / (new_state[0] - state[0]);
363 let dt_to_target = dt * alpha;
364
365 let final_state = rk4_step(&state, t, dt_to_target, ¶ms, &inputs);
367
368 let mut corrected_state = final_state;
370 if corrected_state[0] > params.target_distance_m {
371 corrected_state[0] = params.target_distance_m;
372 }
373
374 trajectory.push((t + dt_to_target, corrected_state));
375 break; }
377
378 state = new_state;
379 t += dt;
380 trajectory.push((t, state));
381
382 if state[0] >= params.target_distance_m {
384 let mut final_state = state;
387 final_state[0] = params.target_distance_m; trajectory.push((t, final_state));
389 break;
390 }
391
392 if state[1] < params.ground_threshold {
395 break;
396 }
397 }
398 }
399 "RK45" | _ => {
400 let mut last_save_x = 0.0; let save_interval_m = params.target_distance_m / 50.0; let effective_max_step =
407 if params.enable_wind_shear && params.wind_shear_model != "none" {
408 if params.target_distance_m > 800.0 {
410 0.01 } else {
412 0.02 }
414 } else {
415 max_step };
417
418 dt = dt.min(effective_max_step).max(0.0001); let max_iterations = 100000; let mut iteration_count = 0;
424
425 while t < t_end && iteration_count < max_iterations {
426 iteration_count += 1;
427
428 if t + dt > t_end {
430 dt = t_end - t;
431 }
432
433 let (new_state, dt_new, _error) =
434 rk45_step(&state, t, dt, ¶ms, &inputs, tolerance);
435
436 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
438 let alpha = (params.target_distance_m - state[0]) / (new_state[0] - state[0]);
440 let dt_to_target = dt * alpha;
441
442 let (final_state, _, _) =
444 rk45_step(&state, t, dt_to_target, ¶ms, &inputs, tolerance);
445
446 let mut corrected_state = final_state;
448 if corrected_state[0] > params.target_distance_m {
449 corrected_state[0] = params.target_distance_m;
450 }
451
452 trajectory.push((t + dt_to_target, corrected_state));
453 break; }
455
456 state = new_state;
458 t += dt;
459
460 if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
462 {
463 trajectory.push((t, state));
465 last_save_x = state[0];
466 }
467
468 dt = dt_new.min(effective_max_step).max(0.0001); if state[0] >= params.target_distance_m {
473 let mut final_state = state;
476 final_state[0] = params.target_distance_m; trajectory.push((t, final_state));
478 break;
479 }
480
481 if state[1] < params.ground_threshold {
484 break;
485 }
486 }
487
488 if iteration_count >= max_iterations {
490 eprintln!(
491 "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
492 max_iterations
493 );
494 eprintln!(" Final time: {}, Target time: {}", t, t_end);
495 eprintln!(
496 " Final position: downrange(x)={}, Target: {}m",
497 state[0], params.target_distance_m
498 );
499 }
500 }
501 }
502
503 trajectory
504}
505
506pub fn solve_trajectory_rust(
508 initial_state: [f64; 6],
509 t_span: (f64, f64),
510 mass_kg: f64,
511 bc: f64,
512 drag_model: DragModel,
513 wind_segments: Vec<WindSegment>,
514 atmos_params: (f64, f64, f64, f64),
515 omega_vector: Option<Vec<f64>>,
516 enable_spin_drift: bool,
517 enable_magnus: bool,
518 enable_coriolis: bool,
519 method: String,
520 tolerance: f64,
521 max_step: f64,
522 target_distance_m: f64,
523) -> Vec<HashMap<String, f64>> {
524 let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
525
526 let params = TrajectoryParams {
527 mass_kg,
528 bc,
529 drag_model,
530 wind_segments,
531 atmos_params,
532 omega_vector: omega_vec,
533 enable_spin_drift,
534 enable_magnus,
535 enable_coriolis,
536 target_distance_m,
537 enable_wind_shear: false, wind_shear_model: "none".to_string(),
539 shooter_altitude_m: 0.0,
540 is_twist_right: true, bullet_diameter: 0.0078232,
544 bullet_length: 0.031496,
545 twist_rate: 10.0,
546 custom_drag_table: None, bc_segments: None, use_bc_segments: false,
549 ground_threshold: -1000.0, };
551
552 let trajectory =
553 integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
554
555 trajectory
557 .into_iter()
558 .map(|(t, state)| {
559 let mut point = HashMap::new();
560 point.insert("t".to_string(), t);
561 point.insert("x".to_string(), state[0]);
562 point.insert("y".to_string(), state[1]);
563 point.insert("z".to_string(), state[2]);
564 point.insert("vx".to_string(), state[3]);
565 point.insert("vy".to_string(), state[4]);
566 point.insert("vz".to_string(), state[5]);
567 point
568 })
569 .collect()
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
577 TrajectoryParams {
578 mass_kg: 0.01134, bc: 0.442,
580 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
583 drag_model: DragModel::G7,
584 wind_segments: vec![],
585 atmos_params: (0.0, 59.0, 29.92, 0.0),
586 omega_vector: None,
587 enable_spin_drift: false,
588 enable_magnus: false,
589 enable_coriolis: false,
590 target_distance_m,
591 enable_wind_shear: false,
592 wind_shear_model: "none".to_string(),
593 shooter_altitude_m: 0.0,
594 is_twist_right: true,
595 custom_drag_table: None,
596 bc_segments: None,
597 use_bc_segments: false,
598 ground_threshold: -1000.0,
599 }
600 }
601
602 #[test]
603 fn test_mba954_ground_threshold_honored() {
604 let initial_state = [0.0, 0.0, 0.0, 300.0, -30.0, 0.0]; let mut shallow = create_test_params(1_000_000.0); shallow.ground_threshold = -20.0; let mut deep = create_test_params(1_000_000.0);
612 deep.ground_threshold = -1000.0; let t_shallow =
615 integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
616 let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
617
618 assert!(
619 t_shallow.len() < t_deep.len(),
620 "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
621 shallow={}, deep={}",
622 t_shallow.len(),
623 t_deep.len()
624 );
625 }
626
627 #[test]
628 fn test_integrate_trajectory_basic() {
629 let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
632
633 let params = TrajectoryParams {
634 mass_kg: 0.01134, bc: 0.442,
636 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
639 drag_model: DragModel::G7,
640 wind_segments: vec![(0.0, 90.0, 914.4)],
641 atmos_params: (0.0, 59.0, 29.92, 0.0),
642 omega_vector: None,
643 enable_spin_drift: false,
644 enable_magnus: false,
645 enable_coriolis: false,
646 target_distance_m: 914.4, enable_wind_shear: false,
648 wind_shear_model: "none".to_string(),
649 shooter_altitude_m: 0.0,
650 is_twist_right: true,
651 custom_drag_table: None,
652 bc_segments: None,
653 use_bc_segments: false,
654 ground_threshold: -1000.0,
655 };
656
657 println!("Running integrate_trajectory test...");
658 println!("Initial state: {:?}", initial_state);
659 println!("Target distance: {} m", params.target_distance_m);
660
661 let trajectory =
662 integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
663
664 println!("Trajectory has {} points", trajectory.len());
665
666 assert!(
668 trajectory.len() > 1,
669 "Trajectory should have more than 1 point, but has {}",
670 trajectory.len()
671 );
672
673 if let Some((_, final_state)) = trajectory.last() {
675 println!("Final state: downrange(x)={}", final_state[0]);
676 assert!(
677 final_state[0] > 0.0,
678 "Final x should be positive (bullet moved downrange)"
679 );
680 assert!(
681 final_state[0] >= 900.0,
682 "Final x should be near target distance"
683 );
684 }
685 }
686
687 #[test]
688 fn test_rk4_vs_rk45_consistency() {
689 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
692
693 let params_rk4 = create_test_params(target_distance);
694 let params_rk45 = create_test_params(target_distance);
695
696 let trajectory_rk4 =
697 integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
698 let trajectory_rk45 =
699 integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
700
701 assert!(!trajectory_rk4.is_empty());
703 assert!(!trajectory_rk45.is_empty());
704
705 let (_, final_rk4) = trajectory_rk4.last().unwrap();
706 let (_, final_rk45) = trajectory_rk45.last().unwrap();
707
708 let rk4_z = final_rk4[0];
710 let rk45_z = final_rk45[0];
711 let diff_percent = ((rk4_z - rk45_z) / rk45_z).abs() * 100.0;
712
713 assert!(
714 diff_percent < 1.0,
715 "RK4 and RK45 final positions differ by {}%: RK4={}, RK45={}",
716 diff_percent,
717 rk4_z,
718 rk45_z
719 );
720 }
721
722 #[test]
723 fn test_ground_impact_detection() {
724 let initial_state = [0.0, 100.0, 0.0, 300.0, -50.0, 0.0]; let mut params = create_test_params(10000.0); params.target_distance_m = 10000.0;
729
730 let trajectory =
731 integrate_trajectory(initial_state, (0.0, 20.0), params, "RK45", 1e-6, 0.01);
732
733 let (_, final_state) = trajectory.last().unwrap();
735
736 assert!(
738 final_state[1] <= -900.0,
739 "Should hit ground, but y={}",
740 final_state[1]
741 );
742 assert!(
743 final_state[0] < 10000.0,
744 "Should not reach target, but z={}",
745 final_state[0]
746 );
747 }
748
749 #[test]
750 fn test_target_distance_reached() {
751 let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; let target_distance = 300.0;
753
754 let params = create_test_params(target_distance);
755
756 let trajectory =
757 integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
758
759 let (_, final_state) = trajectory.last().unwrap();
760
761 assert!(
763 (final_state[0] - target_distance).abs() < 1.0,
764 "Should reach target at {}m, but stopped at {}m",
765 target_distance,
766 final_state[0]
767 );
768 }
769
770 #[test]
771 fn test_wind_affects_trajectory() {
772 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
777
778 let params_no_wind = create_test_params(target_distance);
780
781 let mut params_headwind = create_test_params(target_distance);
783 params_headwind.wind_segments = vec![(72.0, 0.0, 500.0)]; let trajectory_no_wind =
786 integrate_trajectory(initial_state, (0.0, 5.0), params_no_wind, "RK45", 1e-6, 0.01);
787 let trajectory_headwind =
788 integrate_trajectory(initial_state, (0.0, 5.0), params_headwind, "RK45", 1e-6, 0.01);
789
790 assert!(!trajectory_no_wind.is_empty(), "No-wind trajectory should complete");
792 assert!(!trajectory_headwind.is_empty(), "Headwind trajectory should complete");
793
794 let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
795 let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
796
797 let drop_no_wind = final_no_wind[1];
800 let drop_headwind = final_headwind[1];
801
802 println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
805 println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
806
807 assert!(
809 (final_no_wind[0] - target_distance).abs() < 10.0,
810 "No-wind should reach target"
811 );
812 assert!(
813 (final_headwind[0] - target_distance).abs() < 10.0,
814 "Headwind should reach target"
815 );
816 }
817
818 #[test]
819 fn test_solve_trajectory_rust_output_format() {
820 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let result = solve_trajectory_rust(
823 initial_state,
824 (0.0, 2.0),
825 0.01134, 0.442, DragModel::G7, vec![], (0.0, 59.0, 29.92, 0.0), None, false, false, false, "RK45".to_string(), 1e-6, 0.01, 500.0, );
839
840 assert!(!result.is_empty());
842
843 let first_point = &result[0];
844 assert!(first_point.contains_key("t"));
845 assert!(first_point.contains_key("x"));
846 assert!(first_point.contains_key("y"));
847 assert!(first_point.contains_key("z"));
848 assert!(first_point.contains_key("vx"));
849 assert!(first_point.contains_key("vy"));
850 assert!(first_point.contains_key("vz"));
851 }
852
853 #[test]
854 fn test_left_vs_right_twist() {
855 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
857
858 let mut params_right = create_test_params(target_distance);
859 params_right.is_twist_right = true;
860 params_right.enable_spin_drift = true;
861
862 let mut params_left = create_test_params(target_distance);
863 params_left.is_twist_right = false;
864 params_left.enable_spin_drift = true;
865
866 let trajectory_right =
867 integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
868 let trajectory_left =
869 integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
870
871 assert!(!trajectory_right.is_empty());
873 assert!(!trajectory_left.is_empty());
874
875 let (_, final_right) = trajectory_right.last().unwrap();
877 let (_, final_left) = trajectory_left.last().unwrap();
878
879 assert!((final_right[2] - final_left[2]).abs() < 10.0);
881 }
882}