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