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 bc_segments: Option<Vec<(f64, f64)>>, pub use_bc_segments: bool, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>,
277 pub ground_threshold: f64,
280 pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
284}
285
286fn build_inputs(params: &TrajectoryParams, muzzle_velocity_mps: f64) -> BallisticInputs {
292 let mut inputs = BallisticInputs {
293 bc_value: params.bc,
294 bc_type: params.drag_model,
295 bc_reference_standard: crate::cli_api::BcReferenceStandard::Icao,
303 bullet_mass: params.mass_kg, muzzle_velocity: muzzle_velocity_mps,
305 bullet_diameter: params.bullet_diameter, bullet_length: params.bullet_length,
307 twist_rate: params.twist_rate,
308 is_twist_right: params.is_twist_right,
309 enable_advanced_effects: params.enable_spin_drift
310 || params.enable_magnus
311 || params.enable_coriolis,
312 enable_magnus: params.enable_magnus,
313 enable_coriolis: params.enable_coriolis,
314 altitude: params.atmos_params.0,
315 temperature: params.atmos_params.1,
316 pressure: params.atmos_params.2,
317 humidity: params.atmos_params.3,
318 tipoff_yaw: 0.0,
319 cd_delta2: 7.5,
320 target_distance: 1000.0, muzzle_angle: 0.0,
322 wind_speed: if !params.wind_segments.is_empty() {
323 params.wind_segments[0].speed_kmh * 0.2777778 } else {
325 0.0
326 },
327 wind_angle: if !params.wind_segments.is_empty() {
328 params.wind_segments[0].angle_deg.to_radians() } else {
330 0.0
331 },
332 latitude: None,
333 shooting_angle: params.shooting_angle,
334 cant_angle: 0.0,
335 azimuth_angle: 0.0,
336 shot_azimuth: 0.0, use_powder_sensitivity: false,
338 powder_temp_sensitivity: 0.0,
339 powder_temp: 59.0,
340 powder_temp_curve: None,
341 powder_curve_temp_c: None,
342 tipoff_decay_distance: 0.0,
343 ground_threshold: params.ground_threshold, bc_segments: params.bc_segments.clone(),
345 caliber_inches: params.bullet_diameter / 0.0254, weight_grains: params.mass_kg / crate::constants::GRAINS_TO_KG,
347 use_bc_segments: params.use_bc_segments,
348 bullet_id: None,
349 bc_segments_data: params
352 .bc_segments_data
353 .clone()
354 .filter(|segments| !segments.is_empty()),
355 use_enhanced_spin_drift: params.enable_spin_drift,
356 use_form_factor: false,
357 manufacturer: None,
358 bullet_model: None,
359 enable_wind_shear: false,
360 wind_shear_model: "none".to_string(),
361 use_cluster_bc: false,
362 bullet_cluster: None,
363 custom_drag_table: params.custom_drag_table.clone(),
364 cd_scale: params.cd_scale,
365 bc_type_str: None,
366 enable_pitch_damping: false,
367 enable_precession_nutation: false,
368 enable_aerodynamic_jump: false,
374 use_rk4: true,
375 use_adaptive_rk45: false,
376 enable_trajectory_sampling: false,
377 sample_interval: 10.0,
378 sight_height: 0.0,
379 sight_offset_lateral_m: 0.0,
382 muzzle_height: 0.0,
383 target_height: 0.0,
384 zero_poi_vertical_m: 0.0,
388 zero_poi_horizontal_m: 0.0,
389 drops_reference: crate::cli_api::DropsReference::Los,
393 };
394
395 if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
402 inputs.bc_segments_data =
403 crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
404 }
405 inputs
406}
407
408fn compute_derivatives_vec(
410 state: &Vector6<f64>,
411 t: f64,
412 params: &TrajectoryParams,
413 inputs: &BallisticInputs,
414) -> Vector6<f64> {
415 let pos = Vector3::new(state[0], state[1], state[2]);
416 let vel = Vector3::new(state[3], state[4], state[5]);
417
418 let wind_vector = if !params.wind_segments.is_empty() {
420 if params.enable_wind_shear && params.wind_shear_model != "none" {
421 crate::wind_shear::get_wind_at_position(
422 &pos,
423 ¶ms.wind_segments,
424 params.enable_wind_shear,
425 ¶ms.wind_shear_model,
426 params.shooter_altitude_m,
427 )
428 } else {
429 wind_vector_for_range(pos.x, ¶ms.wind_segments)
430 }
431 } else {
432 Vector3::zeros()
433 };
434
435 let deriv_result = compute_derivatives(
438 pos,
439 vel,
440 inputs,
441 wind_vector,
442 params.atmos_params,
443 params.bc,
444 params.omega_vector,
445 t,
446 params.atmo_sock.as_ref(),
447 );
448
449 Vector6::new(
450 deriv_result[0],
451 deriv_result[1],
452 deriv_result[2],
453 deriv_result[3],
454 deriv_result[4],
455 deriv_result[5],
456 )
457}
458
459fn interpolate_target_crossing(
465 start_time: f64,
466 start: &Vector6<f64>,
467 step_dt: f64,
468 end: &Vector6<f64>,
469 target_x: f64,
470) -> (f64, Vector6<f64>) {
471 debug_assert!(start[0] <= target_x && target_x <= end[0] && end[0] > start[0]);
472
473 let alpha = (target_x - start[0]) / (end[0] - start[0]);
474 let crossing_time = start_time + alpha * step_dt;
475 let mut crossing_state = start + alpha * (end - start);
476 crossing_state[0] = target_x;
477
478 (crossing_time, crossing_state)
479}
480
481pub fn try_integrate_trajectory(
487 initial_state: [f64; 6],
488 t_span: (f64, f64),
489 params: TrajectoryParams,
490 method: &str,
491 tolerance: f64,
492 max_step: f64,
493) -> Result<Vec<(f64, Vector6<f64>)>, WindSegmentError> {
494 crate::wind::validate_wind_segments(¶ms.wind_segments)?;
495 Ok(integrate_trajectory(
496 initial_state,
497 t_span,
498 params,
499 method,
500 tolerance,
501 max_step,
502 ))
503}
504
505pub fn integrate_trajectory(
512 initial_state: [f64; 6],
513 t_span: (f64, f64),
514 mut params: TrajectoryParams,
515 method: &str,
516 tolerance: f64,
517 max_step: f64,
518) -> Vec<(f64, Vector6<f64>)> {
519 crate::wind::sort_wind_segments_by_distance(&mut params.wind_segments);
522
523 let mut state = Vector6::new(
524 initial_state[0],
525 initial_state[1],
526 initial_state[2],
527 initial_state[3],
528 initial_state[4],
529 initial_state[5],
530 );
531
532 let mut t = t_span.0;
533 let t_end = t_span.1;
534 let mut dt = (t_end - t) / 1000.0; let mut trajectory = Vec::with_capacity(10000);
537 trajectory.push((t, state));
538 if state[0] >= params.target_distance_m {
539 return trajectory;
540 }
541
542 let muzzle_velocity_mps =
545 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
546 let inputs = build_inputs(¶ms, muzzle_velocity_mps);
547
548 match method {
549 "RK4" => {
550 dt = dt.min(max_step).min(0.001); while t < t_end {
554 if t + dt > t_end {
555 dt = t_end - t;
556 }
557
558 let new_state = rk4_step(&state, t, dt, ¶ms, &inputs);
559
560 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
562 trajectory.push(interpolate_target_crossing(
563 t,
564 &state,
565 dt,
566 &new_state,
567 params.target_distance_m,
568 ));
569 break; }
571
572 state = new_state;
573 t += dt;
574 trajectory.push((t, state));
575
576 if state[0] >= params.target_distance_m {
578 break;
579 }
580
581 if state[1] < params.ground_threshold {
584 break;
585 }
586 }
587 }
588 _ => {
589 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 {
593 tolerance
594 } else {
595 eprintln!(
596 "WARNING: RK45 tolerance must be finite and positive; using {RK45_DEFAULT_TOLERANCE}"
597 );
598 RK45_DEFAULT_TOLERANCE
599 };
600
601 let effective_max_step =
604 if params.enable_wind_shear && params.wind_shear_model != "none" {
605 if params.target_distance_m > 800.0 {
607 0.01 } else {
609 0.02 }
611 } else {
612 max_step };
614 if !effective_max_step.is_finite() || effective_max_step <= 0.0 {
615 eprintln!("WARNING: RK45 max_step must be finite and positive");
616 return trajectory;
617 }
618 let min_step = RK45_MIN_STEP.min(effective_max_step);
619
620 dt = dt.min(effective_max_step).max(min_step);
622
623 let max_iterations = 100000; let mut iteration_count = 0;
626
627 while t < t_end && iteration_count < max_iterations {
628 if t + dt > t_end {
630 dt = t_end - t;
631 }
632
633 let control = Rk45Control {
634 tolerance,
635 min_step,
636 max_step: effective_max_step,
637 max_trials: max_iterations - iteration_count,
638 };
639 let accepted = match adaptive_rk45_step(&state, t, dt, ¶ms, &inputs, control) {
640 Ok(accepted) => accepted,
641 Err(trials) => {
642 iteration_count += trials;
643 if iteration_count < max_iterations {
644 eprintln!("WARNING: RK45 minimum-step trial was non-finite");
645 }
646 break;
647 }
648 };
649 iteration_count += accepted.trials;
650 debug_assert!(accepted.error <= tolerance || accepted.used_dt <= min_step);
651
652 if state[0] < params.target_distance_m
654 && accepted.state[0] >= params.target_distance_m
655 {
656 trajectory.push(interpolate_target_crossing(
657 t,
658 &state,
659 accepted.used_dt,
660 &accepted.state,
661 params.target_distance_m,
662 ));
663 break;
664 }
665
666 state = accepted.state;
668 t += accepted.used_dt;
669
670 if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
672 {
673 trajectory.push((t, state));
675 last_save_x = state[0];
676 }
677
678 dt = accepted.next_dt;
680
681 if state[0] >= params.target_distance_m {
683 break;
684 }
685
686 if state[1] < params.ground_threshold {
689 break;
690 }
691 }
692
693 if iteration_count >= max_iterations
695 && t < t_end
696 && state[0] < params.target_distance_m
697 && state[1] >= params.ground_threshold
698 {
699 eprintln!(
700 "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
701 max_iterations
702 );
703 eprintln!(" Final time: {}, Target time: {}", t, t_end);
704 eprintln!(
705 " Final position: downrange(x)={}, Target: {}m",
706 state[0], params.target_distance_m
707 );
708 }
709 }
710 }
711
712 trajectory
713}
714
715#[allow(clippy::too_many_arguments)] pub fn try_solve_trajectory_rust(
721 initial_state: [f64; 6],
722 t_span: (f64, f64),
723 mass_kg: f64,
724 bc: f64,
725 drag_model: DragModel,
726 wind_segments: Vec<WindSegment>,
727 atmos_params: (f64, f64, f64, f64),
728 omega_vector: Option<Vec<f64>>,
729 enable_spin_drift: bool,
730 enable_magnus: bool,
731 enable_coriolis: bool,
732 method: String,
733 tolerance: f64,
734 max_step: f64,
735 target_distance_m: f64,
736) -> Result<Vec<HashMap<String, f64>>, WindSegmentError> {
737 crate::wind::validate_wind_segments(&wind_segments)?;
738 Ok(solve_trajectory_rust(
739 initial_state,
740 t_span,
741 mass_kg,
742 bc,
743 drag_model,
744 wind_segments,
745 atmos_params,
746 omega_vector,
747 enable_spin_drift,
748 enable_magnus,
749 enable_coriolis,
750 method,
751 tolerance,
752 max_step,
753 target_distance_m,
754 ))
755}
756
757#[allow(clippy::too_many_arguments)] pub fn solve_trajectory_rust(
763 initial_state: [f64; 6],
764 t_span: (f64, f64),
765 mass_kg: f64,
766 bc: f64,
767 drag_model: DragModel,
768 wind_segments: Vec<WindSegment>,
769 atmos_params: (f64, f64, f64, f64),
770 omega_vector: Option<Vec<f64>>,
771 enable_spin_drift: bool,
772 enable_magnus: bool,
773 enable_coriolis: bool,
774 method: String,
775 tolerance: f64,
776 max_step: f64,
777 target_distance_m: f64,
778) -> Vec<HashMap<String, f64>> {
779 let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
780
781 let params = TrajectoryParams {
782 mass_kg,
783 bc,
784 drag_model,
785 wind_segments,
786 atmos_params,
787 omega_vector: omega_vec,
788 enable_spin_drift,
789 enable_magnus,
790 enable_coriolis,
791 target_distance_m,
792 enable_wind_shear: false, wind_shear_model: "none".to_string(),
794 shooter_altitude_m: 0.0,
795 is_twist_right: true, shooting_angle: 0.0, bullet_diameter: 0.0078232,
800 bullet_length: 0.031496,
801 twist_rate: 10.0,
802 custom_drag_table: None, cd_scale: 1.0,
804 bc_segments: None, use_bc_segments: false,
806 bc_segments_data: None,
807 ground_threshold: -1000.0, atmo_sock: None, };
810
811 let trajectory =
812 integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
813
814 trajectory
816 .into_iter()
817 .map(|(t, state)| {
818 let mut point = HashMap::new();
819 point.insert("t".to_string(), t);
820 point.insert("x".to_string(), state[0]);
821 point.insert("y".to_string(), state[1]);
822 point.insert("z".to_string(), state[2]);
823 point.insert("vx".to_string(), state[3]);
824 point.insert("vy".to_string(), state[4]);
825 point.insert("vz".to_string(), state[5]);
826 point
827 })
828 .collect()
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
836 TrajectoryParams {
837 mass_kg: 0.01134, bc: 0.442,
839 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
842 drag_model: DragModel::G7,
843 wind_segments: vec![],
844 atmos_params: (0.0, 15.0, 1013.25, 1.0),
845 omega_vector: None,
846 enable_spin_drift: false,
847 enable_magnus: false,
848 enable_coriolis: false,
849 target_distance_m,
850 enable_wind_shear: false,
851 wind_shear_model: "none".to_string(),
852 shooter_altitude_m: 0.0,
853 is_twist_right: true,
854 shooting_angle: 0.0,
855 custom_drag_table: None,
856 cd_scale: 1.0,
857 bc_segments: None,
858 use_bc_segments: false,
859 bc_segments_data: None,
860 ground_threshold: -1000.0,
861 atmo_sock: None,
862 }
863 }
864
865 #[test]
866 fn try_integrate_trajectory_rejects_malformed_segments_before_any_points() {
867 let mut params = create_test_params(300.0);
870 params.wind_segments = vec![
871 WindSegment::new(10.0, 90.0, 200.0),
872 WindSegment::new(10.0, 90.0, f64::NAN),
873 ];
874 let err = try_integrate_trajectory(
875 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
876 (0.0, 2.0),
877 params,
878 "RK4",
879 1e-6,
880 0.001,
881 )
882 .unwrap_err();
883 assert_eq!(err.index, 1);
884 assert_eq!(err.field, crate::wind::WindSegmentField::UntilM);
885 assert_eq!(
886 err.to_string(),
887 "wind.segments[1].until_m must be finite and greater than zero"
888 );
889 }
890
891 #[test]
892 fn try_integrate_trajectory_matches_unchecked_on_valid_input() {
893 let mk = || {
894 let mut params = create_test_params(300.0);
895 params.wind_segments = vec![WindSegment::new(16.0934, 90.0, 500.0)];
896 params
897 };
898 let checked = try_integrate_trajectory(
899 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
900 (0.0, 2.0),
901 mk(),
902 "RK4",
903 1e-6,
904 0.001,
905 )
906 .expect("valid segments must integrate");
907 let unchecked = integrate_trajectory(
908 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
909 (0.0, 2.0),
910 mk(),
911 "RK4",
912 1e-6,
913 0.001,
914 );
915 assert_eq!(checked.len(), unchecked.len());
916 assert_eq!(checked.last().unwrap().1, unchecked.last().unwrap().1);
917 }
918
919 #[test]
920 fn try_solve_trajectory_rust_rejects_malformed_segments() {
921 let bad = vec![WindSegment::new(-5.0, 0.0, 100.0)];
922 let err = try_solve_trajectory_rust(
923 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
924 (0.0, 2.0),
925 0.01134,
926 0.442,
927 DragModel::G7,
928 bad,
929 (0.0, 15.0, 1013.25, 1.0),
930 None,
931 false,
932 false,
933 false,
934 "RK4".to_string(),
935 1e-6,
936 0.001,
937 300.0,
938 )
939 .unwrap_err();
940 assert_eq!(err.index, 0);
941 assert_eq!(err.field, crate::wind::WindSegmentField::SpeedKmh);
942 assert_eq!(
943 err.to_string(),
944 "wind.segments[0].speed_kmh must be finite and non-negative"
945 );
946 }
947
948 #[test]
949 fn try_solve_trajectory_rust_succeeds_on_valid_segments() {
950 let points = try_solve_trajectory_rust(
951 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
952 (0.0, 2.0),
953 0.01134,
954 0.442,
955 DragModel::G7,
956 vec![WindSegment::new(16.0934, 90.0, 500.0)],
957 (0.0, 15.0, 1013.25, 1.0),
958 None,
959 false,
960 false,
961 false,
962 "RK4".to_string(),
963 1e-6,
964 0.001,
965 300.0,
966 )
967 .expect("valid segments must solve");
968 assert!(!points.is_empty());
969 assert!(points.last().unwrap()["x"] > 0.0);
970 }
971
972 #[test]
973 fn derivative_inputs_preserve_initial_velocity_as_muzzle_speed() {
974 let params = create_test_params(1_000.0);
975 let launch_velocity: Vector3<f64> = Vector3::new(700.0, 30.0, -20.0);
976 let inputs = build_inputs(¶ms, launch_velocity.norm());
977
978 assert_eq!(
979 inputs.muzzle_velocity.to_bits(),
980 launch_velocity.norm().to_bits()
981 );
982 }
983
984 #[test]
985 fn integrated_magnus_retains_nonzero_launch_spin() {
986 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
987 let baseline = integrate_trajectory(
988 initial_state,
989 (0.0, 0.1),
990 create_test_params(1_000.0),
991 "RK4",
992 1e-6,
993 0.001,
994 );
995 let mut magnus_params = create_test_params(1_000.0);
996 magnus_params.enable_magnus = true;
997
998 let trajectory =
999 integrate_trajectory(initial_state, (0.0, 0.1), magnus_params, "RK4", 1e-6, 0.001);
1000 let baseline_y = baseline.last().expect("baseline trajectory is empty").1[1];
1001 let magnus_y = trajectory.last().expect("trajectory is empty").1[1];
1002 let vertical_delta = magnus_y - baseline_y;
1003
1004 assert!(
1005 vertical_delta.is_finite() && vertical_delta < 0.0,
1006 "right-twist Magnus should retain nonzero launch spin and point down, got \
1007 delta_y={vertical_delta}"
1008 );
1009 }
1010
1011 #[test]
1012 fn rk45_retries_rejected_wind_boundary_step() {
1013 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1014 let mut params = create_test_params(100.0);
1015 params.wind_segments = vec![
1016 WindSegment::new(0.0, 90.0, 4.0),
1017 WindSegment::new(1_000.0, 90.0, 10_000.0),
1018 ];
1019
1020 let state = Vector6::from_row_slice(&initial_state);
1021 let launch_speed =
1022 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
1023 let inputs = build_inputs(¶ms, launch_speed);
1024 let initial_dt = 0.01;
1025 let tolerance = 1e-6;
1026 let (rejected_state, suggested_dt, error) =
1027 rk45_step(&state, 0.0, initial_dt, ¶ms, &inputs, tolerance);
1028 assert!(
1029 error > tolerance,
1030 "wind-boundary trial must exceed tolerance, got {error}"
1031 );
1032 assert!(suggested_dt < initial_dt);
1033
1034 let accepted = adaptive_rk45_step(
1035 &state,
1036 0.0,
1037 initial_dt,
1038 ¶ms,
1039 &inputs,
1040 Rk45Control {
1041 tolerance,
1042 min_step: RK45_MIN_STEP,
1043 max_step: initial_dt,
1044 max_trials: 100,
1045 },
1046 )
1047 .expect("a smaller finite trial should satisfy the tolerance");
1048
1049 assert!(accepted.trials > 1, "oversized trial was not retried");
1050 assert!(accepted.used_dt < initial_dt);
1051 assert!(
1052 accepted.error <= tolerance || accepted.used_dt <= RK45_MIN_STEP,
1053 "accepted error {} exceeds tolerance at dt {}",
1054 accepted.error,
1055 accepted.used_dt
1056 );
1057
1058 let (accepted_state, _, accepted_error) =
1059 rk45_step(&state, 0.0, accepted.used_dt, ¶ms, &inputs, tolerance);
1060 assert_eq!(accepted.state, accepted_state);
1061 assert_eq!(accepted.error, accepted_error);
1062 assert_ne!(accepted.state, rejected_state);
1063 assert!((RK45_MIN_STEP..=initial_dt).contains(&accepted.next_dt));
1064 }
1065
1066 #[test]
1067 fn integration_normalizes_wind_segments_by_distance() {
1068 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1069 let sorted_segments = vec![
1070 WindSegment::new(40.0, 270.0, 300.0),
1071 WindSegment::new(20.0, 90.0, 600.0),
1072 ];
1073
1074 let mut sorted_params = create_test_params(100.0);
1075 sorted_params.wind_segments = sorted_segments.clone();
1076 let mut unsorted_params = create_test_params(100.0);
1077 unsorted_params.wind_segments = sorted_segments.into_iter().rev().collect();
1078
1079 let sorted =
1080 integrate_trajectory(initial_state, (0.0, 1.0), sorted_params, "RK4", 1e-6, 0.001);
1081 let unsorted = integrate_trajectory(
1082 initial_state,
1083 (0.0, 1.0),
1084 unsorted_params,
1085 "RK4",
1086 1e-6,
1087 0.001,
1088 );
1089
1090 assert_eq!(unsorted.len(), sorted.len());
1091 for (index, ((sorted_t, sorted_state), (unsorted_t, unsorted_state))) in
1092 sorted.iter().zip(&unsorted).enumerate()
1093 {
1094 assert_eq!(unsorted_t.to_bits(), sorted_t.to_bits());
1095 for component in 0..6 {
1096 assert_eq!(
1097 unsorted_state[component].to_bits(),
1098 sorted_state[component].to_bits(),
1099 "wind segment order changed state component {component} at point {index}"
1100 );
1101 }
1102 }
1103 }
1104
1105 #[test]
1106 fn rk4_target_crossing_interpolates_complete_state_and_time() {
1107 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1108 let target_distance_m = 100.0;
1109 let trajectory = integrate_trajectory(
1110 initial_state,
1111 (0.0, 1.0),
1112 create_test_params(target_distance_m),
1113 "RK4",
1114 1e-6,
1115 0.001,
1116 );
1117
1118 let (previous_t, previous_state) = &trajectory[trajectory.len() - 2];
1119 let (terminal_t, terminal_state) = trajectory.last().expect("trajectory is empty");
1120 let reference_params = create_test_params(target_distance_m);
1121 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1122 let full_step_dt = 0.001;
1123 let bracket_end = rk4_step(
1124 previous_state,
1125 *previous_t,
1126 full_step_dt,
1127 &reference_params,
1128 &inputs,
1129 );
1130 assert!(previous_state[0] < target_distance_m);
1131 assert!(bracket_end[0] >= target_distance_m);
1132
1133 let alpha = (target_distance_m - previous_state[0]) / (bracket_end[0] - previous_state[0]);
1134 let expected_t = previous_t + alpha * full_step_dt;
1135 let mut expected_state = previous_state + alpha * (bracket_end - previous_state);
1136 expected_state[0] = target_distance_m;
1137
1138 assert_eq!(terminal_t.to_bits(), expected_t.to_bits());
1139 for component in 0..6 {
1140 assert_eq!(
1141 terminal_state[component].to_bits(),
1142 expected_state[component].to_bits(),
1143 "terminal component {component} was not interpolated at the target crossing"
1144 );
1145 }
1146 }
1147
1148 #[test]
1149 fn rk45_target_crossing_uses_the_accepted_state_and_time() {
1150 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1151 let initial = Vector6::from_row_slice(&initial_state);
1152 let target_distance_m = 0.5;
1153 let reference_params = create_test_params(target_distance_m);
1154 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1155 let initial_dt = 0.001;
1156 let accepted = adaptive_rk45_step(
1157 &initial,
1158 0.0,
1159 initial_dt,
1160 &reference_params,
1161 &inputs,
1162 Rk45Control {
1163 tolerance: 1e-6,
1164 min_step: RK45_MIN_STEP,
1165 max_step: 0.01,
1166 max_trials: 100_000,
1167 },
1168 )
1169 .expect("first RK45 target bracket should be accepted");
1170 assert!(accepted.state[0] >= target_distance_m);
1171 let expected = interpolate_target_crossing(
1172 0.0,
1173 &initial,
1174 accepted.used_dt,
1175 &accepted.state,
1176 target_distance_m,
1177 );
1178
1179 let trajectory = integrate_trajectory(
1180 initial_state,
1181 (0.0, 1.0),
1182 create_test_params(target_distance_m),
1183 "RK45",
1184 1e-6,
1185 0.01,
1186 );
1187 let actual = trajectory.last().expect("trajectory is empty");
1188
1189 assert_eq!(actual.0.to_bits(), expected.0.to_bits());
1190 for component in 0..6 {
1191 assert_eq!(
1192 actual.1[component].to_bits(),
1193 expected.1[component].to_bits(),
1194 "RK45 terminal component {component} was not interpolated from its accepted step"
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn target_crossing_helper_interpolates_every_component() {
1201 let start = Vector6::new(90.0, 10.0, -4.0, 700.0, -20.0, 5.0);
1202 let end = Vector6::new(130.0, 6.0, 8.0, 660.0, -24.0, 9.0);
1203 let (time, state) = interpolate_target_crossing(2.0, &start, 0.5, &end, 100.0);
1204
1205 assert_eq!(time.to_bits(), 2.125_f64.to_bits());
1206 for (index, expected) in [100.0_f64, 9.0, -1.0, 690.0, -21.0, 6.0]
1207 .into_iter()
1208 .enumerate()
1209 {
1210 assert_eq!(state[index].to_bits(), expected.to_bits());
1211 }
1212 }
1213
1214 #[test]
1215 fn already_at_or_past_target_returns_initial_state_without_advancing() {
1216 let initial = [150.0, 12.0, -3.0, 700.0, -4.0, 5.0];
1217
1218 for method in ["RK4", "RK45"] {
1219 for target in [150.0, 100.0] {
1220 let trajectory = integrate_trajectory(
1221 initial,
1222 (2.0, 3.0),
1223 create_test_params(target),
1224 method,
1225 1e-6,
1226 0.01,
1227 );
1228
1229 assert_eq!(trajectory.len(), 1, "{method} advanced a terminal state");
1230 let (time, state) = &trajectory[0];
1231 assert_eq!(time.to_bits(), 2.0_f64.to_bits());
1232 for index in 0..6 {
1233 assert_eq!(state[index].to_bits(), initial[index].to_bits());
1234 }
1235 }
1236 }
1237 }
1238
1239 #[test]
1240 fn rk45_error_norm_scales_components_independently() {
1241 let state = Vector6::new(1.0e9, 0.0, 0.0, 800.0, 0.0, 0.0);
1242 let fifth_order = state;
1243 let mut fourth_order = state;
1244 fourth_order[4] = 1.0e-3;
1245
1246 let error = rk45_error_norm(&state, &fifth_order, &fourth_order);
1247 let expected = 1.0e-3 / 6.0_f64.sqrt();
1248
1249 assert!(
1250 (error - expected).abs() <= 1e-15,
1251 "large downrange position masked a velocity-component error: {error}"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_mba954_ground_threshold_honored() {
1257 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);
1265 deep.ground_threshold = -1000.0; let t_shallow =
1268 integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
1269 let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
1270
1271 assert!(
1272 t_shallow.len() < t_deep.len(),
1273 "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
1274 shallow={}, deep={}",
1275 t_shallow.len(),
1276 t_deep.len()
1277 );
1278 }
1279
1280 #[test]
1281 fn test_integrate_trajectory_basic() {
1282 let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
1285
1286 let params = TrajectoryParams {
1287 mass_kg: 0.01134, bc: 0.442,
1289 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
1292 drag_model: DragModel::G7,
1293 wind_segments: vec![WindSegment::new(0.0, 90.0, 914.4)],
1294 atmos_params: (0.0, 15.0, 1013.25, 1.0),
1295 omega_vector: None,
1296 enable_spin_drift: false,
1297 enable_magnus: false,
1298 enable_coriolis: false,
1299 target_distance_m: 914.4, enable_wind_shear: false,
1301 wind_shear_model: "none".to_string(),
1302 shooter_altitude_m: 0.0,
1303 is_twist_right: true,
1304 shooting_angle: 0.0,
1305 custom_drag_table: None,
1306 cd_scale: 1.0,
1307 bc_segments: None,
1308 use_bc_segments: false,
1309 bc_segments_data: None,
1310 ground_threshold: -1000.0,
1311 atmo_sock: None,
1312 };
1313
1314 println!("Running integrate_trajectory test...");
1315 println!("Initial state: {:?}", initial_state);
1316 println!("Target distance: {} m", params.target_distance_m);
1317
1318 let trajectory =
1319 integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
1320
1321 println!("Trajectory has {} points", trajectory.len());
1322
1323 assert!(
1325 trajectory.len() > 1,
1326 "Trajectory should have more than 1 point, but has {}",
1327 trajectory.len()
1328 );
1329
1330 if let Some((_, final_state)) = trajectory.last() {
1332 println!("Final state: downrange(x)={}", final_state[0]);
1333 assert!(
1334 final_state[0] > 0.0,
1335 "Final x should be positive (bullet moved downrange)"
1336 );
1337 assert!(
1338 final_state[0] >= 900.0,
1339 "Final x should be near target distance"
1340 );
1341 assert!(
1342 final_state[3] < 0.9 * initial_state[3],
1343 "standard-atmosphere drag should reduce downrange velocity"
1344 );
1345 }
1346 }
1347
1348 #[test]
1349 fn test_rk4_vs_rk45_consistency() {
1350 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1353
1354 let params_rk4 = create_test_params(target_distance);
1355 let params_rk45 = create_test_params(target_distance);
1356
1357 let trajectory_rk4 =
1358 integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
1359 let trajectory_rk45 =
1360 integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
1361
1362 assert!(!trajectory_rk4.is_empty());
1364 assert!(!trajectory_rk45.is_empty());
1365
1366 let (time_rk4, final_rk4) = trajectory_rk4.last().unwrap();
1367 let (time_rk45, final_rk45) = trajectory_rk45.last().unwrap();
1368
1369 assert!(
1371 (time_rk4 - time_rk45).abs() < 1e-4,
1372 "RK4/RK45 time of flight diverged: {time_rk4} vs {time_rk45}"
1373 );
1374 assert!((final_rk4[1] - final_rk45[1]).abs() < 1e-3);
1375 assert!((final_rk4[3] - final_rk45[3]).abs() < 1e-2);
1376 assert!(final_rk45[3] < 0.9 * initial_state[3]);
1377 }
1378
1379 #[test]
1380 fn test_ground_impact_detection() {
1381 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;
1386 let ground_threshold = 0.0;
1387 params.ground_threshold = ground_threshold;
1388
1389 let trajectory =
1390 integrate_trajectory(initial_state, (0.0, 20.0), params, "RK4", 1e-6, 0.01);
1391
1392 let (_, final_state) = trajectory.last().unwrap();
1394
1395 assert!(
1397 final_state[1] <= ground_threshold,
1398 "Should hit ground, but y={}",
1399 final_state[1]
1400 );
1401 assert!(
1402 final_state[0] < 10000.0,
1403 "Should not reach target, but z={}",
1404 final_state[0]
1405 );
1406 }
1407
1408 #[test]
1409 fn test_target_distance_reached() {
1410 let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; let target_distance = 300.0;
1412
1413 let params = create_test_params(target_distance);
1414
1415 let trajectory =
1416 integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
1417
1418 let (_, final_state) = trajectory.last().unwrap();
1419
1420 assert!(
1422 (final_state[0] - target_distance).abs() < 1.0,
1423 "Should reach target at {}m, but stopped at {}m",
1424 target_distance,
1425 final_state[0]
1426 );
1427 }
1428
1429 #[test]
1430 fn test_wind_affects_trajectory() {
1431 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1436
1437 let params_no_wind = create_test_params(target_distance);
1439
1440 let mut params_headwind = create_test_params(target_distance);
1442 params_headwind.wind_segments = vec![WindSegment::new(72.0, 0.0, 500.0)]; let trajectory_no_wind = integrate_trajectory(
1445 initial_state,
1446 (0.0, 5.0),
1447 params_no_wind,
1448 "RK45",
1449 1e-6,
1450 0.01,
1451 );
1452 let trajectory_headwind = integrate_trajectory(
1453 initial_state,
1454 (0.0, 5.0),
1455 params_headwind,
1456 "RK45",
1457 1e-6,
1458 0.01,
1459 );
1460
1461 assert!(
1463 !trajectory_no_wind.is_empty(),
1464 "No-wind trajectory should complete"
1465 );
1466 assert!(
1467 !trajectory_headwind.is_empty(),
1468 "Headwind trajectory should complete"
1469 );
1470
1471 let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
1472 let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
1473
1474 let drop_no_wind = final_no_wind[1];
1477 let drop_headwind = final_headwind[1];
1478
1479 println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
1480 println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
1481
1482 assert!(
1483 *time_headwind > *time_no_wind + 0.001,
1484 "headwind should increase time of flight: no-wind={time_no_wind}, headwind={time_headwind}"
1485 );
1486 assert!(
1487 final_headwind[3] < final_no_wind[3] - 1.0,
1488 "headwind should reduce terminal downrange velocity"
1489 );
1490
1491 assert!(
1493 (final_no_wind[0] - target_distance).abs() < 10.0,
1494 "No-wind should reach target"
1495 );
1496 assert!(
1497 (final_headwind[0] - target_distance).abs() < 10.0,
1498 "Headwind should reach target"
1499 );
1500 }
1501
1502 #[test]
1503 fn test_solve_trajectory_rust_output_format() {
1504 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let result = solve_trajectory_rust(
1507 initial_state,
1508 (0.0, 2.0),
1509 0.01134, 0.442, DragModel::G7, vec![], (0.0, 15.0, 1013.25, 1.0),
1515 None, false, false, false, "RK45".to_string(), 1e-6, 0.01, 500.0, );
1524
1525 assert!(!result.is_empty());
1527
1528 let first_point = &result[0];
1529 assert!(first_point.contains_key("t"));
1530 assert!(first_point.contains_key("x"));
1531 assert!(first_point.contains_key("y"));
1532 assert!(first_point.contains_key("z"));
1533 assert!(first_point.contains_key("vx"));
1534 assert!(first_point.contains_key("vy"));
1535 assert!(first_point.contains_key("vz"));
1536
1537 let final_point = result.last().unwrap();
1538 assert!(
1539 final_point["vx"] < 0.9 * initial_state[3],
1540 "standard-atmosphere wrapper fixture should exercise drag"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_left_vs_right_twist() {
1546 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1548
1549 let mut params_right = create_test_params(target_distance);
1550 params_right.is_twist_right = true;
1551 params_right.enable_spin_drift = true;
1552
1553 let mut params_left = create_test_params(target_distance);
1554 params_left.is_twist_right = false;
1555 params_left.enable_spin_drift = true;
1556
1557 let trajectory_right =
1558 integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
1559 let trajectory_left =
1560 integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
1561
1562 assert!(!trajectory_right.is_empty());
1564 assert!(!trajectory_left.is_empty());
1565
1566 let (_, final_right) = trajectory_right.last().unwrap();
1568 let (_, final_left) = trajectory_left.last().unwrap();
1569
1570 assert!((final_right[2] - final_left[2]).abs() < 10.0);
1572 }
1573}