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