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
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.2 {
45 let wind_speed_mps = seg.0 * 0.2777778; let wind_angle_rad = seg.1.to_radians();
47 return Vector3::new(
48 -wind_speed_mps * wind_angle_rad.cos(),
49 0.0,
50 -wind_speed_mps * wind_angle_rad.sin(),
51 );
52 }
53 }
54 Vector3::zeros()
55}
56
57fn rk4_step(
59 state: &Vector6<f64>,
60 t: f64,
61 dt: f64,
62 params: &TrajectoryParams,
63 inputs: &BallisticInputs,
64) -> Vector6<f64> {
65 let k1 = compute_derivatives_vec(state, t, params, inputs);
67 let k2 = compute_derivatives_vec(&(state + dt * 0.5 * k1), t + dt * 0.5, params, inputs);
68 let k3 = compute_derivatives_vec(&(state + dt * 0.5 * k2), t + dt * 0.5, params, inputs);
69 let k4 = compute_derivatives_vec(&(state + dt * k3), t + dt, params, inputs);
70
71 state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
72}
73
74pub(crate) fn rk45_error_norm(
80 state: &Vector6<f64>,
81 fifth_order: &Vector6<f64>,
82 fourth_order: &Vector6<f64>,
83) -> f64 {
84 let scaled_error_squared: f64 = (0..6)
85 .map(|index| {
86 let scale = 1.0 + state[index].abs().max(fifth_order[index].abs());
87 ((fifth_order[index] - fourth_order[index]) / scale).powi(2)
88 })
89 .sum();
90
91 (scaled_error_squared / 6.0).sqrt()
92}
93
94fn rk45_step(
96 state: &Vector6<f64>,
97 t: f64,
98 dt: f64,
99 params: &TrajectoryParams,
100 inputs: &BallisticInputs,
101 tol: f64,
102) -> (Vector6<f64>, f64, f64) {
103 const A21: f64 = 1.0 / 5.0;
105 const A31: f64 = 3.0 / 40.0;
106 const A32: f64 = 9.0 / 40.0;
107 const A41: f64 = 44.0 / 45.0;
108 const A42: f64 = -56.0 / 15.0;
109 const A43: f64 = 32.0 / 9.0;
110 const A51: f64 = 19372.0 / 6561.0;
111 const A52: f64 = -25360.0 / 2187.0;
112 const A53: f64 = 64448.0 / 6561.0;
113 const A54: f64 = -212.0 / 729.0;
114 const A61: f64 = 9017.0 / 3168.0;
115 const A62: f64 = -355.0 / 33.0;
116 const A63: f64 = 46732.0 / 5247.0;
117 const A64: f64 = 49.0 / 176.0;
118 const A65: f64 = -5103.0 / 18656.0;
119 const A71: f64 = 35.0 / 384.0;
120 const A73: f64 = 500.0 / 1113.0;
121 const A74: f64 = 125.0 / 192.0;
122 const A75: f64 = -2187.0 / 6784.0;
123 const A76: f64 = 11.0 / 84.0;
124
125 const B1: f64 = 35.0 / 384.0;
127 const B3: f64 = 500.0 / 1113.0;
128 const B4: f64 = 125.0 / 192.0;
129 const B5: f64 = -2187.0 / 6784.0;
130 const B6: f64 = 11.0 / 84.0;
131
132 const B1_ERR: f64 = 5179.0 / 57600.0;
134 const B3_ERR: f64 = 7571.0 / 16695.0;
135 const B4_ERR: f64 = 393.0 / 640.0;
136 const B5_ERR: f64 = -92097.0 / 339200.0;
137 const B6_ERR: f64 = 187.0 / 2100.0;
138 const B7_ERR: f64 = 1.0 / 40.0;
139
140 let k1 = compute_derivatives_vec(state, t, params, inputs);
142 let k2 = compute_derivatives_vec(&(state + dt * A21 * k1), t + dt * 0.2, params, inputs);
143 let k3 = compute_derivatives_vec(
144 &(state + dt * (A31 * k1 + A32 * k2)),
145 t + dt * 0.3,
146 params,
147 inputs,
148 );
149 let k4 = compute_derivatives_vec(
150 &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
151 t + dt * 0.8,
152 params,
153 inputs,
154 );
155 let k5 = compute_derivatives_vec(
156 &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
157 t + dt * 8.0 / 9.0,
158 params,
159 inputs,
160 );
161 let k6 = compute_derivatives_vec(
162 &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
163 t + dt,
164 params,
165 inputs,
166 );
167 let k7 = compute_derivatives_vec(
168 &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
169 t + dt,
170 params,
171 inputs,
172 );
173
174 let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
176
177 let y_err = state
179 + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
180
181 let error = rk45_error_norm(state, &y_new, &y_err);
182
183 let step_scale = if !error.is_finite() || !tol.is_finite() || tol <= 0.0 {
185 RK45_MIN_SCALE
186 } else if error == 0.0 {
187 RK45_MAX_SCALE
188 } else {
189 (RK45_SAFETY_FACTOR * (tol / error).powf(0.2)).clamp(RK45_MIN_SCALE, RK45_MAX_SCALE)
190 };
191 let dt_new = dt * step_scale;
192
193 (y_new, dt_new, error)
194}
195
196fn adaptive_rk45_step(
201 state: &Vector6<f64>,
202 t: f64,
203 initial_dt: f64,
204 params: &TrajectoryParams,
205 inputs: &BallisticInputs,
206 control: Rk45Control,
207) -> Result<Rk45AcceptedStep, usize> {
208 let mut trial_dt = initial_dt;
209
210 for trials in 1..=control.max_trials {
211 let (new_state, suggested_dt, error) =
212 rk45_step(state, t, trial_dt, params, inputs, control.tolerance);
213 let candidate_is_finite = error.is_finite()
214 && suggested_dt.is_finite()
215 && new_state.iter().all(|value| value.is_finite());
216 let next_dt = suggested_dt.min(control.max_step).max(control.min_step);
217
218 if candidate_is_finite && (error <= control.tolerance || trial_dt <= control.min_step) {
219 return Ok(Rk45AcceptedStep {
220 state: new_state,
221 used_dt: trial_dt,
222 next_dt,
223 error,
224 trials,
225 });
226 }
227
228 if trial_dt <= control.min_step {
229 return Err(trials);
230 }
231 trial_dt = next_dt;
232 }
233
234 Err(control.max_trials)
235}
236
237pub struct TrajectoryParams {
239 pub mass_kg: f64,
240 pub bc: f64,
241 pub drag_model: DragModel,
242 pub wind_segments: Vec<WindSegment>,
244 pub atmos_params: (f64, f64, f64, f64),
250 pub omega_vector: Option<Vector3<f64>>,
253 pub enable_spin_drift: bool,
254 pub enable_magnus: bool,
255 pub enable_coriolis: bool,
256 pub target_distance_m: f64, pub enable_wind_shear: bool,
258 pub wind_shear_model: String,
259 pub shooter_altitude_m: f64,
260 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,
273 pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
277}
278
279fn build_inputs(params: &TrajectoryParams, muzzle_velocity_mps: f64) -> BallisticInputs {
285 let mut inputs = BallisticInputs {
286 bc_value: params.bc,
287 bc_type: params.drag_model,
288 bullet_mass: params.mass_kg, muzzle_velocity: muzzle_velocity_mps,
290 bullet_diameter: params.bullet_diameter, bullet_length: params.bullet_length,
292 twist_rate: params.twist_rate,
293 is_twist_right: params.is_twist_right,
294 enable_advanced_effects: params.enable_spin_drift
295 || params.enable_magnus
296 || params.enable_coriolis,
297 enable_magnus: params.enable_magnus,
298 enable_coriolis: params.enable_coriolis,
299 altitude: params.atmos_params.0,
300 temperature: params.atmos_params.1,
301 pressure: params.atmos_params.2,
302 humidity: params.atmos_params.3,
303 tipoff_yaw: 0.0,
304 target_distance: 1000.0, muzzle_angle: 0.0,
306 wind_speed: if !params.wind_segments.is_empty() {
307 params.wind_segments[0].0 * 0.2777778 } else {
309 0.0
310 },
311 wind_angle: if !params.wind_segments.is_empty() {
312 params.wind_segments[0].1.to_radians() } else {
314 0.0
315 },
316 latitude: None,
317 shooting_angle: params.shooting_angle,
318 cant_angle: 0.0,
319 azimuth_angle: 0.0,
320 shot_azimuth: 0.0, use_powder_sensitivity: false,
322 powder_temp_sensitivity: 0.0,
323 powder_temp: 59.0,
324 powder_temp_curve: None,
325 powder_curve_temp_c: None,
326 tipoff_decay_distance: 0.0,
327 ground_threshold: params.ground_threshold, bc_segments: params.bc_segments.clone(),
329 caliber_inches: params.bullet_diameter / 0.0254, weight_grains: params.mass_kg / 0.00006479891,
331 use_bc_segments: params.use_bc_segments,
332 bullet_id: None,
333 bc_segments_data: None,
334 use_enhanced_spin_drift: params.enable_spin_drift,
335 use_form_factor: false,
336 manufacturer: None,
337 bullet_model: None,
338 enable_wind_shear: false,
339 wind_shear_model: "none".to_string(),
340 use_cluster_bc: false,
341 bullet_cluster: None,
342 custom_drag_table: params.custom_drag_table.clone(),
343 bc_type_str: None,
344 enable_pitch_damping: false,
345 enable_precession_nutation: false,
346 enable_aerodynamic_jump: false,
352 use_rk4: true,
353 use_adaptive_rk45: false,
354 enable_trajectory_sampling: false,
355 sample_interval: 10.0,
356 sight_height: 0.0,
357 muzzle_height: 0.0,
358 target_height: 0.0,
359 };
360
361 if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
368 inputs.bc_segments_data =
369 crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
370 }
371 inputs
372}
373
374fn compute_derivatives_vec(
376 state: &Vector6<f64>,
377 t: f64,
378 params: &TrajectoryParams,
379 inputs: &BallisticInputs,
380) -> Vector6<f64> {
381 let pos = Vector3::new(state[0], state[1], state[2]);
382 let vel = Vector3::new(state[3], state[4], state[5]);
383
384 let wind_vector = if !params.wind_segments.is_empty() {
386 if params.enable_wind_shear && params.wind_shear_model != "none" {
387 crate::wind_shear::get_wind_at_position(
388 &pos,
389 ¶ms.wind_segments,
390 params.enable_wind_shear,
391 ¶ms.wind_shear_model,
392 params.shooter_altitude_m,
393 )
394 } else {
395 wind_vector_for_range(pos.x, ¶ms.wind_segments)
396 }
397 } else {
398 Vector3::zeros()
399 };
400
401 let deriv_result = compute_derivatives(
404 pos,
405 vel,
406 inputs,
407 wind_vector,
408 params.atmos_params,
409 params.bc,
410 params.omega_vector,
411 t,
412 params.atmo_sock.as_ref(),
413 );
414
415 Vector6::new(
416 deriv_result[0],
417 deriv_result[1],
418 deriv_result[2],
419 deriv_result[3],
420 deriv_result[4],
421 deriv_result[5],
422 )
423}
424
425fn interpolate_target_crossing(
431 start_time: f64,
432 start: &Vector6<f64>,
433 step_dt: f64,
434 end: &Vector6<f64>,
435 target_x: f64,
436) -> (f64, Vector6<f64>) {
437 debug_assert!(start[0] <= target_x && target_x <= end[0] && end[0] > start[0]);
438
439 let alpha = (target_x - start[0]) / (end[0] - start[0]);
440 let crossing_time = start_time + alpha * step_dt;
441 let mut crossing_state = start + alpha * (end - start);
442 crossing_state[0] = target_x;
443
444 (crossing_time, crossing_state)
445}
446
447pub fn integrate_trajectory(
449 initial_state: [f64; 6],
450 t_span: (f64, f64),
451 mut params: TrajectoryParams,
452 method: &str,
453 tolerance: f64,
454 max_step: f64,
455) -> Vec<(f64, Vector6<f64>)> {
456 crate::wind::sort_wind_segments_by_distance(&mut params.wind_segments);
459
460 let mut state = Vector6::new(
461 initial_state[0],
462 initial_state[1],
463 initial_state[2],
464 initial_state[3],
465 initial_state[4],
466 initial_state[5],
467 );
468
469 let mut t = t_span.0;
470 let t_end = t_span.1;
471 let mut dt = (t_end - t) / 1000.0; let mut trajectory = Vec::with_capacity(10000);
474 trajectory.push((t, state));
475 if state[0] >= params.target_distance_m {
476 return trajectory;
477 }
478
479 let muzzle_velocity_mps =
482 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
483 let inputs = build_inputs(¶ms, muzzle_velocity_mps);
484
485 match method {
486 "RK4" => {
487 dt = dt.min(max_step).min(0.001); while t < t_end {
491 if t + dt > t_end {
492 dt = t_end - t;
493 }
494
495 let new_state = rk4_step(&state, t, dt, ¶ms, &inputs);
496
497 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
499 trajectory.push(interpolate_target_crossing(
500 t,
501 &state,
502 dt,
503 &new_state,
504 params.target_distance_m,
505 ));
506 break; }
508
509 state = new_state;
510 t += dt;
511 trajectory.push((t, state));
512
513 if state[0] >= params.target_distance_m {
515 break;
516 }
517
518 if state[1] < params.ground_threshold {
521 break;
522 }
523 }
524 }
525 "RK45" | _ => {
526 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 {
530 tolerance
531 } else {
532 eprintln!(
533 "WARNING: RK45 tolerance must be finite and positive; using {RK45_DEFAULT_TOLERANCE}"
534 );
535 RK45_DEFAULT_TOLERANCE
536 };
537
538 let effective_max_step =
541 if params.enable_wind_shear && params.wind_shear_model != "none" {
542 if params.target_distance_m > 800.0 {
544 0.01 } else {
546 0.02 }
548 } else {
549 max_step };
551 if !effective_max_step.is_finite() || effective_max_step <= 0.0 {
552 eprintln!("WARNING: RK45 max_step must be finite and positive");
553 return trajectory;
554 }
555 let min_step = RK45_MIN_STEP.min(effective_max_step);
556
557 dt = dt.min(effective_max_step).max(min_step);
559
560 let max_iterations = 100000; let mut iteration_count = 0;
563
564 while t < t_end && iteration_count < max_iterations {
565 if t + dt > t_end {
567 dt = t_end - t;
568 }
569
570 let control = Rk45Control {
571 tolerance,
572 min_step,
573 max_step: effective_max_step,
574 max_trials: max_iterations - iteration_count,
575 };
576 let accepted = match adaptive_rk45_step(&state, t, dt, ¶ms, &inputs, control) {
577 Ok(accepted) => accepted,
578 Err(trials) => {
579 iteration_count += trials;
580 if iteration_count < max_iterations {
581 eprintln!("WARNING: RK45 minimum-step trial was non-finite");
582 }
583 break;
584 }
585 };
586 iteration_count += accepted.trials;
587 debug_assert!(accepted.error <= tolerance || accepted.used_dt <= min_step);
588
589 if state[0] < params.target_distance_m
591 && accepted.state[0] >= params.target_distance_m
592 {
593 trajectory.push(interpolate_target_crossing(
594 t,
595 &state,
596 accepted.used_dt,
597 &accepted.state,
598 params.target_distance_m,
599 ));
600 break;
601 }
602
603 state = accepted.state;
605 t += accepted.used_dt;
606
607 if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
609 {
610 trajectory.push((t, state));
612 last_save_x = state[0];
613 }
614
615 dt = accepted.next_dt;
617
618 if state[0] >= params.target_distance_m {
620 break;
621 }
622
623 if state[1] < params.ground_threshold {
626 break;
627 }
628 }
629
630 if iteration_count >= max_iterations
632 && t < t_end
633 && state[0] < params.target_distance_m
634 && state[1] >= params.ground_threshold
635 {
636 eprintln!(
637 "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
638 max_iterations
639 );
640 eprintln!(" Final time: {}, Target time: {}", t, t_end);
641 eprintln!(
642 " Final position: downrange(x)={}, Target: {}m",
643 state[0], params.target_distance_m
644 );
645 }
646 }
647 }
648
649 trajectory
650}
651
652pub fn solve_trajectory_rust(
654 initial_state: [f64; 6],
655 t_span: (f64, f64),
656 mass_kg: f64,
657 bc: f64,
658 drag_model: DragModel,
659 wind_segments: Vec<WindSegment>,
660 atmos_params: (f64, f64, f64, f64),
661 omega_vector: Option<Vec<f64>>,
662 enable_spin_drift: bool,
663 enable_magnus: bool,
664 enable_coriolis: bool,
665 method: String,
666 tolerance: f64,
667 max_step: f64,
668 target_distance_m: f64,
669) -> Vec<HashMap<String, f64>> {
670 let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
671
672 let params = TrajectoryParams {
673 mass_kg,
674 bc,
675 drag_model,
676 wind_segments,
677 atmos_params,
678 omega_vector: omega_vec,
679 enable_spin_drift,
680 enable_magnus,
681 enable_coriolis,
682 target_distance_m,
683 enable_wind_shear: false, wind_shear_model: "none".to_string(),
685 shooter_altitude_m: 0.0,
686 is_twist_right: true, shooting_angle: 0.0, bullet_diameter: 0.0078232,
691 bullet_length: 0.031496,
692 twist_rate: 10.0,
693 custom_drag_table: None, bc_segments: None, use_bc_segments: false,
696 ground_threshold: -1000.0, atmo_sock: None, };
699
700 let trajectory =
701 integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
702
703 trajectory
705 .into_iter()
706 .map(|(t, state)| {
707 let mut point = HashMap::new();
708 point.insert("t".to_string(), t);
709 point.insert("x".to_string(), state[0]);
710 point.insert("y".to_string(), state[1]);
711 point.insert("z".to_string(), state[2]);
712 point.insert("vx".to_string(), state[3]);
713 point.insert("vy".to_string(), state[4]);
714 point.insert("vz".to_string(), state[5]);
715 point
716 })
717 .collect()
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
725 TrajectoryParams {
726 mass_kg: 0.01134, bc: 0.442,
728 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
731 drag_model: DragModel::G7,
732 wind_segments: vec![],
733 atmos_params: (0.0, 15.0, 1013.25, 1.0),
734 omega_vector: None,
735 enable_spin_drift: false,
736 enable_magnus: false,
737 enable_coriolis: false,
738 target_distance_m,
739 enable_wind_shear: false,
740 wind_shear_model: "none".to_string(),
741 shooter_altitude_m: 0.0,
742 is_twist_right: true,
743 shooting_angle: 0.0,
744 custom_drag_table: None,
745 bc_segments: None,
746 use_bc_segments: false,
747 ground_threshold: -1000.0,
748 atmo_sock: None,
749 }
750 }
751
752 #[test]
753 fn derivative_inputs_preserve_initial_velocity_as_muzzle_speed() {
754 let params = create_test_params(1_000.0);
755 let launch_velocity = Vector3::new(700.0, 30.0, -20.0);
756 let inputs = build_inputs(¶ms, launch_velocity.norm());
757
758 assert_eq!(
759 inputs.muzzle_velocity.to_bits(),
760 launch_velocity.norm().to_bits()
761 );
762 }
763
764 #[test]
765 fn integrated_magnus_retains_nonzero_launch_spin() {
766 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
767 let baseline = integrate_trajectory(
768 initial_state,
769 (0.0, 0.1),
770 create_test_params(1_000.0),
771 "RK4",
772 1e-6,
773 0.001,
774 );
775 let mut magnus_params = create_test_params(1_000.0);
776 magnus_params.enable_magnus = true;
777
778 let trajectory = integrate_trajectory(
779 initial_state,
780 (0.0, 0.1),
781 magnus_params,
782 "RK4",
783 1e-6,
784 0.001,
785 );
786 let baseline_y = baseline.last().expect("baseline trajectory is empty").1[1];
787 let magnus_y = trajectory.last().expect("trajectory is empty").1[1];
788 let vertical_delta = magnus_y - baseline_y;
789
790 assert!(
791 vertical_delta.is_finite() && vertical_delta < 0.0,
792 "right-twist Magnus should retain nonzero launch spin and point down, got \
793 delta_y={vertical_delta}"
794 );
795 }
796
797 #[test]
798 fn rk45_retries_rejected_wind_boundary_step() {
799 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
800 let mut params = create_test_params(100.0);
801 params.wind_segments = vec![(0.0, 90.0, 4.0), (1_000.0, 90.0, 10_000.0)];
802
803 let state = Vector6::from_row_slice(&initial_state);
804 let launch_speed =
805 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
806 let inputs = build_inputs(¶ms, launch_speed);
807 let initial_dt = 0.01;
808 let tolerance = 1e-6;
809 let (rejected_state, suggested_dt, error) =
810 rk45_step(&state, 0.0, initial_dt, ¶ms, &inputs, tolerance);
811 assert!(
812 error > tolerance,
813 "wind-boundary trial must exceed tolerance, got {error}"
814 );
815 assert!(suggested_dt < initial_dt);
816
817 let accepted = adaptive_rk45_step(
818 &state,
819 0.0,
820 initial_dt,
821 ¶ms,
822 &inputs,
823 Rk45Control {
824 tolerance,
825 min_step: RK45_MIN_STEP,
826 max_step: initial_dt,
827 max_trials: 100,
828 },
829 )
830 .expect("a smaller finite trial should satisfy the tolerance");
831
832 assert!(accepted.trials > 1, "oversized trial was not retried");
833 assert!(accepted.used_dt < initial_dt);
834 assert!(
835 accepted.error <= tolerance || accepted.used_dt <= RK45_MIN_STEP,
836 "accepted error {} exceeds tolerance at dt {}",
837 accepted.error,
838 accepted.used_dt
839 );
840
841 let (accepted_state, _, accepted_error) =
842 rk45_step(&state, 0.0, accepted.used_dt, ¶ms, &inputs, tolerance);
843 assert_eq!(accepted.state, accepted_state);
844 assert_eq!(accepted.error, accepted_error);
845 assert_ne!(accepted.state, rejected_state);
846 assert!((RK45_MIN_STEP..=initial_dt).contains(&accepted.next_dt));
847 }
848
849 #[test]
850 fn integration_normalizes_wind_segments_by_distance() {
851 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
852 let sorted_segments = vec![(40.0, 270.0, 300.0), (20.0, 90.0, 600.0)];
853
854 let mut sorted_params = create_test_params(100.0);
855 sorted_params.wind_segments = sorted_segments.clone();
856 let mut unsorted_params = create_test_params(100.0);
857 unsorted_params.wind_segments = sorted_segments.into_iter().rev().collect();
858
859 let sorted =
860 integrate_trajectory(initial_state, (0.0, 1.0), sorted_params, "RK4", 1e-6, 0.001);
861 let unsorted = integrate_trajectory(
862 initial_state,
863 (0.0, 1.0),
864 unsorted_params,
865 "RK4",
866 1e-6,
867 0.001,
868 );
869
870 assert_eq!(unsorted.len(), sorted.len());
871 for (index, ((sorted_t, sorted_state), (unsorted_t, unsorted_state))) in
872 sorted.iter().zip(&unsorted).enumerate()
873 {
874 assert_eq!(unsorted_t.to_bits(), sorted_t.to_bits());
875 for component in 0..6 {
876 assert_eq!(
877 unsorted_state[component].to_bits(),
878 sorted_state[component].to_bits(),
879 "wind segment order changed state component {component} at point {index}"
880 );
881 }
882 }
883 }
884
885 #[test]
886 fn rk4_target_crossing_interpolates_complete_state_and_time() {
887 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
888 let target_distance_m = 100.0;
889 let trajectory = integrate_trajectory(
890 initial_state,
891 (0.0, 1.0),
892 create_test_params(target_distance_m),
893 "RK4",
894 1e-6,
895 0.001,
896 );
897
898 let (previous_t, previous_state) = &trajectory[trajectory.len() - 2];
899 let (terminal_t, terminal_state) = trajectory.last().expect("trajectory is empty");
900 let reference_params = create_test_params(target_distance_m);
901 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
902 let full_step_dt = 0.001;
903 let bracket_end = rk4_step(
904 previous_state,
905 *previous_t,
906 full_step_dt,
907 &reference_params,
908 &inputs,
909 );
910 assert!(previous_state[0] < target_distance_m);
911 assert!(bracket_end[0] >= target_distance_m);
912
913 let alpha = (target_distance_m - previous_state[0]) / (bracket_end[0] - previous_state[0]);
914 let expected_t = previous_t + alpha * full_step_dt;
915 let mut expected_state = previous_state + alpha * (bracket_end - previous_state);
916 expected_state[0] = target_distance_m;
917
918 assert_eq!(terminal_t.to_bits(), expected_t.to_bits());
919 for component in 0..6 {
920 assert_eq!(
921 terminal_state[component].to_bits(),
922 expected_state[component].to_bits(),
923 "terminal component {component} was not interpolated at the target crossing"
924 );
925 }
926 }
927
928 #[test]
929 fn rk45_target_crossing_uses_the_accepted_state_and_time() {
930 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
931 let initial = Vector6::from_row_slice(&initial_state);
932 let target_distance_m = 0.5;
933 let reference_params = create_test_params(target_distance_m);
934 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
935 let initial_dt = 0.001;
936 let accepted = adaptive_rk45_step(
937 &initial,
938 0.0,
939 initial_dt,
940 &reference_params,
941 &inputs,
942 Rk45Control {
943 tolerance: 1e-6,
944 min_step: RK45_MIN_STEP,
945 max_step: 0.01,
946 max_trials: 100_000,
947 },
948 )
949 .expect("first RK45 target bracket should be accepted");
950 assert!(accepted.state[0] >= target_distance_m);
951 let expected = interpolate_target_crossing(
952 0.0,
953 &initial,
954 accepted.used_dt,
955 &accepted.state,
956 target_distance_m,
957 );
958
959 let trajectory = integrate_trajectory(
960 initial_state,
961 (0.0, 1.0),
962 create_test_params(target_distance_m),
963 "RK45",
964 1e-6,
965 0.01,
966 );
967 let actual = trajectory.last().expect("trajectory is empty");
968
969 assert_eq!(actual.0.to_bits(), expected.0.to_bits());
970 for component in 0..6 {
971 assert_eq!(
972 actual.1[component].to_bits(),
973 expected.1[component].to_bits(),
974 "RK45 terminal component {component} was not interpolated from its accepted step"
975 );
976 }
977 }
978
979 #[test]
980 fn target_crossing_helper_interpolates_every_component() {
981 let start = Vector6::new(90.0, 10.0, -4.0, 700.0, -20.0, 5.0);
982 let end = Vector6::new(130.0, 6.0, 8.0, 660.0, -24.0, 9.0);
983 let (time, state) = interpolate_target_crossing(2.0, &start, 0.5, &end, 100.0);
984
985 assert_eq!(time.to_bits(), 2.125_f64.to_bits());
986 for (index, expected) in [100.0_f64, 9.0, -1.0, 690.0, -21.0, 6.0]
987 .into_iter()
988 .enumerate()
989 {
990 assert_eq!(state[index].to_bits(), expected.to_bits());
991 }
992 }
993
994 #[test]
995 fn already_at_or_past_target_returns_initial_state_without_advancing() {
996 let initial = [150.0, 12.0, -3.0, 700.0, -4.0, 5.0];
997
998 for method in ["RK4", "RK45"] {
999 for target in [150.0, 100.0] {
1000 let trajectory = integrate_trajectory(
1001 initial,
1002 (2.0, 3.0),
1003 create_test_params(target),
1004 method,
1005 1e-6,
1006 0.01,
1007 );
1008
1009 assert_eq!(trajectory.len(), 1, "{method} advanced a terminal state");
1010 let (time, state) = &trajectory[0];
1011 assert_eq!(time.to_bits(), 2.0_f64.to_bits());
1012 for index in 0..6 {
1013 assert_eq!(state[index].to_bits(), initial[index].to_bits());
1014 }
1015 }
1016 }
1017 }
1018
1019 #[test]
1020 fn rk45_error_norm_scales_components_independently() {
1021 let state = Vector6::new(1.0e9, 0.0, 0.0, 800.0, 0.0, 0.0);
1022 let fifth_order = state;
1023 let mut fourth_order = state;
1024 fourth_order[4] = 1.0e-3;
1025
1026 let error = rk45_error_norm(&state, &fifth_order, &fourth_order);
1027 let expected = 1.0e-3 / 6.0_f64.sqrt();
1028
1029 assert!(
1030 (error - expected).abs() <= 1e-15,
1031 "large downrange position masked a velocity-component error: {error}"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_mba954_ground_threshold_honored() {
1037 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);
1045 deep.ground_threshold = -1000.0; let t_shallow =
1048 integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
1049 let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
1050
1051 assert!(
1052 t_shallow.len() < t_deep.len(),
1053 "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
1054 shallow={}, deep={}",
1055 t_shallow.len(),
1056 t_deep.len()
1057 );
1058 }
1059
1060 #[test]
1061 fn test_integrate_trajectory_basic() {
1062 let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
1065
1066 let params = TrajectoryParams {
1067 mass_kg: 0.01134, bc: 0.442,
1069 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
1072 drag_model: DragModel::G7,
1073 wind_segments: vec![(0.0, 90.0, 914.4)],
1074 atmos_params: (0.0, 15.0, 1013.25, 1.0),
1075 omega_vector: None,
1076 enable_spin_drift: false,
1077 enable_magnus: false,
1078 enable_coriolis: false,
1079 target_distance_m: 914.4, enable_wind_shear: false,
1081 wind_shear_model: "none".to_string(),
1082 shooter_altitude_m: 0.0,
1083 is_twist_right: true,
1084 shooting_angle: 0.0,
1085 custom_drag_table: None,
1086 bc_segments: None,
1087 use_bc_segments: false,
1088 ground_threshold: -1000.0,
1089 atmo_sock: None,
1090 };
1091
1092 println!("Running integrate_trajectory test...");
1093 println!("Initial state: {:?}", initial_state);
1094 println!("Target distance: {} m", params.target_distance_m);
1095
1096 let trajectory =
1097 integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
1098
1099 println!("Trajectory has {} points", trajectory.len());
1100
1101 assert!(
1103 trajectory.len() > 1,
1104 "Trajectory should have more than 1 point, but has {}",
1105 trajectory.len()
1106 );
1107
1108 if let Some((_, final_state)) = trajectory.last() {
1110 println!("Final state: downrange(x)={}", final_state[0]);
1111 assert!(
1112 final_state[0] > 0.0,
1113 "Final x should be positive (bullet moved downrange)"
1114 );
1115 assert!(
1116 final_state[0] >= 900.0,
1117 "Final x should be near target distance"
1118 );
1119 assert!(
1120 final_state[3] < 0.9 * initial_state[3],
1121 "standard-atmosphere drag should reduce downrange velocity"
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn test_rk4_vs_rk45_consistency() {
1128 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1131
1132 let params_rk4 = create_test_params(target_distance);
1133 let params_rk45 = create_test_params(target_distance);
1134
1135 let trajectory_rk4 =
1136 integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
1137 let trajectory_rk45 =
1138 integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
1139
1140 assert!(!trajectory_rk4.is_empty());
1142 assert!(!trajectory_rk45.is_empty());
1143
1144 let (time_rk4, final_rk4) = trajectory_rk4.last().unwrap();
1145 let (time_rk45, final_rk45) = trajectory_rk45.last().unwrap();
1146
1147 assert!(
1149 (time_rk4 - time_rk45).abs() < 1e-4,
1150 "RK4/RK45 time of flight diverged: {time_rk4} vs {time_rk45}"
1151 );
1152 assert!((final_rk4[1] - final_rk45[1]).abs() < 1e-3);
1153 assert!((final_rk4[3] - final_rk45[3]).abs() < 1e-2);
1154 assert!(final_rk45[3] < 0.9 * initial_state[3]);
1155 }
1156
1157 #[test]
1158 fn test_ground_impact_detection() {
1159 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;
1164 let ground_threshold = 0.0;
1165 params.ground_threshold = ground_threshold;
1166
1167 let trajectory =
1168 integrate_trajectory(initial_state, (0.0, 20.0), params, "RK4", 1e-6, 0.01);
1169
1170 let (_, final_state) = trajectory.last().unwrap();
1172
1173 assert!(
1175 final_state[1] <= ground_threshold,
1176 "Should hit ground, but y={}",
1177 final_state[1]
1178 );
1179 assert!(
1180 final_state[0] < 10000.0,
1181 "Should not reach target, but z={}",
1182 final_state[0]
1183 );
1184 }
1185
1186 #[test]
1187 fn test_target_distance_reached() {
1188 let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; let target_distance = 300.0;
1190
1191 let params = create_test_params(target_distance);
1192
1193 let trajectory =
1194 integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
1195
1196 let (_, final_state) = trajectory.last().unwrap();
1197
1198 assert!(
1200 (final_state[0] - target_distance).abs() < 1.0,
1201 "Should reach target at {}m, but stopped at {}m",
1202 target_distance,
1203 final_state[0]
1204 );
1205 }
1206
1207 #[test]
1208 fn test_wind_affects_trajectory() {
1209 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1214
1215 let params_no_wind = create_test_params(target_distance);
1217
1218 let mut params_headwind = create_test_params(target_distance);
1220 params_headwind.wind_segments = vec![(72.0, 0.0, 500.0)]; let trajectory_no_wind = integrate_trajectory(
1223 initial_state,
1224 (0.0, 5.0),
1225 params_no_wind,
1226 "RK45",
1227 1e-6,
1228 0.01,
1229 );
1230 let trajectory_headwind = integrate_trajectory(
1231 initial_state,
1232 (0.0, 5.0),
1233 params_headwind,
1234 "RK45",
1235 1e-6,
1236 0.01,
1237 );
1238
1239 assert!(
1241 !trajectory_no_wind.is_empty(),
1242 "No-wind trajectory should complete"
1243 );
1244 assert!(
1245 !trajectory_headwind.is_empty(),
1246 "Headwind trajectory should complete"
1247 );
1248
1249 let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
1250 let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
1251
1252 let drop_no_wind = final_no_wind[1];
1255 let drop_headwind = final_headwind[1];
1256
1257 println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
1258 println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
1259
1260 assert!(
1261 *time_headwind > *time_no_wind + 0.001,
1262 "headwind should increase time of flight: no-wind={time_no_wind}, headwind={time_headwind}"
1263 );
1264 assert!(
1265 final_headwind[3] < final_no_wind[3] - 1.0,
1266 "headwind should reduce terminal downrange velocity"
1267 );
1268
1269 assert!(
1271 (final_no_wind[0] - target_distance).abs() < 10.0,
1272 "No-wind should reach target"
1273 );
1274 assert!(
1275 (final_headwind[0] - target_distance).abs() < 10.0,
1276 "Headwind should reach target"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_solve_trajectory_rust_output_format() {
1282 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let result = solve_trajectory_rust(
1285 initial_state,
1286 (0.0, 2.0),
1287 0.01134, 0.442, DragModel::G7, vec![], (0.0, 15.0, 1013.25, 1.0),
1293 None, false, false, false, "RK45".to_string(), 1e-6, 0.01, 500.0, );
1302
1303 assert!(!result.is_empty());
1305
1306 let first_point = &result[0];
1307 assert!(first_point.contains_key("t"));
1308 assert!(first_point.contains_key("x"));
1309 assert!(first_point.contains_key("y"));
1310 assert!(first_point.contains_key("z"));
1311 assert!(first_point.contains_key("vx"));
1312 assert!(first_point.contains_key("vy"));
1313 assert!(first_point.contains_key("vz"));
1314
1315 let final_point = result.last().unwrap();
1316 assert!(
1317 final_point["vx"] < 0.9 * initial_state[3],
1318 "standard-atmosphere wrapper fixture should exercise drag"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_left_vs_right_twist() {
1324 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1326
1327 let mut params_right = create_test_params(target_distance);
1328 params_right.is_twist_right = true;
1329 params_right.enable_spin_drift = true;
1330
1331 let mut params_left = create_test_params(target_distance);
1332 params_left.is_twist_right = false;
1333 params_left.enable_spin_drift = true;
1334
1335 let trajectory_right =
1336 integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
1337 let trajectory_left =
1338 integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
1339
1340 assert!(!trajectory_right.is_empty());
1342 assert!(!trajectory_left.is_empty());
1343
1344 let (_, final_right) = trajectory_right.last().unwrap();
1346 let (_, final_left) = trajectory_left.last().unwrap();
1347
1348 assert!((final_right[2] - final_left[2]).abs() < 10.0);
1350 }
1351}