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