1use crate::{
4 calculate_zero_angle_with_conditions, run_monte_carlo_with_direction_std_dev,
5 AtmosphericConditions, BallisticInputs, DragModel, MonteCarloParams, TrajectorySolver,
6 WindConditions,
7};
8use std::os::raw::{c_char, c_double, c_int};
9use std::ptr;
10
11pub const MIN_FFI_STEP_SIZE_MS: c_double = 0.1;
16
17pub const MAX_FFI_DRAG_TABLE_LEN: c_int = 4096;
25
26#[repr(C)]
29pub struct FFIBallisticInputs {
30 pub muzzle_velocity: c_double, pub muzzle_angle: c_double, pub bc_value: c_double, pub bullet_mass: c_double, pub bullet_diameter: c_double, pub bc_type: c_int, pub sight_height: c_double, pub target_distance: c_double, pub temperature: c_double, pub twist_rate: c_double, pub is_twist_right: c_int, pub shooting_angle: c_double, pub altitude: c_double, pub latitude: c_double, pub azimuth_angle: c_double, pub use_rk4: c_int, pub use_adaptive_rk45: c_int, pub enable_wind_shear: c_int, pub enable_trajectory_sampling: c_int, pub sample_interval: c_double, pub enable_pitch_damping: c_int, pub enable_precession_nutation: c_int, pub enable_spin_drift: c_int, pub enable_magnus: c_int, pub enable_coriolis: c_int, pub shot_azimuth: c_double,
59 pub cant_angle: c_double,
63}
64
65#[repr(C)]
66pub struct FFIWindConditions {
67 pub speed: c_double, pub direction: c_double,
71 pub vertical_speed: c_double,
74}
75
76#[repr(C)]
77pub struct FFIAtmosphericConditions {
78 pub temperature: c_double, pub pressure: c_double, pub humidity: c_double, pub altitude: c_double, }
83
84#[repr(C)]
85pub struct FFITrajectorySample {
86 pub distance: c_double, pub time: c_double, pub velocity_mps: c_double, pub energy_joules: c_double, pub drop_meters: c_double, pub windage_meters: c_double, pub mach: c_double, pub spin_rate_rps: c_double, }
95
96#[repr(C)]
97pub struct FFITrajectoryPoint {
98 pub time: c_double,
99 pub position_x: c_double,
100 pub position_y: c_double,
101 pub position_z: c_double,
102 pub velocity_magnitude: c_double,
103 pub kinetic_energy: c_double,
104}
105
106#[repr(C)]
107pub struct FFITrajectoryResult {
108 pub max_range: c_double,
109 pub max_height: c_double,
110 pub time_of_flight: c_double,
111 pub impact_velocity: c_double,
112 pub impact_energy: c_double,
113 pub points: *mut FFITrajectoryPoint,
114 pub point_count: c_int,
115 pub sampled_points: *mut FFITrajectorySample,
116 pub sampled_point_count: c_int,
117 pub min_pitch_damping: c_double, pub transonic_mach: c_double, pub final_pitch_angle: c_double, pub final_yaw_angle: c_double, pub max_yaw_angle: c_double, pub max_precession_angle: c_double, }
124
125#[repr(C)]
127pub struct FFIMonteCarloParams {
128 pub num_simulations: c_int,
129 pub velocity_std_dev: c_double,
130 pub angle_std_dev: c_double,
131 pub bc_std_dev: c_double,
132 pub wind_speed_std_dev: c_double,
133 pub target_distance: c_double, pub base_wind_speed: c_double, pub base_wind_direction: c_double, pub azimuth_std_dev: c_double, }
138
139#[repr(C)]
141pub struct FFIMonteCarloResults {
142 pub ranges: *mut c_double,
143 pub impact_velocities: *mut c_double,
144 pub impact_positions_x: *mut c_double,
145 pub impact_positions_y: *mut c_double,
148 pub impact_positions_z: *mut c_double,
149 pub num_results: c_int,
150 pub mean_range: c_double,
151 pub std_dev_range: c_double,
152 pub mean_impact_velocity: c_double,
153 pub std_dev_impact_velocity: c_double,
154 pub hit_probability: c_double, }
156
157#[allow(clippy::field_reassign_with_default)] fn convert_inputs(inputs: &FFIBallisticInputs) -> BallisticInputs {
160 let mut ballistic_inputs = BallisticInputs::default();
161
162 ballistic_inputs.muzzle_velocity = inputs.muzzle_velocity;
163 ballistic_inputs.muzzle_angle = inputs.muzzle_angle;
164 ballistic_inputs.azimuth_angle = inputs.azimuth_angle;
165 ballistic_inputs.shot_azimuth = inputs.shot_azimuth;
166 ballistic_inputs.cant_angle = inputs.cant_angle;
167 ballistic_inputs.use_rk4 = inputs.use_rk4 != 0;
168 ballistic_inputs.use_adaptive_rk45 = inputs.use_adaptive_rk45 != 0;
169 ballistic_inputs.bc_value = inputs.bc_value;
170 ballistic_inputs.bullet_mass = inputs.bullet_mass;
171 ballistic_inputs.bullet_diameter = inputs.bullet_diameter;
172 ballistic_inputs.bc_type = match inputs.bc_type {
173 1 => DragModel::G7,
174 2 => DragModel::G2,
175 3 => DragModel::G5,
176 4 => DragModel::G6,
177 5 => DragModel::G8,
178 6 => DragModel::GI,
179 7 => DragModel::GS,
180 8 => DragModel::RA4,
183 _ => DragModel::G1,
184 };
185 ballistic_inputs.sight_height = inputs.sight_height;
186 ballistic_inputs.target_distance = inputs.target_distance;
187 ballistic_inputs.temperature = inputs.temperature;
188 ballistic_inputs.twist_rate = inputs.twist_rate;
189 ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
190 ballistic_inputs.shooting_angle = inputs.shooting_angle;
191 ballistic_inputs.altitude = inputs.altitude;
192
193 if !inputs.latitude.is_nan() {
194 ballistic_inputs.latitude = Some(inputs.latitude);
195 }
196
197 ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
199 ballistic_inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
200 ballistic_inputs.bullet_length = {
203 let est = crate::stability::estimate_bullet_length_m(
204 ballistic_inputs.bullet_diameter,
205 ballistic_inputs.bullet_mass,
206 );
207 if est > 0.0 {
208 est
209 } else {
210 ballistic_inputs.bullet_diameter * 4.5
211 }
212 };
213
214 ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
216 ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
217 ballistic_inputs.sample_interval = inputs.sample_interval;
218 ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
219 ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
220 ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
221 ballistic_inputs.enable_advanced_effects =
222 inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
223 ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
225 ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
226
227 ballistic_inputs
228}
229
230unsafe fn drag_table_from_raw(
244 mach: *const c_double,
245 cd: *const c_double,
246 len: c_int,
247) -> Result<crate::drag::DragTable, ()> {
248 if mach.is_null() || cd.is_null() || !(2..=MAX_FFI_DRAG_TABLE_LEN).contains(&len) {
249 return Err(());
250 }
251 let len = len as usize;
252 let mach = unsafe { std::slice::from_raw_parts(mach, len) }.to_vec();
253 let cd = unsafe { std::slice::from_raw_parts(cd, len) }.to_vec();
254 crate::drag::DragTable::try_new(mach, cd).map_err(|_| ())
255}
256
257unsafe fn calculate_trajectory_impl(
263 inputs: *const FFIBallisticInputs,
264 wind: *const FFIWindConditions,
265 atmosphere: *const FFIAtmosphericConditions,
266 max_range: c_double,
267 step_size: c_double,
268 custom_drag_table: Option<crate::drag::DragTable>,
269 cd_scale: c_double,
270) -> *mut FFITrajectoryResult {
271 if inputs.is_null() {
272 return ptr::null_mut();
273 }
274 if !step_size.is_finite() || step_size < MIN_FFI_STEP_SIZE_MS {
275 return ptr::null_mut();
276 }
277
278 let inputs = unsafe { &*inputs };
279 let mut ballistic_inputs = convert_inputs(inputs);
280 ballistic_inputs.custom_drag_table = custom_drag_table;
281 ballistic_inputs.cd_scale = cd_scale;
282 let twist_rate_in = ballistic_inputs.twist_rate;
283
284 let wind_conditions = if wind.is_null() {
285 WindConditions::default()
286 } else {
287 let wind = unsafe { &*wind };
288 WindConditions {
289 speed: wind.speed,
290 direction: wind.direction,
291 vertical_speed: wind.vertical_speed,
292 }
293 };
294
295 let atmospheric_conditions = if atmosphere.is_null() {
296 AtmosphericConditions::default()
297 } else {
298 let atmo = unsafe { &*atmosphere };
299 AtmosphericConditions {
300 temperature: atmo.temperature,
301 pressure: atmo.pressure,
302 humidity: atmo.humidity,
303 altitude: atmo.altitude,
304 }
305 };
306
307 let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
309 atmospheric_conditions.temperature,
310 atmospheric_conditions.pressure,
311 atmospheric_conditions.altitude,
312 );
313 let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
314 atmospheric_conditions.altitude,
315 Some(sample_temp_c),
316 Some(sample_pressure_hpa),
317 atmospheric_conditions.humidity,
318 );
319
320 let mut solver =
321 TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
322
323 solver.set_max_range(max_range);
325 solver.set_time_step(step_size / 1000.0); match solver.solve() {
328 Ok(result) => {
329 let point_count = result.points.len();
331 let points = if point_count > 0 {
332 let mut ffi_points = Vec::with_capacity(point_count);
333 for point in result.points.iter() {
334 ffi_points.push(FFITrajectoryPoint {
335 time: point.time,
336 position_x: point.position[0],
337 position_y: point.position[1],
338 position_z: point.position[2],
339 velocity_magnitude: point.velocity_magnitude,
340 kinetic_energy: point.kinetic_energy,
341 });
342 }
343 let points_ptr = ffi_points.as_mut_ptr();
344 std::mem::forget(ffi_points); points_ptr
346 } else {
347 ptr::null_mut()
348 };
349
350 let (sampled_points, sampled_point_count) =
352 if let Some(ref samples) = result.sampled_points {
353 let mut ffi_samples = Vec::with_capacity(samples.len());
354 for sample in samples {
355 ffi_samples.push(FFITrajectorySample {
356 distance: sample.distance_m,
357 time: sample.time_s,
358 velocity_mps: sample.velocity_mps,
359 energy_joules: sample.energy_j,
360 drop_meters: sample.drop_m,
361 windage_meters: sample.wind_drift_m,
362 mach: if sample_speed_of_sound > 0.0 {
363 sample.velocity_mps / sample_speed_of_sound
364 } else {
365 0.0
366 },
367 spin_rate_rps: if twist_rate_in > 0.0 {
368 sample.velocity_mps / (twist_rate_in * 0.0254)
369 } else {
370 0.0
371 },
372 });
373 }
374 let count = ffi_samples.len() as c_int;
375 let samples_ptr = ffi_samples.as_mut_ptr();
376 std::mem::forget(ffi_samples);
377 (samples_ptr, count)
378 } else {
379 (ptr::null_mut(), 0)
380 };
381
382 let (final_pitch, final_yaw, max_yaw, max_prec) =
384 if let Some(ref angular) = result.angular_state {
385 (
386 angular.pitch_angle,
387 angular.yaw_angle,
388 result.max_yaw_angle.unwrap_or(f64::NAN),
389 result.max_precession_angle.unwrap_or(f64::NAN),
390 )
391 } else {
392 (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
393 };
394
395 let ffi_result = Box::new(FFITrajectoryResult {
397 max_range: result.max_range,
398 max_height: result.max_height,
399 time_of_flight: result.time_of_flight,
400 impact_velocity: result.impact_velocity,
401 impact_energy: result.impact_energy,
402 points,
403 point_count: point_count as c_int,
404 sampled_points,
405 sampled_point_count,
406 min_pitch_damping: result.min_pitch_damping.unwrap_or(f64::NAN),
407 transonic_mach: result.transonic_mach.unwrap_or(f64::NAN),
408 final_pitch_angle: final_pitch,
409 final_yaw_angle: final_yaw,
410 max_yaw_angle: max_yaw,
411 max_precession_angle: max_prec,
412 });
413
414 Box::into_raw(ffi_result)
415 }
416 Err(_) => ptr::null_mut(),
417 }
418}
419
420#[no_mangle]
439pub unsafe extern "C" fn ballistics_calculate_trajectory(
440 inputs: *const FFIBallisticInputs,
441 wind: *const FFIWindConditions,
442 atmosphere: *const FFIAtmosphericConditions,
443 max_range: c_double,
444 step_size: c_double,
445) -> *mut FFITrajectoryResult {
446 unsafe { calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, None, 1.0) }
447}
448
449#[no_mangle]
470pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table(
471 inputs: *const FFIBallisticInputs,
472 wind: *const FFIWindConditions,
473 atmosphere: *const FFIAtmosphericConditions,
474 max_range: c_double,
475 step_size: c_double,
476 drag_mach: *const c_double,
477 drag_cd: *const c_double,
478 drag_table_len: c_int,
479) -> *mut FFITrajectoryResult {
480 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
481 Ok(t) => t,
482 Err(()) => return ptr::null_mut(),
483 };
484 unsafe {
485 calculate_trajectory_impl(
486 inputs, wind, atmosphere, max_range, step_size, Some(table), 1.0,
487 )
488 }
489}
490
491#[no_mangle]
508pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table_scaled(
509 inputs: *const FFIBallisticInputs,
510 wind: *const FFIWindConditions,
511 atmosphere: *const FFIAtmosphericConditions,
512 max_range: c_double,
513 step_size: c_double,
514 drag_mach: *const c_double,
515 drag_cd: *const c_double,
516 drag_table_len: c_int,
517 cd_scale: c_double,
518) -> *mut FFITrajectoryResult {
519 if !cd_scale.is_finite() || cd_scale <= 0.0 {
520 return ptr::null_mut();
521 }
522 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
523 Ok(t) => t,
524 Err(()) => return ptr::null_mut(),
525 };
526 unsafe {
527 calculate_trajectory_impl(
528 inputs, wind, atmosphere, max_range, step_size, Some(table), cd_scale,
529 )
530 }
531}
532
533#[no_mangle]
542pub unsafe extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
543 if !result.is_null() {
544 unsafe {
545 let result = Box::from_raw(result);
546 if !result.points.is_null() && result.point_count > 0 {
547 let points = Vec::from_raw_parts(
548 result.points,
549 result.point_count as usize,
550 result.point_count as usize,
551 );
552 drop(points);
553 }
554 if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
555 let samples = Vec::from_raw_parts(
556 result.sampled_points,
557 result.sampled_point_count as usize,
558 result.sampled_point_count as usize,
559 );
560 drop(samples);
561 }
562 drop(result);
563 }
564 }
565}
566
567unsafe fn calculate_zero_angle_impl(
574 inputs: *const FFIBallisticInputs,
575 wind: *const FFIWindConditions,
576 atmosphere: *const FFIAtmosphericConditions,
577 zero_distance: c_double,
578 custom_drag_table: Option<crate::drag::DragTable>,
579 cd_scale: c_double,
580) -> c_double {
581 if inputs.is_null() {
582 return f64::NAN;
583 }
584
585 let inputs = unsafe { &*inputs };
586 let mut ballistic_inputs = convert_inputs(inputs);
587 ballistic_inputs.custom_drag_table = custom_drag_table;
588 ballistic_inputs.cd_scale = cd_scale;
589
590 let wind_conditions = if wind.is_null() {
591 WindConditions::default()
592 } else {
593 let wind = unsafe { &*wind };
594 WindConditions {
595 speed: wind.speed,
596 direction: wind.direction,
597 vertical_speed: wind.vertical_speed,
598 }
599 };
600
601 let atmospheric_conditions = if atmosphere.is_null() {
602 AtmosphericConditions::default()
603 } else {
604 let atmo = unsafe { &*atmosphere };
605 AtmosphericConditions {
606 temperature: atmo.temperature,
607 pressure: atmo.pressure,
608 humidity: atmo.humidity,
609 altitude: atmo.altitude,
610 }
611 };
612
613 let target_height = ballistic_inputs.sight_height;
616
617 calculate_zero_angle_with_conditions(
618 ballistic_inputs,
619 zero_distance,
620 target_height,
621 wind_conditions,
622 atmospheric_conditions,
623 )
624 .unwrap_or(f64::NAN)
625}
626
627#[no_mangle]
637pub unsafe extern "C" fn ballistics_calculate_zero_angle(
638 inputs: *const FFIBallisticInputs,
639 wind: *const FFIWindConditions,
640 atmosphere: *const FFIAtmosphericConditions,
641 zero_distance: c_double,
642) -> c_double {
643 unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, None, 1.0) }
644}
645
646#[no_mangle]
668pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table(
669 inputs: *const FFIBallisticInputs,
670 wind: *const FFIWindConditions,
671 atmosphere: *const FFIAtmosphericConditions,
672 zero_distance: c_double,
673 drag_mach: *const c_double,
674 drag_cd: *const c_double,
675 drag_table_len: c_int,
676) -> c_double {
677 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
678 Ok(t) => t,
679 Err(()) => return f64::NAN,
680 };
681 unsafe {
682 calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, Some(table), 1.0)
683 }
684}
685
686#[no_mangle]
704pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table_scaled(
705 inputs: *const FFIBallisticInputs,
706 wind: *const FFIWindConditions,
707 atmosphere: *const FFIAtmosphericConditions,
708 zero_distance: c_double,
709 drag_mach: *const c_double,
710 drag_cd: *const c_double,
711 drag_table_len: c_int,
712 cd_scale: c_double,
713) -> c_double {
714 if !cd_scale.is_finite() || cd_scale <= 0.0 {
715 return f64::NAN;
716 }
717 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
718 Ok(t) => t,
719 Err(()) => return f64::NAN,
720 };
721 unsafe {
722 calculate_zero_angle_impl(
723 inputs,
724 wind,
725 atmosphere,
726 zero_distance,
727 Some(table),
728 cd_scale,
729 )
730 }
731}
732
733#[no_mangle]
735#[allow(clippy::field_reassign_with_default)] pub extern "C" fn ballistics_quick_trajectory(
737 muzzle_velocity: c_double,
738 bc: c_double,
739 sight_height: c_double,
740 zero_distance: c_double,
741 target_distance: c_double,
742) -> c_double {
743 let mut inputs = BallisticInputs::default();
747 inputs.muzzle_velocity = muzzle_velocity;
748 inputs.bc_value = bc;
749 inputs.sight_height = sight_height;
750 inputs.target_distance = target_distance;
751
752 let wind = WindConditions::default();
753 let atmo = AtmosphericConditions::default();
754
755 let zero_angle = match calculate_zero_angle_with_conditions(
757 inputs.clone(),
758 zero_distance,
759 sight_height,
760 wind.clone(),
761 atmo.clone(),
762 ) {
763 Ok(angle) => angle,
764 Err(_) => return f64::NAN,
765 };
766
767 inputs.muzzle_angle = zero_angle;
769
770 let mut solver = TrajectorySolver::new(inputs, wind, atmo);
771 solver.set_max_range(target_distance * 1.1);
772
773 match solver.solve() {
774 Ok(result) => {
775 for point in result.points {
777 if point.position[0] >= target_distance {
778 return sight_height - point.position[1];
779 }
780 }
781 f64::NAN
782 }
783 Err(_) => f64::NAN,
784 }
785}
786
787#[no_mangle]
799pub unsafe extern "C" fn ballistics_monte_carlo(
800 inputs: *const FFIBallisticInputs,
801 atmosphere: *const FFIAtmosphericConditions,
802 params: *const FFIMonteCarloParams,
803) -> *mut FFIMonteCarloResults {
804 unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, 0.0) }
805}
806
807#[no_mangle]
817pub unsafe extern "C" fn ballistics_monte_carlo_with_direction_std_dev(
818 inputs: *const FFIBallisticInputs,
819 atmosphere: *const FFIAtmosphericConditions,
820 params: *const FFIMonteCarloParams,
821 wind_direction_std_dev: c_double,
822) -> *mut FFIMonteCarloResults {
823 unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, wind_direction_std_dev) }
824}
825
826unsafe fn ballistics_monte_carlo_impl(
827 inputs: *const FFIBallisticInputs,
828 atmosphere: *const FFIAtmosphericConditions,
829 params: *const FFIMonteCarloParams,
830 wind_direction_std_dev: f64,
831) -> *mut FFIMonteCarloResults {
832 if inputs.is_null() || params.is_null() {
833 return ptr::null_mut();
834 }
835
836 let inputs = unsafe { &*inputs };
837 let params = unsafe { &*params };
838
839 const MAX_SIMULATIONS: c_int = 1_000_000;
845 if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
846 return ptr::null_mut();
847 }
848
849 let mut ballistic_inputs = convert_inputs(inputs);
851 ballistic_inputs.muzzle_height = 1.5;
852 ballistic_inputs.ground_threshold = 0.0;
853 if !atmosphere.is_null() {
854 let atmo = unsafe { &*atmosphere };
855 ballistic_inputs.temperature = atmo.temperature;
856 ballistic_inputs.pressure = atmo.pressure;
857 ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
858 ballistic_inputs.altitude = atmo.altitude;
859 }
860
861 let mc_params = MonteCarloParams {
863 num_simulations: params.num_simulations as usize,
864 velocity_std_dev: params.velocity_std_dev,
865 angle_std_dev: params.angle_std_dev,
866 bc_std_dev: params.bc_std_dev,
867 wind_speed_std_dev: params.wind_speed_std_dev,
868 target_distance: if params.target_distance.is_nan() {
869 None
870 } else {
871 Some(params.target_distance)
872 },
873 base_wind_speed: params.base_wind_speed,
874 base_wind_direction: params.base_wind_direction,
875 azimuth_std_dev: params.azimuth_std_dev,
876 };
877
878 match run_monte_carlo_with_direction_std_dev(
880 ballistic_inputs,
881 mc_params,
882 wind_direction_std_dev,
883 ) {
884 Ok(results) => {
885 let num_results = results.ranges.len() as c_int;
886
887 let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
889 let variance_range: f64 = results
890 .ranges
891 .iter()
892 .map(|r| (r - mean_range).powi(2))
893 .sum::<f64>()
894 / num_results as f64;
895 let std_dev_range = variance_range.sqrt();
896
897 let mean_velocity: f64 =
898 results.impact_velocities.iter().sum::<f64>() / num_results as f64;
899 let variance_velocity: f64 = results
900 .impact_velocities
901 .iter()
902 .map(|v| (v - mean_velocity).powi(2))
903 .sum::<f64>()
904 / num_results as f64;
905 let std_dev_velocity = variance_velocity.sqrt();
906
907 let hit_probability = if params.target_distance.is_nan() {
913 0.0
914 } else {
915 results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
916 };
917
918 let ranges_ptr = unsafe {
920 let ptr = std::alloc::alloc(
921 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
922 ) as *mut c_double;
923 for (i, &range) in results.ranges.iter().enumerate() {
924 *ptr.add(i) = range;
925 }
926 ptr
927 };
928
929 let velocities_ptr = unsafe {
930 let ptr = std::alloc::alloc(
931 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
932 ) as *mut c_double;
933 for (i, &vel) in results.impact_velocities.iter().enumerate() {
934 *ptr.add(i) = vel;
935 }
936 ptr
937 };
938
939 let pos_x_ptr = unsafe {
940 let ptr = std::alloc::alloc(
941 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
942 ) as *mut c_double;
943 for (i, pos) in results.impact_positions.iter().enumerate() {
944 *ptr.add(i) = pos.x;
945 }
946 ptr
947 };
948
949 let pos_y_ptr = unsafe {
950 let ptr = std::alloc::alloc(
951 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
952 ) as *mut c_double;
953 for (i, pos) in results.impact_positions.iter().enumerate() {
954 *ptr.add(i) = pos.y;
955 }
956 ptr
957 };
958
959 let pos_z_ptr = unsafe {
960 let ptr = std::alloc::alloc(
961 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
962 ) as *mut c_double;
963 for (i, pos) in results.impact_positions.iter().enumerate() {
964 *ptr.add(i) = pos.z;
965 }
966 ptr
967 };
968
969 let result = Box::new(FFIMonteCarloResults {
971 ranges: ranges_ptr,
972 impact_velocities: velocities_ptr,
973 impact_positions_x: pos_x_ptr,
974 impact_positions_y: pos_y_ptr,
975 impact_positions_z: pos_z_ptr,
976 num_results,
977 mean_range,
978 std_dev_range,
979 mean_impact_velocity: mean_velocity,
980 std_dev_impact_velocity: std_dev_velocity,
981 hit_probability,
982 });
983
984 Box::into_raw(result)
985 }
986 Err(_) => ptr::null_mut(),
987 }
988}
989
990#[no_mangle]
999pub unsafe extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
1000 if results.is_null() {
1001 return;
1002 }
1003
1004 unsafe {
1005 let results = Box::from_raw(results);
1006 let num = results.num_results as usize;
1007
1008 if !results.ranges.is_null() {
1010 std::alloc::dealloc(
1011 results.ranges as *mut u8,
1012 std::alloc::Layout::array::<c_double>(num).unwrap(),
1013 );
1014 }
1015
1016 if !results.impact_velocities.is_null() {
1017 std::alloc::dealloc(
1018 results.impact_velocities as *mut u8,
1019 std::alloc::Layout::array::<c_double>(num).unwrap(),
1020 );
1021 }
1022
1023 if !results.impact_positions_x.is_null() {
1024 std::alloc::dealloc(
1025 results.impact_positions_x as *mut u8,
1026 std::alloc::Layout::array::<c_double>(num).unwrap(),
1027 );
1028 }
1029
1030 if !results.impact_positions_y.is_null() {
1031 std::alloc::dealloc(
1032 results.impact_positions_y as *mut u8,
1033 std::alloc::Layout::array::<c_double>(num).unwrap(),
1034 );
1035 }
1036
1037 if !results.impact_positions_z.is_null() {
1038 std::alloc::dealloc(
1039 results.impact_positions_z as *mut u8,
1040 std::alloc::Layout::array::<c_double>(num).unwrap(),
1041 );
1042 }
1043
1044 }
1046}
1047
1048pub const FFI_BC_REFERENCE_ICAO: c_int = 0;
1054pub const FFI_BC_REFERENCE_ARMY_STANDARD_METRO: c_int = 1;
1055
1056#[no_mangle]
1075pub extern "C" fn ballistics_bc_for_reference_standard(
1076 bc: c_double,
1077 reference_standard: c_int,
1078) -> c_double {
1079 if reference_standard == FFI_BC_REFERENCE_ARMY_STANDARD_METRO {
1080 bc * crate::constants::ASM_TO_ICAO_BC
1081 } else {
1082 bc
1083 }
1084}
1085
1086#[no_mangle]
1104pub extern "C" fn ballistics_reduce_qnh_pressure(
1105 qnh_hpa: c_double,
1106 altitude_m: c_double,
1107) -> c_double {
1108 if !qnh_hpa.is_finite() || !altitude_m.is_finite() {
1109 return qnh_hpa;
1110 }
1111 crate::atmosphere::reduce_qnh_to_station_pressure(qnh_hpa, altitude_m)
1112}
1113
1114pub const FFI_NO_EXPLICIT_TEMPERATURE: c_double = f64::NAN;
1119
1120#[no_mangle]
1140pub extern "C" fn ballistics_density_altitude_temperature_c(
1141 density_altitude_m: c_double,
1142 explicit_temperature_c: c_double,
1143) -> c_double {
1144 if !density_altitude_m.is_finite() {
1145 return f64::NAN;
1146 }
1147 let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1148 crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).1
1149}
1150
1151#[no_mangle]
1154pub extern "C" fn ballistics_density_altitude_pressure_hpa(
1155 density_altitude_m: c_double,
1156 explicit_temperature_c: c_double,
1157) -> c_double {
1158 if !density_altitude_m.is_finite() {
1159 return f64::NAN;
1160 }
1161 let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1162 crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).2
1163}
1164
1165#[no_mangle]
1170pub extern "C" fn ballistics_density_altitude_altitude_m(
1171 density_altitude_m: c_double,
1172 explicit_temperature_c: c_double,
1173) -> c_double {
1174 if !density_altitude_m.is_finite() {
1175 return f64::NAN;
1176 }
1177 let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1178 crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).0
1179}
1180
1181#[no_mangle]
1183pub extern "C" fn ballistics_get_version() -> *const c_char {
1184 concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193
1194 fn valid_trajectory_inputs() -> FFIBallisticInputs {
1195 FFIBallisticInputs {
1196 muzzle_velocity: 800.0,
1197 muzzle_angle: 0.0,
1198 bc_value: 0.5,
1199 bullet_mass: 0.01,
1200 bullet_diameter: 0.00762,
1201 bc_type: 0,
1202 sight_height: 0.05,
1203 target_distance: 1.0,
1204 temperature: 15.0,
1205 twist_rate: 12.0,
1206 is_twist_right: 1,
1207 shooting_angle: 0.0,
1208 altitude: 0.0,
1209 latitude: f64::NAN,
1210 azimuth_angle: 0.0,
1211 use_rk4: 1,
1212 use_adaptive_rk45: 0,
1213 enable_wind_shear: 0,
1214 enable_trajectory_sampling: 0,
1215 sample_interval: 10.0,
1216 enable_pitch_damping: 0,
1217 enable_precession_nutation: 0,
1218 enable_spin_drift: 0,
1219 enable_magnus: 0,
1220 enable_coriolis: 0,
1221 shot_azimuth: 0.0,
1222 cant_angle: 0.0,
1223 }
1224 }
1225
1226 #[allow(dead_code)]
1227 #[repr(C)]
1228 struct LegacyFFIMonteCarloParams {
1229 num_simulations: c_int,
1230 velocity_std_dev: c_double,
1231 angle_std_dev: c_double,
1232 bc_std_dev: c_double,
1233 wind_speed_std_dev: c_double,
1234 target_distance: c_double,
1235 base_wind_speed: c_double,
1236 base_wind_direction: c_double,
1237 azimuth_std_dev: c_double,
1238 }
1239
1240 #[test]
1241 fn monte_carlo_params_legacy_abi_size_is_unchanged() {
1242 assert_eq!(
1243 std::mem::size_of::<FFIMonteCarloParams>(),
1244 std::mem::size_of::<LegacyFFIMonteCarloParams>()
1245 );
1246 assert_eq!(
1247 std::mem::align_of::<FFIMonteCarloParams>(),
1248 std::mem::align_of::<LegacyFFIMonteCarloParams>()
1249 );
1250 }
1251
1252 #[test]
1257 fn bc_type_8_maps_to_ra4() {
1258 let mut inputs = valid_trajectory_inputs();
1259 inputs.bc_type = 8;
1260 assert_eq!(convert_inputs(&inputs).bc_type, DragModel::RA4);
1261
1262 let expected = [
1264 (0, DragModel::G1),
1265 (1, DragModel::G7),
1266 (2, DragModel::G2),
1267 (3, DragModel::G5),
1268 (4, DragModel::G6),
1269 (5, DragModel::G8),
1270 (6, DragModel::GI),
1271 (7, DragModel::GS),
1272 ];
1273 for (code, model) in expected {
1274 let mut inputs = valid_trajectory_inputs();
1275 inputs.bc_type = code;
1276 assert_eq!(convert_inputs(&inputs).bc_type, model, "code {code}");
1277 }
1278
1279 for code in [9, 42, -1] {
1281 let mut inputs = valid_trajectory_inputs();
1282 inputs.bc_type = code;
1283 assert_eq!(convert_inputs(&inputs).bc_type, DragModel::G1, "code {code}");
1284 }
1285 }
1286
1287 #[test]
1288 fn null_pointer_contracts_return_sentinels_and_free_safely() {
1289 unsafe {
1290 assert!(ballistics_calculate_trajectory(
1291 std::ptr::null(),
1292 std::ptr::null(),
1293 std::ptr::null(),
1294 1_000.0,
1295 1.0,
1296 )
1297 .is_null());
1298 assert!(ballistics_calculate_zero_angle(
1299 std::ptr::null(),
1300 std::ptr::null(),
1301 std::ptr::null(),
1302 100.0,
1303 )
1304 .is_nan());
1305 assert!(ballistics_calculate_trajectory_with_drag_table(
1306 std::ptr::null(),
1307 std::ptr::null(),
1308 std::ptr::null(),
1309 1_000.0,
1310 1.0,
1311 DECK_MACH.as_ptr(),
1312 DECK_CD_LOW.as_ptr(),
1313 DECK_MACH.len() as c_int,
1314 )
1315 .is_null());
1316 assert!(ballistics_calculate_zero_angle_with_drag_table(
1317 std::ptr::null(),
1318 std::ptr::null(),
1319 std::ptr::null(),
1320 100.0,
1321 DECK_MACH.as_ptr(),
1322 DECK_CD_LOW.as_ptr(),
1323 DECK_MACH.len() as c_int,
1324 )
1325 .is_nan());
1326 assert!(
1327 ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1328 .is_null()
1329 );
1330 assert!(ballistics_monte_carlo_with_direction_std_dev(
1331 std::ptr::null(),
1332 std::ptr::null(),
1333 std::ptr::null(),
1334 0.1,
1335 )
1336 .is_null());
1337
1338 ballistics_free_trajectory_result(std::ptr::null_mut());
1339 ballistics_free_monte_carlo_results(std::ptr::null_mut());
1340 }
1341 }
1342
1343 #[test]
1344 fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1345 for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1346 for step_size in [
1347 f64::NAN,
1348 f64::INFINITY,
1349 f64::NEG_INFINITY,
1350 -1.0,
1351 -0.0,
1352 0.0,
1353 0.001,
1354 MIN_FFI_STEP_SIZE_MS - 0.001,
1355 ] {
1356 let mut inputs = valid_trajectory_inputs();
1357 inputs.use_rk4 = use_rk4;
1358 inputs.use_adaptive_rk45 = use_adaptive_rk45;
1359 let result = unsafe {
1360 ballistics_calculate_trajectory(
1361 &inputs,
1362 std::ptr::null(),
1363 std::ptr::null(),
1364 0.01,
1365 step_size,
1366 )
1367 };
1368 assert!(
1369 result.is_null(),
1370 "{mode} step_size={step_size:?} bypassed the FFI floor"
1371 );
1372 }
1373
1374 let mut inputs = valid_trajectory_inputs();
1375 inputs.use_rk4 = use_rk4;
1376 inputs.use_adaptive_rk45 = use_adaptive_rk45;
1377 let result = unsafe {
1378 ballistics_calculate_trajectory(
1379 &inputs,
1380 std::ptr::null(),
1381 std::ptr::null(),
1382 0.01,
1383 MIN_FFI_STEP_SIZE_MS,
1384 )
1385 };
1386 assert!(
1387 !result.is_null(),
1388 "the documented minimum step must remain usable in {mode}"
1389 );
1390 unsafe {
1391 assert!((*result).point_count >= 0);
1392 assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
1393 ballistics_free_trajectory_result(result);
1394 }
1395 }
1396 }
1397
1398 const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
1400 const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
1402
1403 #[test]
1404 fn trajectory_with_drag_table_applies_the_deck() {
1405 let inputs = valid_trajectory_inputs();
1406 unsafe {
1407 let plain = ballistics_calculate_trajectory(
1408 &inputs,
1409 std::ptr::null(),
1410 std::ptr::null(),
1411 300.0,
1412 1.0,
1413 );
1414 let decked = ballistics_calculate_trajectory_with_drag_table(
1415 &inputs,
1416 std::ptr::null(),
1417 std::ptr::null(),
1418 300.0,
1419 1.0,
1420 DECK_MACH.as_ptr(),
1421 DECK_CD_LOW.as_ptr(),
1422 DECK_MACH.len() as c_int,
1423 );
1424 assert!(!plain.is_null() && !decked.is_null());
1425 assert!(
1427 (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
1428 "deck did not change the solve: plain={} decked={}",
1429 (*plain).impact_velocity,
1430 (*decked).impact_velocity
1431 );
1432 ballistics_free_trajectory_result(plain);
1433 ballistics_free_trajectory_result(decked);
1434 }
1435 }
1436
1437 #[test]
1438 fn trajectory_with_drag_table_rejects_invalid_decks() {
1439 let inputs = valid_trajectory_inputs();
1440 let descending = [3.0, 2.0, 1.0, 0.5];
1441 let negative_cd = [0.05, -0.08, 0.06, 0.05];
1442 unsafe {
1443 assert!(ballistics_calculate_trajectory_with_drag_table(
1445 &inputs,
1446 std::ptr::null(),
1447 std::ptr::null(),
1448 300.0,
1449 1.0,
1450 std::ptr::null(),
1451 DECK_CD_LOW.as_ptr(),
1452 4,
1453 )
1454 .is_null());
1455 assert!(ballistics_calculate_trajectory_with_drag_table(
1456 &inputs,
1457 std::ptr::null(),
1458 std::ptr::null(),
1459 300.0,
1460 1.0,
1461 DECK_MACH.as_ptr(),
1462 std::ptr::null(),
1463 4,
1464 )
1465 .is_null());
1466 assert!(ballistics_calculate_trajectory_with_drag_table(
1468 &inputs,
1469 std::ptr::null(),
1470 std::ptr::null(),
1471 300.0,
1472 1.0,
1473 DECK_MACH.as_ptr(),
1474 DECK_CD_LOW.as_ptr(),
1475 1,
1476 )
1477 .is_null());
1478 assert!(ballistics_calculate_trajectory_with_drag_table(
1480 &inputs,
1481 std::ptr::null(),
1482 std::ptr::null(),
1483 300.0,
1484 1.0,
1485 descending.as_ptr(),
1486 DECK_CD_LOW.as_ptr(),
1487 4,
1488 )
1489 .is_null());
1490 assert!(ballistics_calculate_trajectory_with_drag_table(
1492 &inputs,
1493 std::ptr::null(),
1494 std::ptr::null(),
1495 300.0,
1496 1.0,
1497 DECK_MACH.as_ptr(),
1498 negative_cd.as_ptr(),
1499 4,
1500 )
1501 .is_null());
1502 assert!(ballistics_calculate_trajectory_with_drag_table(
1504 std::ptr::null(),
1505 std::ptr::null(),
1506 std::ptr::null(),
1507 300.0,
1508 1.0,
1509 DECK_MACH.as_ptr(),
1510 DECK_CD_LOW.as_ptr(),
1511 4,
1512 )
1513 .is_null());
1514 }
1515 }
1516
1517 #[test]
1518 fn zero_angle_with_drag_table_applies_the_deck() {
1519 let inputs = valid_trajectory_inputs();
1521 unsafe {
1522 let plain =
1523 ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
1524 let decked = ballistics_calculate_zero_angle_with_drag_table(
1525 &inputs,
1526 std::ptr::null(),
1527 std::ptr::null(),
1528 100.0,
1529 DECK_MACH.as_ptr(),
1530 DECK_CD_LOW.as_ptr(),
1531 DECK_MACH.len() as c_int,
1532 );
1533 assert!(plain.is_finite() && decked.is_finite());
1534 assert!(
1537 (plain - decked).abs() > 1e-6,
1538 "deck did not change the zero: plain={plain} decked={decked}"
1539 );
1540 }
1541 }
1542
1543 #[test]
1544 fn zero_angle_with_drag_table_rejects_invalid_decks() {
1545 let inputs = valid_trajectory_inputs();
1546 let descending = [3.0, 2.0, 1.0, 0.5];
1547 unsafe {
1548 assert!(ballistics_calculate_zero_angle_with_drag_table(
1549 &inputs,
1550 std::ptr::null(),
1551 std::ptr::null(),
1552 100.0,
1553 std::ptr::null(),
1554 DECK_CD_LOW.as_ptr(),
1555 4,
1556 )
1557 .is_nan());
1558 assert!(ballistics_calculate_zero_angle_with_drag_table(
1559 &inputs,
1560 std::ptr::null(),
1561 std::ptr::null(),
1562 100.0,
1563 DECK_MACH.as_ptr(),
1564 DECK_CD_LOW.as_ptr(),
1565 0,
1566 )
1567 .is_nan());
1568 assert!(ballistics_calculate_zero_angle_with_drag_table(
1569 &inputs,
1570 std::ptr::null(),
1571 std::ptr::null(),
1572 100.0,
1573 descending.as_ptr(),
1574 DECK_CD_LOW.as_ptr(),
1575 4,
1576 )
1577 .is_nan());
1578 assert!(ballistics_calculate_zero_angle_with_drag_table(
1580 std::ptr::null(),
1581 std::ptr::null(),
1582 std::ptr::null(),
1583 100.0,
1584 DECK_MACH.as_ptr(),
1585 DECK_CD_LOW.as_ptr(),
1586 4,
1587 )
1588 .is_nan());
1589 }
1590 }
1591
1592 #[test]
1593 fn zero_then_fly_with_same_deck_is_consistent() {
1594 let mut inputs = valid_trajectory_inputs();
1598 unsafe {
1599 let angle = ballistics_calculate_zero_angle_with_drag_table(
1600 &inputs,
1601 std::ptr::null(),
1602 std::ptr::null(),
1603 100.0,
1604 DECK_MACH.as_ptr(),
1605 DECK_CD_LOW.as_ptr(),
1606 DECK_MACH.len() as c_int,
1607 );
1608 assert!(angle.is_finite());
1609 inputs.muzzle_angle = angle;
1610 let result = ballistics_calculate_trajectory_with_drag_table(
1611 &inputs,
1612 std::ptr::null(),
1613 std::ptr::null(),
1614 150.0,
1615 1.0,
1616 DECK_MACH.as_ptr(),
1617 DECK_CD_LOW.as_ptr(),
1618 DECK_MACH.len() as c_int,
1619 );
1620 assert!(!result.is_null());
1621 let zero_distance = 100.0;
1625 let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
1626 let bracket = pts
1627 .windows(2)
1628 .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
1629 .expect("trajectory brackets the zero distance");
1630 let (lo, hi) = (&bracket[0], &bracket[1]);
1631 let y_at_zero = if hi.position_x > lo.position_x {
1632 let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
1633 lo.position_y + t * (hi.position_y - lo.position_y)
1634 } else {
1635 lo.position_y
1636 };
1637 assert!(
1638 (y_at_zero - inputs.sight_height).abs() < 0.002,
1639 "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
1640 y_at_zero,
1641 inputs.sight_height
1642 );
1643 ballistics_free_trajectory_result(result);
1644 }
1645 }
1646
1647 #[test]
1648 fn drag_table_len_above_cap_is_rejected() {
1649 let n = (MAX_FFI_DRAG_TABLE_LEN + 1) as usize;
1652 let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1653 let cd: Vec<f64> = vec![0.3; n];
1654 let inputs = valid_trajectory_inputs();
1655 unsafe {
1656 let r = ballistics_calculate_trajectory_with_drag_table(
1657 &inputs,
1658 std::ptr::null(),
1659 std::ptr::null(),
1660 300.0,
1661 1.0,
1662 mach.as_ptr(),
1663 cd.as_ptr(),
1664 n as c_int,
1665 );
1666 assert!(r.is_null(), "len {n} must be rejected by the cap");
1667 }
1668 }
1669
1670 #[test]
1671 fn drag_table_len_at_cap_is_accepted() {
1672 let n = MAX_FFI_DRAG_TABLE_LEN as usize;
1673 let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1674 let cd: Vec<f64> = vec![0.3; n];
1675 let inputs = valid_trajectory_inputs();
1676 unsafe {
1677 let r = ballistics_calculate_trajectory_with_drag_table(
1678 &inputs,
1679 std::ptr::null(),
1680 std::ptr::null(),
1681 300.0,
1682 1.0,
1683 mach.as_ptr(),
1684 cd.as_ptr(),
1685 n as c_int,
1686 );
1687 assert!(!r.is_null(), "len == cap must be accepted");
1688 ballistics_free_trajectory_result(r);
1689 }
1690 }
1691
1692 #[test]
1693 fn ffi_cant_angle_deflects_laterally() {
1694 let mut level = valid_trajectory_inputs();
1695 level.muzzle_angle = 0.003;
1696 let mut canted = valid_trajectory_inputs();
1697 canted.muzzle_angle = 0.003;
1698 canted.cant_angle = 10f64.to_radians();
1699 unsafe {
1700 let a = ballistics_calculate_trajectory(&level, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1701 let b = ballistics_calculate_trajectory(&canted, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1702 assert!(!a.is_null() && !b.is_null());
1703 let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_z;
1704 let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_z;
1705 assert!(zb > za + 0.005, "FFI cant must deflect right: level={za} canted={zb}");
1706 ballistics_free_trajectory_result(a);
1707 ballistics_free_trajectory_result(b);
1708 }
1709 }
1710
1711 #[test]
1712 fn ffi_vertical_wind_raises_trajectory() {
1713 let inputs = valid_trajectory_inputs();
1714 let no_wind = FFIWindConditions {
1715 speed: 0.0,
1716 direction: 0.0,
1717 vertical_speed: 0.0,
1718 };
1719 let updraft = FFIWindConditions {
1720 speed: 0.0,
1721 direction: 0.0,
1722 vertical_speed: 5.0,
1723 };
1724 unsafe {
1725 let a = ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
1726 let b = ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
1727 assert!(!a.is_null() && !b.is_null());
1728 let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_y;
1729 let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_y;
1730 assert!(yb > ya + 0.01, "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}");
1731 ballistics_free_trajectory_result(a);
1732 ballistics_free_trajectory_result(b);
1733 }
1734 }
1735
1736 #[test]
1739 fn trajectory_scaled_at_one_matches_unscaled_export() {
1740 let inputs = valid_trajectory_inputs();
1741 unsafe {
1742 let unscaled = ballistics_calculate_trajectory_with_drag_table(
1743 &inputs,
1744 std::ptr::null(),
1745 std::ptr::null(),
1746 300.0,
1747 1.0,
1748 DECK_MACH.as_ptr(),
1749 DECK_CD_LOW.as_ptr(),
1750 DECK_MACH.len() as c_int,
1751 );
1752 let scaled = ballistics_calculate_trajectory_with_drag_table_scaled(
1753 &inputs,
1754 std::ptr::null(),
1755 std::ptr::null(),
1756 300.0,
1757 1.0,
1758 DECK_MACH.as_ptr(),
1759 DECK_CD_LOW.as_ptr(),
1760 DECK_MACH.len() as c_int,
1761 1.0,
1762 );
1763 assert!(!unscaled.is_null() && !scaled.is_null());
1764 assert_eq!(
1765 (*unscaled).impact_velocity.to_bits(),
1766 (*scaled).impact_velocity.to_bits(),
1767 "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={} scaled={}",
1768 (*unscaled).impact_velocity,
1769 (*scaled).impact_velocity
1770 );
1771 ballistics_free_trajectory_result(unscaled);
1772 ballistics_free_trajectory_result(scaled);
1773 }
1774 }
1775
1776 #[test]
1777 fn trajectory_scaled_at_1_10_lowers_impact_velocity() {
1778 let inputs = valid_trajectory_inputs();
1779 unsafe {
1780 let baseline = ballistics_calculate_trajectory_with_drag_table_scaled(
1781 &inputs,
1782 std::ptr::null(),
1783 std::ptr::null(),
1784 300.0,
1785 1.0,
1786 DECK_MACH.as_ptr(),
1787 DECK_CD_LOW.as_ptr(),
1788 DECK_MACH.len() as c_int,
1789 1.0,
1790 );
1791 let scaled_up = ballistics_calculate_trajectory_with_drag_table_scaled(
1792 &inputs,
1793 std::ptr::null(),
1794 std::ptr::null(),
1795 300.0,
1796 1.0,
1797 DECK_MACH.as_ptr(),
1798 DECK_CD_LOW.as_ptr(),
1799 DECK_MACH.len() as c_int,
1800 1.10,
1801 );
1802 assert!(!baseline.is_null() && !scaled_up.is_null());
1803 assert!(
1804 (*scaled_up).impact_velocity < (*baseline).impact_velocity,
1805 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} scaled={}",
1806 (*baseline).impact_velocity,
1807 (*scaled_up).impact_velocity
1808 );
1809 ballistics_free_trajectory_result(baseline);
1810 ballistics_free_trajectory_result(scaled_up);
1811 }
1812 }
1813
1814 #[test]
1815 fn trajectory_scaled_rejects_invalid_cd_scale() {
1816 let inputs = valid_trajectory_inputs();
1817 unsafe {
1818 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1819 let r = ballistics_calculate_trajectory_with_drag_table_scaled(
1820 &inputs,
1821 std::ptr::null(),
1822 std::ptr::null(),
1823 300.0,
1824 1.0,
1825 DECK_MACH.as_ptr(),
1826 DECK_CD_LOW.as_ptr(),
1827 DECK_MACH.len() as c_int,
1828 bad,
1829 );
1830 assert!(r.is_null(), "cd_scale={bad} must be rejected (null sentinel)");
1831 }
1832 }
1833 }
1834
1835 #[test]
1836 fn zero_angle_scaled_at_one_matches_unscaled_export() {
1837 let inputs = valid_trajectory_inputs();
1838 unsafe {
1839 let unscaled = ballistics_calculate_zero_angle_with_drag_table(
1840 &inputs,
1841 std::ptr::null(),
1842 std::ptr::null(),
1843 100.0,
1844 DECK_MACH.as_ptr(),
1845 DECK_CD_LOW.as_ptr(),
1846 DECK_MACH.len() as c_int,
1847 );
1848 let scaled = ballistics_calculate_zero_angle_with_drag_table_scaled(
1849 &inputs,
1850 std::ptr::null(),
1851 std::ptr::null(),
1852 100.0,
1853 DECK_MACH.as_ptr(),
1854 DECK_CD_LOW.as_ptr(),
1855 DECK_MACH.len() as c_int,
1856 1.0,
1857 );
1858 assert!(unscaled.is_finite() && scaled.is_finite());
1859 assert_eq!(
1860 unscaled.to_bits(),
1861 scaled.to_bits(),
1862 "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={unscaled} scaled={scaled}"
1863 );
1864 }
1865 }
1866
1867 #[test]
1868 fn zero_angle_scaled_at_1_10_differs_from_baseline() {
1869 let inputs = valid_trajectory_inputs();
1870 unsafe {
1871 let baseline = ballistics_calculate_zero_angle_with_drag_table_scaled(
1872 &inputs,
1873 std::ptr::null(),
1874 std::ptr::null(),
1875 100.0,
1876 DECK_MACH.as_ptr(),
1877 DECK_CD_LOW.as_ptr(),
1878 DECK_MACH.len() as c_int,
1879 1.0,
1880 );
1881 let scaled_up = ballistics_calculate_zero_angle_with_drag_table_scaled(
1882 &inputs,
1883 std::ptr::null(),
1884 std::ptr::null(),
1885 100.0,
1886 DECK_MACH.as_ptr(),
1887 DECK_CD_LOW.as_ptr(),
1888 DECK_MACH.len() as c_int,
1889 1.10,
1890 );
1891 assert!(baseline.is_finite() && scaled_up.is_finite());
1892 assert!(
1894 scaled_up > baseline,
1895 "cd_scale=1.10 must need a larger zero angle: base={baseline} scaled={scaled_up}"
1896 );
1897 }
1898 }
1899
1900 #[test]
1901 fn zero_angle_scaled_rejects_invalid_cd_scale() {
1902 let inputs = valid_trajectory_inputs();
1903 unsafe {
1904 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1905 let angle = ballistics_calculate_zero_angle_with_drag_table_scaled(
1906 &inputs,
1907 std::ptr::null(),
1908 std::ptr::null(),
1909 100.0,
1910 DECK_MACH.as_ptr(),
1911 DECK_CD_LOW.as_ptr(),
1912 DECK_MACH.len() as c_int,
1913 bad,
1914 );
1915 assert!(angle.is_nan(), "cd_scale={bad} must be rejected (NaN sentinel)");
1916 }
1917 }
1918 }
1919
1920 #[test]
1924 fn legacy_drag_table_exports_unaffected_by_cd_scale_plumbing() {
1925 let inputs = valid_trajectory_inputs();
1926 unsafe {
1927 let a = ballistics_calculate_trajectory_with_drag_table(
1928 &inputs,
1929 std::ptr::null(),
1930 std::ptr::null(),
1931 300.0,
1932 1.0,
1933 DECK_MACH.as_ptr(),
1934 DECK_CD_LOW.as_ptr(),
1935 DECK_MACH.len() as c_int,
1936 );
1937 let b = ballistics_calculate_trajectory_with_drag_table(
1938 &inputs,
1939 std::ptr::null(),
1940 std::ptr::null(),
1941 300.0,
1942 1.0,
1943 DECK_MACH.as_ptr(),
1944 DECK_CD_LOW.as_ptr(),
1945 DECK_MACH.len() as c_int,
1946 );
1947 assert!(!a.is_null() && !b.is_null());
1948 assert_eq!((*a).impact_velocity.to_bits(), (*b).impact_velocity.to_bits());
1949 ballistics_free_trajectory_result(a);
1950 ballistics_free_trajectory_result(b);
1951
1952 let za = ballistics_calculate_zero_angle_with_drag_table(
1953 &inputs,
1954 std::ptr::null(),
1955 std::ptr::null(),
1956 100.0,
1957 DECK_MACH.as_ptr(),
1958 DECK_CD_LOW.as_ptr(),
1959 DECK_MACH.len() as c_int,
1960 );
1961 let zb = ballistics_calculate_zero_angle_with_drag_table(
1962 &inputs,
1963 std::ptr::null(),
1964 std::ptr::null(),
1965 100.0,
1966 DECK_MACH.as_ptr(),
1967 DECK_CD_LOW.as_ptr(),
1968 DECK_MACH.len() as c_int,
1969 );
1970 assert!(za.is_finite() && zb.is_finite());
1971 assert_eq!(za.to_bits(), zb.to_bits());
1972 }
1973 }
1974
1975 #[test]
1978 fn bc_for_reference_standard_icao_is_a_byte_identical_no_op() {
1979 let bc = 0.475;
1980 assert_eq!(
1981 ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ICAO).to_bits(),
1982 bc.to_bits()
1983 );
1984 }
1985
1986 #[test]
1987 fn bc_for_reference_standard_unrecognized_value_falls_back_to_icao() {
1988 let bc = 0.475;
1991 assert_eq!(
1992 ballistics_bc_for_reference_standard(bc, 99).to_bits(),
1993 bc.to_bits()
1994 );
1995 }
1996
1997 #[test]
1998 fn bc_for_reference_standard_army_standard_metro_applies_the_documented_ratio() {
1999 let bc = 0.475;
2000 let converted =
2001 ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ARMY_STANDARD_METRO);
2002 assert_eq!(converted, bc * crate::constants::ASM_TO_ICAO_BC);
2003 assert!(converted < bc);
2005 }
2006
2007 #[test]
2010 fn reduce_qnh_pressure_matches_the_library_function_and_lowers_pressure() {
2011 let reduced = ballistics_reduce_qnh_pressure(1030.0, 1500.0);
2012 assert_eq!(
2013 reduced,
2014 crate::atmosphere::reduce_qnh_to_station_pressure(1030.0, 1500.0)
2015 );
2016 assert!(reduced < 1030.0);
2017 }
2018
2019 #[test]
2020 fn reduce_qnh_pressure_passes_through_non_finite_inputs() {
2021 assert!(ballistics_reduce_qnh_pressure(f64::NAN, 1500.0).is_nan());
2022 assert_eq!(ballistics_reduce_qnh_pressure(1030.0, f64::INFINITY), 1030.0);
2023 }
2024
2025 #[test]
2033 fn ffi_trajectory_uses_the_reduced_pressure_not_the_raw_qnh() {
2034 let inputs = valid_trajectory_inputs();
2035 let altitude_m = 1500.0;
2036 let qnh_hpa = 1030.0;
2037 let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2038 assert!(reduced < qnh_hpa);
2039
2040 let atmo_reduced = FFIAtmosphericConditions {
2041 temperature: 15.0,
2042 pressure: reduced,
2043 humidity: 50.0,
2044 altitude: altitude_m,
2045 };
2046 let atmo_raw_qnh = FFIAtmosphericConditions {
2047 temperature: 15.0,
2048 pressure: qnh_hpa,
2049 humidity: 50.0,
2050 altitude: altitude_m,
2051 };
2052
2053 unsafe {
2054 let a = ballistics_calculate_trajectory(
2055 &inputs,
2056 std::ptr::null(),
2057 &atmo_reduced,
2058 400.0,
2059 1.0,
2060 );
2061 let b = ballistics_calculate_trajectory(
2062 &inputs,
2063 std::ptr::null(),
2064 &atmo_raw_qnh,
2065 400.0,
2066 1.0,
2067 );
2068 assert!(!a.is_null() && !b.is_null());
2069 let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2070 .last()
2071 .unwrap()
2072 .position_y;
2073 let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2074 .last()
2075 .unwrap()
2076 .position_y;
2077 assert!(
2078 (drop_a - drop_b).abs() > 1e-6,
2079 "reduced vs. raw-QNH pressure must produce materially different trajectories: \
2080 {drop_a} vs {drop_b}"
2081 );
2082 ballistics_free_trajectory_result(a);
2083 ballistics_free_trajectory_result(b);
2084 }
2085 }
2086
2087 #[test]
2091 fn ffi_monte_carlo_uses_the_reduced_pressure_not_the_raw_qnh() {
2092 let inputs = valid_trajectory_inputs();
2093 let altitude_m = 1500.0;
2094 let qnh_hpa = 1030.0;
2095 let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2096
2097 let atmo_reduced = FFIAtmosphericConditions {
2098 temperature: 15.0,
2099 pressure: reduced,
2100 humidity: 50.0,
2101 altitude: altitude_m,
2102 };
2103 let atmo_raw_qnh = FFIAtmosphericConditions {
2104 temperature: 15.0,
2105 pressure: qnh_hpa,
2106 humidity: 50.0,
2107 altitude: altitude_m,
2108 };
2109 let params = FFIMonteCarloParams {
2110 num_simulations: 200,
2111 velocity_std_dev: 1.0,
2112 angle_std_dev: 0.0,
2113 bc_std_dev: 0.0,
2114 wind_speed_std_dev: 0.0,
2115 target_distance: f64::NAN,
2116 base_wind_speed: 0.0,
2117 base_wind_direction: 0.0,
2118 azimuth_std_dev: 0.0,
2119 };
2120
2121 unsafe {
2122 let a = ballistics_monte_carlo(&inputs, &atmo_reduced, ¶ms);
2123 let b = ballistics_monte_carlo(&inputs, &atmo_raw_qnh, ¶ms);
2124 assert!(!a.is_null() && !b.is_null());
2125 assert!(
2126 ((*a).mean_range - (*b).mean_range).abs() > 0.5,
2127 "reduced vs. raw-QNH pressure must change MC mean range materially: \
2128 {} vs {}",
2129 (*a).mean_range,
2130 (*b).mean_range
2131 );
2132 ballistics_free_monte_carlo_results(a);
2133 ballistics_free_monte_carlo_results(b);
2134 }
2135 }
2136
2137 #[test]
2140 fn density_altitude_ffi_exports_match_the_library_function() {
2141 let da_m = 1000.0 * 0.3048;
2142 let expected = crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, None);
2143 assert_eq!(
2144 ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2145 expected.0
2146 );
2147 assert_eq!(
2148 ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2149 expected.1
2150 );
2151 assert_eq!(
2152 ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2153 expected.2
2154 );
2155
2156 assert!((ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE) - da_m).abs() < 1e-6);
2158 }
2159
2160 #[test]
2161 fn density_altitude_ffi_explicit_temperature_is_honored_exactly() {
2162 let da_m = 500.0;
2163 let explicit_temp_c = 30.0;
2164 let expected =
2165 crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
2166 assert_eq!(
2167 ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2168 explicit_temp_c
2169 );
2170 assert_eq!(
2171 ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2172 expected.1
2173 );
2174 assert_eq!(
2175 ballistics_density_altitude_pressure_hpa(da_m, explicit_temp_c),
2176 expected.2
2177 );
2178 assert_eq!(
2179 ballistics_density_altitude_altitude_m(da_m, explicit_temp_c),
2180 expected.0
2181 );
2182 }
2183
2184 #[test]
2185 fn density_altitude_ffi_non_finite_input_returns_nan() {
2186 assert!(
2187 ballistics_density_altitude_temperature_c(f64::INFINITY, FFI_NO_EXPLICIT_TEMPERATURE)
2188 .is_nan()
2189 );
2190 assert!(
2191 ballistics_density_altitude_pressure_hpa(f64::NAN, FFI_NO_EXPLICIT_TEMPERATURE).is_nan()
2192 );
2193 assert!(
2194 ballistics_density_altitude_altitude_m(f64::NEG_INFINITY, FFI_NO_EXPLICIT_TEMPERATURE)
2195 .is_nan()
2196 );
2197 }
2198
2199 #[test]
2204 fn ffi_trajectory_uses_the_density_altitude_derived_station_values() {
2205 let inputs = valid_trajectory_inputs();
2206 let da_m = 3000.0 * 0.3048; let altitude_m =
2208 ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2209 let temperature_c =
2210 ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2211 let pressure_hpa =
2212 ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2213
2214 let atmo_da = FFIAtmosphericConditions {
2215 temperature: temperature_c,
2216 pressure: pressure_hpa,
2217 humidity: 50.0,
2218 altitude: altitude_m,
2219 };
2220 let atmo_sea_level = FFIAtmosphericConditions {
2221 temperature: 15.0,
2222 pressure: 1013.25,
2223 humidity: 50.0,
2224 altitude: 0.0,
2225 };
2226
2227 unsafe {
2228 let a = ballistics_calculate_trajectory(
2229 &inputs,
2230 std::ptr::null(),
2231 &atmo_da,
2232 400.0,
2233 1.0,
2234 );
2235 let b = ballistics_calculate_trajectory(
2236 &inputs,
2237 std::ptr::null(),
2238 &atmo_sea_level,
2239 400.0,
2240 1.0,
2241 );
2242 assert!(!a.is_null() && !b.is_null());
2243 let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2244 .last()
2245 .unwrap()
2246 .position_y;
2247 let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2248 .last()
2249 .unwrap()
2250 .position_y;
2251 assert!(
2252 (drop_a - drop_b).abs() > 1e-6,
2253 "density-altitude-derived vs sea-level atmosphere must produce materially \
2254 different trajectories: {drop_a} vs {drop_b}"
2255 );
2256 ballistics_free_trajectory_result(a);
2257 ballistics_free_trajectory_result(b);
2258 }
2259 }
2260}