Skip to main content

ballistics_engine/
cli_api.rs

1// CLI API module - provides simplified interfaces for command-line tool
2use crate::cluster_bc::ClusterBCDegradation;
3use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
4use crate::precession_nutation::{
5    calculate_combined_angular_motion, AngularState, PrecessionNutationParams,
6};
7use crate::trajectory_sampling::{
8    sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
9};
10use crate::transonic_drag::transonic_correction;
11use crate::wind_shear::WindShearModel;
12use crate::DragModel;
13use nalgebra::Vector3;
14use std::error::Error;
15use std::fmt;
16
17// Unit system for input/output
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum UnitSystem {
20    Imperial,
21    Metric,
22}
23
24// Output format for results
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum OutputFormat {
27    Table,
28    Json,
29    Csv,
30}
31
32// Error type for CLI operations
33#[derive(Debug)]
34pub struct BallisticsError {
35    message: String,
36}
37
38impl fmt::Display for BallisticsError {
39    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40        write!(f, "{}", self.message)
41    }
42}
43
44impl Error for BallisticsError {}
45
46impl From<String> for BallisticsError {
47    fn from(msg: String) -> Self {
48        BallisticsError { message: msg }
49    }
50}
51
52impl From<&str> for BallisticsError {
53    fn from(msg: &str) -> Self {
54        BallisticsError {
55            message: msg.to_string(),
56        }
57    }
58}
59
60// Ballistic input parameters - MBA-151 Reconciled Structure
61// Unified structure used by both ballistics-engine and ballistics_rust
62// Duplicates removed, all necessary fields included
63#[derive(Debug, Clone)]
64pub struct BallisticInputs {
65    // Core ballistics parameters (using intuitive names)
66    pub bc_value: f64,        // Ballistic coefficient (G1, G7, etc.)
67    pub bc_type: DragModel,   // Drag model (G1, G7, G8, etc.)
68    pub bullet_mass: f64,     // kg
69    pub muzzle_velocity: f64, // m/s
70    pub bullet_diameter: f64, // meters
71    pub bullet_length: f64,   // meters
72
73    // Targeting and positioning
74    pub muzzle_angle: f64,     // radians (launch angle)
75    pub target_distance: f64,  // meters
76    pub azimuth_angle: f64,    // horizontal aiming angle in radians
77    pub shooting_angle: f64,   // uphill/downhill angle in radians
78    pub sight_height: f64,     // meters above bore
79    pub muzzle_height: f64,    // meters above ground
80    pub target_height: f64,    // meters above ground for zeroing
81    pub ground_threshold: f64, // meters below which to stop
82
83    // Environmental conditions
84    pub altitude: f64,         // meters
85    pub temperature: f64,      // Celsius
86    pub pressure: f64,         // millibars/hPa
87    pub humidity: f64,         // relative humidity (0-1)
88    pub latitude: Option<f64>, // degrees
89
90    // Wind conditions
91    pub wind_speed: f64, // m/s
92    pub wind_angle: f64, // radians (0=headwind, 90=from right)
93
94    // Bullet characteristics
95    pub twist_rate: f64,               // inches per turn
96    pub is_twist_right: bool,          // right-hand twist
97    pub caliber_inches: f64,           // diameter in inches
98    pub weight_grains: f64,            // mass in grains
99    pub manufacturer: Option<String>,  // Bullet manufacturer
100    pub bullet_model: Option<String>,  // Bullet model name
101    pub bullet_id: Option<String>,     // Unique bullet identifier
102    pub bullet_cluster: Option<usize>, // BC cluster ID for cluster_bc module
103
104    // Integration method selection
105    pub use_rk4: bool,           // Use RK4 integration instead of Euler
106    pub use_adaptive_rk45: bool, // Use RK45 adaptive step size integration
107
108    // Advanced effects flags
109    pub enable_advanced_effects: bool,
110    pub enable_magnus: bool,   // Magnus side force (independent of Coriolis)
111    pub enable_coriolis: bool, // Coriolis deflection (requires latitude)
112    pub use_powder_sensitivity: bool,
113    pub powder_temp_sensitivity: f64,
114    pub powder_temp: f64,           // Celsius
115    pub tipoff_yaw: f64,            // radians
116    pub tipoff_decay_distance: f64, // meters
117    pub use_bc_segments: bool,
118    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-BC pairs
119    pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, // Velocity-BC segments
120    pub use_enhanced_spin_drift: bool,
121    pub use_form_factor: bool,
122    pub enable_wind_shear: bool,
123    pub wind_shear_model: String,
124    pub enable_trajectory_sampling: bool,
125    pub sample_interval: f64, // meters
126    pub enable_pitch_damping: bool,
127    pub enable_precession_nutation: bool,
128    pub use_cluster_bc: bool, // Use cluster-based BC degradation
129
130    // Custom drag model support
131    pub custom_drag_table: Option<crate::drag::DragTable>,
132
133    // Legacy field for compatibility
134    pub bc_type_str: Option<String>,
135}
136
137impl Default for BallisticInputs {
138    fn default() -> Self {
139        let mass_kg = 0.01;
140        let diameter_m = 0.00762;
141        let bc = 0.5;
142        let muzzle_angle_rad = 0.0;
143        let bc_type = DragModel::G1;
144
145        Self {
146            // Core ballistics parameters
147            bc_value: bc,
148            bc_type,
149            bullet_mass: mass_kg,
150            muzzle_velocity: 800.0,
151            bullet_diameter: diameter_m,
152            bullet_length: diameter_m * 4.5, // Approximate (match the CLI's 4.5-caliber heuristic)
153
154            // Targeting and positioning
155            muzzle_angle: muzzle_angle_rad,
156            target_distance: 100.0,
157            azimuth_angle: 0.0,
158            shooting_angle: 0.0,
159            sight_height: 0.05,
160            muzzle_height: 0.0,       // Default 0 - height is in sight_height
161            target_height: 0.0,       // Target at ground level by default
162            ground_threshold: -100.0, // Effectively disable ground detection (allow bullet to drop 100m below start)
163
164            // Environmental conditions
165            altitude: 0.0,
166            temperature: 15.0,
167            pressure: 1013.25, // Standard sea level pressure (millibars)
168            humidity: 0.5,     // 50% relative humidity
169            latitude: None,
170
171            // Wind conditions
172            wind_speed: 0.0,
173            wind_angle: 0.0,
174
175            // Bullet characteristics
176            twist_rate: 12.0, // 1:12" typical
177            is_twist_right: true,
178            caliber_inches: diameter_m / 0.0254, // Convert to inches
179            weight_grains: mass_kg / 0.00006479891, // Convert to grains
180            manufacturer: None,
181            bullet_model: None,
182            bullet_id: None,
183            bullet_cluster: None,
184
185            // Integration method selection
186            use_rk4: true,           // Use Runge-Kutta methods by default
187            use_adaptive_rk45: true, // Default to RK45 adaptive for best accuracy
188
189            // Advanced effects (disabled by default)
190            enable_advanced_effects: false,
191            enable_magnus: false,
192            enable_coriolis: false,
193            use_powder_sensitivity: false,
194            powder_temp_sensitivity: 0.0,
195            powder_temp: 15.0,
196            tipoff_yaw: 0.0,
197            tipoff_decay_distance: 50.0,
198            use_bc_segments: false,
199            bc_segments: None,
200            bc_segments_data: None,
201            use_enhanced_spin_drift: false,
202            use_form_factor: false,
203            enable_wind_shear: false,
204            wind_shear_model: "none".to_string(),
205            enable_trajectory_sampling: false,
206            sample_interval: 10.0, // Default 10 meter intervals
207            enable_pitch_damping: false,
208            enable_precession_nutation: false,
209            use_cluster_bc: false, // Disabled by default for backward compatibility
210
211            // Custom drag model support
212            custom_drag_table: None,
213
214            // Legacy field for compatibility
215            bc_type_str: None,
216        }
217    }
218}
219
220// Wind conditions
221#[derive(Debug, Clone)]
222pub struct WindConditions {
223    pub speed: f64,     // m/s
224    pub direction: f64, // radians (0 = North, PI/2 = East)
225}
226
227impl Default for WindConditions {
228    fn default() -> Self {
229        Self {
230            speed: 0.0,
231            direction: 0.0,
232        }
233    }
234}
235
236// Atmospheric conditions
237#[derive(Debug, Clone)]
238pub struct AtmosphericConditions {
239    pub temperature: f64, // Celsius
240    pub pressure: f64,    // hPa
241    pub humidity: f64,    // percentage (0-100)
242    pub altitude: f64,    // meters
243}
244
245impl Default for AtmosphericConditions {
246    fn default() -> Self {
247        Self {
248            temperature: 15.0,
249            pressure: 1013.25,
250            humidity: 50.0,
251            altitude: 0.0,
252        }
253    }
254}
255
256// Trajectory point data
257#[derive(Debug, Clone)]
258pub struct TrajectoryPoint {
259    pub time: f64,
260    pub position: Vector3<f64>,
261    pub velocity_magnitude: f64,
262    pub kinetic_energy: f64,
263}
264
265// Trajectory result
266#[derive(Debug, Clone)]
267pub struct TrajectoryResult {
268    pub max_range: f64,
269    pub max_height: f64,
270    pub time_of_flight: f64,
271    pub impact_velocity: f64,
272    pub impact_energy: f64,
273    pub points: Vec<TrajectoryPoint>,
274    pub sampled_points: Option<Vec<TrajectorySample>>, // Trajectory samples at regular intervals
275    pub min_pitch_damping: Option<f64>, // Minimum pitch damping coefficient (for stability warning)
276    pub transonic_mach: Option<f64>,    // Mach number when entering transonic regime
277    pub angular_state: Option<AngularState>, // Final angular state if precession/nutation enabled
278    pub max_yaw_angle: Option<f64>,     // Maximum yaw angle during flight (radians)
279    pub max_precession_angle: Option<f64>, // Maximum precession angle (radians)
280}
281
282impl TrajectoryResult {
283    /// Interpolate position at a given downrange distance (X coordinate, McCoy).
284    /// Returns the interpolated (x, y, z) position at that range.
285    /// If the target range exceeds the trajectory, returns the last point.
286    pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
287        if self.points.is_empty() {
288            return None;
289        }
290
291        // Find the two points that bracket the target range
292        for i in 0..self.points.len() - 1 {
293            let p1 = &self.points[i];
294            let p2 = &self.points[i + 1];
295
296            // Check if target range is between these two points (X is downrange)
297            if p1.position.x <= target_range && p2.position.x >= target_range {
298                // Linear interpolation factor
299                let dx = p2.position.x - p1.position.x;
300                if dx.abs() < 1e-10 {
301                    return Some(p1.position);
302                }
303                let t = (target_range - p1.position.x) / dx;
304
305                // Interpolate Y and Z, use exact target_range for X
306                return Some(Vector3::new(
307                    target_range,
308                    p1.position.y + t * (p2.position.y - p1.position.y),
309                    p1.position.z + t * (p2.position.z - p1.position.z),
310                ));
311            }
312        }
313
314        // Target range is beyond trajectory - return last point
315        self.points.last().map(|p| p.position)
316    }
317}
318
319// Trajectory solver
320pub struct TrajectorySolver {
321    inputs: BallisticInputs,
322    wind: WindConditions,
323    atmosphere: AtmosphericConditions,
324    max_range: f64,
325    time_step: f64,
326    cluster_bc: Option<ClusterBCDegradation>,
327}
328
329impl TrajectorySolver {
330    pub fn new(
331        mut inputs: BallisticInputs,
332        wind: WindConditions,
333        atmosphere: AtmosphericConditions,
334    ) -> Self {
335        // Compute derived fields from base units
336        inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
337        inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
338
339        // Initialize cluster BC if enabled
340        let cluster_bc = if inputs.use_cluster_bc {
341            Some(ClusterBCDegradation::new())
342        } else {
343            None
344        };
345
346        Self {
347            inputs,
348            wind,
349            atmosphere,
350            max_range: 1000.0,
351            time_step: 0.001,
352            cluster_bc,
353        }
354    }
355
356    pub fn set_max_range(&mut self, range: f64) {
357        self.max_range = range;
358    }
359
360    pub fn set_time_step(&mut self, step: f64) {
361        self.time_step = step;
362    }
363
364    fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
365        // Scale the operative surface wind by the boundary-layer multiplier. `altitude_m` is the
366        // bullet's height relative to the muzzle (McCoy Y). The multiplier is floored at 1.0, so
367        // flat-fire trajectories keep ~full wind and only high-arcing shots see increased wind.
368        //
369        // We build the vector with THIS solver's non-shear sign convention (X=+cos, Z=+sin; see
370        // the `wind_vector` used in solve_rk4/solve_euler) and scale it, so that "shear on" equals
371        // "shear off" * ratio (ratio == 1.0 for flat fire). The previous code both attenuated the
372        // wind near the line of sight and flipped its sign relative to the non-shear path.
373        let model = if self.inputs.wind_shear_model == "logarithmic" {
374            WindShearModel::Logarithmic
375        } else {
376            WindShearModel::PowerLaw // default to power law
377        };
378        let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
379
380        Vector3::new(
381            self.wind.speed * self.wind.direction.cos() * speed_ratio, // X: downrange head/tail
382            0.0,
383            self.wind.speed * self.wind.direction.sin() * speed_ratio, // Z: lateral crosswind
384        )
385    }
386
387    pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
388        let mut result = if self.inputs.use_rk4 {
389            if self.inputs.use_adaptive_rk45 {
390                self.solve_rk45()?
391            } else {
392                self.solve_rk4()?
393            }
394        } else {
395            self.solve_euler()?
396        };
397        self.apply_spin_drift(&mut result);
398        Ok(result)
399    }
400
401    /// Gyroscopic spin drift via the empirical Litz model, applied in the engine
402    /// (not the WASM formatter) so it covers Euler/RK4/RK45 and all consumers.
403    /// Uses the canonical SI fields and converts to grains/inches correctly,
404    /// avoiding the kg/m-vs-grains/in unit bug in `calculate_enhanced_spin_drift`.
405    /// Frame (McCoy): Z = lateral (windage), so drift adds to `position.z`.
406    fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
407        if !self.inputs.use_enhanced_spin_drift {
408            return;
409        }
410        let d_in = self.inputs.bullet_diameter / 0.0254; // m -> in
411        let m_gr = self.inputs.bullet_mass / 0.00006479891; // kg -> grains
412        let twist_in = self.inputs.twist_rate; // inches/turn
413        if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
414            return;
415        }
416
417        // Real length when available, else 4.5 cal (typical match bullet).
418        let length_in = if self.inputs.bullet_length > 0.0 {
419            self.inputs.bullet_length / 0.0254
420        } else {
421            4.5 * d_in
422        };
423        // MBA-942: apply the canonical Miller atmospheric correction (LINEAR in density ratio,
424        // = rho0/rho via ideal gas: (T/T0)*(P0/P)), matching stability.rs and py_ballisticcalc.
425        // miller_stability returns the bare geometric Sg with no density dependence, so without
426        // this the spin drift under-predicts at altitude (Sg should rise as the air thins). At
427        // standard sea level (15 C, 1013.25 hPa) the factor is exactly 1.0 — a no-op there.
428        let temp_k = self.atmosphere.temperature + 273.15; // Celsius -> Kelvin
429        let press_hpa = self.atmosphere.pressure; // hPa
430        let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
431            (temp_k / 288.15) * (1013.25 / press_hpa)
432        } else {
433            1.0
434        };
435        let sg =
436            crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
437        let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
438
439        for p in result.points.iter_mut() {
440            if p.time <= 0.0 {
441                continue;
442            }
443            let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); // Litz drift, inches
444            p.position.z += sign * sd_in * 0.0254; // in -> m, Z = lateral
445        }
446
447        // sampled_points are snapshotted from the PRE-drift trajectory inside each solver, so the
448        // sampled wind_drift_m column would omit the spin drift that result.points carry. Apply
449        // the same Litz drift to keep the two user-facing outputs consistent.
450        if let Some(samples) = result.sampled_points.as_mut() {
451            for s in samples.iter_mut() {
452                if s.time_s <= 0.0 {
453                    continue;
454                }
455                let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
456                s.wind_drift_m += sign * sd_in * 0.0254;
457            }
458        }
459    }
460
461    fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
462        // Simple trajectory integration using Euler method
463        let mut time = 0.0;
464        // Bullet starts at the BORE position, which is muzzle_height above ground
465        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
466        let mut position = Vector3::new(
467            0.0,
468            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
469            0.0,
470        );
471        // Calculate initial velocity components with both elevation and azimuth
472        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
473        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
474        let mut velocity = Vector3::new(
475            horizontal_velocity * self.inputs.azimuth_angle.cos(), // X: downrange (forward)
476            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), // Y: vertical component
477            horizontal_velocity * self.inputs.azimuth_angle.sin(), // Z: lateral (side deviation)
478        );
479
480        let mut points = Vec::new();
481        let mut max_height = position.y;
482        let mut min_pitch_damping = 1.0; // Track minimum pitch damping coefficient
483        let mut transonic_mach = None; // Track when we enter transonic
484
485        // Initialize angular state for precession/nutation tracking
486        let mut angular_state = if self.inputs.enable_precession_nutation {
487            Some(AngularState {
488                pitch_angle: 0.001, // Small initial disturbance
489                yaw_angle: 0.001,
490                pitch_rate: 0.0,
491                yaw_rate: 0.0,
492                precession_angle: 0.0,
493                nutation_phase: 0.0,
494            })
495        } else {
496            None
497        };
498        let mut max_yaw_angle = 0.0;
499        let mut max_precession_angle = 0.0;
500
501        // Calculate air density
502        let air_density = calculate_air_density(&self.atmosphere);
503
504        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
505        let wind_vector = Vector3::new(
506            self.wind.speed * self.wind.direction.cos(), // X: downrange (head/tail wind)
507            0.0,
508            self.wind.speed * self.wind.direction.sin(), // Z: lateral (crosswind)
509        );
510
511        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
512        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
513        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
514            self.inputs.bullet_model.as_deref().unwrap_or("default"),
515        );
516
517        // Main integration loop (X is downrange)
518        while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
519            // Store trajectory point
520            let velocity_magnitude = velocity.magnitude();
521            let kinetic_energy =
522                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
523
524            points.push(TrajectoryPoint {
525                time,
526                position: position,
527                velocity_magnitude,
528                kinetic_energy,
529            });
530
531            // Debug: log first and every 100th point. Debug builds only — this was ungated and
532            // polluted release/WASM stderr on the --use-euler path (the other solvers have none).
533            // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral
534            #[cfg(debug_assertions)]
535            if points.len() == 1 || points.len() % 100 == 0 {
536                eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
537                    points.len(), time, position.x, position.y, position.z, velocity_magnitude);
538            }
539
540            // Track max height
541            if position.y > max_height {
542                max_height = position.y;
543            }
544
545            // Calculate pitch damping if enabled
546            if self.inputs.enable_pitch_damping {
547                let temp_c = self.atmosphere.temperature;
548                let temp_k = temp_c + 273.15;
549                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
550                let mach = velocity_magnitude / speed_of_sound;
551
552                // Track when we enter transonic
553                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
554                    transonic_mach = Some(mach);
555                }
556
557                // Calculate pitch damping coefficient
558                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
559
560                // Track minimum (most critical for stability)
561                if pitch_damping < min_pitch_damping {
562                    min_pitch_damping = pitch_damping;
563                }
564            }
565
566            // Calculate precession/nutation if enabled
567            if self.inputs.enable_precession_nutation {
568                if let Some(ref mut state) = angular_state {
569                    let velocity_magnitude = velocity.magnitude();
570                    let temp_c = self.atmosphere.temperature;
571                    let temp_k = temp_c + 273.15;
572                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
573                    let mach = velocity_magnitude / speed_of_sound;
574
575                    // Calculate spin rate from twist rate and velocity
576                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
577                        let velocity_fps = velocity_magnitude * 3.28084;
578                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
579                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
580                    } else {
581                        0.0
582                    };
583
584                    // Create precession/nutation parameters
585                    let params = PrecessionNutationParams {
586                        mass_kg: self.inputs.bullet_mass,
587                        caliber_m: self.inputs.bullet_diameter,
588                        length_m: self.inputs.bullet_length,
589                        spin_rate_rad_s,
590                        spin_inertia: 6.94e-8,       // Typical value
591                        transverse_inertia: 9.13e-7, // Typical value
592                        velocity_mps: velocity_magnitude,
593                        air_density_kg_m3: air_density,
594                        mach,
595                        pitch_damping_coeff: -0.8,
596                        nutation_damping_factor: 0.05,
597                    };
598
599                    // Update angular state
600                    *state = calculate_combined_angular_motion(
601                        &params,
602                        state,
603                        time,
604                        self.time_step,
605                        0.001, // Initial disturbance
606                    );
607
608                    // Track maximums
609                    if state.yaw_angle.abs() > max_yaw_angle {
610                        max_yaw_angle = state.yaw_angle.abs();
611                    }
612                    if state.precession_angle.abs() > max_precession_angle {
613                        max_precession_angle = state.precession_angle.abs();
614                    }
615                }
616            }
617
618            // Use the same acceleration kernel as RK4/RK45 so all three solvers share ONE drag
619            // model. solve_euler previously used a bespoke frontal-area drag (0.5*rho*Cd*A*v^2/m)
620            // that IGNORED the ballistic coefficient entirely (diverging up to ~2.3x from the
621            // BC-retardation RK4/RK45 path), and also omitted the Magnus/Coriolis terms.
622            // calculate_acceleration applies BC-retardation drag, gravity, Coriolis, Magnus, wind
623            // shear, and the zero-relative-velocity gravity-only guard.
624            let acceleration =
625                self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
626
627            // Update state
628            velocity += acceleration * self.time_step;
629            position += velocity * self.time_step;
630            time += self.time_step;
631        }
632
633        // Get final values
634        let last_point = points.last().ok_or("No trajectory points generated")?;
635
636        // Create trajectory sampling data if enabled
637        let sampled_points = if self.inputs.enable_trajectory_sampling {
638            let trajectory_data = TrajectoryData {
639                times: points.iter().map(|p| p.time).collect(),
640                positions: points.iter().map(|p| p.position).collect(),
641                velocities: points
642                    .iter()
643                    .map(|p| {
644                        // Reconstruct velocity vectors from magnitude (approximate)
645                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
646                    })
647                    .collect(),
648                transonic_distances: Vec::new(), // TODO: Track Mach transitions
649            };
650
651            // For LOS calculation in ground-referenced coordinates:
652            // sight_position_m is the sight's actual y-position above ground
653            // (muzzle_height + sight_height, not just sight_height)
654            // For flat shots, target is at same height as the sight (horizontal LOS)
655            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
656            let outputs = TrajectoryOutputs {
657                target_distance_horiz_m: last_point.position.x, // X is downrange
658                target_vertical_height_m: sight_position_m,
659                time_of_flight_s: last_point.time,
660                max_ord_dist_horiz_m: max_height,
661                sight_height_m: sight_position_m,
662            };
663
664            // Sample at specified intervals
665            let samples = sample_trajectory(
666                &trajectory_data,
667                &outputs,
668                self.inputs.sample_interval,
669                self.inputs.bullet_mass,
670            );
671            Some(samples)
672        } else {
673            None
674        };
675
676        Ok(TrajectoryResult {
677            max_range: last_point.position.x, // X is downrange
678            max_height,
679            time_of_flight: last_point.time,
680            impact_velocity: last_point.velocity_magnitude,
681            impact_energy: last_point.kinetic_energy,
682            points,
683            sampled_points,
684            min_pitch_damping: if self.inputs.enable_pitch_damping {
685                Some(min_pitch_damping)
686            } else {
687                None
688            },
689            transonic_mach,
690            angular_state,
691            max_yaw_angle: if self.inputs.enable_precession_nutation {
692                Some(max_yaw_angle)
693            } else {
694                None
695            },
696            max_precession_angle: if self.inputs.enable_precession_nutation {
697                Some(max_precession_angle)
698            } else {
699                None
700            },
701        })
702    }
703
704    fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
705        // RK4 trajectory integration for better accuracy
706        let mut time = 0.0;
707        // Bullet starts at the BORE position, which is muzzle_height above ground
708        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
709        // The sight_height affects the LOS calculation and zero angle, not the starting position
710        let mut position = Vector3::new(
711            0.0,
712            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
713            0.0,
714        );
715
716        // Calculate initial velocity components with both elevation and azimuth
717        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
718        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
719        let mut velocity = Vector3::new(
720            horizontal_velocity * self.inputs.azimuth_angle.cos(), // X: downrange (forward)
721            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), // Y: vertical component
722            horizontal_velocity * self.inputs.azimuth_angle.sin(), // Z: lateral (side deviation)
723        );
724
725        let mut points = Vec::new();
726        let mut max_height = position.y;
727        let mut min_pitch_damping = 1.0; // Track minimum pitch damping coefficient
728        let mut transonic_mach = None; // Track when we enter transonic
729
730        // Initialize angular state for precession/nutation tracking
731        let mut angular_state = if self.inputs.enable_precession_nutation {
732            Some(AngularState {
733                pitch_angle: 0.001, // Small initial disturbance
734                yaw_angle: 0.001,
735                pitch_rate: 0.0,
736                yaw_rate: 0.0,
737                precession_angle: 0.0,
738                nutation_phase: 0.0,
739            })
740        } else {
741            None
742        };
743        let mut max_yaw_angle = 0.0;
744        let mut max_precession_angle = 0.0;
745
746        // Calculate air density
747        let air_density = calculate_air_density(&self.atmosphere);
748
749        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
750        let wind_vector = Vector3::new(
751            self.wind.speed * self.wind.direction.cos(), // X: downrange (head/tail wind)
752            0.0,
753            self.wind.speed * self.wind.direction.sin(), // Z: lateral (crosswind)
754        );
755
756        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
757        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
758        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
759            self.inputs.bullet_model.as_deref().unwrap_or("default"),
760        );
761
762        // Main RK4 integration loop (X is downrange)
763        while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
764            // Store trajectory point
765            let velocity_magnitude = velocity.magnitude();
766            let kinetic_energy =
767                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
768
769            points.push(TrajectoryPoint {
770                time,
771                position: position,
772                velocity_magnitude,
773                kinetic_energy,
774            });
775
776            if position.y > max_height {
777                max_height = position.y;
778            }
779
780            // Calculate pitch damping if enabled (RK4 solver)
781            if self.inputs.enable_pitch_damping {
782                let temp_c = self.atmosphere.temperature;
783                let temp_k = temp_c + 273.15;
784                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
785                let mach = velocity_magnitude / speed_of_sound;
786
787                // Track when we enter transonic
788                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
789                    transonic_mach = Some(mach);
790                }
791
792                // Calculate pitch damping coefficient
793                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
794
795                // Track minimum (most critical for stability)
796                if pitch_damping < min_pitch_damping {
797                    min_pitch_damping = pitch_damping;
798                }
799            }
800
801            // Calculate precession/nutation if enabled (RK4 solver)
802            if self.inputs.enable_precession_nutation {
803                if let Some(ref mut state) = angular_state {
804                    let velocity_magnitude = velocity.magnitude();
805                    let temp_c = self.atmosphere.temperature;
806                    let temp_k = temp_c + 273.15;
807                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
808                    let mach = velocity_magnitude / speed_of_sound;
809
810                    // Calculate spin rate from twist rate and velocity
811                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
812                        let velocity_fps = velocity_magnitude * 3.28084;
813                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
814                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
815                    } else {
816                        0.0
817                    };
818
819                    // Create precession/nutation parameters
820                    let params = PrecessionNutationParams {
821                        mass_kg: self.inputs.bullet_mass,
822                        caliber_m: self.inputs.bullet_diameter,
823                        length_m: self.inputs.bullet_length,
824                        spin_rate_rad_s,
825                        spin_inertia: 6.94e-8,       // Typical value
826                        transverse_inertia: 9.13e-7, // Typical value
827                        velocity_mps: velocity_magnitude,
828                        air_density_kg_m3: air_density,
829                        mach,
830                        pitch_damping_coeff: -0.8,
831                        nutation_damping_factor: 0.05,
832                    };
833
834                    // Update angular state
835                    *state = calculate_combined_angular_motion(
836                        &params,
837                        state,
838                        time,
839                        self.time_step,
840                        0.001, // Initial disturbance
841                    );
842
843                    // Track maximums
844                    if state.yaw_angle.abs() > max_yaw_angle {
845                        max_yaw_angle = state.yaw_angle.abs();
846                    }
847                    if state.precession_angle.abs() > max_precession_angle {
848                        max_precession_angle = state.precession_angle.abs();
849                    }
850                }
851            }
852
853            // RK4 method
854            let dt = self.time_step;
855
856            // k1
857            let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
858
859            // k2
860            let pos2 = position + velocity * (dt * 0.5);
861            let vel2 = velocity + acc1 * (dt * 0.5);
862            let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
863
864            // k3
865            let pos3 = position + vel2 * (dt * 0.5);
866            let vel3 = velocity + acc2 * (dt * 0.5);
867            let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
868
869            // k4
870            let pos4 = position + vel3 * dt;
871            let vel4 = velocity + acc3 * dt;
872            let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
873
874            // Update position and velocity
875            position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
876            velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
877            time += dt;
878        }
879
880        // Get final values
881        let last_point = points.last().ok_or("No trajectory points generated")?;
882
883        // Create trajectory sampling data if enabled
884        let sampled_points = if self.inputs.enable_trajectory_sampling {
885            let trajectory_data = TrajectoryData {
886                times: points.iter().map(|p| p.time).collect(),
887                positions: points.iter().map(|p| p.position).collect(),
888                velocities: points
889                    .iter()
890                    .map(|p| {
891                        // Reconstruct velocity vectors from magnitude (approximate)
892                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
893                    })
894                    .collect(),
895                transonic_distances: Vec::new(), // TODO: Track Mach transitions
896            };
897
898            // For LOS calculation in ground-referenced coordinates:
899            // sight_position_m is the sight's actual y-position above ground
900            // (muzzle_height + sight_height, not just sight_height)
901            // For flat shots, target is at same height as the sight (horizontal LOS)
902            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
903            let outputs = TrajectoryOutputs {
904                target_distance_horiz_m: last_point.position.x, // X is downrange
905                target_vertical_height_m: sight_position_m,
906                time_of_flight_s: last_point.time,
907                max_ord_dist_horiz_m: max_height,
908                sight_height_m: sight_position_m,
909            };
910
911            // Sample at specified intervals
912            let samples = sample_trajectory(
913                &trajectory_data,
914                &outputs,
915                self.inputs.sample_interval,
916                self.inputs.bullet_mass,
917            );
918            Some(samples)
919        } else {
920            None
921        };
922
923        Ok(TrajectoryResult {
924            max_range: last_point.position.x, // X is downrange
925            max_height,
926            time_of_flight: last_point.time,
927            impact_velocity: last_point.velocity_magnitude,
928            impact_energy: last_point.kinetic_energy,
929            points,
930            sampled_points,
931            min_pitch_damping: if self.inputs.enable_pitch_damping {
932                Some(min_pitch_damping)
933            } else {
934                None
935            },
936            transonic_mach,
937            angular_state,
938            max_yaw_angle: if self.inputs.enable_precession_nutation {
939                Some(max_yaw_angle)
940            } else {
941                None
942            },
943            max_precession_angle: if self.inputs.enable_precession_nutation {
944                Some(max_precession_angle)
945            } else {
946                None
947            },
948        })
949    }
950
951    fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
952        // RK45 adaptive step size integration (Dormand-Prince method)
953        let mut time = 0.0;
954        // Bullet starts at the BORE position, which is muzzle_height above ground
955        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
956        let mut position = Vector3::new(
957            0.0,
958            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
959            0.0,
960        );
961
962        // Calculate initial velocity components
963        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
964        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
965        let mut velocity = Vector3::new(
966            horizontal_velocity * self.inputs.azimuth_angle.cos(), // X: downrange (forward)
967            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), // Y: vertical component
968            horizontal_velocity * self.inputs.azimuth_angle.sin(), // Z: lateral (side deviation)
969        );
970
971        let mut points = Vec::new();
972        let mut max_height = position.y;
973        let mut dt = 0.001; // Initial step size
974        let tolerance = 1e-6; // Error tolerance
975        let safety_factor = 0.9; // Safety factor for step size adjustment
976        let max_dt = 0.01; // Maximum step size
977        let min_dt = 1e-6; // Minimum step size
978
979        // Add a point counter to debug
980        let mut iteration_count = 0;
981        const MAX_ITERATIONS: usize = 100000;
982
983        // Air density and wind are constant for the whole solve (self.atmosphere / self.wind
984        // are immutable); compute once instead of every iteration (mirrors solve_rk4).
985        let air_density = calculate_air_density(&self.atmosphere);
986        let wind_vector = Vector3::new(
987            self.wind.speed * self.wind.direction.cos(), // X: downrange (head/tail wind)
988            0.0,
989            self.wind.speed * self.wind.direction.sin(), // Z: lateral (crosswind)
990        );
991
992        while position.x < self.max_range
993            && position.y > self.inputs.ground_threshold
994            && time < 100.0
995        {
996            // X is downrange
997            iteration_count += 1;
998            if iteration_count > MAX_ITERATIONS {
999                break; // Prevent infinite loop
1000            }
1001
1002            // Store current point
1003            let velocity_magnitude = velocity.magnitude();
1004            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1005
1006            points.push(TrajectoryPoint {
1007                time,
1008                position: position,
1009                velocity_magnitude,
1010                kinetic_energy,
1011            });
1012
1013            if position.y > max_height {
1014                max_height = position.y;
1015            }
1016
1017            // RK45 step with adaptive step size (air_density / wind_vector hoisted above)
1018            let (new_pos, new_vel, new_dt) = self.rk45_step(
1019                &position,
1020                &velocity,
1021                dt,
1022                air_density,
1023                &wind_vector,
1024                tolerance,
1025            );
1026
1027            // Advance state and time by the dt actually used for THIS step. (Previously dt
1028            // was overwritten with the adapted next-step size BEFORE `time += dt`, so every
1029            // reported time advanced by the NEXT step's dt — desyncing time from state and
1030            // corrupting time_of_flight and per-point / sampled times.)
1031            position = new_pos;
1032            velocity = new_vel;
1033            time += dt;
1034
1035            // Adapt the step size for the NEXT iteration.
1036            dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1037        }
1038
1039        // Ensure we have at least one point
1040        if points.is_empty() {
1041            return Err(BallisticsError::from("No trajectory points calculated"));
1042        }
1043
1044        let last_point = points.last().unwrap();
1045
1046        // Generate sampled trajectory points if enabled
1047        let sampled_points = if self.inputs.enable_trajectory_sampling {
1048            // Build trajectory data for sampling
1049            let trajectory_data = TrajectoryData {
1050                times: points.iter().map(|p| p.time).collect(),
1051                positions: points.iter().map(|p| p.position).collect(),
1052                velocities: points
1053                    .iter()
1054                    .map(|p| {
1055                        // Approximate velocity direction from position changes
1056                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
1057                    })
1058                    .collect(),
1059                transonic_distances: Vec::new(),
1060            };
1061
1062            // For LOS calculation in ground-referenced coordinates:
1063            // sight_position_m is the sight's actual y-position above ground
1064            // (muzzle_height + sight_height, not just sight_height)
1065            // For flat shots, target is at same height as the sight (horizontal LOS)
1066            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1067            let outputs = TrajectoryOutputs {
1068                target_distance_horiz_m: last_point.position.x,
1069                target_vertical_height_m: sight_position_m,
1070                time_of_flight_s: last_point.time,
1071                max_ord_dist_horiz_m: max_height,
1072                sight_height_m: sight_position_m,
1073            };
1074
1075            let samples = sample_trajectory(
1076                &trajectory_data,
1077                &outputs,
1078                self.inputs.sample_interval,
1079                self.inputs.bullet_mass,
1080            );
1081            Some(samples)
1082        } else {
1083            None
1084        };
1085
1086        Ok(TrajectoryResult {
1087            max_range: last_point.position.x, // X is downrange
1088            max_height,
1089            time_of_flight: last_point.time,
1090            impact_velocity: last_point.velocity_magnitude,
1091            impact_energy: last_point.kinetic_energy,
1092            points,
1093            sampled_points,
1094            min_pitch_damping: None,
1095            transonic_mach: None,
1096            angular_state: None,
1097            max_yaw_angle: None,
1098            max_precession_angle: None,
1099        })
1100    }
1101
1102    fn rk45_step(
1103        &self,
1104        position: &Vector3<f64>,
1105        velocity: &Vector3<f64>,
1106        dt: f64,
1107        air_density: f64,
1108        wind_vector: &Vector3<f64>,
1109        tolerance: f64,
1110    ) -> (Vector3<f64>, Vector3<f64>, f64) {
1111        // Dormand-Prince coefficients
1112        const A21: f64 = 1.0 / 5.0;
1113        const A31: f64 = 3.0 / 40.0;
1114        const A32: f64 = 9.0 / 40.0;
1115        const A41: f64 = 44.0 / 45.0;
1116        const A42: f64 = -56.0 / 15.0;
1117        const A43: f64 = 32.0 / 9.0;
1118        const A51: f64 = 19372.0 / 6561.0;
1119        const A52: f64 = -25360.0 / 2187.0;
1120        const A53: f64 = 64448.0 / 6561.0;
1121        const A54: f64 = -212.0 / 729.0;
1122        const A61: f64 = 9017.0 / 3168.0;
1123        const A62: f64 = -355.0 / 33.0;
1124        const A63: f64 = 46732.0 / 5247.0;
1125        const A64: f64 = 49.0 / 176.0;
1126        const A65: f64 = -5103.0 / 18656.0;
1127        const A71: f64 = 35.0 / 384.0;
1128        const A73: f64 = 500.0 / 1113.0;
1129        const A74: f64 = 125.0 / 192.0;
1130        const A75: f64 = -2187.0 / 6784.0;
1131        const A76: f64 = 11.0 / 84.0;
1132
1133        // 5th order coefficients
1134        const B1: f64 = 35.0 / 384.0;
1135        const B3: f64 = 500.0 / 1113.0;
1136        const B4: f64 = 125.0 / 192.0;
1137        const B5: f64 = -2187.0 / 6784.0;
1138        const B6: f64 = 11.0 / 84.0;
1139
1140        // 4th order coefficients for error estimation
1141        const B1_ERR: f64 = 5179.0 / 57600.0;
1142        const B3_ERR: f64 = 7571.0 / 16695.0;
1143        const B4_ERR: f64 = 393.0 / 640.0;
1144        const B5_ERR: f64 = -92097.0 / 339200.0;
1145        const B6_ERR: f64 = 187.0 / 2100.0;
1146        const B7_ERR: f64 = 1.0 / 40.0;
1147
1148        // Compute RK45 stages
1149        let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1150        let k1_p = *velocity;
1151
1152        let p2 = position + dt * A21 * k1_p;
1153        let v2 = velocity + dt * A21 * k1_v;
1154        let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1155        let k2_p = v2;
1156
1157        let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1158        let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1159        let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1160        let k3_p = v3;
1161
1162        let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1163        let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1164        let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1165        let k4_p = v4;
1166
1167        let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1168        let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1169        let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1170        let k5_p = v5;
1171
1172        let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1173        let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1174        let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1175        let k6_p = v6;
1176
1177        let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1178        let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1179        let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1180        let k7_p = v7;
1181
1182        // 5th order solution
1183        let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1184        let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1185
1186        // 4th order solution for error estimate
1187        let pos_err = position
1188            + dt * (B1_ERR * k1_p
1189                + B3_ERR * k3_p
1190                + B4_ERR * k4_p
1191                + B5_ERR * k5_p
1192                + B6_ERR * k6_p
1193                + B7_ERR * k7_p);
1194        let vel_err = velocity
1195            + dt * (B1_ERR * k1_v
1196                + B3_ERR * k3_v
1197                + B4_ERR * k4_v
1198                + B5_ERR * k5_v
1199                + B6_ERR * k6_v
1200                + B7_ERR * k7_v);
1201
1202        // Estimate error
1203        let pos_error = (new_pos - pos_err).magnitude();
1204        let vel_error = (new_vel - vel_err).magnitude();
1205        let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1206
1207        // Calculate new step size
1208        let dt_new = if error < tolerance {
1209            dt * (tolerance / error).powf(0.2).min(2.0)
1210        } else {
1211            dt * (tolerance / error).powf(0.25).max(0.1)
1212        };
1213
1214        (new_pos, new_vel, dt_new)
1215    }
1216
1217    fn calculate_acceleration(
1218        &self,
1219        position: &Vector3<f64>,
1220        velocity: &Vector3<f64>,
1221        air_density: f64,
1222        wind_vector: &Vector3<f64>,
1223    ) -> Vector3<f64> {
1224        // Calculate altitude-dependent wind if wind shear is enabled
1225        let actual_wind = if self.inputs.enable_wind_shear {
1226            self.get_wind_at_altitude(position.y)
1227        } else {
1228            *wind_vector
1229        };
1230
1231        let relative_velocity = velocity - actual_wind;
1232        let velocity_magnitude = relative_velocity.magnitude();
1233
1234        if velocity_magnitude < 0.001 {
1235            return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1236        }
1237
1238        // Get drag coefficient from drag model (Mach-indexed from drag tables)
1239        let cd = self.calculate_drag_coefficient(velocity_magnitude);
1240
1241        // Convert velocity to fps for BC lookups
1242        let velocity_fps = velocity_magnitude * 3.28084;
1243
1244        // Look up BC from segments if available (highest priority - most accurate)
1245        let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1246            // Find matching segment for current velocity
1247            segments
1248                .iter()
1249                .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1250                .map(|seg| seg.bc_value)
1251                .unwrap_or(self.inputs.bc_value)
1252        } else {
1253            self.inputs.bc_value
1254        };
1255
1256        // Apply cluster BC correction if enabled (on top of segment BC)
1257        let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1258            cluster_bc.apply_correction(
1259                base_bc,
1260                self.inputs.caliber_inches, // predict_cluster normalizes against an inches range
1261                self.inputs.weight_grains,
1262                velocity_fps,
1263            )
1264        } else {
1265            base_bc
1266        };
1267        // Guard bc_value == 0 (allowed on the FFI/WASM surfaces, which lack the CLI's 0.001
1268        // lower bound): dividing by effective_bc below would be Inf -> NaN. Inert for valid
1269        // BCs (>= 0.001).
1270        let effective_bc = effective_bc.max(1e-6);
1271
1272        // Use proper ballistics retardation formula
1273        // This matches the proven formula from fast_trajectory.rs
1274        // The standard retardation factor converts Cd to drag deceleration
1275        // Note: velocity_fps already calculated above for BC segment lookup
1276        let cd_to_retard = 0.000683 * 0.30; // Standard ballistics constant
1277        let standard_factor = cd * cd_to_retard;
1278        let density_scale = air_density / 1.225; // Scale relative to standard air (1.225 kg/m³)
1279
1280        // Drag acceleration in ft/s² then convert to m/s²
1281        let a_drag_ft_s2 =
1282            (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1283        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s² to m/s²
1284
1285        // Apply drag opposite to velocity direction
1286        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1287
1288        // Total acceleration = drag + gravity
1289        let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1290
1291        // Coriolis (Earth rotation). McCoy frame: X=downrange, Y=vertical, Z=lateral,
1292        // azimuth 0 = North. McCoy frame: X=downrange, Y=vertical, Z=lateral.
1293        if self.inputs.enable_coriolis {
1294            if let Some(lat_deg) = self.inputs.latitude {
1295                let omega_earth = 7.2921159e-5_f64; // rad/s
1296                let lat = lat_deg.to_radians();
1297                let az = self.inputs.azimuth_angle;
1298                // Earth's angular velocity in the shot frame (X=downrange, Y=up,
1299                // Z=lateral). Projecting Omega=(0, Ω cosφ, Ω sinφ) [local E,N,U] onto
1300                // the azimuth-rotated shot axes gives a NEGATIVE lateral component:
1301                // lateral = downrange × up points East for a North shot, and
1302                // Omega·East = -Ω cosφ sin(az). The previous code dropped that sign.
1303                let omega = Vector3::new(
1304                    omega_earth * lat.cos() * az.cos(),  // X: downrange
1305                    omega_earth * lat.sin(),             // Y: vertical
1306                    -omega_earth * lat.cos() * az.sin(), // Z: lateral (MBA-938: corrected sign)
1307                );
1308                // Coriolis acceleration is the physical -2 Ω×v (MBA-938). The old +2 with
1309                // an "output-preserving relabel" justification produced left-ward drift for
1310                // a North shot in the Northern hemisphere; first principles (and the +Eötvös
1311                // lift for East shots) require -2 with the corrected omega above.
1312                accel += -2.0 * omega.cross(velocity);
1313            }
1314        }
1315
1316        // Magnus side force (spinning projectile). SI units in this solver.
1317        if self.inputs.enable_magnus
1318            && self.inputs.bullet_diameter > 0.0
1319            && self.inputs.twist_rate > 0.0
1320        {
1321            let (_, spin_rad_s) =
1322                crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1323            let temp_k = self.atmosphere.temperature + 273.15;
1324            let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1325            let mach = velocity_magnitude / speed_of_sound;
1326
1327            // Imperial conversions for the stability / yaw-of-repose helpers.
1328            let d_in = self.inputs.bullet_diameter / 0.0254;
1329            let m_gr = self.inputs.bullet_mass / 0.00006479891;
1330            let l_in = if self.inputs.bullet_length > 0.0 {
1331                self.inputs.bullet_length / 0.0254
1332            } else {
1333                4.5 * d_in
1334            };
1335            // MBA-958: apply the canonical linear Miller density correction (T/T0)*(P0/P) to the
1336            // Magnus/yaw-of-repose Sg too, matching the spin-drift Sg (MBA-942) and stability.rs.
1337            // No-op at sea-level standard (15 C, 1013.25 hPa -> factor 1.0).
1338            let press_hpa = self.atmosphere.pressure;
1339            let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1340                (temp_k / 288.15) * (1013.25 / press_hpa)
1341            } else {
1342                1.0
1343            };
1344            let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1345                * density_correction;
1346
1347            // Yaw of repose (radians); zero for unstable bullets (Sg <= 1).
1348            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1349                sg,
1350                velocity_magnitude,
1351                spin_rad_s,
1352                0.0, // crosswind handled elsewhere
1353                0.0, // pitch rate not tracked
1354                air_density,
1355                d_in,
1356                l_in,
1357                m_gr,
1358                mach,
1359                "match",
1360                false,
1361            );
1362
1363            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
1364            let diameter_m = self.inputs.bullet_diameter; // already meters
1365            let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1366            let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1367            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1368            let magnus_force = 0.5
1369                * air_density
1370                * velocity_magnitude.powi(2)
1371                * area
1372                * c_np
1373                * spin_param
1374                * yaw_rad.sin();
1375
1376            // Horizontal direction perpendicular to velocity. In McCoy (RH) frame,
1377            // v_unit × up = +Z (right) for a downrange shot, matching spin-drift sign.
1378            let velocity_unit = relative_velocity / velocity_magnitude;
1379            let up = Vector3::new(0.0, 1.0, 0.0);
1380            let mut dir = velocity_unit.cross(&up);
1381            let dir_norm = dir.norm();
1382            if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1383                dir /= dir_norm;
1384                if !self.inputs.is_twist_right {
1385                    dir = -dir;
1386                }
1387                accel += (magnus_force / self.inputs.bullet_mass) * dir;
1388            }
1389        }
1390
1391        accel
1392    }
1393
1394    fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1395        // Calculate speed of sound based on atmospheric temperature
1396        let temp_c = self.atmosphere.temperature;
1397        let temp_k = temp_c + 273.15;
1398        let gamma = 1.4; // Ratio of specific heats for air
1399        let r_specific = 287.05; // Specific gas constant for air (J/kg·K)
1400        let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1401        let mach = velocity / speed_of_sound;
1402
1403        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is — no G-model
1404        // lookup, no transonic shape correction, no form factor. The supplied curve already
1405        // encodes the projectile's true drag, so applying those would distort/double-count it.
1406        if let Some(ref table) = self.inputs.custom_drag_table {
1407            return table.interpolate(mach);
1408        }
1409
1410        // Get drag coefficient from the drag tables (Mach-indexed)
1411        let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1412
1413        // Borrowed &'static str for the drag-model name. bc_type.to_string() goes through
1414        // Debug and heap-allocates a String on every call; this match is bit-identical
1415        // (Display == Debug == variant name) with no per-step allocation.
1416        let bc_type_str: &str = match self.inputs.bc_type {
1417            crate::DragModel::G1 => "G1",
1418            crate::DragModel::G2 => "G2",
1419            crate::DragModel::G5 => "G5",
1420            crate::DragModel::G6 => "G6",
1421            crate::DragModel::G7 => "G7",
1422            crate::DragModel::G8 => "G8",
1423            crate::DragModel::GI => "GI",
1424            crate::DragModel::GS => "GS",
1425        };
1426
1427        // Determine projectile shape for transonic corrections (MBA-949: shared resolver so
1428        // derivatives/fast_trajectory honor named shapes too; caliber in INCHES, weight in grains).
1429        let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1430            self.inputs.bullet_model.as_deref(),
1431            self.inputs.caliber_inches,
1432            self.inputs.bullet_mass / 0.00006479891, // kg -> grains
1433            bc_type_str,
1434        );
1435
1436        // Apply transonic corrections
1437        // Note: Wave drag is disabled because G7/G1 drag functions already include
1438        // transonic effects. Adding wave drag on top would double-count the drag rise.
1439        // Wave drag should only be enabled for custom drag functions that don't
1440        // include transonic behavior.
1441        let include_wave_drag = false;
1442        let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1443
1444        // MBA-948: honor use_form_factor here too — previously only derivatives.rs applied it,
1445        // so cli_api and fast_trajectory silently ignored the flag. apply_form_factor_to_drag
1446        // short-circuits when the flag is false, so this is a no-op for every current consumer
1447        // (the flag is false on all CLI/FFI/WASM/binding surfaces and defaults false).
1448        crate::form_factor::apply_form_factor_to_drag(
1449            cd,
1450            self.inputs.bullet_model.as_deref(),
1451            &self.inputs.bc_type,
1452            self.inputs.use_form_factor,
1453        )
1454    }
1455}
1456
1457// Monte Carlo parameters
1458#[derive(Debug, Clone)]
1459pub struct MonteCarloParams {
1460    pub num_simulations: usize,
1461    pub velocity_std_dev: f64,
1462    pub angle_std_dev: f64,
1463    pub bc_std_dev: f64,
1464    pub wind_speed_std_dev: f64,
1465    pub target_distance: Option<f64>,
1466    pub base_wind_speed: f64,
1467    pub base_wind_direction: f64,
1468    pub azimuth_std_dev: f64, // Horizontal aiming variation in radians
1469}
1470
1471impl Default for MonteCarloParams {
1472    fn default() -> Self {
1473        Self {
1474            num_simulations: 1000,
1475            velocity_std_dev: 1.0,
1476            angle_std_dev: 0.001,
1477            bc_std_dev: 0.01,
1478            wind_speed_std_dev: 1.0,
1479            target_distance: None,
1480            base_wind_speed: 0.0,
1481            base_wind_direction: 0.0,
1482            azimuth_std_dev: 0.001, // Default horizontal spread ~0.057 degrees
1483        }
1484    }
1485}
1486
1487// Monte Carlo results
1488#[derive(Debug, Clone)]
1489pub struct MonteCarloResults {
1490    pub ranges: Vec<f64>,
1491    pub impact_velocities: Vec<f64>,
1492    pub impact_positions: Vec<Vector3<f64>>,
1493}
1494
1495// Run Monte Carlo simulation (backwards compatibility)
1496pub fn run_monte_carlo(
1497    base_inputs: BallisticInputs,
1498    params: MonteCarloParams,
1499) -> Result<MonteCarloResults, BallisticsError> {
1500    let base_wind = WindConditions {
1501        speed: params.base_wind_speed,
1502        direction: params.base_wind_direction,
1503    };
1504    run_monte_carlo_with_wind(base_inputs, base_wind, params)
1505}
1506
1507// Run Monte Carlo simulation with wind
1508pub fn run_monte_carlo_with_wind(
1509    base_inputs: BallisticInputs,
1510    base_wind: WindConditions,
1511    params: MonteCarloParams,
1512) -> Result<MonteCarloResults, BallisticsError> {
1513    use rand::thread_rng;
1514    use rand_distr::{Distribution, Normal};
1515
1516    let mut rng = thread_rng();
1517    let mut ranges = Vec::new();
1518    let mut impact_velocities = Vec::new();
1519    let mut impact_positions = Vec::new();
1520
1521    // First, calculate baseline trajectory with no variations
1522    let baseline_solver =
1523        TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1524    let baseline_result = baseline_solver.solve()?;
1525
1526    // Determine target distance: use explicit target or baseline max range
1527    let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1528
1529    // Get baseline position at target distance (interpolated)
1530    let baseline_at_target = baseline_result
1531        .position_at_range(target_distance)
1532        .ok_or("Could not interpolate baseline at target distance")?;
1533
1534    // Create normal distributions for variations
1535    let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1536        .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1537    let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1538        .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1539    let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1540        .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1541    let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1542        .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1543    // MBA-952: wind-direction spread is APPROXIMATED from the wind-SPEED std dev (×0.1), a unit
1544    // conflation (m/s scaled as radians) — there is no dedicated wind_direction_std_dev field yet.
1545    // The dead WASM `--wind-dir-std` setter was removed (it set nothing). A proper fix is an
1546    // API-breaking wind_direction_std_dev on MonteCarloParams plumbed through WASM/FFI/main.
1547    let wind_dir_dist =
1548        Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1549            .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1550    let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1551        .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1552
1553    // Create distribution for pointing errors (simulates shooter's aiming consistency)
1554    let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1555        .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1556
1557    for _ in 0..params.num_simulations {
1558        // Create varied inputs
1559        let mut inputs = base_inputs.clone();
1560        inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1561        inputs.muzzle_angle = angle_dist.sample(&mut rng);
1562        inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1563        inputs.azimuth_angle = azimuth_dist.sample(&mut rng); // Add horizontal variation
1564
1565        // Create varied wind (now based on base wind conditions)
1566        let wind = WindConditions {
1567            speed: wind_speed_dist.sample(&mut rng).abs(),
1568            direction: wind_dir_dist.sample(&mut rng),
1569        };
1570
1571        // Run trajectory
1572        let solver = TrajectorySolver::new(inputs, wind, Default::default());
1573        match solver.solve() {
1574            Ok(result) => {
1575                // Skip samples that fell short of the target (e.g. a low muzzle-velocity draw):
1576                // position_at_range would clamp to the ground-impact point (a large spurious
1577                // deviation). Exclude such samples from ALL THREE result vectors so they stay
1578                // equal-length and per-sample aligned — the FFI exposes them under ONE count
1579                // (ranges.len()), so a shorter impact_positions would leave uninitialized tail
1580                // slots read across the C ABI. The common case (all samples reach the target) is
1581                // unaffected; range/velocity stats stay consistent with the dispersion stats.
1582                if result.max_range < target_distance {
1583                    continue;
1584                }
1585                // Position at target distance (not ground impact). Always Some here since
1586                // max_range >= target_distance and an Ok result has a non-empty trajectory;
1587                // skip defensively (keeping the vectors aligned) if it ever returns None.
1588                let pos_at_target = match result.position_at_range(target_distance) {
1589                    Some(p) => p,
1590                    None => continue,
1591                };
1592
1593                ranges.push(result.max_range);
1594                impact_velocities.push(result.impact_velocity);
1595
1596                // Calculate deviation from baseline at the SAME target distance (McCoy)
1597                // X = downrange (0 here), Y = vertical (elevation), Z = lateral (windage)
1598                let mut deviation = Vector3::new(
1599                    0.0, // X downrange deviation is 0 since we compare at same range
1600                    pos_at_target.y - baseline_at_target.y, // Vertical deviation
1601                    pos_at_target.z - baseline_at_target.z, // Lateral deviation (windage)
1602                );
1603
1604                // Add additional pointing error to simulate realistic group sizes
1605                // This represents the shooter's ability to aim consistently
1606                let pointing_error_y = pointing_error_dist.sample(&mut rng);
1607                deviation.y += pointing_error_y;
1608
1609                impact_positions.push(deviation);
1610            }
1611            Err(_) => {
1612                // Skip failed simulations
1613                continue;
1614            }
1615        }
1616    }
1617
1618    if ranges.is_empty() {
1619        return Err("No successful simulations".into());
1620    }
1621
1622    Ok(MonteCarloResults {
1623        ranges,
1624        impact_velocities,
1625        impact_positions,
1626    })
1627}
1628
1629// Calculate zero angle for a target
1630pub fn calculate_zero_angle(
1631    inputs: BallisticInputs,
1632    target_distance: f64,
1633    target_height: f64,
1634) -> Result<f64, BallisticsError> {
1635    calculate_zero_angle_with_conditions(
1636        inputs,
1637        target_distance,
1638        target_height,
1639        WindConditions::default(),
1640        AtmosphericConditions::default(),
1641    )
1642}
1643
1644pub fn calculate_zero_angle_with_conditions(
1645    inputs: BallisticInputs,
1646    target_distance: f64,
1647    target_height: f64,
1648    wind: WindConditions,
1649    atmosphere: AtmosphericConditions,
1650) -> Result<f64, BallisticsError> {
1651    // Helper function to get height at target distance for a given angle
1652    let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1653        let mut test_inputs = inputs.clone();
1654        test_inputs.muzzle_angle = angle;
1655
1656        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1657        solver.set_max_range(target_distance * 2.0);
1658        solver.set_time_step(0.001);
1659        let result = solver.solve()?;
1660
1661        // X is downrange in McCoy coordinates
1662        for i in 0..result.points.len() {
1663            if result.points[i].position.x >= target_distance {
1664                if i > 0 {
1665                    let p1 = &result.points[i - 1];
1666                    let p2 = &result.points[i];
1667                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1668                    return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1669                } else {
1670                    return Ok(Some(result.points[i].position.y));
1671                }
1672            }
1673        }
1674        Ok(None)
1675    };
1676
1677    // Binary search for the angle that hits the target
1678    // Use only positive angles to ensure proper ballistic arc (upward trajectory)
1679    let mut low_angle = 0.0; // radians (horizontal)
1680    let mut high_angle = 0.2; // radians (about 11 degrees)
1681    let tolerance = 0.00001; // radians
1682    let max_iterations = 50;
1683
1684    // MBA-194: Validate bracketing before starting binary search
1685    // Check that the target height is actually between low and high angle trajectories
1686    let low_height = get_height_at_angle(low_angle)?;
1687    let high_height = get_height_at_angle(high_angle)?;
1688
1689    match (low_height, high_height) {
1690        (Some(lh), Some(hh)) => {
1691            let low_error = lh - target_height;
1692            let high_error = hh - target_height;
1693
1694            // For proper bracketing, low angle should undershoot (negative error)
1695            // and high angle should overshoot (positive error)
1696            if low_error > 0.0 && high_error > 0.0 {
1697                // Both angles overshoot - target is too close or height too low
1698                // This shouldn't happen for typical zeroing, but handle gracefully
1699                // Try to find a valid bracket by reducing low_angle (can't go negative)
1700                // Since we can't go below 0, just proceed and let binary search find best
1701            } else if low_error < 0.0 && high_error < 0.0 {
1702                // Both angles undershoot - target is beyond effective range
1703                // Try expanding high_angle up to 45 degrees (0.785 rad)
1704                let mut expanded = false;
1705                for multiplier in [2.0, 3.0, 4.0] {
1706                    let new_high = (high_angle * multiplier).min(0.785);
1707                    if let Ok(Some(h)) = get_height_at_angle(new_high) {
1708                        if h - target_height > 0.0 {
1709                            high_angle = new_high;
1710                            expanded = true;
1711                            break;
1712                        }
1713                    }
1714                    if new_high >= 0.785 {
1715                        break;
1716                    }
1717                }
1718                if !expanded {
1719                    return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1720                }
1721            }
1722            // If signs are opposite, we have valid bracketing - proceed
1723        }
1724        (None, Some(_hh)) => {
1725            // Low angle doesn't reach target, high does - this is fine
1726            // Binary search will increase low_angle until trajectory reaches
1727        }
1728        (Some(_lh), None) => {
1729            // High angle doesn't reach target - shouldn't happen
1730            return Err(
1731                "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1732                    .into(),
1733            );
1734        }
1735        (None, None) => {
1736            // Neither reaches target - target too far
1737            return Err(
1738                "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1739                    .into(),
1740            );
1741        }
1742    }
1743
1744    for _iteration in 0..max_iterations {
1745        let mid_angle = (low_angle + high_angle) / 2.0;
1746
1747        let mut test_inputs = inputs.clone();
1748        test_inputs.muzzle_angle = mid_angle;
1749
1750        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1751        // Make sure we calculate far enough to reach the target
1752        solver.set_max_range(target_distance * 2.0);
1753        solver.set_time_step(0.001);
1754        let result = solver.solve()?;
1755
1756        // Find the height at target distance (X is downrange)
1757        let mut height_at_target = None;
1758        for i in 0..result.points.len() {
1759            if result.points[i].position.x >= target_distance {
1760                if i > 0 {
1761                    // Linear interpolation
1762                    let p1 = &result.points[i - 1];
1763                    let p2 = &result.points[i];
1764                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1765                    height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1766                } else {
1767                    height_at_target = Some(result.points[i].position.y);
1768                }
1769                break;
1770            }
1771        }
1772
1773        match height_at_target {
1774            Some(height) => {
1775                let error = height - target_height;
1776                // MBA-193: Check height error FIRST (primary convergence criterion)
1777                // Height accuracy is what matters for zeroing - angle tolerance is secondary
1778                if error.abs() < 0.001 {
1779                    return Ok(mid_angle);
1780                }
1781
1782                // Only use angle tolerance as convergence criterion if we have
1783                // exhausted angle precision AND height error is still acceptable
1784                // (within 10mm which is reasonable for long range)
1785                if (high_angle - low_angle).abs() < tolerance {
1786                    if error.abs() < 0.01 {
1787                        // Height error within 10mm - acceptable for practical use
1788                        return Ok(mid_angle);
1789                    }
1790                    // Angle bracket collapsed but the height error is still too large: the
1791                    // target is not actually reachable / was never bracketed. Returning
1792                    // Ok(mid_angle) here reported a NOT-zeroed angle as success (callers use
1793                    // it directly as muzzle_angle); surface it as an error instead.
1794                    return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1795                }
1796
1797                if error > 0.0 {
1798                    high_angle = mid_angle;
1799                } else {
1800                    low_angle = mid_angle;
1801                }
1802            }
1803            None => {
1804                // Trajectory didn't reach target distance, increase angle
1805                low_angle = mid_angle;
1806
1807                // MBA-193: Check angle tolerance for None case too
1808                if (high_angle - low_angle).abs() < tolerance {
1809                    return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1810                }
1811            }
1812        }
1813    }
1814
1815    Err("Failed to find zero angle".into())
1816}
1817
1818// Estimate BC from trajectory data
1819pub fn estimate_bc_from_trajectory(
1820    velocity: f64,
1821    mass: f64,
1822    diameter: f64,
1823    points: &[(f64, f64)], // (distance, drop) pairs
1824) -> Result<f64, BallisticsError> {
1825    // Simple BC estimation using least squares
1826    let mut best_bc = 0.5;
1827    let mut best_error = f64::MAX;
1828    let mut found_valid = false;
1829
1830    // Try different BC values
1831    for bc in (100..1000).step_by(10) {
1832        let bc_value = bc as f64 / 1000.0;
1833
1834        let inputs = BallisticInputs {
1835            muzzle_velocity: velocity,
1836            bc_value,
1837            bullet_mass: mass,
1838            bullet_diameter: diameter,
1839            ..Default::default()
1840        };
1841
1842        let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
1843        // Set max range for BC estimation
1844        solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
1845
1846        let result = match solver.solve() {
1847            Ok(r) => r,
1848            Err(_) => continue, // Skip this BC value if solve fails
1849        };
1850
1851        // Calculate error
1852        let mut total_error = 0.0;
1853        for (target_dist, target_drop) in points {
1854            // Find drop at this distance
1855            let mut calculated_drop = None;
1856            for i in 0..result.points.len() {
1857                if result.points[i].position.x >= *target_dist {
1858                    if i > 0 {
1859                        // Linear interpolation
1860                        let p1 = &result.points[i - 1];
1861                        let p2 = &result.points[i];
1862                        let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
1863                        calculated_drop =
1864                            Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
1865                    } else {
1866                        calculated_drop = Some(-result.points[i].position.y);
1867                    }
1868                    break;
1869                }
1870            }
1871
1872            if let Some(drop) = calculated_drop {
1873                let error = (drop - target_drop).abs();
1874                total_error += error * error;
1875            }
1876        }
1877
1878        if total_error < best_error {
1879            best_error = total_error;
1880            best_bc = bc_value;
1881            found_valid = true;
1882        }
1883    }
1884
1885    if !found_valid {
1886        return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
1887    }
1888
1889    Ok(best_bc)
1890}
1891
1892// Helper function to calculate air density
1893fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
1894    // Use the shared atmosphere model so ALL solvers agree on air density. An explicitly-set
1895    // pressure/temperature is authoritative; the previous body's extra exp(-altitude/8000) factor
1896    // double-counted altitude and ignored humidity. resolve_station_pressure / _temperature pass
1897    // explicit values through unchanged, but when either is left at its sea-level default they
1898    // return None so altitude drives BOTH the station pressure AND the lapse-rate temperature via
1899    // the ICAO standard — otherwise `--altitude` alone gave sea-level density (and, with only the
1900    // pressure derived, still 15 °C air, ~7% too thin at 3 km). Humidity (0-100) is always applied.
1901    crate::atmosphere::calculate_atmosphere(
1902        atmosphere.altitude,
1903        crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
1904        crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
1905        atmosphere.humidity,
1906    )
1907    .0
1908}
1909
1910// Add rand dependencies for Monte Carlo
1911use rand;
1912use rand_distr;
1913
1914#[cfg(test)]
1915mod ground_termination_tests {
1916    use super::*;
1917
1918    // Regression lock for the unified ground termination: solve_euler/solve_rk4/solve_rk45 all
1919    // loop while `position.y > ground_threshold` (default -100.0), so they agree with RK45. A
1920    // lofted shot that returns to launch level before reaching max_range must keep descending to
1921    // the -100 m floor instead of stopping at y = 0 — and RK4-fixed and RK45 must behave the same.
1922    #[test]
1923    fn rk4_and_rk45_descend_to_ground_threshold() {
1924        for adaptive in [false, true] {
1925            let mut inputs = BallisticInputs::default();
1926            inputs.muzzle_angle = 0.1; // ~5.7 deg: arcs up, then descends past launch level
1927            inputs.use_rk4 = true;
1928            inputs.use_adaptive_rk45 = adaptive;
1929            assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
1930
1931            let mut solver = TrajectorySolver::new(
1932                inputs,
1933                WindConditions::default(),
1934                AtmosphericConditions::default(),
1935            );
1936            // Huge max range: termination must be driven by ground_threshold, not the range cap.
1937            solver.set_max_range(1.0e7);
1938
1939            let result = solver.solve().expect("solve should succeed");
1940            let final_y = result
1941                .points
1942                .last()
1943                .expect("trajectory has points")
1944                .position
1945                .y;
1946            assert!(
1947                final_y < -1.0,
1948                "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
1949                 past launch level toward the ground_threshold floor, not stop at y = 0"
1950            );
1951        }
1952    }
1953}