1use nalgebra::{Vector3, Vector6};
10use std::collections::HashMap;
11
12use crate::derivatives::compute_derivatives;
13use crate::wind::{WindSegment, WindSegmentError};
14use crate::BallisticInputs;
15use crate::DragModel;
16
17const RK45_MIN_STEP: f64 = 1e-6;
18const RK45_DEFAULT_TOLERANCE: f64 = 1e-6;
19const RK45_SAFETY_FACTOR: f64 = 0.9;
20const RK45_MIN_SCALE: f64 = 0.1;
21const RK45_MAX_SCALE: f64 = 2.0;
22
23#[derive(Clone, Copy)]
24struct Rk45Control {
25 tolerance: f64,
26 min_step: f64,
27 max_step: f64,
28 max_trials: usize,
29}
30
31struct Rk45AcceptedStep {
32 state: Vector6<f64>,
33 used_dt: f64,
34 next_dt: f64,
35 error: f64,
36 trials: usize,
37}
38
39fn wind_vector_for_range(range_m: f64, wind_segments: &[WindSegment]) -> Vector3<f64> {
40 if range_m.is_nan() {
41 return Vector3::zeros();
42 }
43 for seg in wind_segments {
44 if range_m < seg.until_m {
45 let wind_speed_mps = seg.speed_kmh * 0.2777778; let wind_angle_rad = seg.angle_deg.to_radians();
47 return crate::wind::wind_vector(wind_speed_mps, wind_angle_rad, seg.vertical_mps);
50 }
51 }
52 Vector3::zeros()
53}
54
55fn rk4_step(
57 state: &Vector6<f64>,
58 t: f64,
59 dt: f64,
60 params: &TrajectoryParams,
61 inputs: &BallisticInputs,
62) -> Vector6<f64> {
63 let k1 = compute_derivatives_vec(state, t, params, inputs);
65 let k2 = compute_derivatives_vec(&(state + dt * 0.5 * k1), t + dt * 0.5, params, inputs);
66 let k3 = compute_derivatives_vec(&(state + dt * 0.5 * k2), t + dt * 0.5, params, inputs);
67 let k4 = compute_derivatives_vec(&(state + dt * k3), t + dt, params, inputs);
68
69 state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
70}
71
72pub(crate) fn rk45_error_norm(
78 state: &Vector6<f64>,
79 fifth_order: &Vector6<f64>,
80 fourth_order: &Vector6<f64>,
81) -> f64 {
82 let scaled_error_squared: f64 = (0..6)
83 .map(|index| {
84 let scale = 1.0 + state[index].abs().max(fifth_order[index].abs());
85 ((fifth_order[index] - fourth_order[index]) / scale).powi(2)
86 })
87 .sum();
88
89 (scaled_error_squared / 6.0).sqrt()
90}
91
92fn rk45_step(
94 state: &Vector6<f64>,
95 t: f64,
96 dt: f64,
97 params: &TrajectoryParams,
98 inputs: &BallisticInputs,
99 tol: f64,
100) -> (Vector6<f64>, f64, f64) {
101 const A21: f64 = 1.0 / 5.0;
103 const A31: f64 = 3.0 / 40.0;
104 const A32: f64 = 9.0 / 40.0;
105 const A41: f64 = 44.0 / 45.0;
106 const A42: f64 = -56.0 / 15.0;
107 const A43: f64 = 32.0 / 9.0;
108 const A51: f64 = 19372.0 / 6561.0;
109 const A52: f64 = -25360.0 / 2187.0;
110 const A53: f64 = 64448.0 / 6561.0;
111 const A54: f64 = -212.0 / 729.0;
112 const A61: f64 = 9017.0 / 3168.0;
113 const A62: f64 = -355.0 / 33.0;
114 const A63: f64 = 46732.0 / 5247.0;
115 const A64: f64 = 49.0 / 176.0;
116 const A65: f64 = -5103.0 / 18656.0;
117 const A71: f64 = 35.0 / 384.0;
118 const A73: f64 = 500.0 / 1113.0;
119 const A74: f64 = 125.0 / 192.0;
120 const A75: f64 = -2187.0 / 6784.0;
121 const A76: f64 = 11.0 / 84.0;
122
123 const B1: f64 = 35.0 / 384.0;
125 const B3: f64 = 500.0 / 1113.0;
126 const B4: f64 = 125.0 / 192.0;
127 const B5: f64 = -2187.0 / 6784.0;
128 const B6: f64 = 11.0 / 84.0;
129
130 const B1_ERR: f64 = 5179.0 / 57600.0;
132 const B3_ERR: f64 = 7571.0 / 16695.0;
133 const B4_ERR: f64 = 393.0 / 640.0;
134 const B5_ERR: f64 = -92097.0 / 339200.0;
135 const B6_ERR: f64 = 187.0 / 2100.0;
136 const B7_ERR: f64 = 1.0 / 40.0;
137
138 let k1 = compute_derivatives_vec(state, t, params, inputs);
140 let k2 = compute_derivatives_vec(&(state + dt * A21 * k1), t + dt * 0.2, params, inputs);
141 let k3 = compute_derivatives_vec(
142 &(state + dt * (A31 * k1 + A32 * k2)),
143 t + dt * 0.3,
144 params,
145 inputs,
146 );
147 let k4 = compute_derivatives_vec(
148 &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
149 t + dt * 0.8,
150 params,
151 inputs,
152 );
153 let k5 = compute_derivatives_vec(
154 &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
155 t + dt * 8.0 / 9.0,
156 params,
157 inputs,
158 );
159 let k6 = compute_derivatives_vec(
160 &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
161 t + dt,
162 params,
163 inputs,
164 );
165 let k7 = compute_derivatives_vec(
166 &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
167 t + dt,
168 params,
169 inputs,
170 );
171
172 let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
174
175 let y_err = state
177 + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
178
179 let error = rk45_error_norm(state, &y_new, &y_err);
180
181 let step_scale = if !error.is_finite() || !tol.is_finite() || tol <= 0.0 {
183 RK45_MIN_SCALE
184 } else if error == 0.0 {
185 RK45_MAX_SCALE
186 } else {
187 (RK45_SAFETY_FACTOR * (tol / error).powf(0.2)).clamp(RK45_MIN_SCALE, RK45_MAX_SCALE)
188 };
189 let dt_new = dt * step_scale;
190
191 (y_new, dt_new, error)
192}
193
194fn adaptive_rk45_step(
199 state: &Vector6<f64>,
200 t: f64,
201 initial_dt: f64,
202 params: &TrajectoryParams,
203 inputs: &BallisticInputs,
204 control: Rk45Control,
205) -> Result<Rk45AcceptedStep, usize> {
206 let mut trial_dt = initial_dt;
207
208 for trials in 1..=control.max_trials {
209 let (new_state, suggested_dt, error) =
210 rk45_step(state, t, trial_dt, params, inputs, control.tolerance);
211 let candidate_is_finite = error.is_finite()
212 && suggested_dt.is_finite()
213 && new_state.iter().all(|value| value.is_finite());
214 let next_dt = suggested_dt.min(control.max_step).max(control.min_step);
215
216 if candidate_is_finite && (error <= control.tolerance || trial_dt <= control.min_step) {
217 return Ok(Rk45AcceptedStep {
218 state: new_state,
219 used_dt: trial_dt,
220 next_dt,
221 error,
222 trials,
223 });
224 }
225
226 if trial_dt <= control.min_step {
227 return Err(trials);
228 }
229 trial_dt = next_dt;
230 }
231
232 Err(control.max_trials)
233}
234
235pub struct TrajectoryParams {
237 pub mass_kg: f64,
238 pub bc: f64,
239 pub drag_model: DragModel,
240 pub wind_segments: Vec<WindSegment>,
242 pub atmos_params: (f64, f64, f64, f64),
248 pub omega_vector: Option<Vector3<f64>>,
251 pub enable_spin_drift: bool,
252 pub enable_magnus: bool,
253 pub enable_coriolis: bool,
254 pub target_distance_m: f64, pub enable_wind_shear: bool,
256 pub wind_shear_model: String,
257 pub shooter_altitude_m: f64,
258 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 cd_scale: f64,
269 pub custom_drag_reference: bool,
277 pub bc_segments: Option<Vec<(f64, f64)>>, pub use_bc_segments: bool, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>,
285 pub ground_threshold: f64,
288 pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
292}
293
294fn build_inputs(params: &TrajectoryParams, muzzle_velocity_mps: f64) -> BallisticInputs {
300 let mut inputs = BallisticInputs {
301 bc_value: params.bc,
302 bc_type: params.drag_model,
303 bc_reference_standard: crate::cli_api::BcReferenceStandard::Icao,
311 bullet_mass: params.mass_kg, muzzle_velocity: muzzle_velocity_mps,
313 bullet_diameter: params.bullet_diameter, bullet_length: params.bullet_length,
315 twist_rate: params.twist_rate,
316 is_twist_right: params.is_twist_right,
317 enable_advanced_effects: params.enable_spin_drift
318 || params.enable_magnus
319 || params.enable_coriolis,
320 enable_magnus: params.enable_magnus,
321 enable_coriolis: params.enable_coriolis,
322 altitude: params.atmos_params.0,
323 temperature: params.atmos_params.1,
324 pressure: params.atmos_params.2,
325 humidity: params.atmos_params.3,
326 tipoff_yaw: 0.0,
327 cd_delta2: 7.5,
328 target_distance: 1000.0, muzzle_angle: 0.0,
330 wind_speed: if !params.wind_segments.is_empty() {
331 params.wind_segments[0].speed_kmh * 0.2777778 } else {
333 0.0
334 },
335 wind_angle: if !params.wind_segments.is_empty() {
336 params.wind_segments[0].angle_deg.to_radians() } else {
338 0.0
339 },
340 latitude: None,
341 shooting_angle: params.shooting_angle,
342 cant_angle: 0.0,
343 azimuth_angle: 0.0,
344 shot_azimuth: 0.0, use_powder_sensitivity: false,
346 powder_temp_sensitivity: 0.0,
347 powder_temp: 59.0,
348 powder_temp_curve: None,
349 powder_curve_temp_c: None,
350 tipoff_decay_distance: 0.0,
351 ground_threshold: params.ground_threshold, bc_segments: params.bc_segments.clone(),
353 caliber_inches: params.bullet_diameter / 0.0254, weight_grains: params.mass_kg / crate::constants::GRAINS_TO_KG,
355 use_bc_segments: params.use_bc_segments,
356 bullet_id: None,
357 bc_segments_data: params
360 .bc_segments_data
361 .clone()
362 .filter(|segments| !segments.is_empty()),
363 use_enhanced_spin_drift: params.enable_spin_drift,
364 use_form_factor: false,
365 manufacturer: None,
366 bullet_model: None,
367 enable_wind_shear: false,
368 wind_shear_model: "none".to_string(),
369 use_cluster_bc: false,
370 bullet_cluster: None,
371 custom_drag_table: params.custom_drag_table.clone(),
372 cd_scale: params.cd_scale,
373 custom_drag_reference: params.custom_drag_reference,
374 bc_type_str: None,
375 enable_pitch_damping: false,
376 enable_precession_nutation: false,
377 enable_aerodynamic_jump: false,
383 use_rk4: true,
384 use_adaptive_rk45: false,
385 enable_trajectory_sampling: false,
386 sample_interval: 10.0,
387 sight_height: 0.0,
388 sight_offset_lateral_m: 0.0,
391 muzzle_height: 0.0,
392 target_height: 0.0,
393 zero_poi_vertical_m: 0.0,
397 zero_poi_horizontal_m: 0.0,
398 drops_reference: crate::cli_api::DropsReference::Los,
402 };
403
404 if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
411 inputs.bc_segments_data =
412 crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
413 }
414 inputs
415}
416
417fn compute_derivatives_vec(
419 state: &Vector6<f64>,
420 t: f64,
421 params: &TrajectoryParams,
422 inputs: &BallisticInputs,
423) -> Vector6<f64> {
424 let pos = Vector3::new(state[0], state[1], state[2]);
425 let vel = Vector3::new(state[3], state[4], state[5]);
426
427 let wind_vector = if !params.wind_segments.is_empty() {
429 if params.enable_wind_shear && params.wind_shear_model != "none" {
430 crate::wind_shear::get_wind_at_position(
431 &pos,
432 ¶ms.wind_segments,
433 params.enable_wind_shear,
434 ¶ms.wind_shear_model,
435 params.shooter_altitude_m,
436 )
437 } else {
438 wind_vector_for_range(pos.x, ¶ms.wind_segments)
439 }
440 } else {
441 Vector3::zeros()
442 };
443
444 let deriv_result = compute_derivatives(
447 pos,
448 vel,
449 inputs,
450 wind_vector,
451 params.atmos_params,
452 params.bc,
453 params.omega_vector,
454 t,
455 params.atmo_sock.as_ref(),
456 );
457
458 Vector6::new(
459 deriv_result[0],
460 deriv_result[1],
461 deriv_result[2],
462 deriv_result[3],
463 deriv_result[4],
464 deriv_result[5],
465 )
466}
467
468fn interpolate_target_crossing(
474 start_time: f64,
475 start: &Vector6<f64>,
476 step_dt: f64,
477 end: &Vector6<f64>,
478 target_x: f64,
479) -> (f64, Vector6<f64>) {
480 debug_assert!(start[0] <= target_x && target_x <= end[0] && end[0] > start[0]);
481
482 let alpha = (target_x - start[0]) / (end[0] - start[0]);
483 let crossing_time = start_time + alpha * step_dt;
484 let mut crossing_state = start + alpha * (end - start);
485 crossing_state[0] = target_x;
486
487 (crossing_time, crossing_state)
488}
489
490pub fn try_integrate_trajectory(
496 initial_state: [f64; 6],
497 t_span: (f64, f64),
498 params: TrajectoryParams,
499 method: &str,
500 tolerance: f64,
501 max_step: f64,
502) -> Result<Vec<(f64, Vector6<f64>)>, WindSegmentError> {
503 crate::wind::validate_wind_segments(¶ms.wind_segments)?;
504 Ok(integrate_trajectory(
505 initial_state,
506 t_span,
507 params,
508 method,
509 tolerance,
510 max_step,
511 ))
512}
513
514pub fn integrate_trajectory(
521 initial_state: [f64; 6],
522 t_span: (f64, f64),
523 mut params: TrajectoryParams,
524 method: &str,
525 tolerance: f64,
526 max_step: f64,
527) -> Vec<(f64, Vector6<f64>)> {
528 crate::wind::sort_wind_segments_by_distance(&mut params.wind_segments);
531
532 let mut state = Vector6::new(
533 initial_state[0],
534 initial_state[1],
535 initial_state[2],
536 initial_state[3],
537 initial_state[4],
538 initial_state[5],
539 );
540
541 let mut t = t_span.0;
542 let t_end = t_span.1;
543 let mut dt = (t_end - t) / 1000.0; let mut trajectory = Vec::with_capacity(10000);
546 trajectory.push((t, state));
547 if state[0] >= params.target_distance_m {
548 return trajectory;
549 }
550
551 let muzzle_velocity_mps =
554 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
555 let inputs = build_inputs(¶ms, muzzle_velocity_mps);
556
557 match method {
558 "RK4" => {
559 dt = dt.min(max_step).min(0.001); while t < t_end {
563 if t + dt > t_end {
564 dt = t_end - t;
565 }
566
567 let new_state = rk4_step(&state, t, dt, ¶ms, &inputs);
568
569 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
571 trajectory.push(interpolate_target_crossing(
572 t,
573 &state,
574 dt,
575 &new_state,
576 params.target_distance_m,
577 ));
578 break; }
580
581 state = new_state;
582 t += dt;
583 trajectory.push((t, state));
584
585 if state[0] >= params.target_distance_m {
587 break;
588 }
589
590 if state[1] < params.ground_threshold {
593 break;
594 }
595 }
596 }
597 _ => {
598 let mut last_save_x = 0.0; let save_interval_m = params.target_distance_m / 50.0; let tolerance = if tolerance.is_finite() && tolerance > 0.0 {
602 tolerance
603 } else {
604 eprintln!(
605 "WARNING: RK45 tolerance must be finite and positive; using {RK45_DEFAULT_TOLERANCE}"
606 );
607 RK45_DEFAULT_TOLERANCE
608 };
609
610 let effective_max_step =
613 if params.enable_wind_shear && params.wind_shear_model != "none" {
614 if params.target_distance_m > 800.0 {
616 0.01 } else {
618 0.02 }
620 } else {
621 max_step };
623 if !effective_max_step.is_finite() || effective_max_step <= 0.0 {
624 eprintln!("WARNING: RK45 max_step must be finite and positive");
625 return trajectory;
626 }
627 let min_step = RK45_MIN_STEP.min(effective_max_step);
628
629 dt = dt.min(effective_max_step).max(min_step);
631
632 let max_iterations = 100000; let mut iteration_count = 0;
635
636 while t < t_end && iteration_count < max_iterations {
637 if t + dt > t_end {
639 dt = t_end - t;
640 }
641
642 let control = Rk45Control {
643 tolerance,
644 min_step,
645 max_step: effective_max_step,
646 max_trials: max_iterations - iteration_count,
647 };
648 let accepted = match adaptive_rk45_step(&state, t, dt, ¶ms, &inputs, control) {
649 Ok(accepted) => accepted,
650 Err(trials) => {
651 iteration_count += trials;
652 if iteration_count < max_iterations {
653 eprintln!("WARNING: RK45 minimum-step trial was non-finite");
654 }
655 break;
656 }
657 };
658 iteration_count += accepted.trials;
659 debug_assert!(accepted.error <= tolerance || accepted.used_dt <= min_step);
660
661 if state[0] < params.target_distance_m
663 && accepted.state[0] >= params.target_distance_m
664 {
665 trajectory.push(interpolate_target_crossing(
666 t,
667 &state,
668 accepted.used_dt,
669 &accepted.state,
670 params.target_distance_m,
671 ));
672 break;
673 }
674
675 state = accepted.state;
677 t += accepted.used_dt;
678
679 if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
681 {
682 trajectory.push((t, state));
684 last_save_x = state[0];
685 }
686
687 dt = accepted.next_dt;
689
690 if state[0] >= params.target_distance_m {
692 break;
693 }
694
695 if state[1] < params.ground_threshold {
698 break;
699 }
700 }
701
702 if iteration_count >= max_iterations
704 && t < t_end
705 && state[0] < params.target_distance_m
706 && state[1] >= params.ground_threshold
707 {
708 eprintln!(
709 "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
710 max_iterations
711 );
712 eprintln!(" Final time: {}, Target time: {}", t, t_end);
713 eprintln!(
714 " Final position: downrange(x)={}, Target: {}m",
715 state[0], params.target_distance_m
716 );
717 }
718 }
719 }
720
721 trajectory
722}
723
724#[allow(clippy::too_many_arguments)] pub fn try_solve_trajectory_rust(
730 initial_state: [f64; 6],
731 t_span: (f64, f64),
732 mass_kg: f64,
733 bc: f64,
734 drag_model: DragModel,
735 wind_segments: Vec<WindSegment>,
736 atmos_params: (f64, f64, f64, f64),
737 omega_vector: Option<Vec<f64>>,
738 enable_spin_drift: bool,
739 enable_magnus: bool,
740 enable_coriolis: bool,
741 method: String,
742 tolerance: f64,
743 max_step: f64,
744 target_distance_m: f64,
745) -> Result<Vec<HashMap<String, f64>>, WindSegmentError> {
746 crate::wind::validate_wind_segments(&wind_segments)?;
747 Ok(solve_trajectory_rust(
748 initial_state,
749 t_span,
750 mass_kg,
751 bc,
752 drag_model,
753 wind_segments,
754 atmos_params,
755 omega_vector,
756 enable_spin_drift,
757 enable_magnus,
758 enable_coriolis,
759 method,
760 tolerance,
761 max_step,
762 target_distance_m,
763 ))
764}
765
766#[allow(clippy::too_many_arguments)] pub fn solve_trajectory_rust(
772 initial_state: [f64; 6],
773 t_span: (f64, f64),
774 mass_kg: f64,
775 bc: f64,
776 drag_model: DragModel,
777 wind_segments: Vec<WindSegment>,
778 atmos_params: (f64, f64, f64, f64),
779 omega_vector: Option<Vec<f64>>,
780 enable_spin_drift: bool,
781 enable_magnus: bool,
782 enable_coriolis: bool,
783 method: String,
784 tolerance: f64,
785 max_step: f64,
786 target_distance_m: f64,
787) -> Vec<HashMap<String, f64>> {
788 let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
789
790 let params = TrajectoryParams {
791 mass_kg,
792 bc,
793 drag_model,
794 wind_segments,
795 atmos_params,
796 omega_vector: omega_vec,
797 enable_spin_drift,
798 enable_magnus,
799 enable_coriolis,
800 target_distance_m,
801 enable_wind_shear: false, wind_shear_model: "none".to_string(),
803 shooter_altitude_m: 0.0,
804 is_twist_right: true, shooting_angle: 0.0, bullet_diameter: 0.0078232,
809 bullet_length: 0.031496,
810 twist_rate: 10.0,
811 custom_drag_table: None, cd_scale: 1.0,
813 custom_drag_reference: false,
814 bc_segments: None, use_bc_segments: false,
816 bc_segments_data: None,
817 ground_threshold: -1000.0, atmo_sock: None, };
820
821 let trajectory =
822 integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
823
824 trajectory
826 .into_iter()
827 .map(|(t, state)| {
828 let mut point = HashMap::new();
829 point.insert("t".to_string(), t);
830 point.insert("x".to_string(), state[0]);
831 point.insert("y".to_string(), state[1]);
832 point.insert("z".to_string(), state[2]);
833 point.insert("vx".to_string(), state[3]);
834 point.insert("vy".to_string(), state[4]);
835 point.insert("vz".to_string(), state[5]);
836 point
837 })
838 .collect()
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844
845 fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
846 TrajectoryParams {
847 mass_kg: 0.01134, bc: 0.442,
849 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
852 drag_model: DragModel::G7,
853 wind_segments: vec![],
854 atmos_params: (0.0, 15.0, 1013.25, 1.0),
855 omega_vector: None,
856 enable_spin_drift: false,
857 enable_magnus: false,
858 enable_coriolis: false,
859 target_distance_m,
860 enable_wind_shear: false,
861 wind_shear_model: "none".to_string(),
862 shooter_altitude_m: 0.0,
863 is_twist_right: true,
864 shooting_angle: 0.0,
865 custom_drag_table: None,
866 cd_scale: 1.0,
867 custom_drag_reference: false,
868 bc_segments: None,
869 use_bc_segments: false,
870 bc_segments_data: None,
871 ground_threshold: -1000.0,
872 atmo_sock: None,
873 }
874 }
875
876 #[test]
879 fn build_inputs_carries_the_custom_table_fields() {
880 for (cd_scale, reference) in [(1.0, false), (1.25, false), (1.0, true), (0.8, true)] {
881 let params = TrajectoryParams {
882 custom_drag_table: Some(crate::drag::DragTable::new(
883 vec![0.5, 3.0],
884 vec![0.2, 0.3],
885 )),
886 cd_scale,
887 custom_drag_reference: reference,
888 ..create_test_params(300.0)
889 };
890 let inputs = build_inputs(¶ms, 800.0);
891 assert!(inputs.custom_drag_table.is_some());
892 assert_eq!(inputs.cd_scale, cd_scale);
893 assert_eq!(inputs.custom_drag_reference, reference);
894 }
895 }
896
897 #[test]
898 fn try_integrate_trajectory_rejects_malformed_segments_before_any_points() {
899 let mut params = create_test_params(300.0);
902 params.wind_segments = vec![
903 WindSegment::new(10.0, 90.0, 200.0),
904 WindSegment::new(10.0, 90.0, f64::NAN),
905 ];
906 let err = try_integrate_trajectory(
907 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
908 (0.0, 2.0),
909 params,
910 "RK4",
911 1e-6,
912 0.001,
913 )
914 .unwrap_err();
915 assert_eq!(err.index, 1);
916 assert_eq!(err.field, crate::wind::WindSegmentField::UntilM);
917 assert_eq!(
918 err.to_string(),
919 "wind.segments[1].until_m must be finite and greater than zero"
920 );
921 }
922
923 #[test]
924 fn try_integrate_trajectory_matches_unchecked_on_valid_input() {
925 let mk = || {
926 let mut params = create_test_params(300.0);
927 params.wind_segments = vec![WindSegment::new(16.0934, 90.0, 500.0)];
928 params
929 };
930 let checked = try_integrate_trajectory(
931 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
932 (0.0, 2.0),
933 mk(),
934 "RK4",
935 1e-6,
936 0.001,
937 )
938 .expect("valid segments must integrate");
939 let unchecked = integrate_trajectory(
940 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
941 (0.0, 2.0),
942 mk(),
943 "RK4",
944 1e-6,
945 0.001,
946 );
947 assert_eq!(checked.len(), unchecked.len());
948 assert_eq!(checked.last().unwrap().1, unchecked.last().unwrap().1);
949 }
950
951 #[test]
952 fn try_solve_trajectory_rust_rejects_malformed_segments() {
953 let bad = vec![WindSegment::new(-5.0, 0.0, 100.0)];
954 let err = try_solve_trajectory_rust(
955 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
956 (0.0, 2.0),
957 0.01134,
958 0.442,
959 DragModel::G7,
960 bad,
961 (0.0, 15.0, 1013.25, 1.0),
962 None,
963 false,
964 false,
965 false,
966 "RK4".to_string(),
967 1e-6,
968 0.001,
969 300.0,
970 )
971 .unwrap_err();
972 assert_eq!(err.index, 0);
973 assert_eq!(err.field, crate::wind::WindSegmentField::SpeedKmh);
974 assert_eq!(
975 err.to_string(),
976 "wind.segments[0].speed_kmh must be finite and non-negative"
977 );
978 }
979
980 #[test]
981 fn try_solve_trajectory_rust_succeeds_on_valid_segments() {
982 let points = try_solve_trajectory_rust(
983 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
984 (0.0, 2.0),
985 0.01134,
986 0.442,
987 DragModel::G7,
988 vec![WindSegment::new(16.0934, 90.0, 500.0)],
989 (0.0, 15.0, 1013.25, 1.0),
990 None,
991 false,
992 false,
993 false,
994 "RK4".to_string(),
995 1e-6,
996 0.001,
997 300.0,
998 )
999 .expect("valid segments must solve");
1000 assert!(!points.is_empty());
1001 assert!(points.last().unwrap()["x"] > 0.0);
1002 }
1003
1004 #[test]
1005 fn derivative_inputs_preserve_initial_velocity_as_muzzle_speed() {
1006 let params = create_test_params(1_000.0);
1007 let launch_velocity: Vector3<f64> = Vector3::new(700.0, 30.0, -20.0);
1008 let inputs = build_inputs(¶ms, launch_velocity.norm());
1009
1010 assert_eq!(
1011 inputs.muzzle_velocity.to_bits(),
1012 launch_velocity.norm().to_bits()
1013 );
1014 }
1015
1016 #[test]
1017 fn integrated_magnus_retains_nonzero_launch_spin() {
1018 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1019 let baseline = integrate_trajectory(
1020 initial_state,
1021 (0.0, 0.1),
1022 create_test_params(1_000.0),
1023 "RK4",
1024 1e-6,
1025 0.001,
1026 );
1027 let mut magnus_params = create_test_params(1_000.0);
1028 magnus_params.enable_magnus = true;
1029
1030 let trajectory =
1031 integrate_trajectory(initial_state, (0.0, 0.1), magnus_params, "RK4", 1e-6, 0.001);
1032 let baseline_y = baseline.last().expect("baseline trajectory is empty").1[1];
1033 let magnus_y = trajectory.last().expect("trajectory is empty").1[1];
1034 let vertical_delta = magnus_y - baseline_y;
1035
1036 assert!(
1037 vertical_delta.is_finite() && vertical_delta < 0.0,
1038 "right-twist Magnus should retain nonzero launch spin and point down, got \
1039 delta_y={vertical_delta}"
1040 );
1041 }
1042
1043 #[test]
1044 fn rk45_retries_rejected_wind_boundary_step() {
1045 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1046 let mut params = create_test_params(100.0);
1047 params.wind_segments = vec![
1048 WindSegment::new(0.0, 90.0, 4.0),
1049 WindSegment::new(1_000.0, 90.0, 10_000.0),
1050 ];
1051
1052 let state = Vector6::from_row_slice(&initial_state);
1053 let launch_speed =
1054 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
1055 let inputs = build_inputs(¶ms, launch_speed);
1056 let initial_dt = 0.01;
1057 let tolerance = 1e-6;
1058 let (rejected_state, suggested_dt, error) =
1059 rk45_step(&state, 0.0, initial_dt, ¶ms, &inputs, tolerance);
1060 assert!(
1061 error > tolerance,
1062 "wind-boundary trial must exceed tolerance, got {error}"
1063 );
1064 assert!(suggested_dt < initial_dt);
1065
1066 let accepted = adaptive_rk45_step(
1067 &state,
1068 0.0,
1069 initial_dt,
1070 ¶ms,
1071 &inputs,
1072 Rk45Control {
1073 tolerance,
1074 min_step: RK45_MIN_STEP,
1075 max_step: initial_dt,
1076 max_trials: 100,
1077 },
1078 )
1079 .expect("a smaller finite trial should satisfy the tolerance");
1080
1081 assert!(accepted.trials > 1, "oversized trial was not retried");
1082 assert!(accepted.used_dt < initial_dt);
1083 assert!(
1084 accepted.error <= tolerance || accepted.used_dt <= RK45_MIN_STEP,
1085 "accepted error {} exceeds tolerance at dt {}",
1086 accepted.error,
1087 accepted.used_dt
1088 );
1089
1090 let (accepted_state, _, accepted_error) =
1091 rk45_step(&state, 0.0, accepted.used_dt, ¶ms, &inputs, tolerance);
1092 assert_eq!(accepted.state, accepted_state);
1093 assert_eq!(accepted.error, accepted_error);
1094 assert_ne!(accepted.state, rejected_state);
1095 assert!((RK45_MIN_STEP..=initial_dt).contains(&accepted.next_dt));
1096 }
1097
1098 #[test]
1099 fn integration_normalizes_wind_segments_by_distance() {
1100 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1101 let sorted_segments = vec![
1102 WindSegment::new(40.0, 270.0, 300.0),
1103 WindSegment::new(20.0, 90.0, 600.0),
1104 ];
1105
1106 let mut sorted_params = create_test_params(100.0);
1107 sorted_params.wind_segments = sorted_segments.clone();
1108 let mut unsorted_params = create_test_params(100.0);
1109 unsorted_params.wind_segments = sorted_segments.into_iter().rev().collect();
1110
1111 let sorted =
1112 integrate_trajectory(initial_state, (0.0, 1.0), sorted_params, "RK4", 1e-6, 0.001);
1113 let unsorted = integrate_trajectory(
1114 initial_state,
1115 (0.0, 1.0),
1116 unsorted_params,
1117 "RK4",
1118 1e-6,
1119 0.001,
1120 );
1121
1122 assert_eq!(unsorted.len(), sorted.len());
1123 for (index, ((sorted_t, sorted_state), (unsorted_t, unsorted_state))) in
1124 sorted.iter().zip(&unsorted).enumerate()
1125 {
1126 assert_eq!(unsorted_t.to_bits(), sorted_t.to_bits());
1127 for component in 0..6 {
1128 assert_eq!(
1129 unsorted_state[component].to_bits(),
1130 sorted_state[component].to_bits(),
1131 "wind segment order changed state component {component} at point {index}"
1132 );
1133 }
1134 }
1135 }
1136
1137 #[test]
1138 fn rk4_target_crossing_interpolates_complete_state_and_time() {
1139 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1140 let target_distance_m = 100.0;
1141 let trajectory = integrate_trajectory(
1142 initial_state,
1143 (0.0, 1.0),
1144 create_test_params(target_distance_m),
1145 "RK4",
1146 1e-6,
1147 0.001,
1148 );
1149
1150 let (previous_t, previous_state) = &trajectory[trajectory.len() - 2];
1151 let (terminal_t, terminal_state) = trajectory.last().expect("trajectory is empty");
1152 let reference_params = create_test_params(target_distance_m);
1153 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1154 let full_step_dt = 0.001;
1155 let bracket_end = rk4_step(
1156 previous_state,
1157 *previous_t,
1158 full_step_dt,
1159 &reference_params,
1160 &inputs,
1161 );
1162 assert!(previous_state[0] < target_distance_m);
1163 assert!(bracket_end[0] >= target_distance_m);
1164
1165 let alpha = (target_distance_m - previous_state[0]) / (bracket_end[0] - previous_state[0]);
1166 let expected_t = previous_t + alpha * full_step_dt;
1167 let mut expected_state = previous_state + alpha * (bracket_end - previous_state);
1168 expected_state[0] = target_distance_m;
1169
1170 assert_eq!(terminal_t.to_bits(), expected_t.to_bits());
1171 for component in 0..6 {
1172 assert_eq!(
1173 terminal_state[component].to_bits(),
1174 expected_state[component].to_bits(),
1175 "terminal component {component} was not interpolated at the target crossing"
1176 );
1177 }
1178 }
1179
1180 #[test]
1181 fn rk45_target_crossing_uses_the_accepted_state_and_time() {
1182 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1183 let initial = Vector6::from_row_slice(&initial_state);
1184 let target_distance_m = 0.5;
1185 let reference_params = create_test_params(target_distance_m);
1186 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1187 let initial_dt = 0.001;
1188 let accepted = adaptive_rk45_step(
1189 &initial,
1190 0.0,
1191 initial_dt,
1192 &reference_params,
1193 &inputs,
1194 Rk45Control {
1195 tolerance: 1e-6,
1196 min_step: RK45_MIN_STEP,
1197 max_step: 0.01,
1198 max_trials: 100_000,
1199 },
1200 )
1201 .expect("first RK45 target bracket should be accepted");
1202 assert!(accepted.state[0] >= target_distance_m);
1203 let expected = interpolate_target_crossing(
1204 0.0,
1205 &initial,
1206 accepted.used_dt,
1207 &accepted.state,
1208 target_distance_m,
1209 );
1210
1211 let trajectory = integrate_trajectory(
1212 initial_state,
1213 (0.0, 1.0),
1214 create_test_params(target_distance_m),
1215 "RK45",
1216 1e-6,
1217 0.01,
1218 );
1219 let actual = trajectory.last().expect("trajectory is empty");
1220
1221 assert_eq!(actual.0.to_bits(), expected.0.to_bits());
1222 for component in 0..6 {
1223 assert_eq!(
1224 actual.1[component].to_bits(),
1225 expected.1[component].to_bits(),
1226 "RK45 terminal component {component} was not interpolated from its accepted step"
1227 );
1228 }
1229 }
1230
1231 #[test]
1232 fn target_crossing_helper_interpolates_every_component() {
1233 let start = Vector6::new(90.0, 10.0, -4.0, 700.0, -20.0, 5.0);
1234 let end = Vector6::new(130.0, 6.0, 8.0, 660.0, -24.0, 9.0);
1235 let (time, state) = interpolate_target_crossing(2.0, &start, 0.5, &end, 100.0);
1236
1237 assert_eq!(time.to_bits(), 2.125_f64.to_bits());
1238 for (index, expected) in [100.0_f64, 9.0, -1.0, 690.0, -21.0, 6.0]
1239 .into_iter()
1240 .enumerate()
1241 {
1242 assert_eq!(state[index].to_bits(), expected.to_bits());
1243 }
1244 }
1245
1246 #[test]
1247 fn already_at_or_past_target_returns_initial_state_without_advancing() {
1248 let initial = [150.0, 12.0, -3.0, 700.0, -4.0, 5.0];
1249
1250 for method in ["RK4", "RK45"] {
1251 for target in [150.0, 100.0] {
1252 let trajectory = integrate_trajectory(
1253 initial,
1254 (2.0, 3.0),
1255 create_test_params(target),
1256 method,
1257 1e-6,
1258 0.01,
1259 );
1260
1261 assert_eq!(trajectory.len(), 1, "{method} advanced a terminal state");
1262 let (time, state) = &trajectory[0];
1263 assert_eq!(time.to_bits(), 2.0_f64.to_bits());
1264 for index in 0..6 {
1265 assert_eq!(state[index].to_bits(), initial[index].to_bits());
1266 }
1267 }
1268 }
1269 }
1270
1271 #[test]
1272 fn rk45_error_norm_scales_components_independently() {
1273 let state = Vector6::new(1.0e9, 0.0, 0.0, 800.0, 0.0, 0.0);
1274 let fifth_order = state;
1275 let mut fourth_order = state;
1276 fourth_order[4] = 1.0e-3;
1277
1278 let error = rk45_error_norm(&state, &fifth_order, &fourth_order);
1279 let expected = 1.0e-3 / 6.0_f64.sqrt();
1280
1281 assert!(
1282 (error - expected).abs() <= 1e-15,
1283 "large downrange position masked a velocity-component error: {error}"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_mba954_ground_threshold_honored() {
1289 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);
1297 deep.ground_threshold = -1000.0; let t_shallow =
1300 integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
1301 let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
1302
1303 assert!(
1304 t_shallow.len() < t_deep.len(),
1305 "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
1306 shallow={}, deep={}",
1307 t_shallow.len(),
1308 t_deep.len()
1309 );
1310 }
1311
1312 #[test]
1313 fn test_integrate_trajectory_basic() {
1314 let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
1317
1318 let params = TrajectoryParams {
1319 mass_kg: 0.01134, bc: 0.442,
1321 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
1324 drag_model: DragModel::G7,
1325 wind_segments: vec![WindSegment::new(0.0, 90.0, 914.4)],
1326 atmos_params: (0.0, 15.0, 1013.25, 1.0),
1327 omega_vector: None,
1328 enable_spin_drift: false,
1329 enable_magnus: false,
1330 enable_coriolis: false,
1331 target_distance_m: 914.4, enable_wind_shear: false,
1333 wind_shear_model: "none".to_string(),
1334 shooter_altitude_m: 0.0,
1335 is_twist_right: true,
1336 shooting_angle: 0.0,
1337 custom_drag_table: None,
1338 cd_scale: 1.0,
1339 custom_drag_reference: false,
1340 bc_segments: None,
1341 use_bc_segments: false,
1342 bc_segments_data: None,
1343 ground_threshold: -1000.0,
1344 atmo_sock: None,
1345 };
1346
1347 println!("Running integrate_trajectory test...");
1348 println!("Initial state: {:?}", initial_state);
1349 println!("Target distance: {} m", params.target_distance_m);
1350
1351 let trajectory =
1352 integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
1353
1354 println!("Trajectory has {} points", trajectory.len());
1355
1356 assert!(
1358 trajectory.len() > 1,
1359 "Trajectory should have more than 1 point, but has {}",
1360 trajectory.len()
1361 );
1362
1363 if let Some((_, final_state)) = trajectory.last() {
1365 println!("Final state: downrange(x)={}", final_state[0]);
1366 assert!(
1367 final_state[0] > 0.0,
1368 "Final x should be positive (bullet moved downrange)"
1369 );
1370 assert!(
1371 final_state[0] >= 900.0,
1372 "Final x should be near target distance"
1373 );
1374 assert!(
1375 final_state[3] < 0.9 * initial_state[3],
1376 "standard-atmosphere drag should reduce downrange velocity"
1377 );
1378 }
1379 }
1380
1381 #[test]
1382 fn test_rk4_vs_rk45_consistency() {
1383 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1386
1387 let params_rk4 = create_test_params(target_distance);
1388 let params_rk45 = create_test_params(target_distance);
1389
1390 let trajectory_rk4 =
1391 integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
1392 let trajectory_rk45 =
1393 integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
1394
1395 assert!(!trajectory_rk4.is_empty());
1397 assert!(!trajectory_rk45.is_empty());
1398
1399 let (time_rk4, final_rk4) = trajectory_rk4.last().unwrap();
1400 let (time_rk45, final_rk45) = trajectory_rk45.last().unwrap();
1401
1402 assert!(
1404 (time_rk4 - time_rk45).abs() < 1e-4,
1405 "RK4/RK45 time of flight diverged: {time_rk4} vs {time_rk45}"
1406 );
1407 assert!((final_rk4[1] - final_rk45[1]).abs() < 1e-3);
1408 assert!((final_rk4[3] - final_rk45[3]).abs() < 1e-2);
1409 assert!(final_rk45[3] < 0.9 * initial_state[3]);
1410 }
1411
1412 #[test]
1413 fn test_ground_impact_detection() {
1414 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;
1419 let ground_threshold = 0.0;
1420 params.ground_threshold = ground_threshold;
1421
1422 let trajectory =
1423 integrate_trajectory(initial_state, (0.0, 20.0), params, "RK4", 1e-6, 0.01);
1424
1425 let (_, final_state) = trajectory.last().unwrap();
1427
1428 assert!(
1430 final_state[1] <= ground_threshold,
1431 "Should hit ground, but y={}",
1432 final_state[1]
1433 );
1434 assert!(
1435 final_state[0] < 10000.0,
1436 "Should not reach target, but z={}",
1437 final_state[0]
1438 );
1439 }
1440
1441 #[test]
1442 fn test_target_distance_reached() {
1443 let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; let target_distance = 300.0;
1445
1446 let params = create_test_params(target_distance);
1447
1448 let trajectory =
1449 integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
1450
1451 let (_, final_state) = trajectory.last().unwrap();
1452
1453 assert!(
1455 (final_state[0] - target_distance).abs() < 1.0,
1456 "Should reach target at {}m, but stopped at {}m",
1457 target_distance,
1458 final_state[0]
1459 );
1460 }
1461
1462 #[test]
1463 fn test_wind_affects_trajectory() {
1464 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1469
1470 let params_no_wind = create_test_params(target_distance);
1472
1473 let mut params_headwind = create_test_params(target_distance);
1475 params_headwind.wind_segments = vec![WindSegment::new(72.0, 0.0, 500.0)]; let trajectory_no_wind = integrate_trajectory(
1478 initial_state,
1479 (0.0, 5.0),
1480 params_no_wind,
1481 "RK45",
1482 1e-6,
1483 0.01,
1484 );
1485 let trajectory_headwind = integrate_trajectory(
1486 initial_state,
1487 (0.0, 5.0),
1488 params_headwind,
1489 "RK45",
1490 1e-6,
1491 0.01,
1492 );
1493
1494 assert!(
1496 !trajectory_no_wind.is_empty(),
1497 "No-wind trajectory should complete"
1498 );
1499 assert!(
1500 !trajectory_headwind.is_empty(),
1501 "Headwind trajectory should complete"
1502 );
1503
1504 let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
1505 let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
1506
1507 let drop_no_wind = final_no_wind[1];
1510 let drop_headwind = final_headwind[1];
1511
1512 println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
1513 println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
1514
1515 assert!(
1516 *time_headwind > *time_no_wind + 0.001,
1517 "headwind should increase time of flight: no-wind={time_no_wind}, headwind={time_headwind}"
1518 );
1519 assert!(
1520 final_headwind[3] < final_no_wind[3] - 1.0,
1521 "headwind should reduce terminal downrange velocity"
1522 );
1523
1524 assert!(
1526 (final_no_wind[0] - target_distance).abs() < 10.0,
1527 "No-wind should reach target"
1528 );
1529 assert!(
1530 (final_headwind[0] - target_distance).abs() < 10.0,
1531 "Headwind should reach target"
1532 );
1533 }
1534
1535 #[test]
1536 fn test_solve_trajectory_rust_output_format() {
1537 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let result = solve_trajectory_rust(
1540 initial_state,
1541 (0.0, 2.0),
1542 0.01134, 0.442, DragModel::G7, vec![], (0.0, 15.0, 1013.25, 1.0),
1548 None, false, false, false, "RK45".to_string(), 1e-6, 0.01, 500.0, );
1557
1558 assert!(!result.is_empty());
1560
1561 let first_point = &result[0];
1562 assert!(first_point.contains_key("t"));
1563 assert!(first_point.contains_key("x"));
1564 assert!(first_point.contains_key("y"));
1565 assert!(first_point.contains_key("z"));
1566 assert!(first_point.contains_key("vx"));
1567 assert!(first_point.contains_key("vy"));
1568 assert!(first_point.contains_key("vz"));
1569
1570 let final_point = result.last().unwrap();
1571 assert!(
1572 final_point["vx"] < 0.9 * initial_state[3],
1573 "standard-atmosphere wrapper fixture should exercise drag"
1574 );
1575 }
1576
1577 #[test]
1578 fn test_left_vs_right_twist() {
1579 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1581
1582 let mut params_right = create_test_params(target_distance);
1583 params_right.is_twist_right = true;
1584 params_right.enable_spin_drift = true;
1585
1586 let mut params_left = create_test_params(target_distance);
1587 params_left.is_twist_right = false;
1588 params_left.enable_spin_drift = true;
1589
1590 let trajectory_right =
1591 integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
1592 let trajectory_left =
1593 integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
1594
1595 assert!(!trajectory_right.is_empty());
1597 assert!(!trajectory_left.is_empty());
1598
1599 let (_, final_right) = trajectory_right.last().unwrap();
1601 let (_, final_left) = trajectory_left.last().unwrap();
1602
1603 assert!((final_right[2] - final_left[2]).abs() < 10.0);
1605 }
1606}