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 wind_vector_for_range(range_m: f64, wind_segments: &[WindSegment]) -> Vector3<f64> {
18 if range_m.is_nan() {
19 return Vector3::zeros();
20 }
21 for seg in wind_segments {
22 if range_m < seg.2 {
23 let wind_speed_mps = seg.0 * 0.2777778; let wind_angle_rad = seg.1.to_radians();
25 return Vector3::new(
26 -wind_speed_mps * wind_angle_rad.cos(),
27 0.0,
28 -wind_speed_mps * wind_angle_rad.sin(),
29 );
30 }
31 }
32 Vector3::zeros()
33}
34
35fn rk4_step(
37 state: &Vector6<f64>,
38 t: f64,
39 dt: f64,
40 params: &TrajectoryParams,
41 inputs: &BallisticInputs,
42) -> Vector6<f64> {
43 let k1 = compute_derivatives_vec(state, t, params, inputs);
45 let k2 = compute_derivatives_vec(&(state + dt * 0.5 * k1), t + dt * 0.5, params, inputs);
46 let k3 = compute_derivatives_vec(&(state + dt * 0.5 * k2), t + dt * 0.5, params, inputs);
47 let k4 = compute_derivatives_vec(&(state + dt * k3), t + dt, params, inputs);
48
49 state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
50}
51
52fn rk45_step(
54 state: &Vector6<f64>,
55 t: f64,
56 dt: f64,
57 params: &TrajectoryParams,
58 inputs: &BallisticInputs,
59 tol: f64,
60) -> (Vector6<f64>, f64, f64) {
61 const A21: f64 = 1.0 / 5.0;
63 const A31: f64 = 3.0 / 40.0;
64 const A32: f64 = 9.0 / 40.0;
65 const A41: f64 = 44.0 / 45.0;
66 const A42: f64 = -56.0 / 15.0;
67 const A43: f64 = 32.0 / 9.0;
68 const A51: f64 = 19372.0 / 6561.0;
69 const A52: f64 = -25360.0 / 2187.0;
70 const A53: f64 = 64448.0 / 6561.0;
71 const A54: f64 = -212.0 / 729.0;
72 const A61: f64 = 9017.0 / 3168.0;
73 const A62: f64 = -355.0 / 33.0;
74 const A63: f64 = 46732.0 / 5247.0;
75 const A64: f64 = 49.0 / 176.0;
76 const A65: f64 = -5103.0 / 18656.0;
77 const A71: f64 = 35.0 / 384.0;
78 const A73: f64 = 500.0 / 1113.0;
79 const A74: f64 = 125.0 / 192.0;
80 const A75: f64 = -2187.0 / 6784.0;
81 const A76: f64 = 11.0 / 84.0;
82
83 const B1: f64 = 35.0 / 384.0;
85 const B3: f64 = 500.0 / 1113.0;
86 const B4: f64 = 125.0 / 192.0;
87 const B5: f64 = -2187.0 / 6784.0;
88 const B6: f64 = 11.0 / 84.0;
89
90 const B1_ERR: f64 = 5179.0 / 57600.0;
92 const B3_ERR: f64 = 7571.0 / 16695.0;
93 const B4_ERR: f64 = 393.0 / 640.0;
94 const B5_ERR: f64 = -92097.0 / 339200.0;
95 const B6_ERR: f64 = 187.0 / 2100.0;
96 const B7_ERR: f64 = 1.0 / 40.0;
97
98 let k1 = compute_derivatives_vec(state, t, params, inputs);
100 let k2 = compute_derivatives_vec(&(state + dt * A21 * k1), t + dt * 0.2, params, inputs);
101 let k3 = compute_derivatives_vec(
102 &(state + dt * (A31 * k1 + A32 * k2)),
103 t + dt * 0.3,
104 params,
105 inputs,
106 );
107 let k4 = compute_derivatives_vec(
108 &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
109 t + dt * 0.8,
110 params,
111 inputs,
112 );
113 let k5 = compute_derivatives_vec(
114 &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
115 t + dt * 8.0 / 9.0,
116 params,
117 inputs,
118 );
119 let k6 = compute_derivatives_vec(
120 &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
121 t + dt,
122 params,
123 inputs,
124 );
125 let k7 = compute_derivatives_vec(
126 &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
127 t + dt,
128 params,
129 inputs,
130 );
131
132 let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
134
135 let y_err = state
137 + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
138
139 let error = (y_new - y_err).norm() / (1.0 + state.norm());
141
142 let safety = 0.9;
144 let dt_new = if error < tol {
145 dt * safety * (tol / error).powf(0.2).min(2.0)
146 } else {
147 dt * safety * (tol / error).powf(0.25).max(0.1)
148 };
149
150 (y_new, dt_new, error)
151}
152
153pub struct TrajectoryParams {
155 pub mass_kg: f64,
156 pub bc: f64,
157 pub drag_model: DragModel,
158 pub wind_segments: Vec<WindSegment>,
159 pub atmos_params: (f64, f64, f64, f64),
165 pub omega_vector: Option<Vector3<f64>>,
166 pub enable_spin_drift: bool,
167 pub enable_magnus: bool,
168 pub enable_coriolis: bool,
169 pub target_distance_m: f64, pub enable_wind_shear: bool,
171 pub wind_shear_model: String,
172 pub shooter_altitude_m: f64,
173 pub is_twist_right: bool, pub shooting_angle: f64, 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,
186 pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
190}
191
192fn build_inputs(params: &TrajectoryParams) -> BallisticInputs {
198 let mut inputs = BallisticInputs {
199 bc_value: params.bc,
200 bc_type: params.drag_model,
201 bullet_mass: params.mass_kg, muzzle_velocity: 0.0, bullet_diameter: params.bullet_diameter, bullet_length: params.bullet_length,
205 twist_rate: params.twist_rate,
206 is_twist_right: params.is_twist_right,
207 enable_advanced_effects: params.enable_spin_drift
208 || params.enable_magnus
209 || params.enable_coriolis,
210 enable_magnus: params.enable_magnus,
211 enable_coriolis: params.enable_coriolis,
212 altitude: params.atmos_params.0,
213 temperature: params.atmos_params.1,
214 pressure: params.atmos_params.2,
215 humidity: params.atmos_params.3,
216 tipoff_yaw: 0.0,
217 target_distance: 1000.0, muzzle_angle: 0.0,
219 wind_speed: if !params.wind_segments.is_empty() {
220 params.wind_segments[0].0 * 0.2777778 } else {
222 0.0
223 },
224 wind_angle: if !params.wind_segments.is_empty() {
225 params.wind_segments[0].1.to_radians() } else {
227 0.0
228 },
229 latitude: None,
230 shooting_angle: params.shooting_angle,
231 azimuth_angle: 0.0,
232 shot_azimuth: 0.0, use_powder_sensitivity: false,
234 powder_temp_sensitivity: 0.0,
235 powder_temp: 59.0,
236 powder_temp_curve: None,
237 powder_curve_temp_c: None,
238 tipoff_decay_distance: 0.0,
239 ground_threshold: params.ground_threshold, bc_segments: params.bc_segments.clone(),
241 caliber_inches: params.bullet_diameter / 0.0254, weight_grains: params.mass_kg / 0.00006479891,
243 use_bc_segments: params.use_bc_segments,
244 bullet_id: None,
245 bc_segments_data: None,
246 use_enhanced_spin_drift: params.enable_spin_drift,
247 use_form_factor: false,
248 manufacturer: None,
249 bullet_model: None,
250 enable_wind_shear: false,
251 wind_shear_model: "none".to_string(),
252 use_cluster_bc: false,
253 bullet_cluster: None,
254 custom_drag_table: params.custom_drag_table.clone(),
255 bc_type_str: None,
256 enable_pitch_damping: false,
257 enable_precession_nutation: false,
258 enable_aerodynamic_jump: false,
266 use_rk4: true,
267 use_adaptive_rk45: false,
268 enable_trajectory_sampling: false,
269 sample_interval: 10.0,
270 sight_height: 0.0,
271 muzzle_height: 0.0,
272 target_height: 0.0,
273 };
274
275 if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
282 inputs.bc_segments_data =
283 crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
284 }
285 inputs
286}
287
288fn compute_derivatives_vec(
290 state: &Vector6<f64>,
291 t: f64,
292 params: &TrajectoryParams,
293 inputs: &BallisticInputs,
294) -> Vector6<f64> {
295 let pos = Vector3::new(state[0], state[1], state[2]);
296 let vel = Vector3::new(state[3], state[4], state[5]);
297
298 let wind_vector = if !params.wind_segments.is_empty() {
300 if params.enable_wind_shear && params.wind_shear_model != "none" {
301 crate::wind_shear::get_wind_at_position(
302 &pos,
303 ¶ms.wind_segments,
304 params.enable_wind_shear,
305 ¶ms.wind_shear_model,
306 params.shooter_altitude_m,
307 )
308 } else {
309 wind_vector_for_range(pos.x, ¶ms.wind_segments)
310 }
311 } else {
312 Vector3::zeros()
313 };
314
315 let deriv_result = compute_derivatives(
318 pos,
319 vel,
320 inputs,
321 wind_vector,
322 params.atmos_params,
323 params.bc,
324 params.omega_vector,
325 t,
326 params.atmo_sock.as_ref(),
327 );
328
329 Vector6::new(
330 deriv_result[0],
331 deriv_result[1],
332 deriv_result[2],
333 deriv_result[3],
334 deriv_result[4],
335 deriv_result[5],
336 )
337}
338
339pub fn integrate_trajectory(
341 initial_state: [f64; 6],
342 t_span: (f64, f64),
343 params: TrajectoryParams,
344 method: &str,
345 tolerance: f64,
346 max_step: f64,
347) -> Vec<(f64, Vector6<f64>)> {
348 let mut state = Vector6::new(
349 initial_state[0],
350 initial_state[1],
351 initial_state[2],
352 initial_state[3],
353 initial_state[4],
354 initial_state[5],
355 );
356
357 let mut t = t_span.0;
358 let t_end = t_span.1;
359 let mut dt = (t_end - t) / 1000.0; let mut trajectory = Vec::with_capacity(10000);
362 trajectory.push((t, state));
363
364 let inputs = build_inputs(¶ms);
367
368 match method {
369 "RK4" => {
370 dt = dt.min(max_step).min(0.001); while t < t_end {
374 if t + dt > t_end {
375 dt = t_end - t;
376 }
377
378 let new_state = rk4_step(&state, t, dt, ¶ms, &inputs);
379
380 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
382 let alpha = (params.target_distance_m - state[0]) / (new_state[0] - state[0]);
384 let dt_to_target = dt * alpha;
385
386 let final_state = rk4_step(&state, t, dt_to_target, ¶ms, &inputs);
388
389 let mut corrected_state = final_state;
391 if corrected_state[0] > params.target_distance_m {
392 corrected_state[0] = params.target_distance_m;
393 }
394
395 trajectory.push((t + dt_to_target, corrected_state));
396 break; }
398
399 state = new_state;
400 t += dt;
401 trajectory.push((t, state));
402
403 if state[0] >= params.target_distance_m {
405 let mut final_state = state;
408 final_state[0] = params.target_distance_m; trajectory.push((t, final_state));
410 break;
411 }
412
413 if state[1] < params.ground_threshold {
416 break;
417 }
418 }
419 }
420 "RK45" | _ => {
421 let mut last_save_x = 0.0; let save_interval_m = params.target_distance_m / 50.0; let effective_max_step =
428 if params.enable_wind_shear && params.wind_shear_model != "none" {
429 if params.target_distance_m > 800.0 {
431 0.01 } else {
433 0.02 }
435 } else {
436 max_step };
438
439 dt = dt.min(effective_max_step).max(0.0001); let max_iterations = 100000; let mut iteration_count = 0;
445
446 while t < t_end && iteration_count < max_iterations {
447 iteration_count += 1;
448
449 if t + dt > t_end {
451 dt = t_end - t;
452 }
453
454 let (new_state, dt_new, _error) =
455 rk45_step(&state, t, dt, ¶ms, &inputs, tolerance);
456
457 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
459 let alpha = (params.target_distance_m - state[0]) / (new_state[0] - state[0]);
461 let dt_to_target = dt * alpha;
462
463 let (final_state, _, _) =
465 rk45_step(&state, t, dt_to_target, ¶ms, &inputs, tolerance);
466
467 let mut corrected_state = final_state;
469 if corrected_state[0] > params.target_distance_m {
470 corrected_state[0] = params.target_distance_m;
471 }
472
473 trajectory.push((t + dt_to_target, corrected_state));
474 break; }
476
477 state = new_state;
479 t += dt;
480
481 if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
483 {
484 trajectory.push((t, state));
486 last_save_x = state[0];
487 }
488
489 dt = dt_new.min(effective_max_step).max(0.0001); if state[0] >= params.target_distance_m {
494 let mut final_state = state;
497 final_state[0] = params.target_distance_m; trajectory.push((t, final_state));
499 break;
500 }
501
502 if state[1] < params.ground_threshold {
505 break;
506 }
507 }
508
509 if iteration_count >= max_iterations {
511 eprintln!(
512 "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
513 max_iterations
514 );
515 eprintln!(" Final time: {}, Target time: {}", t, t_end);
516 eprintln!(
517 " Final position: downrange(x)={}, Target: {}m",
518 state[0], params.target_distance_m
519 );
520 }
521 }
522 }
523
524 trajectory
525}
526
527pub fn solve_trajectory_rust(
529 initial_state: [f64; 6],
530 t_span: (f64, f64),
531 mass_kg: f64,
532 bc: f64,
533 drag_model: DragModel,
534 wind_segments: Vec<WindSegment>,
535 atmos_params: (f64, f64, f64, f64),
536 omega_vector: Option<Vec<f64>>,
537 enable_spin_drift: bool,
538 enable_magnus: bool,
539 enable_coriolis: bool,
540 method: String,
541 tolerance: f64,
542 max_step: f64,
543 target_distance_m: f64,
544) -> Vec<HashMap<String, f64>> {
545 let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
546
547 let params = TrajectoryParams {
548 mass_kg,
549 bc,
550 drag_model,
551 wind_segments,
552 atmos_params,
553 omega_vector: omega_vec,
554 enable_spin_drift,
555 enable_magnus,
556 enable_coriolis,
557 target_distance_m,
558 enable_wind_shear: false, wind_shear_model: "none".to_string(),
560 shooter_altitude_m: 0.0,
561 is_twist_right: true, shooting_angle: 0.0, bullet_diameter: 0.0078232,
566 bullet_length: 0.031496,
567 twist_rate: 10.0,
568 custom_drag_table: None, bc_segments: None, use_bc_segments: false,
571 ground_threshold: -1000.0, atmo_sock: None, };
574
575 let trajectory =
576 integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
577
578 trajectory
580 .into_iter()
581 .map(|(t, state)| {
582 let mut point = HashMap::new();
583 point.insert("t".to_string(), t);
584 point.insert("x".to_string(), state[0]);
585 point.insert("y".to_string(), state[1]);
586 point.insert("z".to_string(), state[2]);
587 point.insert("vx".to_string(), state[3]);
588 point.insert("vy".to_string(), state[4]);
589 point.insert("vz".to_string(), state[5]);
590 point
591 })
592 .collect()
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
600 TrajectoryParams {
601 mass_kg: 0.01134, bc: 0.442,
603 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
606 drag_model: DragModel::G7,
607 wind_segments: vec![],
608 atmos_params: (0.0, 59.0, 29.92, 0.0),
609 omega_vector: None,
610 enable_spin_drift: false,
611 enable_magnus: false,
612 enable_coriolis: false,
613 target_distance_m,
614 enable_wind_shear: false,
615 wind_shear_model: "none".to_string(),
616 shooter_altitude_m: 0.0,
617 is_twist_right: true,
618 shooting_angle: 0.0,
619 custom_drag_table: None,
620 bc_segments: None,
621 use_bc_segments: false,
622 ground_threshold: -1000.0,
623 atmo_sock: None,
624 }
625 }
626
627 #[test]
628 fn test_mba954_ground_threshold_honored() {
629 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);
637 deep.ground_threshold = -1000.0; let t_shallow =
640 integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
641 let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
642
643 assert!(
644 t_shallow.len() < t_deep.len(),
645 "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
646 shallow={}, deep={}",
647 t_shallow.len(),
648 t_deep.len()
649 );
650 }
651
652 #[test]
653 fn test_integrate_trajectory_basic() {
654 let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
657
658 let params = TrajectoryParams {
659 mass_kg: 0.01134, bc: 0.442,
661 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
664 drag_model: DragModel::G7,
665 wind_segments: vec![(0.0, 90.0, 914.4)],
666 atmos_params: (0.0, 59.0, 29.92, 0.0),
667 omega_vector: None,
668 enable_spin_drift: false,
669 enable_magnus: false,
670 enable_coriolis: false,
671 target_distance_m: 914.4, enable_wind_shear: false,
673 wind_shear_model: "none".to_string(),
674 shooter_altitude_m: 0.0,
675 is_twist_right: true,
676 shooting_angle: 0.0,
677 custom_drag_table: None,
678 bc_segments: None,
679 use_bc_segments: false,
680 ground_threshold: -1000.0,
681 atmo_sock: None,
682 };
683
684 println!("Running integrate_trajectory test...");
685 println!("Initial state: {:?}", initial_state);
686 println!("Target distance: {} m", params.target_distance_m);
687
688 let trajectory =
689 integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
690
691 println!("Trajectory has {} points", trajectory.len());
692
693 assert!(
695 trajectory.len() > 1,
696 "Trajectory should have more than 1 point, but has {}",
697 trajectory.len()
698 );
699
700 if let Some((_, final_state)) = trajectory.last() {
702 println!("Final state: downrange(x)={}", final_state[0]);
703 assert!(
704 final_state[0] > 0.0,
705 "Final x should be positive (bullet moved downrange)"
706 );
707 assert!(
708 final_state[0] >= 900.0,
709 "Final x should be near target distance"
710 );
711 }
712 }
713
714 #[test]
715 fn test_rk4_vs_rk45_consistency() {
716 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
719
720 let params_rk4 = create_test_params(target_distance);
721 let params_rk45 = create_test_params(target_distance);
722
723 let trajectory_rk4 =
724 integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
725 let trajectory_rk45 =
726 integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
727
728 assert!(!trajectory_rk4.is_empty());
730 assert!(!trajectory_rk45.is_empty());
731
732 let (_, final_rk4) = trajectory_rk4.last().unwrap();
733 let (_, final_rk45) = trajectory_rk45.last().unwrap();
734
735 let rk4_z = final_rk4[0];
737 let rk45_z = final_rk45[0];
738 let diff_percent = ((rk4_z - rk45_z) / rk45_z).abs() * 100.0;
739
740 assert!(
741 diff_percent < 1.0,
742 "RK4 and RK45 final positions differ by {}%: RK4={}, RK45={}",
743 diff_percent,
744 rk4_z,
745 rk45_z
746 );
747 }
748
749 #[test]
750 fn test_ground_impact_detection() {
751 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;
756
757 let trajectory =
758 integrate_trajectory(initial_state, (0.0, 20.0), params, "RK45", 1e-6, 0.01);
759
760 let (_, final_state) = trajectory.last().unwrap();
762
763 assert!(
765 final_state[1] <= -900.0,
766 "Should hit ground, but y={}",
767 final_state[1]
768 );
769 assert!(
770 final_state[0] < 10000.0,
771 "Should not reach target, but z={}",
772 final_state[0]
773 );
774 }
775
776 #[test]
777 fn test_target_distance_reached() {
778 let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; let target_distance = 300.0;
780
781 let params = create_test_params(target_distance);
782
783 let trajectory =
784 integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
785
786 let (_, final_state) = trajectory.last().unwrap();
787
788 assert!(
790 (final_state[0] - target_distance).abs() < 1.0,
791 "Should reach target at {}m, but stopped at {}m",
792 target_distance,
793 final_state[0]
794 );
795 }
796
797 #[test]
798 fn test_wind_affects_trajectory() {
799 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
804
805 let params_no_wind = create_test_params(target_distance);
807
808 let mut params_headwind = create_test_params(target_distance);
810 params_headwind.wind_segments = vec![(72.0, 0.0, 500.0)]; let trajectory_no_wind = integrate_trajectory(
813 initial_state,
814 (0.0, 5.0),
815 params_no_wind,
816 "RK45",
817 1e-6,
818 0.01,
819 );
820 let trajectory_headwind = integrate_trajectory(
821 initial_state,
822 (0.0, 5.0),
823 params_headwind,
824 "RK45",
825 1e-6,
826 0.01,
827 );
828
829 assert!(
831 !trajectory_no_wind.is_empty(),
832 "No-wind trajectory should complete"
833 );
834 assert!(
835 !trajectory_headwind.is_empty(),
836 "Headwind trajectory should complete"
837 );
838
839 let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
840 let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
841
842 let drop_no_wind = final_no_wind[1];
845 let drop_headwind = final_headwind[1];
846
847 println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
850 println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
851
852 assert!(
854 (final_no_wind[0] - target_distance).abs() < 10.0,
855 "No-wind should reach target"
856 );
857 assert!(
858 (final_headwind[0] - target_distance).abs() < 10.0,
859 "Headwind should reach target"
860 );
861 }
862
863 #[test]
864 fn test_solve_trajectory_rust_output_format() {
865 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let result = solve_trajectory_rust(
868 initial_state,
869 (0.0, 2.0),
870 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, );
884
885 assert!(!result.is_empty());
887
888 let first_point = &result[0];
889 assert!(first_point.contains_key("t"));
890 assert!(first_point.contains_key("x"));
891 assert!(first_point.contains_key("y"));
892 assert!(first_point.contains_key("z"));
893 assert!(first_point.contains_key("vx"));
894 assert!(first_point.contains_key("vy"));
895 assert!(first_point.contains_key("vz"));
896 }
897
898 #[test]
899 fn test_left_vs_right_twist() {
900 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
902
903 let mut params_right = create_test_params(target_distance);
904 params_right.is_twist_right = true;
905 params_right.enable_spin_drift = true;
906
907 let mut params_left = create_test_params(target_distance);
908 params_left.is_twist_right = false;
909 params_left.enable_spin_drift = true;
910
911 let trajectory_right =
912 integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
913 let trajectory_left =
914 integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
915
916 assert!(!trajectory_right.is_empty());
918 assert!(!trajectory_left.is_empty());
919
920 let (_, final_right) = trajectory_right.last().unwrap();
922 let (_, final_left) = trajectory_left.last().unwrap();
923
924 assert!((final_right[2] - final_left[2]).abs() < 10.0);
926 }
927}