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