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