Skip to main content

ballistics_engine/
trajectory_integration.rs

1//! Advanced trajectory integration methods (RK4, RK45)
2//!
3//! This module provides production-grade numerical integration for ballistic trajectories:
4//! - RK4: 4th-order Runge-Kutta (fixed step)
5//! - RK45: Dormand-Prince adaptive method (same as scipy.integrate.solve_ivp)
6//!
7//! MBA-155: Upstreamed from ballistics_rust for shared use
8
9use crate::gravity::Gravity;
10use nalgebra::{Vector3, Vector6};
11use std::collections::HashMap;
12
13use crate::derivatives::compute_derivatives_with_gravity;
14use crate::wind::{WindSegment, WindSegmentError};
15use crate::BallisticInputs;
16use crate::DragModel;
17
18const RK45_MIN_STEP: f64 = 1e-6;
19const RK45_DEFAULT_TOLERANCE: f64 = 1e-6;
20const RK45_SAFETY_FACTOR: f64 = 0.9;
21const RK45_MIN_SCALE: f64 = 0.1;
22const RK45_MAX_SCALE: f64 = 2.0;
23
24#[derive(Clone, Copy)]
25struct Rk45Control {
26    tolerance: f64,
27    min_step: f64,
28    max_step: f64,
29    max_trials: usize,
30}
31
32struct Rk45AcceptedStep {
33    state: Vector6<f64>,
34    used_dt: f64,
35    next_dt: f64,
36    error: f64,
37    trials: usize,
38}
39
40fn wind_vector_for_range(range_m: f64, wind_segments: &[WindSegment]) -> Vector3<f64> {
41    if range_m.is_nan() {
42        return Vector3::zeros();
43    }
44    for seg in wind_segments {
45        if range_m < seg.until_m {
46            let wind_speed_mps = seg.speed_kmh * 0.2777778; // km/h to m/s
47            let wind_angle_rad = seg.angle_deg.to_radians();
48            // MBA-728: per-segment vertical passes straight through (not derived from
49            // speed/angle), matching wind::WindSock::calc_vec.
50            return crate::wind::wind_vector(wind_speed_mps, wind_angle_rad, seg.vertical_mps);
51        }
52    }
53    Vector3::zeros()
54}
55
56/// RK4 integration step
57fn rk4_step_with_gravity(
58    state: &Vector6<f64>,
59    t: f64,
60    dt: f64,
61    params: &TrajectoryParams,
62    inputs: &BallisticInputs,
63    gravity: Gravity,
64) -> Vector6<f64> {
65    // RK4 integration
66    let k1 = compute_derivatives_vec_with_gravity(state, t, params, inputs, gravity);
67    let k2 = compute_derivatives_vec_with_gravity(
68        &(state + dt * 0.5 * k1),
69        t + dt * 0.5,
70        params,
71        inputs,
72        gravity,
73    );
74    let k3 = compute_derivatives_vec_with_gravity(
75        &(state + dt * 0.5 * k2),
76        t + dt * 0.5,
77        params,
78        inputs,
79        gravity,
80    );
81    let k4 =
82        compute_derivatives_vec_with_gravity(&(state + dt * k3), t + dt, params, inputs, gravity);
83
84    state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
85}
86
87/// Weighted RMS error for a mixed position/velocity state.
88///
89/// Each component is scaled independently so a large downrange position cannot hide an error in
90/// a near-zero lateral velocity (and vice versa). The caller's tolerance therefore acts as both
91/// an absolute and relative tolerance in each component's own unit.
92pub(crate) fn rk45_error_norm(
93    state: &Vector6<f64>,
94    fifth_order: &Vector6<f64>,
95    fourth_order: &Vector6<f64>,
96) -> f64 {
97    let scaled_error_squared: f64 = (0..6)
98        .map(|index| {
99            let scale = 1.0 + state[index].abs().max(fifth_order[index].abs());
100            ((fifth_order[index] - fourth_order[index]) / scale).powi(2)
101        })
102        .sum();
103
104    (scaled_error_squared / 6.0).sqrt()
105}
106
107/// Adaptive RK45 integration step (Dormand-Prince method)
108#[allow(clippy::too_many_arguments)]
109fn rk45_step_with_gravity(
110    state: &Vector6<f64>,
111    t: f64,
112    dt: f64,
113    params: &TrajectoryParams,
114    inputs: &BallisticInputs,
115    tol: f64,
116    gravity: Gravity,
117) -> (Vector6<f64>, f64, f64) {
118    // Dormand-Prince coefficients (same as scipy.integrate.solve_ivp RK45)
119    const A21: f64 = 1.0 / 5.0;
120    const A31: f64 = 3.0 / 40.0;
121    const A32: f64 = 9.0 / 40.0;
122    const A41: f64 = 44.0 / 45.0;
123    const A42: f64 = -56.0 / 15.0;
124    const A43: f64 = 32.0 / 9.0;
125    const A51: f64 = 19372.0 / 6561.0;
126    const A52: f64 = -25360.0 / 2187.0;
127    const A53: f64 = 64448.0 / 6561.0;
128    const A54: f64 = -212.0 / 729.0;
129    const A61: f64 = 9017.0 / 3168.0;
130    const A62: f64 = -355.0 / 33.0;
131    const A63: f64 = 46732.0 / 5247.0;
132    const A64: f64 = 49.0 / 176.0;
133    const A65: f64 = -5103.0 / 18656.0;
134    const A71: f64 = 35.0 / 384.0;
135    const A73: f64 = 500.0 / 1113.0;
136    const A74: f64 = 125.0 / 192.0;
137    const A75: f64 = -2187.0 / 6784.0;
138    const A76: f64 = 11.0 / 84.0;
139
140    // 5th order coefficients
141    const B1: f64 = 35.0 / 384.0;
142    const B3: f64 = 500.0 / 1113.0;
143    const B4: f64 = 125.0 / 192.0;
144    const B5: f64 = -2187.0 / 6784.0;
145    const B6: f64 = 11.0 / 84.0;
146
147    // 4th order coefficients (for error estimation)
148    const B1_ERR: f64 = 5179.0 / 57600.0;
149    const B3_ERR: f64 = 7571.0 / 16695.0;
150    const B4_ERR: f64 = 393.0 / 640.0;
151    const B5_ERR: f64 = -92097.0 / 339200.0;
152    const B6_ERR: f64 = 187.0 / 2100.0;
153    const B7_ERR: f64 = 1.0 / 40.0;
154
155    // Compute stages
156    let k1 = compute_derivatives_vec_with_gravity(state, t, params, inputs, gravity);
157    let k2 = compute_derivatives_vec_with_gravity(
158        &(state + dt * A21 * k1),
159        t + dt * 0.2,
160        params,
161        inputs,
162        gravity,
163    );
164    let k3 = compute_derivatives_vec_with_gravity(
165        &(state + dt * (A31 * k1 + A32 * k2)),
166        t + dt * 0.3,
167        params,
168        inputs,
169        gravity,
170    );
171    let k4 = compute_derivatives_vec_with_gravity(
172        &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
173        t + dt * 0.8,
174        params,
175        inputs,
176        gravity,
177    );
178    let k5 = compute_derivatives_vec_with_gravity(
179        &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
180        t + dt * 8.0 / 9.0,
181        params,
182        inputs,
183        gravity,
184    );
185    let k6 = compute_derivatives_vec_with_gravity(
186        &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
187        t + dt,
188        params,
189        inputs,
190        gravity,
191    );
192    let k7 = compute_derivatives_vec_with_gravity(
193        &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
194        t + dt,
195        params,
196        inputs,
197        gravity,
198    );
199
200    // 5th order solution
201    let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
202
203    // 4th order solution for error estimate
204    let y_err = state
205        + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
206
207    let error = rk45_error_norm(state, &y_new, &y_err);
208
209    // Dormand-Prince 5(4) controls both accepted and rejected trials with a fifth-root scale.
210    let step_scale = if !error.is_finite() || !tol.is_finite() || tol <= 0.0 {
211        RK45_MIN_SCALE
212    } else if error == 0.0 {
213        RK45_MAX_SCALE
214    } else {
215        (RK45_SAFETY_FACTOR * (tol / error).powf(0.2)).clamp(RK45_MIN_SCALE, RK45_MAX_SCALE)
216    };
217    let dt_new = dt * step_scale;
218
219    (y_new, dt_new, error)
220}
221
222/// Retry an RK45 step from the same state until its embedded error estimate is acceptable.
223///
224/// `Err(n)` means no finite acceptable candidate was found in `n` trials. Rejected candidates
225/// never escape this function, so callers cannot accidentally advance state or time with one.
226#[allow(clippy::too_many_arguments)]
227fn adaptive_rk45_step_with_gravity(
228    state: &Vector6<f64>,
229    t: f64,
230    initial_dt: f64,
231    params: &TrajectoryParams,
232    inputs: &BallisticInputs,
233    control: Rk45Control,
234    gravity: Gravity,
235) -> Result<Rk45AcceptedStep, usize> {
236    let mut trial_dt = initial_dt;
237
238    for trials in 1..=control.max_trials {
239        let (new_state, suggested_dt, error) = rk45_step_with_gravity(
240            state,
241            t,
242            trial_dt,
243            params,
244            inputs,
245            control.tolerance,
246            gravity,
247        );
248        let candidate_is_finite = error.is_finite()
249            && suggested_dt.is_finite()
250            && new_state.iter().all(|value| value.is_finite());
251        let next_dt = suggested_dt.min(control.max_step).max(control.min_step);
252
253        if candidate_is_finite && (error <= control.tolerance || trial_dt <= control.min_step) {
254            return Ok(Rk45AcceptedStep {
255                state: new_state,
256                used_dt: trial_dt,
257                next_dt,
258                error,
259                trials,
260            });
261        }
262
263        if trial_dt <= control.min_step {
264            return Err(trials);
265        }
266        trial_dt = next_dt;
267    }
268
269    Err(control.max_trials)
270}
271
272/// Parameters for trajectory computation
273pub struct TrajectoryParams {
274    pub mass_kg: f64,
275    pub bc: f64,
276    pub drag_model: DragModel,
277    /// Downrange wind zones, normalized by `until_distance_m` when integration begins.
278    pub wind_segments: Vec<WindSegment>,
279    /// Dual-mode atmosphere tuple consumed by `compute_derivatives`:
280    /// **Standard** `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` — note
281    /// slot 3 is a density RATIO, NOT humidity, even though it rides in the `humidity` field;
282    /// or **Direct** `(air_density, speed_of_sound, 0.0, 0.0)` — slots 2 and 3 are zero
283    /// sentinels. A pressure of 0 that is not the direct-mode sentinel disables drag.
284    pub atmos_params: (f64, f64, f64, f64),
285    /// Earth rotation in level downrange/up/lateral axes. The derivative kernel projects it into
286    /// the inclined shot frame using `shooting_angle` before applying Coriolis acceleration.
287    pub omega_vector: Option<Vector3<f64>>,
288    pub enable_spin_drift: bool,
289    pub enable_magnus: bool,
290    pub enable_coriolis: bool,
291    pub target_distance_m: f64, // Target horizontal distance in meters
292    /// Boundary-layer wind shear. Its height above the ground is the shot-frame y (the ground
293    /// runs parallel to the line of sight, as for `ground_threshold`) with y = 0 at the launch
294    /// point, taken as 1.5 m above the ground. `fast_integrate_with_segments` instead measures y
295    /// from its inputs' `muzzle_height` datum.
296    pub enable_wind_shear: bool,
297    pub wind_shear_model: String,
298    /// Site elevation above sea level, kept for API compatibility: nothing in this kernel reads
299    /// it, and boundary-layer shear is relative to the local ground.
300    pub shooter_altitude_m: f64,
301    pub is_twist_right: bool, // True for right-hand twist, false for left-hand
302    pub shooting_angle: f64,  // uphill/downhill angle in radians
303    // MBA-717: real bullet geometry so spin-drift / Magnus / stability on this fast/MC
304    // path use the actual bullet instead of hardcoded .308 / 1.24in / 10-twist placeholders.
305    pub bullet_diameter: f64,                              // meters
306    pub bullet_length: f64, // meters (0.0 -> derivatives falls back to the 4.5-caliber heuristic)
307    pub twist_rate: f64,    // inches per turn
308    pub custom_drag_table: Option<crate::drag::DragTable>, // Custom Drag Model (CDM) data
309    /// MBA-1356: whole-curve scale for the custom deck (1.0 = neutral). Threaded so the
310    /// binding entry point (fast_integrate_with_segments) cannot silently drop it.
311    pub cd_scale: f64,
312    /// Whether `custom_drag_table` is a reference curve that the BCs were measured against,
313    /// flown as `Cd / BC` with the BC the kernel resolves (a ladder's band BC, else the scalar
314    /// `bc`), rather than the projectile's own Cd over sectional density. Mirrors
315    /// `BallisticInputs::custom_drag_reference` and is threaded beside `cd_scale` for the same
316    /// reason: `build_inputs` copies it onto the derivative inputs, and a flag dropped beside
317    /// `cd_scale = 1.0` would fly a reference curve as the projectile's own Cd. `false` for a
318    /// caller with no table, or with the projectile's own curve.
319    pub custom_drag_reference: bool,
320    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-based BC segments: (mach, bc)
321    pub use_bc_segments: bool,                // Whether to use BC segment interpolation
322    /// MBA-1602: the caller's velocity-keyed BC ladder (fps bands), flown when
323    /// `use_bc_segments` is on and the ladder is non-empty. `None` or an empty ladder is no
324    /// ladder: with `use_bc_segments` on and no Mach `bc_segments`, the kernel keeps its
325    /// historical opt-in auto-estimate (MBA-955). Threaded so the binding entry point
326    /// (fast_integrate_with_segments) cannot silently drop it.
327    pub bc_segments_data: Option<Vec<crate::BCSegmentData>>,
328    /// MBA-954: altitude (m, relative to launch) below which integration stops. -1000.0 is the
329    /// historical default — effectively "no early ground impact" for normal flat-fire shots.
330    pub ground_threshold: f64,
331    /// MBA-1137: optional downrange-segmented atmosphere. When `Some`, `compute_derivatives`
332    /// swaps the standard-mode base T/P/H for the zone selected by downrange distance before the
333    /// altitude lapse. `None` (default) is byte-identical to pre-feature behavior.
334    pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
335}
336
337/// Build the loop-invariant BallisticInputs for the derivatives function ONCE per integration,
338/// instead of rebuilding it (a "none".to_string() alloc plus bc_segments / custom_drag_table
339/// clones) on every derivative evaluation (4x per RK4 step, 7x per RK45 step). The launch-speed
340/// magnitude supplies the muzzle-set Magnus spin; every other field depends only on `params`, so
341/// the struct is constant for the whole integration.
342fn build_inputs(params: &TrajectoryParams, muzzle_velocity_mps: f64) -> BallisticInputs {
343    let mut inputs = BallisticInputs {
344        bc_value: params.bc,
345        bc_type: params.drag_model,
346        // This generic RK4/RK45 kernel is fed a raw `TrajectoryParams.bc` that its callers
347        // (fast_trajectory::fast_integrate_with_segments) read directly from
348        // `BallisticInputs.bc_value` WITHOUT going through `TrajectorySolver::new` — the
349        // single normalization boundary for MBA-1365. Any ASM-reference conversion is
350        // therefore the caller's responsibility before populating `TrajectoryParams`;
351        // this constant-for-the-whole-integration struct always reports ICAO (a no-op)
352        // since it takes no reference-standard input of its own.
353        bc_reference_standard: crate::cli_api::BcReferenceStandard::Icao,
354        bullet_mass: params.mass_kg, // kg
355        muzzle_velocity: muzzle_velocity_mps,
356        bullet_diameter: params.bullet_diameter, // MBA-717: real geometry, not placeholders
357        bullet_length: params.bullet_length,
358        twist_rate: params.twist_rate,
359        is_twist_right: params.is_twist_right,
360        enable_advanced_effects: params.enable_spin_drift
361            || params.enable_magnus
362            || params.enable_coriolis,
363        enable_magnus: params.enable_magnus,
364        enable_coriolis: params.enable_coriolis,
365        altitude: params.atmos_params.0,
366        temperature: params.atmos_params.1,
367        pressure: params.atmos_params.2,
368        humidity: params.atmos_params.3,
369        tipoff_yaw: 0.0,
370        cd_delta2: 7.5,
371        target_distance: 1000.0, // default
372        muzzle_angle: 0.0,
373        wind_speed: if !params.wind_segments.is_empty() {
374            params.wind_segments[0].speed_kmh * 0.2777778 // km/h -> m/s
375        } else {
376            0.0
377        },
378        wind_angle: if !params.wind_segments.is_empty() {
379            params.wind_segments[0].angle_deg.to_radians() // degrees -> radians
380        } else {
381            0.0
382        },
383        latitude: None,
384        shooting_angle: params.shooting_angle,
385        cant_angle: 0.0,
386        azimuth_angle: 0.0,
387        shot_azimuth: 0.0, // this fast path doesn't plumb latitude/bearing (no directional Coriolis here)
388        use_powder_sensitivity: false,
389        powder_temp_sensitivity: 0.0,
390        powder_temp: 59.0,
391        powder_temp_curve: None,
392        powder_curve_temp_c: None,
393        tipoff_decay_distance: 0.0,
394        ground_threshold: params.ground_threshold, // MBA-954: honor the configured ground plane
395        bc_segments: params.bc_segments.clone(),
396        caliber_inches: params.bullet_diameter / 0.0254, // MBA-717: from real diameter
397        weight_grains: params.mass_kg / crate::constants::GRAINS_TO_KG,
398        use_bc_segments: params.use_bc_segments,
399        bullet_id: None,
400        // MBA-1602: fly the caller's ladder. An empty ladder is no ladder, so it collapses to
401        // None here and the MBA-955 pre-population below behaves exactly as before.
402        bc_segments_data: params
403            .bc_segments_data
404            .clone()
405            .filter(|segments| !segments.is_empty()),
406        use_enhanced_spin_drift: params.enable_spin_drift,
407        use_form_factor: false,
408        manufacturer: None,
409        bullet_model: None,
410        enable_wind_shear: false,
411        wind_shear_model: "none".to_string(),
412        use_cluster_bc: false,
413        bullet_cluster: None,
414        custom_drag_table: params.custom_drag_table.clone(),
415        cd_scale: params.cd_scale,
416        custom_drag_reference: params.custom_drag_reference,
417        bc_type_str: None,
418        enable_pitch_damping: false,
419        enable_precession_nutation: false,
420        // MBA-959/MBA-1183: aerodynamic jump stays OFF inside this low-level raw-state integrator.
421        // The high-level fast wrappers form Sg from their complete BallisticInputs and rotate the
422        // prebuilt initial velocity before entering their integration loops; enabling it again
423        // here would double-apply the launch perturbation. Direct low-level callers likewise own
424        // any desired launch-state rotation. (Real geometry is still carried for spin/Magnus.)
425        enable_aerodynamic_jump: false,
426        use_rk4: true,
427        use_adaptive_rk45: false,
428        enable_trajectory_sampling: false,
429        sample_interval: 10.0,
430        sight_height: 0.0,
431        // MBA-1396: like cant_angle above, the fast kernel does not plumb sight-mount
432        // geometry — callers own the initial lateral state.
433        sight_offset_lateral_m: 0.0,
434        muzzle_height: 0.0,
435        target_height: 0.0,
436        // MBA-1359: zero-POI offsets bias a ZERO SOLVE; this low-level kernel never solves
437        // a zero (callers hand it a finished launch state), so they are structurally inert
438        // here — same reasoning as cant_angle/shot_azimuth above.
439        zero_poi_vertical_m: 0.0,
440        zero_poi_horizontal_m: 0.0,
441        // MBA-1403: an output-mode toggle for the trajectory sampler; this kernel never
442        // runs trajectory sampling (enable_trajectory_sampling is false above), so the
443        // default LOS reference is structurally inert here.
444        drops_reference: crate::cli_api::DropsReference::Los,
445    };
446
447    // MBA-955: pre-populate velocity-BC segments ONCE here, instead of get_bc_for_velocity
448    // rebuilding them (a model String + a segment Vec) on every derivative evaluation (4-7x per
449    // step). Gated to EXACTLY the case where the per-step path would estimate: use_bc_segments on,
450    // no explicit velocity segments, and no Mach-based bc_segments (those take a different,
451    // unchanged path). bc_used there == params.bc == inputs.bc_value, so the estimated segments are
452    // identical and the per-step fast-path lookup returns the same BC -> byte-identical output.
453    if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
454        inputs.bc_segments_data =
455            crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
456    }
457    inputs
458}
459
460/// Convert state to Vector6 and call compute_derivatives
461fn compute_derivatives_vec_with_gravity(
462    state: &Vector6<f64>,
463    t: f64,
464    params: &TrajectoryParams,
465    inputs: &BallisticInputs,
466    gravity: Gravity,
467) -> Vector6<f64> {
468    let pos = Vector3::new(state[0], state[1], state[2]);
469    let vel = Vector3::new(state[3], state[4], state[5]);
470
471    // Calculate wind at current position with shear support. Boundary-layer shear reads the
472    // height above the ground: the shot-frame y, plus 0 when y is measured from the ground the
473    // caller's `muzzle_height` was measured from, or the assumed bore height when y = 0 is the
474    // launch point (`inputs.muzzle_height` 0, which every direct TrajectoryParams caller gets;
475    // see integrate_trajectory_with_gravity). The ground is the one the ground_threshold check
476    // uses, parallel to the line of sight, so the shooting angle does not enter. Site elevation
477    // (`shooter_altitude_m`) is MSL and does not enter shear.
478    let wind_vector = if !params.wind_segments.is_empty() {
479        if params.enable_wind_shear && params.wind_shear_model != "none" {
480            crate::wind_shear::apply_boundary_layer_shear_agl(
481                wind_vector_for_range(pos.x, &params.wind_segments),
482                crate::wind_shear::shear_height_agl_m(pos.y, inputs.muzzle_height),
483                crate::wind_shear::boundary_layer_model_from_name(&params.wind_shear_model),
484            )
485        } else {
486            wind_vector_for_range(pos.x, &params.wind_segments)
487        }
488    } else {
489        Vector3::zeros()
490    };
491
492    // Call compute_derivatives - returns [f64; 6] directly. `inputs` is built once per
493    // integration by build_inputs() and threaded in, instead of rebuilt every call.
494    let deriv_result = compute_derivatives_with_gravity(
495        pos,
496        vel,
497        inputs,
498        wind_vector,
499        params.atmos_params,
500        params.bc,
501        params.omega_vector,
502        t,
503        params.atmo_sock.as_ref(),
504        gravity,
505    );
506
507    Vector6::new(
508        deriv_result[0],
509        deriv_result[1],
510        deriv_result[2],
511        deriv_result[3],
512        deriv_result[4],
513        deriv_result[5],
514    )
515}
516
517/// Linearly localize a target crossing within an accepted forward integration step.
518///
519/// Callers provide a bracket with `start[0] <= target_x <= end[0]` and increasing downrange X.
520/// The same crossing fraction is applied to time and every phase-space component; X is then set
521/// exactly to the public target value to remove interpolation roundoff.
522fn interpolate_target_crossing(
523    start_time: f64,
524    start: &Vector6<f64>,
525    step_dt: f64,
526    end: &Vector6<f64>,
527    target_x: f64,
528) -> (f64, Vector6<f64>) {
529    debug_assert!(start[0] <= target_x && target_x <= end[0] && end[0] > start[0]);
530
531    let alpha = (target_x - start[0]) / (end[0] - start[0]);
532    let crossing_time = start_time + alpha * step_dt;
533    let mut crossing_state = start + alpha * (end - start);
534    crossing_state[0] = target_x;
535
536    (crossing_time, crossing_state)
537}
538
539/// Checked sibling of [`integrate_trajectory`] (MBA-1338): rejects malformed wind
540/// segments with a typed [`WindSegmentError`] **before** sorting, vector precomputation,
541/// or producing any trajectory points, so a caller can never receive a poisoned
542/// trajectory from non-finite segment fields. The error's `index` refers to the
543/// caller's own segment ordering.
544pub fn try_integrate_trajectory(
545    initial_state: [f64; 6],
546    t_span: (f64, f64),
547    params: TrajectoryParams,
548    method: &str,
549    tolerance: f64,
550    max_step: f64,
551) -> Result<Vec<(f64, Vector6<f64>)>, WindSegmentError> {
552    crate::wind::validate_wind_segments(&params.wind_segments)?;
553    Ok(integrate_trajectory(
554        initial_state,
555        t_span,
556        params,
557        method,
558        tolerance,
559        max_step,
560    ))
561}
562
563/// Main trajectory integration function
564///
565/// **Legacy/unchecked entry point** (MBA-1338): wind segments are sorted and consumed
566/// without validation, so non-finite fields silently poison the returned trajectory.
567/// Prefer [`try_integrate_trajectory`], which rejects malformed segments with a typed
568/// error before any integration work.
569pub fn integrate_trajectory(
570    initial_state: [f64; 6],
571    t_span: (f64, f64),
572    params: TrajectoryParams,
573    method: &str,
574    tolerance: f64,
575    max_step: f64,
576) -> Vec<(f64, Vector6<f64>)> {
577    integrate_trajectory_with_gravity(
578        initial_state,
579        t_span,
580        params,
581        method,
582        tolerance,
583        max_step,
584        Gravity::EARTH,
585    )
586}
587
588/// [`integrate_trajectory`] with an explicit gravity. `initial_state`'s y = 0 is the launch
589/// point, as for every direct [`TrajectoryParams`] caller; see
590/// [`integrate_trajectory_from_muzzle_height`] for a caller whose y is measured from the
591/// ground.
592#[allow(clippy::too_many_arguments)]
593pub(crate) fn integrate_trajectory_with_gravity(
594    initial_state: [f64; 6],
595    t_span: (f64, f64),
596    params: TrajectoryParams,
597    method: &str,
598    tolerance: f64,
599    max_step: f64,
600    gravity: Gravity,
601) -> Vec<(f64, Vector6<f64>)> {
602    integrate_trajectory_from_muzzle_height(
603        initial_state,
604        t_span,
605        params,
606        method,
607        tolerance,
608        max_step,
609        gravity,
610        0.0,
611    )
612}
613
614/// [`integrate_trajectory_with_gravity`] for a launch state whose y is measured from the level
615/// `muzzle_height_m` is measured from, as `BallisticInputs::muzzle_height` states it: the
616/// ground when it is nonzero, the launch point (the bore) when it is 0. Only boundary-layer
617/// shear reads it, to measure the height above the ground; this kernel used to add an assumed
618/// 1.5 m bore height to every y, which counted a real bore height twice.
619#[allow(clippy::too_many_arguments)]
620pub(crate) fn integrate_trajectory_from_muzzle_height(
621    initial_state: [f64; 6],
622    t_span: (f64, f64),
623    mut params: TrajectoryParams,
624    method: &str,
625    tolerance: f64,
626    max_step: f64,
627    gravity: Gravity,
628    muzzle_height_m: f64,
629) -> Vec<(f64, Vector6<f64>)> {
630    // Normalize once before build_inputs reads the first zone and before any RK stage performs a
631    // first-match lookup. Callers may supply zones in any order.
632    crate::wind::sort_wind_segments_by_distance(&mut params.wind_segments);
633
634    let mut state = Vector6::new(
635        initial_state[0],
636        initial_state[1],
637        initial_state[2],
638        initial_state[3],
639        initial_state[4],
640        initial_state[5],
641    );
642
643    let mut t = t_span.0;
644    let t_end = t_span.1;
645    let mut dt = (t_end - t) / 1000.0; // Initial step size
646
647    let mut trajectory = Vec::with_capacity(10000);
648    trajectory.push((t, state));
649    if state[0] >= params.target_distance_m {
650        return trajectory;
651    }
652
653    // Build the (loop-invariant) derivative inputs once for the whole integration, instead of
654    // rebuilding the struct on every derivative evaluation.
655    let muzzle_velocity_mps =
656        Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
657    let mut inputs = build_inputs(&params, muzzle_velocity_mps);
658    // The frame datum is the caller's, not a TrajectoryParams field (adding one would break
659    // every struct-literal constructor). The derivative model ignores muzzle_height; only the
660    // shear height in compute_derivatives_vec_with_gravity reads it.
661    inputs.muzzle_height = muzzle_height_m;
662
663    match method {
664        "RK4" => {
665            // Fixed step RK4 with target detection
666            dt = dt.min(max_step).min(0.001); // Use smaller steps for accuracy
667
668            while t < t_end {
669                if t + dt > t_end {
670                    dt = t_end - t;
671                }
672
673                let new_state = rk4_step_with_gravity(&state, t, dt, &params, &inputs, gravity);
674
675                // Check if we're about to pass the target (X is downrange, McCoy)
676                if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
677                    trajectory.push(interpolate_target_crossing(
678                        t,
679                        &state,
680                        dt,
681                        &new_state,
682                        params.target_distance_m,
683                    ));
684                    break; // Stop at target
685                }
686
687                state = new_state;
688                t += dt;
689                trajectory.push((t, state));
690
691                // Check if we've reached or passed the target
692                if state[0] >= params.target_distance_m {
693                    break;
694                }
695
696                // Check if bullet hit ground (MBA-954: honor the configured ground plane,
697                // not a hardcoded -1000.0)
698                if state[1] < params.ground_threshold {
699                    break;
700                }
701            }
702        }
703        _ => {
704            // Adaptive RK45 with better sampling
705            let mut last_save_x = 0.0; // X is downrange (McCoy)
706            let save_interval_m = params.target_distance_m / 50.0; // Save ~50 points minimum
707            let tolerance = if tolerance.is_finite() && tolerance > 0.0 {
708                tolerance
709            } else {
710                eprintln!(
711                    "WARNING: RK45 tolerance must be finite and positive; using {RK45_DEFAULT_TOLERANCE}"
712                );
713                RK45_DEFAULT_TOLERANCE
714            };
715
716            // OPTIMIZATION: Adjust max step size when wind shear is enabled
717            // This improves numerical stability at long ranges
718            let effective_max_step =
719                if params.enable_wind_shear && params.wind_shear_model != "none" {
720                    // Use smaller steps for wind shear, but not TOO small
721                    if params.target_distance_m > 800.0 {
722                        0.01 // Smaller steps for long range with shear (10ms)
723                    } else {
724                        0.02 // Normal steps for medium range with shear (20ms)
725                    }
726                } else {
727                    max_step // Use provided max_step when no wind shear
728                };
729            if !effective_max_step.is_finite() || effective_max_step <= 0.0 {
730                eprintln!("WARNING: RK45 max_step must be finite and positive");
731                return trajectory;
732            }
733            let min_step = RK45_MIN_STEP.min(effective_max_step);
734
735            // Set initial step size - ensure it's reasonable
736            dt = dt.min(effective_max_step).max(min_step);
737
738            // Safety check: maximum iterations to prevent infinite loops
739            let max_iterations = 100000; // Should be more than enough for any realistic trajectory
740            let mut iteration_count = 0;
741
742            while t < t_end && iteration_count < max_iterations {
743                // Limit time step for better resolution
744                if t + dt > t_end {
745                    dt = t_end - t;
746                }
747
748                let control = Rk45Control {
749                    tolerance,
750                    min_step,
751                    max_step: effective_max_step,
752                    max_trials: max_iterations - iteration_count,
753                };
754                let accepted = match adaptive_rk45_step_with_gravity(
755                    &state, t, dt, &params, &inputs, control, gravity,
756                ) {
757                    Ok(accepted) => accepted,
758                    Err(trials) => {
759                        iteration_count += trials;
760                        if iteration_count < max_iterations {
761                            eprintln!("WARNING: RK45 minimum-step trial was non-finite");
762                        }
763                        break;
764                    }
765                };
766                iteration_count += accepted.trials;
767                debug_assert!(accepted.error <= tolerance || accepted.used_dt <= min_step);
768
769                // Target detection only examines an accepted candidate.
770                if state[0] < params.target_distance_m
771                    && accepted.state[0] >= params.target_distance_m
772                {
773                    trajectory.push(interpolate_target_crossing(
774                        t,
775                        &state,
776                        accepted.used_dt,
777                        &accepted.state,
778                        params.target_distance_m,
779                    ));
780                    break;
781                }
782
783                // Update state and time using the interval that actually passed acceptance.
784                state = accepted.state;
785                t += accepted.used_dt;
786
787                // Save trajectory point if we've moved enough distance
788                if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
789                {
790                    // X is downrange
791                    trajectory.push((t, state));
792                    last_save_x = state[0];
793                }
794
795                // Limit the proposal for the next trial; this does not change the time just used.
796                dt = accepted.next_dt;
797
798                // Stop if we've reached the target
799                if state[0] >= params.target_distance_m {
800                    break;
801                }
802
803                // Check if bullet hit ground (MBA-954: honor the configured ground plane,
804                // not a hardcoded -1000.0)
805                if state[1] < params.ground_threshold {
806                    break;
807                }
808            }
809
810            // Warn if we hit the iteration limit
811            if iteration_count >= max_iterations
812                && t < t_end
813                && state[0] < params.target_distance_m
814                && state[1] >= params.ground_threshold
815            {
816                eprintln!(
817                    "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
818                    max_iterations
819                );
820                eprintln!("  Final time: {}, Target time: {}", t, t_end);
821                eprintln!(
822                    "  Final position: downrange(x)={}, Target: {}m",
823                    state[0], params.target_distance_m
824                );
825            }
826        }
827    }
828
829    trajectory
830}
831
832/// Checked sibling of [`solve_trajectory_rust`] (MBA-1338): rejects malformed wind
833/// segments with a typed [`WindSegmentError`] before any integration work or trajectory
834/// points are produced. Bindings should migrate to this entry point so malformed
835/// segments surface as a structured error instead of a silently poisoned trajectory.
836#[allow(clippy::too_many_arguments)] // Mirrors the binding-compatibility signature below.
837pub fn try_solve_trajectory_rust(
838    initial_state: [f64; 6],
839    t_span: (f64, f64),
840    mass_kg: f64,
841    bc: f64,
842    drag_model: DragModel,
843    wind_segments: Vec<WindSegment>,
844    atmos_params: (f64, f64, f64, f64),
845    omega_vector: Option<Vec<f64>>,
846    enable_spin_drift: bool,
847    enable_magnus: bool,
848    enable_coriolis: bool,
849    method: String,
850    tolerance: f64,
851    max_step: f64,
852    target_distance_m: f64,
853) -> Result<Vec<HashMap<String, f64>>, WindSegmentError> {
854    crate::wind::validate_wind_segments(&wind_segments)?;
855    Ok(solve_trajectory_rust(
856        initial_state,
857        t_span,
858        mass_kg,
859        bc,
860        drag_model,
861        wind_segments,
862        atmos_params,
863        omega_vector,
864        enable_spin_drift,
865        enable_magnus,
866        enable_coriolis,
867        method,
868        tolerance,
869        max_step,
870        target_distance_m,
871    ))
872}
873
874/// Python-exposed function for complete trajectory integration
875///
876/// **Legacy/unchecked entry point** (MBA-1338): consumes wind segments without
877/// validation. Prefer [`try_solve_trajectory_rust`].
878#[allow(clippy::too_many_arguments)] // Binding compatibility API; grouping would be breaking.
879pub fn solve_trajectory_rust(
880    initial_state: [f64; 6],
881    t_span: (f64, f64),
882    mass_kg: f64,
883    bc: f64,
884    drag_model: DragModel,
885    wind_segments: Vec<WindSegment>,
886    atmos_params: (f64, f64, f64, f64),
887    omega_vector: Option<Vec<f64>>,
888    enable_spin_drift: bool,
889    enable_magnus: bool,
890    enable_coriolis: bool,
891    method: String,
892    tolerance: f64,
893    max_step: f64,
894    target_distance_m: f64,
895) -> Vec<HashMap<String, f64>> {
896    let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
897
898    let params = TrajectoryParams {
899        mass_kg,
900        bc,
901        drag_model,
902        wind_segments,
903        atmos_params,
904        omega_vector: omega_vec,
905        enable_spin_drift,
906        enable_magnus,
907        enable_coriolis,
908        target_distance_m,
909        enable_wind_shear: false, // Default for test function
910        wind_shear_model: "none".to_string(),
911        shooter_altitude_m: 0.0,
912        is_twist_right: true, // Default for test function
913        shooting_angle: 0.0,  // This legacy entry takes no inclined-fire arg; flat fire only
914        // This legacy entry takes no geometry args; keep the historical placeholders so its
915        // behavior is unchanged (callers needing real geometry use fast_integrate_with_segments).
916        bullet_diameter: 0.0078232,
917        bullet_length: 0.031496,
918        twist_rate: 10.0,
919        custom_drag_table: None, // No CDM for test function
920        cd_scale: 1.0,
921        custom_drag_reference: false,
922        bc_segments: None, // No BC segments for legacy function
923        use_bc_segments: false,
924        bc_segments_data: None,
925        ground_threshold: -1000.0, // MBA-954: preserve the historical default
926        atmo_sock: None,           // MBA-1137: legacy entry has no downrange atmosphere
927    };
928
929    let trajectory =
930        integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
931
932    // Convert to Python-friendly format
933    trajectory
934        .into_iter()
935        .map(|(t, state)| {
936            let mut point = HashMap::new();
937            point.insert("t".to_string(), t);
938            point.insert("x".to_string(), state[0]);
939            point.insert("y".to_string(), state[1]);
940            point.insert("z".to_string(), state[2]);
941            point.insert("vx".to_string(), state[3]);
942            point.insert("vy".to_string(), state[4]);
943            point.insert("vz".to_string(), state[5]);
944            point
945        })
946        .collect()
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952
953    #[test]
954    fn uniform_gravity_integrators_match_a_ball_in_near_vacuum() {
955        let initial = [0.0, 10.0, 0.0, 10.0, 2.0, 0.0];
956        for method in ["RK4", "RK45"] {
957            for magnitude in [0.0, 1.62, 9.80665, 20.0] {
958                let mut params = create_test_params(10.0);
959                params.mass_kg = 0.0459;
960                params.bullet_diameter = 0.0427;
961                params.bullet_length = 0.0427;
962                params.atmos_params = (1e-12, 340.0, 0.0, 0.0);
963                let result = integrate_trajectory_with_gravity(
964                    initial,
965                    (0.0, 2.0),
966                    params,
967                    method,
968                    1e-8,
969                    0.001,
970                    Gravity::new(magnitude).unwrap(),
971                );
972                let (time, state) = result.last().unwrap();
973                assert!((time - 1.0).abs() < 1e-8);
974                assert!(
975                    (state[1] - (10.0 + 2.0 * time - 0.5 * magnitude * time * time)).abs() < 1e-5
976                );
977                assert!((state[4] - (2.0 - magnitude * time)).abs() < 1e-8);
978            }
979        }
980    }
981
982    fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
983        TrajectoryParams {
984            mass_kg: 0.01134, // 175 grains in kg
985            bc: 0.442,
986            bullet_diameter: 0.0078232, // .308 in
987            bullet_length: 0.031496,    // 1.24 in
988            twist_rate: 10.0,
989            drag_model: DragModel::G7,
990            wind_segments: vec![],
991            atmos_params: (0.0, 15.0, 1013.25, 1.0),
992            omega_vector: None,
993            enable_spin_drift: false,
994            enable_magnus: false,
995            enable_coriolis: false,
996            target_distance_m,
997            enable_wind_shear: false,
998            wind_shear_model: "none".to_string(),
999            shooter_altitude_m: 0.0,
1000            is_twist_right: true,
1001            shooting_angle: 0.0,
1002            custom_drag_table: None,
1003            cd_scale: 1.0,
1004            custom_drag_reference: false,
1005            bc_segments: None,
1006            use_bc_segments: false,
1007            bc_segments_data: None,
1008            ground_threshold: -1000.0,
1009            atmo_sock: None,
1010        }
1011    }
1012
1013    /// `build_inputs` is where the derivative kernel's inputs are rebuilt from the params, so a
1014    /// field it forgets is silently dropped (MBA-1356). Both custom-table fields arrive as set.
1015    #[test]
1016    fn build_inputs_carries_the_custom_table_fields() {
1017        for (cd_scale, reference) in [(1.0, false), (1.25, false), (1.0, true), (0.8, true)] {
1018            let params = TrajectoryParams {
1019                custom_drag_table: Some(crate::drag::DragTable::new(
1020                    vec![0.5, 3.0],
1021                    vec![0.2, 0.3],
1022                )),
1023                cd_scale,
1024                custom_drag_reference: reference,
1025                ..create_test_params(300.0)
1026            };
1027            let inputs = build_inputs(&params, 800.0);
1028            assert!(inputs.custom_drag_table.is_some());
1029            assert_eq!(inputs.cd_scale, cd_scale);
1030            assert_eq!(inputs.custom_drag_reference, reference);
1031        }
1032    }
1033
1034    #[test]
1035    fn try_integrate_trajectory_rejects_malformed_segments_before_any_points() {
1036        // MBA-1338: a poisoned segment (index 1, caller order) must yield the typed error
1037        // and NO trajectory points — validation precedes sorting and integration.
1038        let mut params = create_test_params(300.0);
1039        params.wind_segments = vec![
1040            WindSegment::new(10.0, 90.0, 200.0),
1041            WindSegment::new(10.0, 90.0, f64::NAN),
1042        ];
1043        let err = try_integrate_trajectory(
1044            [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1045            (0.0, 2.0),
1046            params,
1047            "RK4",
1048            1e-6,
1049            0.001,
1050        )
1051        .unwrap_err();
1052        assert_eq!(err.index, 1);
1053        assert_eq!(err.field, crate::wind::WindSegmentField::UntilM);
1054        assert_eq!(
1055            err.to_string(),
1056            "wind.segments[1].until_m must be finite and greater than zero"
1057        );
1058    }
1059
1060    #[test]
1061    fn try_integrate_trajectory_matches_unchecked_on_valid_input() {
1062        let mk = || {
1063            let mut params = create_test_params(300.0);
1064            params.wind_segments = vec![WindSegment::new(16.0934, 90.0, 500.0)];
1065            params
1066        };
1067        let checked = try_integrate_trajectory(
1068            [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1069            (0.0, 2.0),
1070            mk(),
1071            "RK4",
1072            1e-6,
1073            0.001,
1074        )
1075        .expect("valid segments must integrate");
1076        let unchecked = integrate_trajectory(
1077            [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1078            (0.0, 2.0),
1079            mk(),
1080            "RK4",
1081            1e-6,
1082            0.001,
1083        );
1084        assert_eq!(checked.len(), unchecked.len());
1085        assert_eq!(checked.last().unwrap().1, unchecked.last().unwrap().1);
1086    }
1087
1088    #[test]
1089    fn try_solve_trajectory_rust_rejects_malformed_segments() {
1090        let bad = vec![WindSegment::new(-5.0, 0.0, 100.0)];
1091        let err = try_solve_trajectory_rust(
1092            [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1093            (0.0, 2.0),
1094            0.01134,
1095            0.442,
1096            DragModel::G7,
1097            bad,
1098            (0.0, 15.0, 1013.25, 1.0),
1099            None,
1100            false,
1101            false,
1102            false,
1103            "RK4".to_string(),
1104            1e-6,
1105            0.001,
1106            300.0,
1107        )
1108        .unwrap_err();
1109        assert_eq!(err.index, 0);
1110        assert_eq!(err.field, crate::wind::WindSegmentField::SpeedKmh);
1111        assert_eq!(
1112            err.to_string(),
1113            "wind.segments[0].speed_kmh must be finite and non-negative"
1114        );
1115    }
1116
1117    #[test]
1118    fn try_solve_trajectory_rust_succeeds_on_valid_segments() {
1119        let points = try_solve_trajectory_rust(
1120            [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
1121            (0.0, 2.0),
1122            0.01134,
1123            0.442,
1124            DragModel::G7,
1125            vec![WindSegment::new(16.0934, 90.0, 500.0)],
1126            (0.0, 15.0, 1013.25, 1.0),
1127            None,
1128            false,
1129            false,
1130            false,
1131            "RK4".to_string(),
1132            1e-6,
1133            0.001,
1134            300.0,
1135        )
1136        .expect("valid segments must solve");
1137        assert!(!points.is_empty());
1138        assert!(points.last().unwrap()["x"] > 0.0);
1139    }
1140
1141    #[test]
1142    fn derivative_inputs_preserve_initial_velocity_as_muzzle_speed() {
1143        let params = create_test_params(1_000.0);
1144        let launch_velocity: Vector3<f64> = Vector3::new(700.0, 30.0, -20.0);
1145        let inputs = build_inputs(&params, launch_velocity.norm());
1146
1147        assert_eq!(
1148            inputs.muzzle_velocity.to_bits(),
1149            launch_velocity.norm().to_bits()
1150        );
1151    }
1152
1153    #[test]
1154    fn integrated_magnus_retains_nonzero_launch_spin() {
1155        let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1156        let baseline = integrate_trajectory(
1157            initial_state,
1158            (0.0, 0.1),
1159            create_test_params(1_000.0),
1160            "RK4",
1161            1e-6,
1162            0.001,
1163        );
1164        let mut magnus_params = create_test_params(1_000.0);
1165        magnus_params.enable_magnus = true;
1166
1167        let trajectory =
1168            integrate_trajectory(initial_state, (0.0, 0.1), magnus_params, "RK4", 1e-6, 0.001);
1169        let baseline_y = baseline.last().expect("baseline trajectory is empty").1[1];
1170        let magnus_y = trajectory.last().expect("trajectory is empty").1[1];
1171        let vertical_delta = magnus_y - baseline_y;
1172
1173        assert!(
1174            vertical_delta.is_finite() && vertical_delta < 0.0,
1175            "right-twist Magnus should retain nonzero launch spin and point down, got \
1176             delta_y={vertical_delta}"
1177        );
1178    }
1179
1180    #[test]
1181    fn rk45_retries_rejected_wind_boundary_step() {
1182        let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1183        let mut params = create_test_params(100.0);
1184        params.wind_segments = vec![
1185            WindSegment::new(0.0, 90.0, 4.0),
1186            WindSegment::new(1_000.0, 90.0, 10_000.0),
1187        ];
1188
1189        let state = Vector6::from_row_slice(&initial_state);
1190        let launch_speed =
1191            Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
1192        let inputs = build_inputs(&params, launch_speed);
1193        let initial_dt = 0.01;
1194        let tolerance = 1e-6;
1195        let (rejected_state, suggested_dt, error) = rk45_step_with_gravity(
1196            &state,
1197            0.0,
1198            initial_dt,
1199            &params,
1200            &inputs,
1201            tolerance,
1202            Gravity::EARTH,
1203        );
1204        assert!(
1205            error > tolerance,
1206            "wind-boundary trial must exceed tolerance, got {error}"
1207        );
1208        assert!(suggested_dt < initial_dt);
1209
1210        let accepted = adaptive_rk45_step_with_gravity(
1211            &state,
1212            0.0,
1213            initial_dt,
1214            &params,
1215            &inputs,
1216            Rk45Control {
1217                tolerance,
1218                min_step: RK45_MIN_STEP,
1219                max_step: initial_dt,
1220                max_trials: 100,
1221            },
1222            Gravity::EARTH,
1223        )
1224        .expect("a smaller finite trial should satisfy the tolerance");
1225
1226        assert!(accepted.trials > 1, "oversized trial was not retried");
1227        assert!(accepted.used_dt < initial_dt);
1228        assert!(
1229            accepted.error <= tolerance || accepted.used_dt <= RK45_MIN_STEP,
1230            "accepted error {} exceeds tolerance at dt {}",
1231            accepted.error,
1232            accepted.used_dt
1233        );
1234
1235        let (accepted_state, _, accepted_error) = rk45_step_with_gravity(
1236            &state,
1237            0.0,
1238            accepted.used_dt,
1239            &params,
1240            &inputs,
1241            tolerance,
1242            Gravity::EARTH,
1243        );
1244        assert_eq!(accepted.state, accepted_state);
1245        assert_eq!(accepted.error, accepted_error);
1246        assert_ne!(accepted.state, rejected_state);
1247        assert!((RK45_MIN_STEP..=initial_dt).contains(&accepted.next_dt));
1248    }
1249
1250    #[test]
1251    fn integration_normalizes_wind_segments_by_distance() {
1252        let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1253        let sorted_segments = vec![
1254            WindSegment::new(40.0, 270.0, 300.0),
1255            WindSegment::new(20.0, 90.0, 600.0),
1256        ];
1257
1258        let mut sorted_params = create_test_params(100.0);
1259        sorted_params.wind_segments = sorted_segments.clone();
1260        let mut unsorted_params = create_test_params(100.0);
1261        unsorted_params.wind_segments = sorted_segments.into_iter().rev().collect();
1262
1263        let sorted =
1264            integrate_trajectory(initial_state, (0.0, 1.0), sorted_params, "RK4", 1e-6, 0.001);
1265        let unsorted = integrate_trajectory(
1266            initial_state,
1267            (0.0, 1.0),
1268            unsorted_params,
1269            "RK4",
1270            1e-6,
1271            0.001,
1272        );
1273
1274        assert_eq!(unsorted.len(), sorted.len());
1275        for (index, ((sorted_t, sorted_state), (unsorted_t, unsorted_state))) in
1276            sorted.iter().zip(&unsorted).enumerate()
1277        {
1278            assert_eq!(unsorted_t.to_bits(), sorted_t.to_bits());
1279            for component in 0..6 {
1280                assert_eq!(
1281                    unsorted_state[component].to_bits(),
1282                    sorted_state[component].to_bits(),
1283                    "wind segment order changed state component {component} at point {index}"
1284                );
1285            }
1286        }
1287    }
1288
1289    #[test]
1290    fn rk4_target_crossing_interpolates_complete_state_and_time() {
1291        let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1292        let target_distance_m = 100.0;
1293        let trajectory = integrate_trajectory(
1294            initial_state,
1295            (0.0, 1.0),
1296            create_test_params(target_distance_m),
1297            "RK4",
1298            1e-6,
1299            0.001,
1300        );
1301
1302        let (previous_t, previous_state) = &trajectory[trajectory.len() - 2];
1303        let (terminal_t, terminal_state) = trajectory.last().expect("trajectory is empty");
1304        let reference_params = create_test_params(target_distance_m);
1305        let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1306        let full_step_dt = 0.001;
1307        let bracket_end = rk4_step_with_gravity(
1308            previous_state,
1309            *previous_t,
1310            full_step_dt,
1311            &reference_params,
1312            &inputs,
1313            Gravity::EARTH,
1314        );
1315        assert!(previous_state[0] < target_distance_m);
1316        assert!(bracket_end[0] >= target_distance_m);
1317
1318        let alpha = (target_distance_m - previous_state[0]) / (bracket_end[0] - previous_state[0]);
1319        let expected_t = previous_t + alpha * full_step_dt;
1320        let mut expected_state = previous_state + alpha * (bracket_end - previous_state);
1321        expected_state[0] = target_distance_m;
1322
1323        assert_eq!(terminal_t.to_bits(), expected_t.to_bits());
1324        for component in 0..6 {
1325            assert_eq!(
1326                terminal_state[component].to_bits(),
1327                expected_state[component].to_bits(),
1328                "terminal component {component} was not interpolated at the target crossing"
1329            );
1330        }
1331    }
1332
1333    #[test]
1334    fn rk45_target_crossing_uses_the_accepted_state_and_time() {
1335        let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
1336        let initial = Vector6::from_row_slice(&initial_state);
1337        let target_distance_m = 0.5;
1338        let reference_params = create_test_params(target_distance_m);
1339        let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
1340        let initial_dt = 0.001;
1341        let accepted = adaptive_rk45_step_with_gravity(
1342            &initial,
1343            0.0,
1344            initial_dt,
1345            &reference_params,
1346            &inputs,
1347            Rk45Control {
1348                tolerance: 1e-6,
1349                min_step: RK45_MIN_STEP,
1350                max_step: 0.01,
1351                max_trials: 100_000,
1352            },
1353            Gravity::EARTH,
1354        )
1355        .expect("first RK45 target bracket should be accepted");
1356        assert!(accepted.state[0] >= target_distance_m);
1357        let expected = interpolate_target_crossing(
1358            0.0,
1359            &initial,
1360            accepted.used_dt,
1361            &accepted.state,
1362            target_distance_m,
1363        );
1364
1365        let trajectory = integrate_trajectory(
1366            initial_state,
1367            (0.0, 1.0),
1368            create_test_params(target_distance_m),
1369            "RK45",
1370            1e-6,
1371            0.01,
1372        );
1373        let actual = trajectory.last().expect("trajectory is empty");
1374
1375        assert_eq!(actual.0.to_bits(), expected.0.to_bits());
1376        for component in 0..6 {
1377            assert_eq!(
1378                actual.1[component].to_bits(),
1379                expected.1[component].to_bits(),
1380                "RK45 terminal component {component} was not interpolated from its accepted step"
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn target_crossing_helper_interpolates_every_component() {
1387        let start = Vector6::new(90.0, 10.0, -4.0, 700.0, -20.0, 5.0);
1388        let end = Vector6::new(130.0, 6.0, 8.0, 660.0, -24.0, 9.0);
1389        let (time, state) = interpolate_target_crossing(2.0, &start, 0.5, &end, 100.0);
1390
1391        assert_eq!(time.to_bits(), 2.125_f64.to_bits());
1392        for (index, expected) in [100.0_f64, 9.0, -1.0, 690.0, -21.0, 6.0]
1393            .into_iter()
1394            .enumerate()
1395        {
1396            assert_eq!(state[index].to_bits(), expected.to_bits());
1397        }
1398    }
1399
1400    #[test]
1401    fn already_at_or_past_target_returns_initial_state_without_advancing() {
1402        let initial = [150.0, 12.0, -3.0, 700.0, -4.0, 5.0];
1403
1404        for method in ["RK4", "RK45"] {
1405            for target in [150.0, 100.0] {
1406                let trajectory = integrate_trajectory(
1407                    initial,
1408                    (2.0, 3.0),
1409                    create_test_params(target),
1410                    method,
1411                    1e-6,
1412                    0.01,
1413                );
1414
1415                assert_eq!(trajectory.len(), 1, "{method} advanced a terminal state");
1416                let (time, state) = &trajectory[0];
1417                assert_eq!(time.to_bits(), 2.0_f64.to_bits());
1418                for index in 0..6 {
1419                    assert_eq!(state[index].to_bits(), initial[index].to_bits());
1420                }
1421            }
1422        }
1423    }
1424
1425    #[test]
1426    fn rk45_error_norm_scales_components_independently() {
1427        let state = Vector6::new(1.0e9, 0.0, 0.0, 800.0, 0.0, 0.0);
1428        let fifth_order = state;
1429        let mut fourth_order = state;
1430        fourth_order[4] = 1.0e-3;
1431
1432        let error = rk45_error_norm(&state, &fifth_order, &fourth_order);
1433        let expected = 1.0e-3 / 6.0_f64.sqrt();
1434
1435        assert!(
1436            (error - expected).abs() <= 1e-15,
1437            "large downrange position masked a velocity-component error: {error}"
1438        );
1439    }
1440
1441    #[test]
1442    fn test_mba954_ground_threshold_honored() {
1443        // MBA-954: integrate_trajectory must honor the configured ground plane, not a hardcoded
1444        // -1000.0. A descending bullet with a shallow ground_threshold must terminate earlier
1445        // (fewer points) than one with the historical deep default.
1446        let initial_state = [0.0, 0.0, 0.0, 300.0, -30.0, 0.0]; // descending (vy = -30 m/s)
1447
1448        let mut shallow = create_test_params(1_000_000.0); // huge target so range never terminates
1449        shallow.ground_threshold = -20.0; // stop ~20 m below launch
1450        let mut deep = create_test_params(1_000_000.0);
1451        deep.ground_threshold = -1000.0; // historical default
1452
1453        let t_shallow =
1454            integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
1455        let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
1456
1457        assert!(
1458            t_shallow.len() < t_deep.len(),
1459            "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
1460             shallow={}, deep={}",
1461            t_shallow.len(),
1462            t_deep.len()
1463        );
1464    }
1465
1466    #[test]
1467    fn test_integrate_trajectory_basic() {
1468        // Initial state [x,y,z,vx,vy,vz] (McCoy: X=downrange, Z=lateral)
1469        // x=0 (downrange start), vx=821.52 (downrange velocity)
1470        let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
1471
1472        let params = TrajectoryParams {
1473            mass_kg: 0.01134, // 175 grains in kg
1474            bc: 0.442,
1475            bullet_diameter: 0.0078232, // .308 in
1476            bullet_length: 0.031496,    // 1.24 in
1477            twist_rate: 10.0,
1478            drag_model: DragModel::G7,
1479            wind_segments: vec![WindSegment::new(0.0, 90.0, 914.4)],
1480            atmos_params: (0.0, 15.0, 1013.25, 1.0),
1481            omega_vector: None,
1482            enable_spin_drift: false,
1483            enable_magnus: false,
1484            enable_coriolis: false,
1485            target_distance_m: 914.4, // 1000 yards in meters
1486            enable_wind_shear: false,
1487            wind_shear_model: "none".to_string(),
1488            shooter_altitude_m: 0.0,
1489            is_twist_right: true,
1490            shooting_angle: 0.0,
1491            custom_drag_table: None,
1492            cd_scale: 1.0,
1493            custom_drag_reference: false,
1494            bc_segments: None,
1495            use_bc_segments: false,
1496            bc_segments_data: None,
1497            ground_threshold: -1000.0,
1498            atmo_sock: None,
1499        };
1500
1501        println!("Running integrate_trajectory test...");
1502        println!("Initial state: {:?}", initial_state);
1503        println!("Target distance: {} m", params.target_distance_m);
1504
1505        let trajectory =
1506            integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
1507
1508        println!("Trajectory has {} points", trajectory.len());
1509
1510        // Should have more than just initial point
1511        assert!(
1512            trajectory.len() > 1,
1513            "Trajectory should have more than 1 point, but has {}",
1514            trajectory.len()
1515        );
1516
1517        // Check that we actually moved downrange
1518        if let Some((_, final_state)) = trajectory.last() {
1519            println!("Final state: downrange(x)={}", final_state[0]);
1520            assert!(
1521                final_state[0] > 0.0,
1522                "Final x should be positive (bullet moved downrange)"
1523            );
1524            assert!(
1525                final_state[0] >= 900.0,
1526                "Final x should be near target distance"
1527            );
1528            assert!(
1529                final_state[3] < 0.9 * initial_state[3],
1530                "standard-atmosphere drag should reduce downrange velocity"
1531            );
1532        }
1533    }
1534
1535    #[test]
1536    fn test_rk4_vs_rk45_consistency() {
1537        // Both methods should give similar results for the same trajectory
1538        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1539        let target_distance = 500.0;
1540
1541        let params_rk4 = create_test_params(target_distance);
1542        let params_rk45 = create_test_params(target_distance);
1543
1544        let trajectory_rk4 =
1545            integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
1546        let trajectory_rk45 =
1547            integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
1548
1549        // Both should reach target
1550        assert!(!trajectory_rk4.is_empty());
1551        assert!(!trajectory_rk45.is_empty());
1552
1553        let (time_rk4, final_rk4) = trajectory_rk4.last().unwrap();
1554        let (time_rk45, final_rk45) = trajectory_rk45.last().unwrap();
1555
1556        // Compare quantities that are not forced equal by target-distance clamping.
1557        assert!(
1558            (time_rk4 - time_rk45).abs() < 1e-4,
1559            "RK4/RK45 time of flight diverged: {time_rk4} vs {time_rk45}"
1560        );
1561        assert!((final_rk4[1] - final_rk45[1]).abs() < 1e-3);
1562        assert!((final_rk4[3] - final_rk45[3]).abs() < 1e-2);
1563        assert!(final_rk45[3] < 0.9 * initial_state[3]);
1564    }
1565
1566    #[test]
1567    fn test_ground_impact_detection() {
1568        // Trajectory with steep downward angle should hit ground
1569        let initial_state = [0.0, 100.0, 0.0, 300.0, -50.0, 0.0]; // McCoy: vx=downrange // Steep descent
1570
1571        let mut params = create_test_params(10000.0); // Far target
1572        params.target_distance_m = 10000.0;
1573        let ground_threshold = 0.0;
1574        params.ground_threshold = ground_threshold;
1575
1576        let trajectory =
1577            integrate_trajectory(initial_state, (0.0, 20.0), params, "RK4", 1e-6, 0.01);
1578
1579        // Should stop before reaching target due to ground impact
1580        let (_, final_state) = trajectory.last().unwrap();
1581
1582        // y should have crossed the configured ground threshold.
1583        assert!(
1584            final_state[1] <= ground_threshold,
1585            "Should hit ground, but y={}",
1586            final_state[1]
1587        );
1588        assert!(
1589            final_state[0] < 10000.0,
1590            "Should not reach target, but z={}",
1591            final_state[0]
1592        );
1593    }
1594
1595    #[test]
1596    fn test_target_distance_reached() {
1597        let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; // McCoy: vx=downrange
1598        let target_distance = 300.0;
1599
1600        let params = create_test_params(target_distance);
1601
1602        let trajectory =
1603            integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
1604
1605        let (_, final_state) = trajectory.last().unwrap();
1606
1607        // Should stop at or very near target distance
1608        assert!(
1609            (final_state[0] - target_distance).abs() < 1.0,
1610            "Should reach target at {}m, but stopped at {}m",
1611            target_distance,
1612            final_state[0]
1613        );
1614    }
1615
1616    #[test]
1617    fn test_wind_affects_trajectory() {
1618        // Test that wind segments are properly stored and passed through
1619        // The actual wind effect depends on the derivatives computation which
1620        // uses the wind vector in the drag calculation
1621        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1622        let target_distance = 500.0;
1623
1624        // No wind
1625        let params_no_wind = create_test_params(target_distance);
1626
1627        // Strong headwind (0 degrees = headwind)
1628        let mut params_headwind = create_test_params(target_distance);
1629        params_headwind.wind_segments = vec![WindSegment::new(72.0, 0.0, 500.0)]; // 72 km/h = 20 m/s headwind
1630
1631        let trajectory_no_wind = integrate_trajectory(
1632            initial_state,
1633            (0.0, 5.0),
1634            params_no_wind,
1635            "RK45",
1636            1e-6,
1637            0.01,
1638        );
1639        let trajectory_headwind = integrate_trajectory(
1640            initial_state,
1641            (0.0, 5.0),
1642            params_headwind,
1643            "RK45",
1644            1e-6,
1645            0.01,
1646        );
1647
1648        // Both trajectories should complete
1649        assert!(
1650            !trajectory_no_wind.is_empty(),
1651            "No-wind trajectory should complete"
1652        );
1653        assert!(
1654            !trajectory_headwind.is_empty(),
1655            "Headwind trajectory should complete"
1656        );
1657
1658        let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
1659        let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
1660
1661        // Headwind should slow the bullet, resulting in longer flight time
1662        // or different drop at same distance
1663        let drop_no_wind = final_no_wind[1];
1664        let drop_headwind = final_headwind[1];
1665
1666        println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
1667        println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
1668
1669        assert!(
1670            *time_headwind > *time_no_wind + 0.001,
1671            "headwind should increase time of flight: no-wind={time_no_wind}, headwind={time_headwind}"
1672        );
1673        assert!(
1674            final_headwind[3] < final_no_wind[3] - 1.0,
1675            "headwind should reduce terminal downrange velocity"
1676        );
1677
1678        // Both should reach approximately the target distance
1679        assert!(
1680            (final_no_wind[0] - target_distance).abs() < 10.0,
1681            "No-wind should reach target"
1682        );
1683        assert!(
1684            (final_headwind[0] - target_distance).abs() < 10.0,
1685            "Headwind should reach target"
1686        );
1687    }
1688
1689    #[test]
1690    fn test_solve_trajectory_rust_output_format() {
1691        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1692
1693        let result = solve_trajectory_rust(
1694            initial_state,
1695            (0.0, 2.0),
1696            0.01134,       // mass_kg
1697            0.442,         // bc
1698            DragModel::G7, // drag_model
1699            vec![],        // wind_segments
1700            // Standard atmosphere: altitude m, temperature C, pressure hPa, density ratio.
1701            (0.0, 15.0, 1013.25, 1.0),
1702            None,               // omega_vector
1703            false,              // enable_spin_drift
1704            false,              // enable_magnus
1705            false,              // enable_coriolis
1706            "RK45".to_string(), // method
1707            1e-6,               // tolerance
1708            0.01,               // max_step
1709            500.0,              // target_distance_m
1710        );
1711
1712        // Should return Vec of HashMaps with expected keys
1713        assert!(!result.is_empty());
1714
1715        let first_point = &result[0];
1716        assert!(first_point.contains_key("t"));
1717        assert!(first_point.contains_key("x"));
1718        assert!(first_point.contains_key("y"));
1719        assert!(first_point.contains_key("z"));
1720        assert!(first_point.contains_key("vx"));
1721        assert!(first_point.contains_key("vy"));
1722        assert!(first_point.contains_key("vz"));
1723
1724        let final_point = result.last().unwrap();
1725        assert!(
1726            final_point["vx"] < 0.9 * initial_state[3],
1727            "standard-atmosphere wrapper fixture should exercise drag"
1728        );
1729    }
1730
1731    #[test]
1732    fn test_left_vs_right_twist() {
1733        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1734        let target_distance = 500.0;
1735
1736        let mut params_right = create_test_params(target_distance);
1737        params_right.is_twist_right = true;
1738        params_right.enable_spin_drift = true;
1739
1740        let mut params_left = create_test_params(target_distance);
1741        params_left.is_twist_right = false;
1742        params_left.enable_spin_drift = true;
1743
1744        let trajectory_right =
1745            integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
1746        let trajectory_left =
1747            integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
1748
1749        // Both should complete
1750        assert!(!trajectory_right.is_empty());
1751        assert!(!trajectory_left.is_empty());
1752
1753        // Right and left twist should produce valid trajectories
1754        let (_, final_right) = trajectory_right.last().unwrap();
1755        let (_, final_left) = trajectory_left.last().unwrap();
1756
1757        // Both should reach approximately the same downrange distance
1758        assert!((final_right[2] - final_left[2]).abs() < 10.0);
1759    }
1760
1761    /// Asserts that the boundary-layer multiplier this kernel applies at `(x, y)` is
1762    /// `expected_ratio`: its derivatives with shear on equal its derivatives with shear off and
1763    /// the wind scaled by that ratio. `muzzle_height` is the level the caller's y is measured
1764    /// from, as `integrate_trajectory_with_gravity` sets it on the derivative inputs.
1765    fn assert_kernel_shear_ratio(
1766        model: &str,
1767        muzzle_height: f64,
1768        shooting_angle: f64,
1769        (x, y): (f64, f64),
1770        expected_ratio: f64,
1771    ) {
1772        let state = Vector6::new(x, y, 0.0, 700.0, 5.0, 0.0);
1773        let run = |enable_wind_shear: bool, wind_speed_kmh: f64| {
1774            let mut params = create_test_params(2_000.0);
1775            params.wind_segments = vec![WindSegment::new(wind_speed_kmh, 90.0, 2_000.0)];
1776            params.enable_wind_shear = enable_wind_shear;
1777            params.wind_shear_model = model.to_string();
1778            params.shooting_angle = shooting_angle;
1779            let mut inputs = build_inputs(&params, 700.0);
1780            inputs.muzzle_height = muzzle_height;
1781            compute_derivatives_vec_with_gravity(&state, 0.0, &params, &inputs, Gravity::EARTH)
1782        };
1783        let sheared = run(true, 36.0);
1784        let scaled = run(false, 36.0 * expected_ratio);
1785        for component in 3..6 {
1786            assert!(
1787                (sheared[component] - scaled[component]).abs() < 1e-12,
1788                "{model}, muzzle_height {muzzle_height} m, shooting angle {shooting_angle} rad, \
1789                 ({x}, {y}): the shear multiplier is not {expected_ratio} (component \
1790                 {component}: {} vs {})",
1791                sheared[component],
1792                scaled[component]
1793            );
1794        }
1795    }
1796
1797    fn shear_ratio_rel_launch(height_rel_launch_m: f64, model: &str) -> f64 {
1798        crate::wind_shear::boundary_layer_speed_ratio(
1799            height_rel_launch_m,
1800            crate::wind_shear::boundary_layer_model_from_name(model),
1801        )
1802    }
1803
1804    /// A direct `TrajectoryParams` caller's y = 0 is the launch point (the bore), so on a level
1805    /// shot the shear height is y plus the assumed 1.5 m bore height, as before. A caller that
1806    /// states a real `muzzle_height` (fast_integrate_with_segments forwards its inputs') has
1807    /// y = 0 on the ground instead, and the same height above the ground reads the same
1808    /// multiplier.
1809    #[test]
1810    fn kernel_shear_reads_one_multiplier_at_one_height_above_ground() {
1811        for model in ["logarithmic", "power_law"] {
1812            let expected = shear_ratio_rel_launch(58.5, model);
1813            assert!(
1814                expected > 1.2,
1815                "{model}: the fixture must be well above the floor"
1816            );
1817            assert_kernel_shear_ratio(model, 0.0, 0.0, (1_000.0, 58.5), expected);
1818            assert_kernel_shear_ratio(model, 1.5, 0.0, (1_000.0, 60.0), expected);
1819        }
1820    }
1821
1822    /// On an inclined shot this kernel works in the shot frame like the other two, so its shear
1823    /// height is the shot-frame y plus the height of y = 0 above the ground, the ground running
1824    /// parallel to the line of sight as it does for the ground_threshold check, not the world
1825    /// height above the shooter's level, x·sin θ + y·cos θ.
1826    #[test]
1827    fn inclined_kernel_shear_measures_height_along_the_line_of_sight() {
1828        let (x, y) = (800.0, 60.0);
1829        for angle in [0.3_f64, -0.3] {
1830            let world_height = crate::atmosphere::shot_frame_altitude(0.0, x, y, angle);
1831            for model in ["logarithmic", "power_law"] {
1832                let bore_frame = shear_ratio_rel_launch(y, model);
1833                let ground_frame = shear_ratio_rel_launch(y - 1.5, model);
1834                assert!(
1835                    bore_frame > 1.25 && ground_frame > 1.25,
1836                    "{model}: fixture too low"
1837                );
1838                assert!(
1839                    (shear_ratio_rel_launch(world_height, model) - bore_frame).abs() > 0.1,
1840                    "{model}, angle {angle}: the fixture must tell the world height apart"
1841                );
1842                assert_kernel_shear_ratio(model, 0.0, angle, (x, y), bore_frame);
1843                assert_kernel_shear_ratio(model, 1.5, angle, (x, y), ground_frame);
1844            }
1845        }
1846    }
1847}