ballistics_engine/
reynolds.rs

1//! Reynolds-number utilities and a legacy correction for genuinely low-Re flow.
2//!
3//! Standard projectile drag tables already include the Reynolds-number range in which they were
4//! measured, so this module leaves ordinary ballistic inputs unchanged. The bounded correction is
5//! retained only below `Re=10,000`, outside that standard-table regime.
6
7/// Flow regime classification based on Reynolds number
8#[allow(dead_code)]
9#[derive(Debug, Clone, Copy, PartialEq)]
10enum FlowRegime {
11    Laminar,      // Re < 2000
12    Transitional, // 2000 < Re < 5e5
13    Turbulent,    // Re > 5e5
14}
15
16/// Calculate Reynolds number for a projectile
17///
18/// Re = ρ × V × L / μ
19///
20/// # Arguments
21/// * `velocity_mps` - Velocity in meters per second
22/// * `diameter_m` - Projectile diameter in meters
23/// * `air_density_kg_m3` - Air density in kg/m³
24/// * `temperature_k` - Temperature in Kelvin
25fn calculate_reynolds_number(
26    velocity_mps: f64,
27    diameter_m: f64,
28    air_density_kg_m3: f64,
29    temperature_k: f64,
30) -> f64 {
31    let mu = calculate_air_viscosity(temperature_k);
32    air_density_kg_m3 * velocity_mps * diameter_m / mu
33}
34
35/// Calculate dynamic viscosity of air using Sutherland's formula
36///
37/// # Arguments
38/// * `temperature_k` - Temperature in Kelvin
39///
40/// # Returns
41/// Dynamic viscosity in Pa·s (kg/m·s)
42fn calculate_air_viscosity(temperature_k: f64) -> f64 {
43    // Reference values
44    const T0: f64 = 273.15; // Reference temperature (K)
45    const MU0: f64 = 1.716e-5; // Reference viscosity at T0 (Pa·s)
46    const S: f64 = 110.4; // Sutherland's constant for air (K)
47
48    // Sutherland's formula
49    MU0 * (T0 + S) / (temperature_k + S) * (temperature_k / T0).powf(1.5)
50}
51
52/// Determine flow regime based on Reynolds number
53#[allow(dead_code)]
54fn get_flow_regime(reynolds_number: f64) -> FlowRegime {
55    if reynolds_number < 2000.0 {
56        FlowRegime::Laminar
57    } else if reynolds_number < 5e5 {
58        FlowRegime::Transitional
59    } else {
60        FlowRegime::Turbulent
61    }
62}
63
64/// Clift-Gauvin correlation for the drag coefficient of a sphere in continuum flow.
65///
66/// Used only as a bounded, relative low-Re shape for this opt-in projectile correction.
67fn clift_gauvin_sphere_cd(reynolds_number: f64) -> f64 {
68    24.0 / reynolds_number * (1.0 + 0.15 * reynolds_number.powf(0.687))
69        + 0.42 / (1.0 + 42_500.0 / reynolds_number.powf(1.16))
70}
71
72/// Calculate drag coefficient correction factor based on Reynolds number
73///
74/// Standard G-model drag is returned unchanged at `Re >= 10,000`. Below that threshold, the
75/// legacy low-Re factor is blended back to `1.0` as either Reynolds number or Mach approaches the
76/// standard regime.
77///
78/// # Arguments
79/// * `reynolds_number` - Reynolds number
80/// * `mach` - Mach number
81/// * `base_cd` - Base drag coefficient from standard model
82///
83/// # Returns
84/// Correction factor (multiply by base Cd)
85fn reynolds_drag_correction(reynolds_number: f64, mach: f64, _base_cd: f64) -> f64 {
86    // Standard projectile drag tables already cover the ordinary ballistic Reynolds-number
87    // range. Restrict this opt-in correction to genuinely low-Re flow instead of treating
88    // Re=1e6 as the single condition where the tables are valid.
89    const FULL_LOW_RE_CORRECTION: f64 = 1e3;
90    const STANDARD_TABLE_RE: f64 = 1e4;
91
92    if !reynolds_number.is_finite() || reynolds_number <= 0.0 {
93        return 1.0;
94    }
95
96    if reynolds_number >= STANDARD_TABLE_RE {
97        return 1.0;
98    }
99
100    // Only apply corrections for subsonic flow.
101    if mach >= 1.0 {
102        return 1.0;
103    }
104
105    // Normalize one continuous sphere-drag curve at the standard-table boundary instead of
106    // grafting an absolute Stokes Cd onto a multiplicative factor. The lower bound prevents this
107    // specialized helper from reducing the projectile's empirical base Cd.
108    let reference_cd = clift_gauvin_sphere_cd(STANDARD_TABLE_RE);
109    let correction = (clift_gauvin_sphere_cd(reynolds_number) / reference_cd).clamp(1.0, 5.0);
110
111    // Fade the specialized low-Re correction into the standard-table regime, avoiding a new
112    // discontinuity at Re=1e4. Also fade it through the upper subsonic band so the factor reaches
113    // exactly 1.0 at Mach 1 instead of introducing a sonic Cd step.
114    const SONIC_FADE_START: f64 = 0.8;
115    let reynolds_weight = ((STANDARD_TABLE_RE - reynolds_number)
116        / (STANDARD_TABLE_RE - FULL_LOW_RE_CORRECTION))
117        .clamp(0.0, 1.0);
118    let sonic_weight = ((1.0 - mach) / (1.0 - SONIC_FADE_START)).clamp(0.0, 1.0);
119
120    1.0 + (correction - 1.0) * reynolds_weight * sonic_weight
121}
122
123/// Calculate drag coefficient with Reynolds number correction
124///
125/// # Arguments
126/// * `velocity_mps` - Velocity in meters per second
127/// * `diameter_m` - Projectile diameter in meters
128/// * `air_density_kg_m3` - Air density in kg/m³
129/// * `temperature_k` - Temperature in Kelvin
130/// * `mach` - Mach number
131/// * `base_cd` - Base drag coefficient from standard model
132///
133/// # Returns
134/// Tuple of (corrected_cd, reynolds_number)
135fn calculate_corrected_drag(
136    velocity_mps: f64,
137    diameter_m: f64,
138    air_density_kg_m3: f64,
139    temperature_k: f64,
140    mach: f64,
141    base_cd: f64,
142) -> (f64, f64) {
143    let re = calculate_reynolds_number(velocity_mps, diameter_m, air_density_kg_m3, temperature_k);
144    let correction = reynolds_drag_correction(re, mach, base_cd);
145    let corrected_cd = base_cd * correction;
146
147    (corrected_cd, re)
148}
149
150/// Apply the low-Re correction to a drag coefficient (convenience function).
151///
152/// Coefficients from standard projectile drag tables are unchanged for ordinary ballistic
153/// Reynolds numbers (`Re >= 10,000`) because those empirical tables already include their
154/// measured Reynolds-number dependence.
155///
156/// # Arguments
157/// * `base_cd` - Base drag coefficient from G1/G7 model
158/// * `velocity_mps` - Velocity in meters per second
159/// * `diameter_inches` - Bullet diameter in inches
160/// * `air_density_kg_m3` - Air density in kg/m³
161/// * `temperature_c` - Temperature in Celsius
162/// * `mach` - Mach number
163///
164/// # Returns
165/// Corrected drag coefficient
166pub fn apply_reynolds_correction(
167    base_cd: f64,
168    velocity_mps: f64,
169    diameter_inches: f64,
170    air_density_kg_m3: f64,
171    temperature_c: f64,
172    mach: f64,
173) -> f64 {
174    // Convert units
175    let diameter_m = diameter_inches * 0.0254; // inches to meters
176    let temperature_k = temperature_c + 273.15; // Celsius to Kelvin
177
178    // Skip correction for very high velocities
179    if velocity_mps > 1000.0 || mach > 3.0 {
180        return base_cd;
181    }
182
183    // Calculate and apply correction
184    let (corrected_cd, _) = calculate_corrected_drag(
185        velocity_mps,
186        diameter_m,
187        air_density_kg_m3,
188        temperature_k,
189        mach,
190        base_cd,
191    );
192
193    corrected_cd
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_air_viscosity() {
202        // Test at standard temperature (15°C = 288.15K)
203        let mu = calculate_air_viscosity(288.15);
204        assert!((mu - 1.789e-5).abs() < 1e-7);
205    }
206
207    #[test]
208    fn test_reynolds_number() {
209        let re = calculate_reynolds_number(100.0, 0.00782, 1.225, 288.15);
210        // Should be around 5.4e4
211        assert!(re > 5e4 && re < 6e4);
212    }
213
214    #[test]
215    fn test_flow_regime() {
216        assert_eq!(get_flow_regime(1000.0), FlowRegime::Laminar);
217        assert_eq!(get_flow_regime(1e5), FlowRegime::Transitional);
218        assert_eq!(get_flow_regime(1e6), FlowRegime::Turbulent);
219    }
220
221    #[test]
222    fn test_reynolds_correction() {
223        // Test no correction for supersonic
224        let correction = reynolds_drag_correction(1e5, 1.5, 0.5);
225        assert_eq!(correction, 1.0);
226
227        // Test correction for low Re
228        let correction = reynolds_drag_correction(500.0, 0.5, 0.5);
229        assert!(correction > 1.0);
230
231        // Test extreme low Re
232        let correction = reynolds_drag_correction(100.0, 0.1, 0.5);
233        // Just check that there's significant correction
234        assert!(correction > 1.0);
235        assert!(correction <= 5.0); // Should be capped
236    }
237
238    #[test]
239    fn low_re_correction_uses_normalized_sphere_drag_curve() {
240        let cases = [
241            (10.0, 5.0),
242            (100.0, 2.624_751_023_486_579),
243            (1000.0, 1.118_623_125_825_208),
244        ];
245
246        for (reynolds_number, expected) in cases {
247            let actual = reynolds_drag_correction(reynolds_number, 0.5, 0.5);
248            assert!(
249                (actual - expected).abs() < 1e-12,
250                "sphere-curve correction mismatch at Re={reynolds_number}: actual={actual} expected={expected}"
251            );
252        }
253
254        let mach_faded = reynolds_drag_correction(100.0, 0.9, 0.5);
255        assert!((mach_faded - 1.812_375_511_743_289).abs() < 1e-12);
256
257        for invalid_re in [0.0, -1.0, f64::NAN, f64::INFINITY] {
258            assert_eq!(reynolds_drag_correction(invalid_re, 0.5, 0.5), 1.0);
259        }
260    }
261
262    #[test]
263    fn reynolds_correction_is_continuous_at_low_re_curve_seam() {
264        let below = reynolds_drag_correction(1000.0 - 1e-6, 0.5, 0.5);
265        let above = reynolds_drag_correction(1000.0 + 1e-6, 0.5, 0.5);
266
267        assert!(
268            (below - above).abs() < 1e-8,
269            "low-Re correction jumped at Re=1000: below={below}, above={above}"
270        );
271    }
272
273    #[test]
274    fn ordinary_rifle_reynolds_range_uses_standard_table_unchanged() {
275        let cases = [
276            (100.0, 100.0 / 340.0, 53_559.675_375),
277            (300.0, 300.0 / 340.0, 160_679.026_126),
278        ];
279
280        for (velocity_mps, mach, expected_re) in cases {
281            let re = calculate_reynolds_number(velocity_mps, 0.308 * 0.0254, 1.225, 288.15);
282            assert!((re - expected_re).abs() < 0.1);
283
284            let base_cd = 0.5;
285            let corrected_cd =
286                apply_reynolds_correction(base_cd, velocity_mps, 0.308, 1.225, 15.0, mach);
287
288            assert_eq!(
289                corrected_cd.to_bits(),
290                base_cd.to_bits(),
291                "ordinary rifle Re changed drag at Re={re}"
292            );
293        }
294    }
295
296    #[test]
297    fn reynolds_correction_is_continuous_at_mach_one() {
298        let base_cd = 0.5;
299        let below = apply_reynolds_correction(base_cd, 340.0, 0.308, 1.225, 15.0, 1.0 - 1e-6);
300        let above = apply_reynolds_correction(base_cd, 340.0, 0.308, 1.225, 15.0, 1.0 + 1e-6);
301
302        assert!(
303            (below - above).abs() <= base_cd * 1e-5,
304            "sonic Reynolds correction jumped: below={below}, above={above}"
305        );
306    }
307
308    #[test]
309    fn low_re_residual_fades_to_identity_at_mach_one() {
310        let below = reynolds_drag_correction(5e3, 1.0 - 1e-8, 0.5);
311        let above = reynolds_drag_correction(5e3, 1.0 + 1e-8, 0.5);
312
313        assert!(
314            (below - above).abs() <= 1e-6,
315            "low-Re correction jumped at Mach 1: below={below}, above={above}"
316        );
317    }
318
319    #[test]
320    fn reynolds_correction_is_continuous_at_standard_table_threshold() {
321        let below = reynolds_drag_correction(1e4 - 1e-6, 0.5, 0.5);
322        let above = reynolds_drag_correction(1e4 + 1e-6, 0.5, 0.5);
323
324        assert!(
325            (below - above).abs() <= 1e-9,
326            "Reynolds correction jumped at standard-table threshold: below={below}, above={above}"
327        );
328    }
329}
330
331// Removed Python-specific function