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.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 bc_segments: Option<Vec<(f64, f64)>>, pub use_bc_segments: bool, pub ground_threshold: f64,
271 pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
275}
276
277fn build_inputs(params: &TrajectoryParams, muzzle_velocity_mps: f64) -> BallisticInputs {
283 let mut inputs = BallisticInputs {
284 bc_value: params.bc,
285 bc_type: params.drag_model,
286 bullet_mass: params.mass_kg, muzzle_velocity: muzzle_velocity_mps,
288 bullet_diameter: params.bullet_diameter, bullet_length: params.bullet_length,
290 twist_rate: params.twist_rate,
291 is_twist_right: params.is_twist_right,
292 enable_advanced_effects: params.enable_spin_drift
293 || params.enable_magnus
294 || params.enable_coriolis,
295 enable_magnus: params.enable_magnus,
296 enable_coriolis: params.enable_coriolis,
297 altitude: params.atmos_params.0,
298 temperature: params.atmos_params.1,
299 pressure: params.atmos_params.2,
300 humidity: params.atmos_params.3,
301 tipoff_yaw: 0.0,
302 target_distance: 1000.0, muzzle_angle: 0.0,
304 wind_speed: if !params.wind_segments.is_empty() {
305 params.wind_segments[0].speed_kmh * 0.2777778 } else {
307 0.0
308 },
309 wind_angle: if !params.wind_segments.is_empty() {
310 params.wind_segments[0].angle_deg.to_radians() } else {
312 0.0
313 },
314 latitude: None,
315 shooting_angle: params.shooting_angle,
316 cant_angle: 0.0,
317 azimuth_angle: 0.0,
318 shot_azimuth: 0.0, use_powder_sensitivity: false,
320 powder_temp_sensitivity: 0.0,
321 powder_temp: 59.0,
322 powder_temp_curve: None,
323 powder_curve_temp_c: None,
324 tipoff_decay_distance: 0.0,
325 ground_threshold: params.ground_threshold, bc_segments: params.bc_segments.clone(),
327 caliber_inches: params.bullet_diameter / 0.0254, weight_grains: params.mass_kg / 0.00006479891,
329 use_bc_segments: params.use_bc_segments,
330 bullet_id: None,
331 bc_segments_data: None,
332 use_enhanced_spin_drift: params.enable_spin_drift,
333 use_form_factor: false,
334 manufacturer: None,
335 bullet_model: None,
336 enable_wind_shear: false,
337 wind_shear_model: "none".to_string(),
338 use_cluster_bc: false,
339 bullet_cluster: None,
340 custom_drag_table: params.custom_drag_table.clone(),
341 bc_type_str: None,
342 enable_pitch_damping: false,
343 enable_precession_nutation: false,
344 enable_aerodynamic_jump: false,
350 use_rk4: true,
351 use_adaptive_rk45: false,
352 enable_trajectory_sampling: false,
353 sample_interval: 10.0,
354 sight_height: 0.0,
355 muzzle_height: 0.0,
356 target_height: 0.0,
357 };
358
359 if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
366 inputs.bc_segments_data =
367 crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
368 }
369 inputs
370}
371
372fn compute_derivatives_vec(
374 state: &Vector6<f64>,
375 t: f64,
376 params: &TrajectoryParams,
377 inputs: &BallisticInputs,
378) -> Vector6<f64> {
379 let pos = Vector3::new(state[0], state[1], state[2]);
380 let vel = Vector3::new(state[3], state[4], state[5]);
381
382 let wind_vector = if !params.wind_segments.is_empty() {
384 if params.enable_wind_shear && params.wind_shear_model != "none" {
385 crate::wind_shear::get_wind_at_position(
386 &pos,
387 ¶ms.wind_segments,
388 params.enable_wind_shear,
389 ¶ms.wind_shear_model,
390 params.shooter_altitude_m,
391 )
392 } else {
393 wind_vector_for_range(pos.x, ¶ms.wind_segments)
394 }
395 } else {
396 Vector3::zeros()
397 };
398
399 let deriv_result = compute_derivatives(
402 pos,
403 vel,
404 inputs,
405 wind_vector,
406 params.atmos_params,
407 params.bc,
408 params.omega_vector,
409 t,
410 params.atmo_sock.as_ref(),
411 );
412
413 Vector6::new(
414 deriv_result[0],
415 deriv_result[1],
416 deriv_result[2],
417 deriv_result[3],
418 deriv_result[4],
419 deriv_result[5],
420 )
421}
422
423fn interpolate_target_crossing(
429 start_time: f64,
430 start: &Vector6<f64>,
431 step_dt: f64,
432 end: &Vector6<f64>,
433 target_x: f64,
434) -> (f64, Vector6<f64>) {
435 debug_assert!(start[0] <= target_x && target_x <= end[0] && end[0] > start[0]);
436
437 let alpha = (target_x - start[0]) / (end[0] - start[0]);
438 let crossing_time = start_time + alpha * step_dt;
439 let mut crossing_state = start + alpha * (end - start);
440 crossing_state[0] = target_x;
441
442 (crossing_time, crossing_state)
443}
444
445pub fn integrate_trajectory(
447 initial_state: [f64; 6],
448 t_span: (f64, f64),
449 mut params: TrajectoryParams,
450 method: &str,
451 tolerance: f64,
452 max_step: f64,
453) -> Vec<(f64, Vector6<f64>)> {
454 crate::wind::sort_wind_segments_by_distance(&mut params.wind_segments);
457
458 let mut state = Vector6::new(
459 initial_state[0],
460 initial_state[1],
461 initial_state[2],
462 initial_state[3],
463 initial_state[4],
464 initial_state[5],
465 );
466
467 let mut t = t_span.0;
468 let t_end = t_span.1;
469 let mut dt = (t_end - t) / 1000.0; let mut trajectory = Vec::with_capacity(10000);
472 trajectory.push((t, state));
473 if state[0] >= params.target_distance_m {
474 return trajectory;
475 }
476
477 let muzzle_velocity_mps =
480 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
481 let inputs = build_inputs(¶ms, muzzle_velocity_mps);
482
483 match method {
484 "RK4" => {
485 dt = dt.min(max_step).min(0.001); while t < t_end {
489 if t + dt > t_end {
490 dt = t_end - t;
491 }
492
493 let new_state = rk4_step(&state, t, dt, ¶ms, &inputs);
494
495 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
497 trajectory.push(interpolate_target_crossing(
498 t,
499 &state,
500 dt,
501 &new_state,
502 params.target_distance_m,
503 ));
504 break; }
506
507 state = new_state;
508 t += dt;
509 trajectory.push((t, state));
510
511 if state[0] >= params.target_distance_m {
513 break;
514 }
515
516 if state[1] < params.ground_threshold {
519 break;
520 }
521 }
522 }
523 _ => {
524 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 {
528 tolerance
529 } else {
530 eprintln!(
531 "WARNING: RK45 tolerance must be finite and positive; using {RK45_DEFAULT_TOLERANCE}"
532 );
533 RK45_DEFAULT_TOLERANCE
534 };
535
536 let effective_max_step =
539 if params.enable_wind_shear && params.wind_shear_model != "none" {
540 if params.target_distance_m > 800.0 {
542 0.01 } else {
544 0.02 }
546 } else {
547 max_step };
549 if !effective_max_step.is_finite() || effective_max_step <= 0.0 {
550 eprintln!("WARNING: RK45 max_step must be finite and positive");
551 return trajectory;
552 }
553 let min_step = RK45_MIN_STEP.min(effective_max_step);
554
555 dt = dt.min(effective_max_step).max(min_step);
557
558 let max_iterations = 100000; let mut iteration_count = 0;
561
562 while t < t_end && iteration_count < max_iterations {
563 if t + dt > t_end {
565 dt = t_end - t;
566 }
567
568 let control = Rk45Control {
569 tolerance,
570 min_step,
571 max_step: effective_max_step,
572 max_trials: max_iterations - iteration_count,
573 };
574 let accepted = match adaptive_rk45_step(&state, t, dt, ¶ms, &inputs, control) {
575 Ok(accepted) => accepted,
576 Err(trials) => {
577 iteration_count += trials;
578 if iteration_count < max_iterations {
579 eprintln!("WARNING: RK45 minimum-step trial was non-finite");
580 }
581 break;
582 }
583 };
584 iteration_count += accepted.trials;
585 debug_assert!(accepted.error <= tolerance || accepted.used_dt <= min_step);
586
587 if state[0] < params.target_distance_m
589 && accepted.state[0] >= params.target_distance_m
590 {
591 trajectory.push(interpolate_target_crossing(
592 t,
593 &state,
594 accepted.used_dt,
595 &accepted.state,
596 params.target_distance_m,
597 ));
598 break;
599 }
600
601 state = accepted.state;
603 t += accepted.used_dt;
604
605 if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
607 {
608 trajectory.push((t, state));
610 last_save_x = state[0];
611 }
612
613 dt = accepted.next_dt;
615
616 if state[0] >= params.target_distance_m {
618 break;
619 }
620
621 if state[1] < params.ground_threshold {
624 break;
625 }
626 }
627
628 if iteration_count >= max_iterations
630 && t < t_end
631 && state[0] < params.target_distance_m
632 && state[1] >= params.ground_threshold
633 {
634 eprintln!(
635 "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
636 max_iterations
637 );
638 eprintln!(" Final time: {}, Target time: {}", t, t_end);
639 eprintln!(
640 " Final position: downrange(x)={}, Target: {}m",
641 state[0], params.target_distance_m
642 );
643 }
644 }
645 }
646
647 trajectory
648}
649
650#[allow(clippy::too_many_arguments)] pub fn solve_trajectory_rust(
653 initial_state: [f64; 6],
654 t_span: (f64, f64),
655 mass_kg: f64,
656 bc: f64,
657 drag_model: DragModel,
658 wind_segments: Vec<WindSegment>,
659 atmos_params: (f64, f64, f64, f64),
660 omega_vector: Option<Vec<f64>>,
661 enable_spin_drift: bool,
662 enable_magnus: bool,
663 enable_coriolis: bool,
664 method: String,
665 tolerance: f64,
666 max_step: f64,
667 target_distance_m: f64,
668) -> Vec<HashMap<String, f64>> {
669 let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
670
671 let params = TrajectoryParams {
672 mass_kg,
673 bc,
674 drag_model,
675 wind_segments,
676 atmos_params,
677 omega_vector: omega_vec,
678 enable_spin_drift,
679 enable_magnus,
680 enable_coriolis,
681 target_distance_m,
682 enable_wind_shear: false, wind_shear_model: "none".to_string(),
684 shooter_altitude_m: 0.0,
685 is_twist_right: true, shooting_angle: 0.0, bullet_diameter: 0.0078232,
690 bullet_length: 0.031496,
691 twist_rate: 10.0,
692 custom_drag_table: None, bc_segments: None, use_bc_segments: false,
695 ground_threshold: -1000.0, atmo_sock: None, };
698
699 let trajectory =
700 integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
701
702 trajectory
704 .into_iter()
705 .map(|(t, state)| {
706 let mut point = HashMap::new();
707 point.insert("t".to_string(), t);
708 point.insert("x".to_string(), state[0]);
709 point.insert("y".to_string(), state[1]);
710 point.insert("z".to_string(), state[2]);
711 point.insert("vx".to_string(), state[3]);
712 point.insert("vy".to_string(), state[4]);
713 point.insert("vz".to_string(), state[5]);
714 point
715 })
716 .collect()
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
724 TrajectoryParams {
725 mass_kg: 0.01134, bc: 0.442,
727 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
730 drag_model: DragModel::G7,
731 wind_segments: vec![],
732 atmos_params: (0.0, 15.0, 1013.25, 1.0),
733 omega_vector: None,
734 enable_spin_drift: false,
735 enable_magnus: false,
736 enable_coriolis: false,
737 target_distance_m,
738 enable_wind_shear: false,
739 wind_shear_model: "none".to_string(),
740 shooter_altitude_m: 0.0,
741 is_twist_right: true,
742 shooting_angle: 0.0,
743 custom_drag_table: None,
744 bc_segments: None,
745 use_bc_segments: false,
746 ground_threshold: -1000.0,
747 atmo_sock: None,
748 }
749 }
750
751 #[test]
752 fn derivative_inputs_preserve_initial_velocity_as_muzzle_speed() {
753 let params = create_test_params(1_000.0);
754 let launch_velocity = Vector3::new(700.0, 30.0, -20.0);
755 let inputs = build_inputs(¶ms, launch_velocity.norm());
756
757 assert_eq!(
758 inputs.muzzle_velocity.to_bits(),
759 launch_velocity.norm().to_bits()
760 );
761 }
762
763 #[test]
764 fn integrated_magnus_retains_nonzero_launch_spin() {
765 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
766 let baseline = integrate_trajectory(
767 initial_state,
768 (0.0, 0.1),
769 create_test_params(1_000.0),
770 "RK4",
771 1e-6,
772 0.001,
773 );
774 let mut magnus_params = create_test_params(1_000.0);
775 magnus_params.enable_magnus = true;
776
777 let trajectory = integrate_trajectory(
778 initial_state,
779 (0.0, 0.1),
780 magnus_params,
781 "RK4",
782 1e-6,
783 0.001,
784 );
785 let baseline_y = baseline.last().expect("baseline trajectory is empty").1[1];
786 let magnus_y = trajectory.last().expect("trajectory is empty").1[1];
787 let vertical_delta = magnus_y - baseline_y;
788
789 assert!(
790 vertical_delta.is_finite() && vertical_delta < 0.0,
791 "right-twist Magnus should retain nonzero launch spin and point down, got \
792 delta_y={vertical_delta}"
793 );
794 }
795
796 #[test]
797 fn rk45_retries_rejected_wind_boundary_step() {
798 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
799 let mut params = create_test_params(100.0);
800 params.wind_segments = vec![
801 WindSegment::new(0.0, 90.0, 4.0),
802 WindSegment::new(1_000.0, 90.0, 10_000.0),
803 ];
804
805 let state = Vector6::from_row_slice(&initial_state);
806 let launch_speed =
807 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
808 let inputs = build_inputs(¶ms, launch_speed);
809 let initial_dt = 0.01;
810 let tolerance = 1e-6;
811 let (rejected_state, suggested_dt, error) =
812 rk45_step(&state, 0.0, initial_dt, ¶ms, &inputs, tolerance);
813 assert!(
814 error > tolerance,
815 "wind-boundary trial must exceed tolerance, got {error}"
816 );
817 assert!(suggested_dt < initial_dt);
818
819 let accepted = adaptive_rk45_step(
820 &state,
821 0.0,
822 initial_dt,
823 ¶ms,
824 &inputs,
825 Rk45Control {
826 tolerance,
827 min_step: RK45_MIN_STEP,
828 max_step: initial_dt,
829 max_trials: 100,
830 },
831 )
832 .expect("a smaller finite trial should satisfy the tolerance");
833
834 assert!(accepted.trials > 1, "oversized trial was not retried");
835 assert!(accepted.used_dt < initial_dt);
836 assert!(
837 accepted.error <= tolerance || accepted.used_dt <= RK45_MIN_STEP,
838 "accepted error {} exceeds tolerance at dt {}",
839 accepted.error,
840 accepted.used_dt
841 );
842
843 let (accepted_state, _, accepted_error) =
844 rk45_step(&state, 0.0, accepted.used_dt, ¶ms, &inputs, tolerance);
845 assert_eq!(accepted.state, accepted_state);
846 assert_eq!(accepted.error, accepted_error);
847 assert_ne!(accepted.state, rejected_state);
848 assert!((RK45_MIN_STEP..=initial_dt).contains(&accepted.next_dt));
849 }
850
851 #[test]
852 fn integration_normalizes_wind_segments_by_distance() {
853 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
854 let sorted_segments = vec![
855 WindSegment::new(40.0, 270.0, 300.0),
856 WindSegment::new(20.0, 90.0, 600.0),
857 ];
858
859 let mut sorted_params = create_test_params(100.0);
860 sorted_params.wind_segments = sorted_segments.clone();
861 let mut unsorted_params = create_test_params(100.0);
862 unsorted_params.wind_segments = sorted_segments.into_iter().rev().collect();
863
864 let sorted =
865 integrate_trajectory(initial_state, (0.0, 1.0), sorted_params, "RK4", 1e-6, 0.001);
866 let unsorted = integrate_trajectory(
867 initial_state,
868 (0.0, 1.0),
869 unsorted_params,
870 "RK4",
871 1e-6,
872 0.001,
873 );
874
875 assert_eq!(unsorted.len(), sorted.len());
876 for (index, ((sorted_t, sorted_state), (unsorted_t, unsorted_state))) in
877 sorted.iter().zip(&unsorted).enumerate()
878 {
879 assert_eq!(unsorted_t.to_bits(), sorted_t.to_bits());
880 for component in 0..6 {
881 assert_eq!(
882 unsorted_state[component].to_bits(),
883 sorted_state[component].to_bits(),
884 "wind segment order changed state component {component} at point {index}"
885 );
886 }
887 }
888 }
889
890 #[test]
891 fn rk4_target_crossing_interpolates_complete_state_and_time() {
892 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
893 let target_distance_m = 100.0;
894 let trajectory = integrate_trajectory(
895 initial_state,
896 (0.0, 1.0),
897 create_test_params(target_distance_m),
898 "RK4",
899 1e-6,
900 0.001,
901 );
902
903 let (previous_t, previous_state) = &trajectory[trajectory.len() - 2];
904 let (terminal_t, terminal_state) = trajectory.last().expect("trajectory is empty");
905 let reference_params = create_test_params(target_distance_m);
906 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
907 let full_step_dt = 0.001;
908 let bracket_end = rk4_step(
909 previous_state,
910 *previous_t,
911 full_step_dt,
912 &reference_params,
913 &inputs,
914 );
915 assert!(previous_state[0] < target_distance_m);
916 assert!(bracket_end[0] >= target_distance_m);
917
918 let alpha = (target_distance_m - previous_state[0]) / (bracket_end[0] - previous_state[0]);
919 let expected_t = previous_t + alpha * full_step_dt;
920 let mut expected_state = previous_state + alpha * (bracket_end - previous_state);
921 expected_state[0] = target_distance_m;
922
923 assert_eq!(terminal_t.to_bits(), expected_t.to_bits());
924 for component in 0..6 {
925 assert_eq!(
926 terminal_state[component].to_bits(),
927 expected_state[component].to_bits(),
928 "terminal component {component} was not interpolated at the target crossing"
929 );
930 }
931 }
932
933 #[test]
934 fn rk45_target_crossing_uses_the_accepted_state_and_time() {
935 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
936 let initial = Vector6::from_row_slice(&initial_state);
937 let target_distance_m = 0.5;
938 let reference_params = create_test_params(target_distance_m);
939 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
940 let initial_dt = 0.001;
941 let accepted = adaptive_rk45_step(
942 &initial,
943 0.0,
944 initial_dt,
945 &reference_params,
946 &inputs,
947 Rk45Control {
948 tolerance: 1e-6,
949 min_step: RK45_MIN_STEP,
950 max_step: 0.01,
951 max_trials: 100_000,
952 },
953 )
954 .expect("first RK45 target bracket should be accepted");
955 assert!(accepted.state[0] >= target_distance_m);
956 let expected = interpolate_target_crossing(
957 0.0,
958 &initial,
959 accepted.used_dt,
960 &accepted.state,
961 target_distance_m,
962 );
963
964 let trajectory = integrate_trajectory(
965 initial_state,
966 (0.0, 1.0),
967 create_test_params(target_distance_m),
968 "RK45",
969 1e-6,
970 0.01,
971 );
972 let actual = trajectory.last().expect("trajectory is empty");
973
974 assert_eq!(actual.0.to_bits(), expected.0.to_bits());
975 for component in 0..6 {
976 assert_eq!(
977 actual.1[component].to_bits(),
978 expected.1[component].to_bits(),
979 "RK45 terminal component {component} was not interpolated from its accepted step"
980 );
981 }
982 }
983
984 #[test]
985 fn target_crossing_helper_interpolates_every_component() {
986 let start = Vector6::new(90.0, 10.0, -4.0, 700.0, -20.0, 5.0);
987 let end = Vector6::new(130.0, 6.0, 8.0, 660.0, -24.0, 9.0);
988 let (time, state) = interpolate_target_crossing(2.0, &start, 0.5, &end, 100.0);
989
990 assert_eq!(time.to_bits(), 2.125_f64.to_bits());
991 for (index, expected) in [100.0_f64, 9.0, -1.0, 690.0, -21.0, 6.0]
992 .into_iter()
993 .enumerate()
994 {
995 assert_eq!(state[index].to_bits(), expected.to_bits());
996 }
997 }
998
999 #[test]
1000 fn already_at_or_past_target_returns_initial_state_without_advancing() {
1001 let initial = [150.0, 12.0, -3.0, 700.0, -4.0, 5.0];
1002
1003 for method in ["RK4", "RK45"] {
1004 for target in [150.0, 100.0] {
1005 let trajectory = integrate_trajectory(
1006 initial,
1007 (2.0, 3.0),
1008 create_test_params(target),
1009 method,
1010 1e-6,
1011 0.01,
1012 );
1013
1014 assert_eq!(trajectory.len(), 1, "{method} advanced a terminal state");
1015 let (time, state) = &trajectory[0];
1016 assert_eq!(time.to_bits(), 2.0_f64.to_bits());
1017 for index in 0..6 {
1018 assert_eq!(state[index].to_bits(), initial[index].to_bits());
1019 }
1020 }
1021 }
1022 }
1023
1024 #[test]
1025 fn rk45_error_norm_scales_components_independently() {
1026 let state = Vector6::new(1.0e9, 0.0, 0.0, 800.0, 0.0, 0.0);
1027 let fifth_order = state;
1028 let mut fourth_order = state;
1029 fourth_order[4] = 1.0e-3;
1030
1031 let error = rk45_error_norm(&state, &fifth_order, &fourth_order);
1032 let expected = 1.0e-3 / 6.0_f64.sqrt();
1033
1034 assert!(
1035 (error - expected).abs() <= 1e-15,
1036 "large downrange position masked a velocity-component error: {error}"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_mba954_ground_threshold_honored() {
1042 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);
1050 deep.ground_threshold = -1000.0; let t_shallow =
1053 integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
1054 let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
1055
1056 assert!(
1057 t_shallow.len() < t_deep.len(),
1058 "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
1059 shallow={}, deep={}",
1060 t_shallow.len(),
1061 t_deep.len()
1062 );
1063 }
1064
1065 #[test]
1066 fn test_integrate_trajectory_basic() {
1067 let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
1070
1071 let params = TrajectoryParams {
1072 mass_kg: 0.01134, bc: 0.442,
1074 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
1077 drag_model: DragModel::G7,
1078 wind_segments: vec![WindSegment::new(0.0, 90.0, 914.4)],
1079 atmos_params: (0.0, 15.0, 1013.25, 1.0),
1080 omega_vector: None,
1081 enable_spin_drift: false,
1082 enable_magnus: false,
1083 enable_coriolis: false,
1084 target_distance_m: 914.4, enable_wind_shear: false,
1086 wind_shear_model: "none".to_string(),
1087 shooter_altitude_m: 0.0,
1088 is_twist_right: true,
1089 shooting_angle: 0.0,
1090 custom_drag_table: None,
1091 bc_segments: None,
1092 use_bc_segments: false,
1093 ground_threshold: -1000.0,
1094 atmo_sock: None,
1095 };
1096
1097 println!("Running integrate_trajectory test...");
1098 println!("Initial state: {:?}", initial_state);
1099 println!("Target distance: {} m", params.target_distance_m);
1100
1101 let trajectory =
1102 integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
1103
1104 println!("Trajectory has {} points", trajectory.len());
1105
1106 assert!(
1108 trajectory.len() > 1,
1109 "Trajectory should have more than 1 point, but has {}",
1110 trajectory.len()
1111 );
1112
1113 if let Some((_, final_state)) = trajectory.last() {
1115 println!("Final state: downrange(x)={}", final_state[0]);
1116 assert!(
1117 final_state[0] > 0.0,
1118 "Final x should be positive (bullet moved downrange)"
1119 );
1120 assert!(
1121 final_state[0] >= 900.0,
1122 "Final x should be near target distance"
1123 );
1124 assert!(
1125 final_state[3] < 0.9 * initial_state[3],
1126 "standard-atmosphere drag should reduce downrange velocity"
1127 );
1128 }
1129 }
1130
1131 #[test]
1132 fn test_rk4_vs_rk45_consistency() {
1133 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1136
1137 let params_rk4 = create_test_params(target_distance);
1138 let params_rk45 = create_test_params(target_distance);
1139
1140 let trajectory_rk4 =
1141 integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
1142 let trajectory_rk45 =
1143 integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
1144
1145 assert!(!trajectory_rk4.is_empty());
1147 assert!(!trajectory_rk45.is_empty());
1148
1149 let (time_rk4, final_rk4) = trajectory_rk4.last().unwrap();
1150 let (time_rk45, final_rk45) = trajectory_rk45.last().unwrap();
1151
1152 assert!(
1154 (time_rk4 - time_rk45).abs() < 1e-4,
1155 "RK4/RK45 time of flight diverged: {time_rk4} vs {time_rk45}"
1156 );
1157 assert!((final_rk4[1] - final_rk45[1]).abs() < 1e-3);
1158 assert!((final_rk4[3] - final_rk45[3]).abs() < 1e-2);
1159 assert!(final_rk45[3] < 0.9 * initial_state[3]);
1160 }
1161
1162 #[test]
1163 fn test_ground_impact_detection() {
1164 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;
1169 let ground_threshold = 0.0;
1170 params.ground_threshold = ground_threshold;
1171
1172 let trajectory =
1173 integrate_trajectory(initial_state, (0.0, 20.0), params, "RK4", 1e-6, 0.01);
1174
1175 let (_, final_state) = trajectory.last().unwrap();
1177
1178 assert!(
1180 final_state[1] <= ground_threshold,
1181 "Should hit ground, but y={}",
1182 final_state[1]
1183 );
1184 assert!(
1185 final_state[0] < 10000.0,
1186 "Should not reach target, but z={}",
1187 final_state[0]
1188 );
1189 }
1190
1191 #[test]
1192 fn test_target_distance_reached() {
1193 let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; let target_distance = 300.0;
1195
1196 let params = create_test_params(target_distance);
1197
1198 let trajectory =
1199 integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
1200
1201 let (_, final_state) = trajectory.last().unwrap();
1202
1203 assert!(
1205 (final_state[0] - target_distance).abs() < 1.0,
1206 "Should reach target at {}m, but stopped at {}m",
1207 target_distance,
1208 final_state[0]
1209 );
1210 }
1211
1212 #[test]
1213 fn test_wind_affects_trajectory() {
1214 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1219
1220 let params_no_wind = create_test_params(target_distance);
1222
1223 let mut params_headwind = create_test_params(target_distance);
1225 params_headwind.wind_segments = vec![WindSegment::new(72.0, 0.0, 500.0)]; let trajectory_no_wind = integrate_trajectory(
1228 initial_state,
1229 (0.0, 5.0),
1230 params_no_wind,
1231 "RK45",
1232 1e-6,
1233 0.01,
1234 );
1235 let trajectory_headwind = integrate_trajectory(
1236 initial_state,
1237 (0.0, 5.0),
1238 params_headwind,
1239 "RK45",
1240 1e-6,
1241 0.01,
1242 );
1243
1244 assert!(
1246 !trajectory_no_wind.is_empty(),
1247 "No-wind trajectory should complete"
1248 );
1249 assert!(
1250 !trajectory_headwind.is_empty(),
1251 "Headwind trajectory should complete"
1252 );
1253
1254 let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
1255 let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
1256
1257 let drop_no_wind = final_no_wind[1];
1260 let drop_headwind = final_headwind[1];
1261
1262 println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
1263 println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
1264
1265 assert!(
1266 *time_headwind > *time_no_wind + 0.001,
1267 "headwind should increase time of flight: no-wind={time_no_wind}, headwind={time_headwind}"
1268 );
1269 assert!(
1270 final_headwind[3] < final_no_wind[3] - 1.0,
1271 "headwind should reduce terminal downrange velocity"
1272 );
1273
1274 assert!(
1276 (final_no_wind[0] - target_distance).abs() < 10.0,
1277 "No-wind should reach target"
1278 );
1279 assert!(
1280 (final_headwind[0] - target_distance).abs() < 10.0,
1281 "Headwind should reach target"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_solve_trajectory_rust_output_format() {
1287 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let result = solve_trajectory_rust(
1290 initial_state,
1291 (0.0, 2.0),
1292 0.01134, 0.442, DragModel::G7, vec![], (0.0, 15.0, 1013.25, 1.0),
1298 None, false, false, false, "RK45".to_string(), 1e-6, 0.01, 500.0, );
1307
1308 assert!(!result.is_empty());
1310
1311 let first_point = &result[0];
1312 assert!(first_point.contains_key("t"));
1313 assert!(first_point.contains_key("x"));
1314 assert!(first_point.contains_key("y"));
1315 assert!(first_point.contains_key("z"));
1316 assert!(first_point.contains_key("vx"));
1317 assert!(first_point.contains_key("vy"));
1318 assert!(first_point.contains_key("vz"));
1319
1320 let final_point = result.last().unwrap();
1321 assert!(
1322 final_point["vx"] < 0.9 * initial_state[3],
1323 "standard-atmosphere wrapper fixture should exercise drag"
1324 );
1325 }
1326
1327 #[test]
1328 fn test_left_vs_right_twist() {
1329 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1331
1332 let mut params_right = create_test_params(target_distance);
1333 params_right.is_twist_right = true;
1334 params_right.enable_spin_drift = true;
1335
1336 let mut params_left = create_test_params(target_distance);
1337 params_left.is_twist_right = false;
1338 params_left.enable_spin_drift = true;
1339
1340 let trajectory_right =
1341 integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
1342 let trajectory_left =
1343 integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
1344
1345 assert!(!trajectory_right.is_empty());
1347 assert!(!trajectory_left.is_empty());
1348
1349 let (_, final_right) = trajectory_right.last().unwrap();
1351 let (_, final_left) = trajectory_left.last().unwrap();
1352
1353 assert!((final_right[2] - final_left[2]).abs() < 10.0);
1355 }
1356}