1use crate::gravity::Gravity;
10use nalgebra::{Vector3, Vector6};
11use std::collections::HashMap;
12
13use crate::derivatives::compute_derivatives_with_gravity;
14use crate::wind::{WindSegment, WindSegmentError};
15use crate::BallisticInputs;
16use crate::DragModel;
17
18const RK45_MIN_STEP: f64 = 1e-6;
19const RK45_DEFAULT_TOLERANCE: f64 = 1e-6;
20const RK45_SAFETY_FACTOR: f64 = 0.9;
21const RK45_MIN_SCALE: f64 = 0.1;
22const RK45_MAX_SCALE: f64 = 2.0;
23
24#[derive(Clone, Copy)]
25struct Rk45Control {
26 tolerance: f64,
27 min_step: f64,
28 max_step: f64,
29 max_trials: usize,
30}
31
32struct Rk45AcceptedStep {
33 state: Vector6<f64>,
34 used_dt: f64,
35 next_dt: f64,
36 error: f64,
37 trials: usize,
38}
39
40fn wind_vector_for_range(range_m: f64, wind_segments: &[WindSegment]) -> Vector3<f64> {
41 if range_m.is_nan() {
42 return Vector3::zeros();
43 }
44 for seg in wind_segments {
45 if range_m < seg.until_m {
46 let wind_speed_mps = seg.speed_kmh * 0.2777778; let wind_angle_rad = seg.angle_deg.to_radians();
48 return crate::wind::wind_vector(wind_speed_mps, wind_angle_rad, seg.vertical_mps);
51 }
52 }
53 Vector3::zeros()
54}
55
56fn rk4_step_with_gravity(
58 state: &Vector6<f64>,
59 t: f64,
60 dt: f64,
61 params: &TrajectoryParams,
62 inputs: &BallisticInputs,
63 gravity: Gravity,
64) -> Vector6<f64> {
65 let k1 = compute_derivatives_vec_with_gravity(state, t, params, inputs, gravity);
67 let k2 = compute_derivatives_vec_with_gravity(
68 &(state + dt * 0.5 * k1),
69 t + dt * 0.5,
70 params,
71 inputs,
72 gravity,
73 );
74 let k3 = compute_derivatives_vec_with_gravity(
75 &(state + dt * 0.5 * k2),
76 t + dt * 0.5,
77 params,
78 inputs,
79 gravity,
80 );
81 let k4 =
82 compute_derivatives_vec_with_gravity(&(state + dt * k3), t + dt, params, inputs, gravity);
83
84 state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
85}
86
87pub(crate) fn rk45_error_norm(
93 state: &Vector6<f64>,
94 fifth_order: &Vector6<f64>,
95 fourth_order: &Vector6<f64>,
96) -> f64 {
97 let scaled_error_squared: f64 = (0..6)
98 .map(|index| {
99 let scale = 1.0 + state[index].abs().max(fifth_order[index].abs());
100 ((fifth_order[index] - fourth_order[index]) / scale).powi(2)
101 })
102 .sum();
103
104 (scaled_error_squared / 6.0).sqrt()
105}
106
107#[allow(clippy::too_many_arguments)]
109fn rk45_step_with_gravity(
110 state: &Vector6<f64>,
111 t: f64,
112 dt: f64,
113 params: &TrajectoryParams,
114 inputs: &BallisticInputs,
115 tol: f64,
116 gravity: Gravity,
117) -> (Vector6<f64>, f64, f64) {
118 const A21: f64 = 1.0 / 5.0;
120 const A31: f64 = 3.0 / 40.0;
121 const A32: f64 = 9.0 / 40.0;
122 const A41: f64 = 44.0 / 45.0;
123 const A42: f64 = -56.0 / 15.0;
124 const A43: f64 = 32.0 / 9.0;
125 const A51: f64 = 19372.0 / 6561.0;
126 const A52: f64 = -25360.0 / 2187.0;
127 const A53: f64 = 64448.0 / 6561.0;
128 const A54: f64 = -212.0 / 729.0;
129 const A61: f64 = 9017.0 / 3168.0;
130 const A62: f64 = -355.0 / 33.0;
131 const A63: f64 = 46732.0 / 5247.0;
132 const A64: f64 = 49.0 / 176.0;
133 const A65: f64 = -5103.0 / 18656.0;
134 const A71: f64 = 35.0 / 384.0;
135 const A73: f64 = 500.0 / 1113.0;
136 const A74: f64 = 125.0 / 192.0;
137 const A75: f64 = -2187.0 / 6784.0;
138 const A76: f64 = 11.0 / 84.0;
139
140 const B1: f64 = 35.0 / 384.0;
142 const B3: f64 = 500.0 / 1113.0;
143 const B4: f64 = 125.0 / 192.0;
144 const B5: f64 = -2187.0 / 6784.0;
145 const B6: f64 = 11.0 / 84.0;
146
147 const B1_ERR: f64 = 5179.0 / 57600.0;
149 const B3_ERR: f64 = 7571.0 / 16695.0;
150 const B4_ERR: f64 = 393.0 / 640.0;
151 const B5_ERR: f64 = -92097.0 / 339200.0;
152 const B6_ERR: f64 = 187.0 / 2100.0;
153 const B7_ERR: f64 = 1.0 / 40.0;
154
155 let k1 = compute_derivatives_vec_with_gravity(state, t, params, inputs, gravity);
157 let k2 = compute_derivatives_vec_with_gravity(
158 &(state + dt * A21 * k1),
159 t + dt * 0.2,
160 params,
161 inputs,
162 gravity,
163 );
164 let k3 = compute_derivatives_vec_with_gravity(
165 &(state + dt * (A31 * k1 + A32 * k2)),
166 t + dt * 0.3,
167 params,
168 inputs,
169 gravity,
170 );
171 let k4 = compute_derivatives_vec_with_gravity(
172 &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
173 t + dt * 0.8,
174 params,
175 inputs,
176 gravity,
177 );
178 let k5 = compute_derivatives_vec_with_gravity(
179 &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
180 t + dt * 8.0 / 9.0,
181 params,
182 inputs,
183 gravity,
184 );
185 let k6 = compute_derivatives_vec_with_gravity(
186 &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
187 t + dt,
188 params,
189 inputs,
190 gravity,
191 );
192 let k7 = compute_derivatives_vec_with_gravity(
193 &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
194 t + dt,
195 params,
196 inputs,
197 gravity,
198 );
199
200 let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
202
203 let y_err = state
205 + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
206
207 let error = rk45_error_norm(state, &y_new, &y_err);
208
209 let step_scale = if !error.is_finite() || !tol.is_finite() || tol <= 0.0 {
211 RK45_MIN_SCALE
212 } else if error == 0.0 {
213 RK45_MAX_SCALE
214 } else {
215 (RK45_SAFETY_FACTOR * (tol / error).powf(0.2)).clamp(RK45_MIN_SCALE, RK45_MAX_SCALE)
216 };
217 let dt_new = dt * step_scale;
218
219 (y_new, dt_new, error)
220}
221
222#[allow(clippy::too_many_arguments)]
227fn adaptive_rk45_step_with_gravity(
228 state: &Vector6<f64>,
229 t: f64,
230 initial_dt: f64,
231 params: &TrajectoryParams,
232 inputs: &BallisticInputs,
233 control: Rk45Control,
234 gravity: Gravity,
235) -> Result<Rk45AcceptedStep, usize> {
236 let mut trial_dt = initial_dt;
237
238 for trials in 1..=control.max_trials {
239 let (new_state, suggested_dt, error) = rk45_step_with_gravity(
240 state,
241 t,
242 trial_dt,
243 params,
244 inputs,
245 control.tolerance,
246 gravity,
247 );
248 let candidate_is_finite = error.is_finite()
249 && suggested_dt.is_finite()
250 && new_state.iter().all(|value| value.is_finite());
251 let next_dt = suggested_dt.min(control.max_step).max(control.min_step);
252
253 if candidate_is_finite && (error <= control.tolerance || trial_dt <= control.min_step) {
254 return Ok(Rk45AcceptedStep {
255 state: new_state,
256 used_dt: trial_dt,
257 next_dt,
258 error,
259 trials,
260 });
261 }
262
263 if trial_dt <= control.min_step {
264 return Err(trials);
265 }
266 trial_dt = next_dt;
267 }
268
269 Err(control.max_trials)
270}
271
272pub struct TrajectoryParams {
274 pub mass_kg: f64,
275 pub bc: f64,
276 pub drag_model: DragModel,
277 pub wind_segments: Vec<WindSegment>,
279 pub atmos_params: (f64, f64, f64, f64),
285 pub omega_vector: Option<Vector3<f64>>,
288 pub enable_spin_drift: bool,
289 pub enable_magnus: bool,
290 pub enable_coriolis: bool,
291 pub target_distance_m: f64, pub enable_wind_shear: bool,
297 pub wind_shear_model: String,
298 pub shooter_altitude_m: f64,
301 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,
312 pub custom_drag_reference: bool,
320 pub bc_segments: Option<Vec<(f64, f64)>>, pub use_bc_segments: bool, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>,
328 pub ground_threshold: f64,
331 pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
335}
336
337fn build_inputs(params: &TrajectoryParams, muzzle_velocity_mps: f64) -> BallisticInputs {
343 let mut inputs = BallisticInputs {
344 bc_value: params.bc,
345 bc_type: params.drag_model,
346 bc_reference_standard: crate::cli_api::BcReferenceStandard::Icao,
354 bullet_mass: params.mass_kg, muzzle_velocity: muzzle_velocity_mps,
356 bullet_diameter: params.bullet_diameter, bullet_length: params.bullet_length,
358 twist_rate: params.twist_rate,
359 is_twist_right: params.is_twist_right,
360 enable_advanced_effects: params.enable_spin_drift
361 || params.enable_magnus
362 || params.enable_coriolis,
363 enable_magnus: params.enable_magnus,
364 enable_coriolis: params.enable_coriolis,
365 altitude: params.atmos_params.0,
366 temperature: params.atmos_params.1,
367 pressure: params.atmos_params.2,
368 humidity: params.atmos_params.3,
369 tipoff_yaw: 0.0,
370 cd_delta2: 7.5,
371 target_distance: 1000.0, muzzle_angle: 0.0,
373 wind_speed: if !params.wind_segments.is_empty() {
374 params.wind_segments[0].speed_kmh * 0.2777778 } else {
376 0.0
377 },
378 wind_angle: if !params.wind_segments.is_empty() {
379 params.wind_segments[0].angle_deg.to_radians() } else {
381 0.0
382 },
383 latitude: None,
384 shooting_angle: params.shooting_angle,
385 cant_angle: 0.0,
386 azimuth_angle: 0.0,
387 shot_azimuth: 0.0, use_powder_sensitivity: false,
389 powder_temp_sensitivity: 0.0,
390 powder_temp: 59.0,
391 powder_temp_curve: None,
392 powder_curve_temp_c: None,
393 tipoff_decay_distance: 0.0,
394 ground_threshold: params.ground_threshold, bc_segments: params.bc_segments.clone(),
396 caliber_inches: params.bullet_diameter / 0.0254, weight_grains: params.mass_kg / crate::constants::GRAINS_TO_KG,
398 use_bc_segments: params.use_bc_segments,
399 bullet_id: None,
400 bc_segments_data: params
403 .bc_segments_data
404 .clone()
405 .filter(|segments| !segments.is_empty()),
406 use_enhanced_spin_drift: params.enable_spin_drift,
407 use_form_factor: false,
408 manufacturer: None,
409 bullet_model: None,
410 enable_wind_shear: false,
411 wind_shear_model: "none".to_string(),
412 use_cluster_bc: false,
413 bullet_cluster: None,
414 custom_drag_table: params.custom_drag_table.clone(),
415 cd_scale: params.cd_scale,
416 custom_drag_reference: params.custom_drag_reference,
417 bc_type_str: None,
418 enable_pitch_damping: false,
419 enable_precession_nutation: false,
420 enable_aerodynamic_jump: false,
426 use_rk4: true,
427 use_adaptive_rk45: false,
428 enable_trajectory_sampling: false,
429 sample_interval: 10.0,
430 sight_height: 0.0,
431 sight_offset_lateral_m: 0.0,
434 muzzle_height: 0.0,
435 target_height: 0.0,
436 zero_poi_vertical_m: 0.0,
440 zero_poi_horizontal_m: 0.0,
441 drops_reference: crate::cli_api::DropsReference::Los,
445 };
446
447 if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
454 inputs.bc_segments_data =
455 crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
456 }
457 inputs
458}
459
460fn compute_derivatives_vec_with_gravity(
462 state: &Vector6<f64>,
463 t: f64,
464 params: &TrajectoryParams,
465 inputs: &BallisticInputs,
466 gravity: Gravity,
467) -> Vector6<f64> {
468 let pos = Vector3::new(state[0], state[1], state[2]);
469 let vel = Vector3::new(state[3], state[4], state[5]);
470
471 let wind_vector = if !params.wind_segments.is_empty() {
479 if params.enable_wind_shear && params.wind_shear_model != "none" {
480 crate::wind_shear::apply_boundary_layer_shear_agl(
481 wind_vector_for_range(pos.x, ¶ms.wind_segments),
482 crate::wind_shear::shear_height_agl_m(pos.y, inputs.muzzle_height),
483 crate::wind_shear::boundary_layer_model_from_name(¶ms.wind_shear_model),
484 )
485 } else {
486 wind_vector_for_range(pos.x, ¶ms.wind_segments)
487 }
488 } else {
489 Vector3::zeros()
490 };
491
492 let deriv_result = compute_derivatives_with_gravity(
495 pos,
496 vel,
497 inputs,
498 wind_vector,
499 params.atmos_params,
500 params.bc,
501 params.omega_vector,
502 t,
503 params.atmo_sock.as_ref(),
504 gravity,
505 );
506
507 Vector6::new(
508 deriv_result[0],
509 deriv_result[1],
510 deriv_result[2],
511 deriv_result[3],
512 deriv_result[4],
513 deriv_result[5],
514 )
515}
516
517fn interpolate_target_crossing(
523 start_time: f64,
524 start: &Vector6<f64>,
525 step_dt: f64,
526 end: &Vector6<f64>,
527 target_x: f64,
528) -> (f64, Vector6<f64>) {
529 debug_assert!(start[0] <= target_x && target_x <= end[0] && end[0] > start[0]);
530
531 let alpha = (target_x - start[0]) / (end[0] - start[0]);
532 let crossing_time = start_time + alpha * step_dt;
533 let mut crossing_state = start + alpha * (end - start);
534 crossing_state[0] = target_x;
535
536 (crossing_time, crossing_state)
537}
538
539pub fn try_integrate_trajectory(
545 initial_state: [f64; 6],
546 t_span: (f64, f64),
547 params: TrajectoryParams,
548 method: &str,
549 tolerance: f64,
550 max_step: f64,
551) -> Result<Vec<(f64, Vector6<f64>)>, WindSegmentError> {
552 crate::wind::validate_wind_segments(¶ms.wind_segments)?;
553 Ok(integrate_trajectory(
554 initial_state,
555 t_span,
556 params,
557 method,
558 tolerance,
559 max_step,
560 ))
561}
562
563pub fn integrate_trajectory(
570 initial_state: [f64; 6],
571 t_span: (f64, f64),
572 params: TrajectoryParams,
573 method: &str,
574 tolerance: f64,
575 max_step: f64,
576) -> Vec<(f64, Vector6<f64>)> {
577 integrate_trajectory_with_gravity(
578 initial_state,
579 t_span,
580 params,
581 method,
582 tolerance,
583 max_step,
584 Gravity::EARTH,
585 )
586}
587
588#[allow(clippy::too_many_arguments)]
593pub(crate) fn integrate_trajectory_with_gravity(
594 initial_state: [f64; 6],
595 t_span: (f64, f64),
596 params: TrajectoryParams,
597 method: &str,
598 tolerance: f64,
599 max_step: f64,
600 gravity: Gravity,
601) -> Vec<(f64, Vector6<f64>)> {
602 integrate_trajectory_from_muzzle_height(
603 initial_state,
604 t_span,
605 params,
606 method,
607 tolerance,
608 max_step,
609 gravity,
610 0.0,
611 )
612}
613
614#[allow(clippy::too_many_arguments)]
620pub(crate) fn integrate_trajectory_from_muzzle_height(
621 initial_state: [f64; 6],
622 t_span: (f64, f64),
623 mut params: TrajectoryParams,
624 method: &str,
625 tolerance: f64,
626 max_step: f64,
627 gravity: Gravity,
628 muzzle_height_m: f64,
629) -> Vec<(f64, Vector6<f64>)> {
630 crate::wind::sort_wind_segments_by_distance(&mut params.wind_segments);
633
634 let mut state = Vector6::new(
635 initial_state[0],
636 initial_state[1],
637 initial_state[2],
638 initial_state[3],
639 initial_state[4],
640 initial_state[5],
641 );
642
643 let mut t = t_span.0;
644 let t_end = t_span.1;
645 let mut dt = (t_end - t) / 1000.0; let mut trajectory = Vec::with_capacity(10000);
648 trajectory.push((t, state));
649 if state[0] >= params.target_distance_m {
650 return trajectory;
651 }
652
653 let muzzle_velocity_mps =
656 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
657 let mut inputs = build_inputs(¶ms, muzzle_velocity_mps);
658 inputs.muzzle_height = muzzle_height_m;
662
663 match method {
664 "RK4" => {
665 dt = dt.min(max_step).min(0.001); while t < t_end {
669 if t + dt > t_end {
670 dt = t_end - t;
671 }
672
673 let new_state = rk4_step_with_gravity(&state, t, dt, ¶ms, &inputs, gravity);
674
675 if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
677 trajectory.push(interpolate_target_crossing(
678 t,
679 &state,
680 dt,
681 &new_state,
682 params.target_distance_m,
683 ));
684 break; }
686
687 state = new_state;
688 t += dt;
689 trajectory.push((t, state));
690
691 if state[0] >= params.target_distance_m {
693 break;
694 }
695
696 if state[1] < params.ground_threshold {
699 break;
700 }
701 }
702 }
703 _ => {
704 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 {
708 tolerance
709 } else {
710 eprintln!(
711 "WARNING: RK45 tolerance must be finite and positive; using {RK45_DEFAULT_TOLERANCE}"
712 );
713 RK45_DEFAULT_TOLERANCE
714 };
715
716 let effective_max_step =
719 if params.enable_wind_shear && params.wind_shear_model != "none" {
720 if params.target_distance_m > 800.0 {
722 0.01 } else {
724 0.02 }
726 } else {
727 max_step };
729 if !effective_max_step.is_finite() || effective_max_step <= 0.0 {
730 eprintln!("WARNING: RK45 max_step must be finite and positive");
731 return trajectory;
732 }
733 let min_step = RK45_MIN_STEP.min(effective_max_step);
734
735 dt = dt.min(effective_max_step).max(min_step);
737
738 let max_iterations = 100000; let mut iteration_count = 0;
741
742 while t < t_end && iteration_count < max_iterations {
743 if t + dt > t_end {
745 dt = t_end - t;
746 }
747
748 let control = Rk45Control {
749 tolerance,
750 min_step,
751 max_step: effective_max_step,
752 max_trials: max_iterations - iteration_count,
753 };
754 let accepted = match adaptive_rk45_step_with_gravity(
755 &state, t, dt, ¶ms, &inputs, control, gravity,
756 ) {
757 Ok(accepted) => accepted,
758 Err(trials) => {
759 iteration_count += trials;
760 if iteration_count < max_iterations {
761 eprintln!("WARNING: RK45 minimum-step trial was non-finite");
762 }
763 break;
764 }
765 };
766 iteration_count += accepted.trials;
767 debug_assert!(accepted.error <= tolerance || accepted.used_dt <= min_step);
768
769 if state[0] < params.target_distance_m
771 && accepted.state[0] >= params.target_distance_m
772 {
773 trajectory.push(interpolate_target_crossing(
774 t,
775 &state,
776 accepted.used_dt,
777 &accepted.state,
778 params.target_distance_m,
779 ));
780 break;
781 }
782
783 state = accepted.state;
785 t += accepted.used_dt;
786
787 if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
789 {
790 trajectory.push((t, state));
792 last_save_x = state[0];
793 }
794
795 dt = accepted.next_dt;
797
798 if state[0] >= params.target_distance_m {
800 break;
801 }
802
803 if state[1] < params.ground_threshold {
806 break;
807 }
808 }
809
810 if iteration_count >= max_iterations
812 && t < t_end
813 && state[0] < params.target_distance_m
814 && state[1] >= params.ground_threshold
815 {
816 eprintln!(
817 "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
818 max_iterations
819 );
820 eprintln!(" Final time: {}, Target time: {}", t, t_end);
821 eprintln!(
822 " Final position: downrange(x)={}, Target: {}m",
823 state[0], params.target_distance_m
824 );
825 }
826 }
827 }
828
829 trajectory
830}
831
832#[allow(clippy::too_many_arguments)] pub fn try_solve_trajectory_rust(
838 initial_state: [f64; 6],
839 t_span: (f64, f64),
840 mass_kg: f64,
841 bc: f64,
842 drag_model: DragModel,
843 wind_segments: Vec<WindSegment>,
844 atmos_params: (f64, f64, f64, f64),
845 omega_vector: Option<Vec<f64>>,
846 enable_spin_drift: bool,
847 enable_magnus: bool,
848 enable_coriolis: bool,
849 method: String,
850 tolerance: f64,
851 max_step: f64,
852 target_distance_m: f64,
853) -> Result<Vec<HashMap<String, f64>>, WindSegmentError> {
854 crate::wind::validate_wind_segments(&wind_segments)?;
855 Ok(solve_trajectory_rust(
856 initial_state,
857 t_span,
858 mass_kg,
859 bc,
860 drag_model,
861 wind_segments,
862 atmos_params,
863 omega_vector,
864 enable_spin_drift,
865 enable_magnus,
866 enable_coriolis,
867 method,
868 tolerance,
869 max_step,
870 target_distance_m,
871 ))
872}
873
874#[allow(clippy::too_many_arguments)] pub fn solve_trajectory_rust(
880 initial_state: [f64; 6],
881 t_span: (f64, f64),
882 mass_kg: f64,
883 bc: f64,
884 drag_model: DragModel,
885 wind_segments: Vec<WindSegment>,
886 atmos_params: (f64, f64, f64, f64),
887 omega_vector: Option<Vec<f64>>,
888 enable_spin_drift: bool,
889 enable_magnus: bool,
890 enable_coriolis: bool,
891 method: String,
892 tolerance: f64,
893 max_step: f64,
894 target_distance_m: f64,
895) -> Vec<HashMap<String, f64>> {
896 let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
897
898 let params = TrajectoryParams {
899 mass_kg,
900 bc,
901 drag_model,
902 wind_segments,
903 atmos_params,
904 omega_vector: omega_vec,
905 enable_spin_drift,
906 enable_magnus,
907 enable_coriolis,
908 target_distance_m,
909 enable_wind_shear: false, wind_shear_model: "none".to_string(),
911 shooter_altitude_m: 0.0,
912 is_twist_right: true, shooting_angle: 0.0, bullet_diameter: 0.0078232,
917 bullet_length: 0.031496,
918 twist_rate: 10.0,
919 custom_drag_table: None, cd_scale: 1.0,
921 custom_drag_reference: false,
922 bc_segments: None, use_bc_segments: false,
924 bc_segments_data: None,
925 ground_threshold: -1000.0, atmo_sock: None, };
928
929 let trajectory =
930 integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
931
932 trajectory
934 .into_iter()
935 .map(|(t, state)| {
936 let mut point = HashMap::new();
937 point.insert("t".to_string(), t);
938 point.insert("x".to_string(), state[0]);
939 point.insert("y".to_string(), state[1]);
940 point.insert("z".to_string(), state[2]);
941 point.insert("vx".to_string(), state[3]);
942 point.insert("vy".to_string(), state[4]);
943 point.insert("vz".to_string(), state[5]);
944 point
945 })
946 .collect()
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn uniform_gravity_integrators_match_a_ball_in_near_vacuum() {
955 let initial = [0.0, 10.0, 0.0, 10.0, 2.0, 0.0];
956 for method in ["RK4", "RK45"] {
957 for magnitude in [0.0, 1.62, 9.80665, 20.0] {
958 let mut params = create_test_params(10.0);
959 params.mass_kg = 0.0459;
960 params.bullet_diameter = 0.0427;
961 params.bullet_length = 0.0427;
962 params.atmos_params = (1e-12, 340.0, 0.0, 0.0);
963 let result = integrate_trajectory_with_gravity(
964 initial,
965 (0.0, 2.0),
966 params,
967 method,
968 1e-8,
969 0.001,
970 Gravity::new(magnitude).unwrap(),
971 );
972 let (time, state) = result.last().unwrap();
973 assert!((time - 1.0).abs() < 1e-8);
974 assert!(
975 (state[1] - (10.0 + 2.0 * time - 0.5 * magnitude * time * time)).abs() < 1e-5
976 );
977 assert!((state[4] - (2.0 - magnitude * time)).abs() < 1e-8);
978 }
979 }
980 }
981
982 fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
983 TrajectoryParams {
984 mass_kg: 0.01134, bc: 0.442,
986 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
989 drag_model: DragModel::G7,
990 wind_segments: vec![],
991 atmos_params: (0.0, 15.0, 1013.25, 1.0),
992 omega_vector: None,
993 enable_spin_drift: false,
994 enable_magnus: false,
995 enable_coriolis: false,
996 target_distance_m,
997 enable_wind_shear: false,
998 wind_shear_model: "none".to_string(),
999 shooter_altitude_m: 0.0,
1000 is_twist_right: true,
1001 shooting_angle: 0.0,
1002 custom_drag_table: None,
1003 cd_scale: 1.0,
1004 custom_drag_reference: false,
1005 bc_segments: None,
1006 use_bc_segments: false,
1007 bc_segments_data: None,
1008 ground_threshold: -1000.0,
1009 atmo_sock: None,
1010 }
1011 }
1012
1013 #[test]
1016 fn build_inputs_carries_the_custom_table_fields() {
1017 for (cd_scale, reference) in [(1.0, false), (1.25, false), (1.0, true), (0.8, true)] {
1018 let params = TrajectoryParams {
1019 custom_drag_table: Some(crate::drag::DragTable::new(
1020 vec![0.5, 3.0],
1021 vec![0.2, 0.3],
1022 )),
1023 cd_scale,
1024 custom_drag_reference: reference,
1025 ..create_test_params(300.0)
1026 };
1027 let inputs = build_inputs(¶ms, 800.0);
1028 assert!(inputs.custom_drag_table.is_some());
1029 assert_eq!(inputs.cd_scale, cd_scale);
1030 assert_eq!(inputs.custom_drag_reference, reference);
1031 }
1032 }
1033
1034 #[test]
1035 fn try_integrate_trajectory_rejects_malformed_segments_before_any_points() {
1036 let mut params = create_test_params(300.0);
1039 params.wind_segments = vec![
1040 WindSegment::new(10.0, 90.0, 200.0),
1041 WindSegment::new(10.0, 90.0, f64::NAN),
1042 ];
1043 let err = try_integrate_trajectory(
1044 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1045 (0.0, 2.0),
1046 params,
1047 "RK4",
1048 1e-6,
1049 0.001,
1050 )
1051 .unwrap_err();
1052 assert_eq!(err.index, 1);
1053 assert_eq!(err.field, crate::wind::WindSegmentField::UntilM);
1054 assert_eq!(
1055 err.to_string(),
1056 "wind.segments[1].until_m must be finite and greater than zero"
1057 );
1058 }
1059
1060 #[test]
1061 fn try_integrate_trajectory_matches_unchecked_on_valid_input() {
1062 let mk = || {
1063 let mut params = create_test_params(300.0);
1064 params.wind_segments = vec![WindSegment::new(16.0934, 90.0, 500.0)];
1065 params
1066 };
1067 let checked = try_integrate_trajectory(
1068 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1069 (0.0, 2.0),
1070 mk(),
1071 "RK4",
1072 1e-6,
1073 0.001,
1074 )
1075 .expect("valid segments must integrate");
1076 let unchecked = integrate_trajectory(
1077 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1078 (0.0, 2.0),
1079 mk(),
1080 "RK4",
1081 1e-6,
1082 0.001,
1083 );
1084 assert_eq!(checked.len(), unchecked.len());
1085 assert_eq!(checked.last().unwrap().1, unchecked.last().unwrap().1);
1086 }
1087
1088 #[test]
1089 fn try_solve_trajectory_rust_rejects_malformed_segments() {
1090 let bad = vec![WindSegment::new(-5.0, 0.0, 100.0)];
1091 let err = try_solve_trajectory_rust(
1092 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1093 (0.0, 2.0),
1094 0.01134,
1095 0.442,
1096 DragModel::G7,
1097 bad,
1098 (0.0, 15.0, 1013.25, 1.0),
1099 None,
1100 false,
1101 false,
1102 false,
1103 "RK4".to_string(),
1104 1e-6,
1105 0.001,
1106 300.0,
1107 )
1108 .unwrap_err();
1109 assert_eq!(err.index, 0);
1110 assert_eq!(err.field, crate::wind::WindSegmentField::SpeedKmh);
1111 assert_eq!(
1112 err.to_string(),
1113 "wind.segments[0].speed_kmh must be finite and non-negative"
1114 );
1115 }
1116
1117 #[test]
1118 fn try_solve_trajectory_rust_succeeds_on_valid_segments() {
1119 let points = try_solve_trajectory_rust(
1120 [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1121 (0.0, 2.0),
1122 0.01134,
1123 0.442,
1124 DragModel::G7,
1125 vec![WindSegment::new(16.0934, 90.0, 500.0)],
1126 (0.0, 15.0, 1013.25, 1.0),
1127 None,
1128 false,
1129 false,
1130 false,
1131 "RK4".to_string(),
1132 1e-6,
1133 0.001,
1134 300.0,
1135 )
1136 .expect("valid segments must solve");
1137 assert!(!points.is_empty());
1138 assert!(points.last().unwrap()["x"] > 0.0);
1139 }
1140
1141 #[test]
1142 fn derivative_inputs_preserve_initial_velocity_as_muzzle_speed() {
1143 let params = create_test_params(1_000.0);
1144 let launch_velocity: Vector3<f64> = Vector3::new(700.0, 30.0, -20.0);
1145 let inputs = build_inputs(¶ms, launch_velocity.norm());
1146
1147 assert_eq!(
1148 inputs.muzzle_velocity.to_bits(),
1149 launch_velocity.norm().to_bits()
1150 );
1151 }
1152
1153 #[test]
1154 fn integrated_magnus_retains_nonzero_launch_spin() {
1155 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1156 let baseline = integrate_trajectory(
1157 initial_state,
1158 (0.0, 0.1),
1159 create_test_params(1_000.0),
1160 "RK4",
1161 1e-6,
1162 0.001,
1163 );
1164 let mut magnus_params = create_test_params(1_000.0);
1165 magnus_params.enable_magnus = true;
1166
1167 let trajectory =
1168 integrate_trajectory(initial_state, (0.0, 0.1), magnus_params, "RK4", 1e-6, 0.001);
1169 let baseline_y = baseline.last().expect("baseline trajectory is empty").1[1];
1170 let magnus_y = trajectory.last().expect("trajectory is empty").1[1];
1171 let vertical_delta = magnus_y - baseline_y;
1172
1173 assert!(
1174 vertical_delta.is_finite() && vertical_delta < 0.0,
1175 "right-twist Magnus should retain nonzero launch spin and point down, got \
1176 delta_y={vertical_delta}"
1177 );
1178 }
1179
1180 #[test]
1181 fn rk45_retries_rejected_wind_boundary_step() {
1182 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1183 let mut params = create_test_params(100.0);
1184 params.wind_segments = vec![
1185 WindSegment::new(0.0, 90.0, 4.0),
1186 WindSegment::new(1_000.0, 90.0, 10_000.0),
1187 ];
1188
1189 let state = Vector6::from_row_slice(&initial_state);
1190 let launch_speed =
1191 Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
1192 let inputs = build_inputs(¶ms, launch_speed);
1193 let initial_dt = 0.01;
1194 let tolerance = 1e-6;
1195 let (rejected_state, suggested_dt, error) = rk45_step_with_gravity(
1196 &state,
1197 0.0,
1198 initial_dt,
1199 ¶ms,
1200 &inputs,
1201 tolerance,
1202 Gravity::EARTH,
1203 );
1204 assert!(
1205 error > tolerance,
1206 "wind-boundary trial must exceed tolerance, got {error}"
1207 );
1208 assert!(suggested_dt < initial_dt);
1209
1210 let accepted = adaptive_rk45_step_with_gravity(
1211 &state,
1212 0.0,
1213 initial_dt,
1214 ¶ms,
1215 &inputs,
1216 Rk45Control {
1217 tolerance,
1218 min_step: RK45_MIN_STEP,
1219 max_step: initial_dt,
1220 max_trials: 100,
1221 },
1222 Gravity::EARTH,
1223 )
1224 .expect("a smaller finite trial should satisfy the tolerance");
1225
1226 assert!(accepted.trials > 1, "oversized trial was not retried");
1227 assert!(accepted.used_dt < initial_dt);
1228 assert!(
1229 accepted.error <= tolerance || accepted.used_dt <= RK45_MIN_STEP,
1230 "accepted error {} exceeds tolerance at dt {}",
1231 accepted.error,
1232 accepted.used_dt
1233 );
1234
1235 let (accepted_state, _, accepted_error) = rk45_step_with_gravity(
1236 &state,
1237 0.0,
1238 accepted.used_dt,
1239 ¶ms,
1240 &inputs,
1241 tolerance,
1242 Gravity::EARTH,
1243 );
1244 assert_eq!(accepted.state, accepted_state);
1245 assert_eq!(accepted.error, accepted_error);
1246 assert_ne!(accepted.state, rejected_state);
1247 assert!((RK45_MIN_STEP..=initial_dt).contains(&accepted.next_dt));
1248 }
1249
1250 #[test]
1251 fn integration_normalizes_wind_segments_by_distance() {
1252 let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1253 let sorted_segments = vec![
1254 WindSegment::new(40.0, 270.0, 300.0),
1255 WindSegment::new(20.0, 90.0, 600.0),
1256 ];
1257
1258 let mut sorted_params = create_test_params(100.0);
1259 sorted_params.wind_segments = sorted_segments.clone();
1260 let mut unsorted_params = create_test_params(100.0);
1261 unsorted_params.wind_segments = sorted_segments.into_iter().rev().collect();
1262
1263 let sorted =
1264 integrate_trajectory(initial_state, (0.0, 1.0), sorted_params, "RK4", 1e-6, 0.001);
1265 let unsorted = integrate_trajectory(
1266 initial_state,
1267 (0.0, 1.0),
1268 unsorted_params,
1269 "RK4",
1270 1e-6,
1271 0.001,
1272 );
1273
1274 assert_eq!(unsorted.len(), sorted.len());
1275 for (index, ((sorted_t, sorted_state), (unsorted_t, unsorted_state))) in
1276 sorted.iter().zip(&unsorted).enumerate()
1277 {
1278 assert_eq!(unsorted_t.to_bits(), sorted_t.to_bits());
1279 for component in 0..6 {
1280 assert_eq!(
1281 unsorted_state[component].to_bits(),
1282 sorted_state[component].to_bits(),
1283 "wind segment order changed state component {component} at point {index}"
1284 );
1285 }
1286 }
1287 }
1288
1289 #[test]
1290 fn rk4_target_crossing_interpolates_complete_state_and_time() {
1291 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1292 let target_distance_m = 100.0;
1293 let trajectory = integrate_trajectory(
1294 initial_state,
1295 (0.0, 1.0),
1296 create_test_params(target_distance_m),
1297 "RK4",
1298 1e-6,
1299 0.001,
1300 );
1301
1302 let (previous_t, previous_state) = &trajectory[trajectory.len() - 2];
1303 let (terminal_t, terminal_state) = trajectory.last().expect("trajectory is empty");
1304 let reference_params = create_test_params(target_distance_m);
1305 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1306 let full_step_dt = 0.001;
1307 let bracket_end = rk4_step_with_gravity(
1308 previous_state,
1309 *previous_t,
1310 full_step_dt,
1311 &reference_params,
1312 &inputs,
1313 Gravity::EARTH,
1314 );
1315 assert!(previous_state[0] < target_distance_m);
1316 assert!(bracket_end[0] >= target_distance_m);
1317
1318 let alpha = (target_distance_m - previous_state[0]) / (bracket_end[0] - previous_state[0]);
1319 let expected_t = previous_t + alpha * full_step_dt;
1320 let mut expected_state = previous_state + alpha * (bracket_end - previous_state);
1321 expected_state[0] = target_distance_m;
1322
1323 assert_eq!(terminal_t.to_bits(), expected_t.to_bits());
1324 for component in 0..6 {
1325 assert_eq!(
1326 terminal_state[component].to_bits(),
1327 expected_state[component].to_bits(),
1328 "terminal component {component} was not interpolated at the target crossing"
1329 );
1330 }
1331 }
1332
1333 #[test]
1334 fn rk45_target_crossing_uses_the_accepted_state_and_time() {
1335 let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1336 let initial = Vector6::from_row_slice(&initial_state);
1337 let target_distance_m = 0.5;
1338 let reference_params = create_test_params(target_distance_m);
1339 let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1340 let initial_dt = 0.001;
1341 let accepted = adaptive_rk45_step_with_gravity(
1342 &initial,
1343 0.0,
1344 initial_dt,
1345 &reference_params,
1346 &inputs,
1347 Rk45Control {
1348 tolerance: 1e-6,
1349 min_step: RK45_MIN_STEP,
1350 max_step: 0.01,
1351 max_trials: 100_000,
1352 },
1353 Gravity::EARTH,
1354 )
1355 .expect("first RK45 target bracket should be accepted");
1356 assert!(accepted.state[0] >= target_distance_m);
1357 let expected = interpolate_target_crossing(
1358 0.0,
1359 &initial,
1360 accepted.used_dt,
1361 &accepted.state,
1362 target_distance_m,
1363 );
1364
1365 let trajectory = integrate_trajectory(
1366 initial_state,
1367 (0.0, 1.0),
1368 create_test_params(target_distance_m),
1369 "RK45",
1370 1e-6,
1371 0.01,
1372 );
1373 let actual = trajectory.last().expect("trajectory is empty");
1374
1375 assert_eq!(actual.0.to_bits(), expected.0.to_bits());
1376 for component in 0..6 {
1377 assert_eq!(
1378 actual.1[component].to_bits(),
1379 expected.1[component].to_bits(),
1380 "RK45 terminal component {component} was not interpolated from its accepted step"
1381 );
1382 }
1383 }
1384
1385 #[test]
1386 fn target_crossing_helper_interpolates_every_component() {
1387 let start = Vector6::new(90.0, 10.0, -4.0, 700.0, -20.0, 5.0);
1388 let end = Vector6::new(130.0, 6.0, 8.0, 660.0, -24.0, 9.0);
1389 let (time, state) = interpolate_target_crossing(2.0, &start, 0.5, &end, 100.0);
1390
1391 assert_eq!(time.to_bits(), 2.125_f64.to_bits());
1392 for (index, expected) in [100.0_f64, 9.0, -1.0, 690.0, -21.0, 6.0]
1393 .into_iter()
1394 .enumerate()
1395 {
1396 assert_eq!(state[index].to_bits(), expected.to_bits());
1397 }
1398 }
1399
1400 #[test]
1401 fn already_at_or_past_target_returns_initial_state_without_advancing() {
1402 let initial = [150.0, 12.0, -3.0, 700.0, -4.0, 5.0];
1403
1404 for method in ["RK4", "RK45"] {
1405 for target in [150.0, 100.0] {
1406 let trajectory = integrate_trajectory(
1407 initial,
1408 (2.0, 3.0),
1409 create_test_params(target),
1410 method,
1411 1e-6,
1412 0.01,
1413 );
1414
1415 assert_eq!(trajectory.len(), 1, "{method} advanced a terminal state");
1416 let (time, state) = &trajectory[0];
1417 assert_eq!(time.to_bits(), 2.0_f64.to_bits());
1418 for index in 0..6 {
1419 assert_eq!(state[index].to_bits(), initial[index].to_bits());
1420 }
1421 }
1422 }
1423 }
1424
1425 #[test]
1426 fn rk45_error_norm_scales_components_independently() {
1427 let state = Vector6::new(1.0e9, 0.0, 0.0, 800.0, 0.0, 0.0);
1428 let fifth_order = state;
1429 let mut fourth_order = state;
1430 fourth_order[4] = 1.0e-3;
1431
1432 let error = rk45_error_norm(&state, &fifth_order, &fourth_order);
1433 let expected = 1.0e-3 / 6.0_f64.sqrt();
1434
1435 assert!(
1436 (error - expected).abs() <= 1e-15,
1437 "large downrange position masked a velocity-component error: {error}"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_mba954_ground_threshold_honored() {
1443 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);
1451 deep.ground_threshold = -1000.0; let t_shallow =
1454 integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
1455 let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
1456
1457 assert!(
1458 t_shallow.len() < t_deep.len(),
1459 "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
1460 shallow={}, deep={}",
1461 t_shallow.len(),
1462 t_deep.len()
1463 );
1464 }
1465
1466 #[test]
1467 fn test_integrate_trajectory_basic() {
1468 let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
1471
1472 let params = TrajectoryParams {
1473 mass_kg: 0.01134, bc: 0.442,
1475 bullet_diameter: 0.0078232, bullet_length: 0.031496, twist_rate: 10.0,
1478 drag_model: DragModel::G7,
1479 wind_segments: vec![WindSegment::new(0.0, 90.0, 914.4)],
1480 atmos_params: (0.0, 15.0, 1013.25, 1.0),
1481 omega_vector: None,
1482 enable_spin_drift: false,
1483 enable_magnus: false,
1484 enable_coriolis: false,
1485 target_distance_m: 914.4, enable_wind_shear: false,
1487 wind_shear_model: "none".to_string(),
1488 shooter_altitude_m: 0.0,
1489 is_twist_right: true,
1490 shooting_angle: 0.0,
1491 custom_drag_table: None,
1492 cd_scale: 1.0,
1493 custom_drag_reference: false,
1494 bc_segments: None,
1495 use_bc_segments: false,
1496 bc_segments_data: None,
1497 ground_threshold: -1000.0,
1498 atmo_sock: None,
1499 };
1500
1501 println!("Running integrate_trajectory test...");
1502 println!("Initial state: {:?}", initial_state);
1503 println!("Target distance: {} m", params.target_distance_m);
1504
1505 let trajectory =
1506 integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
1507
1508 println!("Trajectory has {} points", trajectory.len());
1509
1510 assert!(
1512 trajectory.len() > 1,
1513 "Trajectory should have more than 1 point, but has {}",
1514 trajectory.len()
1515 );
1516
1517 if let Some((_, final_state)) = trajectory.last() {
1519 println!("Final state: downrange(x)={}", final_state[0]);
1520 assert!(
1521 final_state[0] > 0.0,
1522 "Final x should be positive (bullet moved downrange)"
1523 );
1524 assert!(
1525 final_state[0] >= 900.0,
1526 "Final x should be near target distance"
1527 );
1528 assert!(
1529 final_state[3] < 0.9 * initial_state[3],
1530 "standard-atmosphere drag should reduce downrange velocity"
1531 );
1532 }
1533 }
1534
1535 #[test]
1536 fn test_rk4_vs_rk45_consistency() {
1537 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1540
1541 let params_rk4 = create_test_params(target_distance);
1542 let params_rk45 = create_test_params(target_distance);
1543
1544 let trajectory_rk4 =
1545 integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
1546 let trajectory_rk45 =
1547 integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
1548
1549 assert!(!trajectory_rk4.is_empty());
1551 assert!(!trajectory_rk45.is_empty());
1552
1553 let (time_rk4, final_rk4) = trajectory_rk4.last().unwrap();
1554 let (time_rk45, final_rk45) = trajectory_rk45.last().unwrap();
1555
1556 assert!(
1558 (time_rk4 - time_rk45).abs() < 1e-4,
1559 "RK4/RK45 time of flight diverged: {time_rk4} vs {time_rk45}"
1560 );
1561 assert!((final_rk4[1] - final_rk45[1]).abs() < 1e-3);
1562 assert!((final_rk4[3] - final_rk45[3]).abs() < 1e-2);
1563 assert!(final_rk45[3] < 0.9 * initial_state[3]);
1564 }
1565
1566 #[test]
1567 fn test_ground_impact_detection() {
1568 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;
1573 let ground_threshold = 0.0;
1574 params.ground_threshold = ground_threshold;
1575
1576 let trajectory =
1577 integrate_trajectory(initial_state, (0.0, 20.0), params, "RK4", 1e-6, 0.01);
1578
1579 let (_, final_state) = trajectory.last().unwrap();
1581
1582 assert!(
1584 final_state[1] <= ground_threshold,
1585 "Should hit ground, but y={}",
1586 final_state[1]
1587 );
1588 assert!(
1589 final_state[0] < 10000.0,
1590 "Should not reach target, but z={}",
1591 final_state[0]
1592 );
1593 }
1594
1595 #[test]
1596 fn test_target_distance_reached() {
1597 let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; let target_distance = 300.0;
1599
1600 let params = create_test_params(target_distance);
1601
1602 let trajectory =
1603 integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
1604
1605 let (_, final_state) = trajectory.last().unwrap();
1606
1607 assert!(
1609 (final_state[0] - target_distance).abs() < 1.0,
1610 "Should reach target at {}m, but stopped at {}m",
1611 target_distance,
1612 final_state[0]
1613 );
1614 }
1615
1616 #[test]
1617 fn test_wind_affects_trajectory() {
1618 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1623
1624 let params_no_wind = create_test_params(target_distance);
1626
1627 let mut params_headwind = create_test_params(target_distance);
1629 params_headwind.wind_segments = vec![WindSegment::new(72.0, 0.0, 500.0)]; let trajectory_no_wind = integrate_trajectory(
1632 initial_state,
1633 (0.0, 5.0),
1634 params_no_wind,
1635 "RK45",
1636 1e-6,
1637 0.01,
1638 );
1639 let trajectory_headwind = integrate_trajectory(
1640 initial_state,
1641 (0.0, 5.0),
1642 params_headwind,
1643 "RK45",
1644 1e-6,
1645 0.01,
1646 );
1647
1648 assert!(
1650 !trajectory_no_wind.is_empty(),
1651 "No-wind trajectory should complete"
1652 );
1653 assert!(
1654 !trajectory_headwind.is_empty(),
1655 "Headwind trajectory should complete"
1656 );
1657
1658 let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
1659 let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
1660
1661 let drop_no_wind = final_no_wind[1];
1664 let drop_headwind = final_headwind[1];
1665
1666 println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
1667 println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
1668
1669 assert!(
1670 *time_headwind > *time_no_wind + 0.001,
1671 "headwind should increase time of flight: no-wind={time_no_wind}, headwind={time_headwind}"
1672 );
1673 assert!(
1674 final_headwind[3] < final_no_wind[3] - 1.0,
1675 "headwind should reduce terminal downrange velocity"
1676 );
1677
1678 assert!(
1680 (final_no_wind[0] - target_distance).abs() < 10.0,
1681 "No-wind should reach target"
1682 );
1683 assert!(
1684 (final_headwind[0] - target_distance).abs() < 10.0,
1685 "Headwind should reach target"
1686 );
1687 }
1688
1689 #[test]
1690 fn test_solve_trajectory_rust_output_format() {
1691 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let result = solve_trajectory_rust(
1694 initial_state,
1695 (0.0, 2.0),
1696 0.01134, 0.442, DragModel::G7, vec![], (0.0, 15.0, 1013.25, 1.0),
1702 None, false, false, false, "RK45".to_string(), 1e-6, 0.01, 500.0, );
1711
1712 assert!(!result.is_empty());
1714
1715 let first_point = &result[0];
1716 assert!(first_point.contains_key("t"));
1717 assert!(first_point.contains_key("x"));
1718 assert!(first_point.contains_key("y"));
1719 assert!(first_point.contains_key("z"));
1720 assert!(first_point.contains_key("vx"));
1721 assert!(first_point.contains_key("vy"));
1722 assert!(first_point.contains_key("vz"));
1723
1724 let final_point = result.last().unwrap();
1725 assert!(
1726 final_point["vx"] < 0.9 * initial_state[3],
1727 "standard-atmosphere wrapper fixture should exercise drag"
1728 );
1729 }
1730
1731 #[test]
1732 fn test_left_vs_right_twist() {
1733 let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; let target_distance = 500.0;
1735
1736 let mut params_right = create_test_params(target_distance);
1737 params_right.is_twist_right = true;
1738 params_right.enable_spin_drift = true;
1739
1740 let mut params_left = create_test_params(target_distance);
1741 params_left.is_twist_right = false;
1742 params_left.enable_spin_drift = true;
1743
1744 let trajectory_right =
1745 integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
1746 let trajectory_left =
1747 integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
1748
1749 assert!(!trajectory_right.is_empty());
1751 assert!(!trajectory_left.is_empty());
1752
1753 let (_, final_right) = trajectory_right.last().unwrap();
1755 let (_, final_left) = trajectory_left.last().unwrap();
1756
1757 assert!((final_right[2] - final_left[2]).abs() < 10.0);
1759 }
1760
1761 fn assert_kernel_shear_ratio(
1766 model: &str,
1767 muzzle_height: f64,
1768 shooting_angle: f64,
1769 (x, y): (f64, f64),
1770 expected_ratio: f64,
1771 ) {
1772 let state = Vector6::new(x, y, 0.0, 700.0, 5.0, 0.0);
1773 let run = |enable_wind_shear: bool, wind_speed_kmh: f64| {
1774 let mut params = create_test_params(2_000.0);
1775 params.wind_segments = vec![WindSegment::new(wind_speed_kmh, 90.0, 2_000.0)];
1776 params.enable_wind_shear = enable_wind_shear;
1777 params.wind_shear_model = model.to_string();
1778 params.shooting_angle = shooting_angle;
1779 let mut inputs = build_inputs(¶ms, 700.0);
1780 inputs.muzzle_height = muzzle_height;
1781 compute_derivatives_vec_with_gravity(&state, 0.0, ¶ms, &inputs, Gravity::EARTH)
1782 };
1783 let sheared = run(true, 36.0);
1784 let scaled = run(false, 36.0 * expected_ratio);
1785 for component in 3..6 {
1786 assert!(
1787 (sheared[component] - scaled[component]).abs() < 1e-12,
1788 "{model}, muzzle_height {muzzle_height} m, shooting angle {shooting_angle} rad, \
1789 ({x}, {y}): the shear multiplier is not {expected_ratio} (component \
1790 {component}: {} vs {})",
1791 sheared[component],
1792 scaled[component]
1793 );
1794 }
1795 }
1796
1797 fn shear_ratio_rel_launch(height_rel_launch_m: f64, model: &str) -> f64 {
1798 crate::wind_shear::boundary_layer_speed_ratio(
1799 height_rel_launch_m,
1800 crate::wind_shear::boundary_layer_model_from_name(model),
1801 )
1802 }
1803
1804 #[test]
1810 fn kernel_shear_reads_one_multiplier_at_one_height_above_ground() {
1811 for model in ["logarithmic", "power_law"] {
1812 let expected = shear_ratio_rel_launch(58.5, model);
1813 assert!(
1814 expected > 1.2,
1815 "{model}: the fixture must be well above the floor"
1816 );
1817 assert_kernel_shear_ratio(model, 0.0, 0.0, (1_000.0, 58.5), expected);
1818 assert_kernel_shear_ratio(model, 1.5, 0.0, (1_000.0, 60.0), expected);
1819 }
1820 }
1821
1822 #[test]
1827 fn inclined_kernel_shear_measures_height_along_the_line_of_sight() {
1828 let (x, y) = (800.0, 60.0);
1829 for angle in [0.3_f64, -0.3] {
1830 let world_height = crate::atmosphere::shot_frame_altitude(0.0, x, y, angle);
1831 for model in ["logarithmic", "power_law"] {
1832 let bore_frame = shear_ratio_rel_launch(y, model);
1833 let ground_frame = shear_ratio_rel_launch(y - 1.5, model);
1834 assert!(
1835 bore_frame > 1.25 && ground_frame > 1.25,
1836 "{model}: fixture too low"
1837 );
1838 assert!(
1839 (shear_ratio_rel_launch(world_height, model) - bore_frame).abs() > 0.1,
1840 "{model}, angle {angle}: the fixture must tell the world height apart"
1841 );
1842 assert_kernel_shear_ratio(model, 0.0, angle, (x, y), bore_frame);
1843 assert_kernel_shear_ratio(model, 1.5, angle, (x, y), ground_frame);
1844 }
1845 }
1846 }
1847}