Skip to main content

ballistics_engine/
adjustment.rs

1//! Turret adjustment-unit conversions and click-value parsing (MBA-1355).
2//!
3//! Shared by the CLI (`main.rs`) and the WASM terminal (`wasm.rs`). This lives in the
4//! library crate — not `main.rs` — so it compiles for `wasm32-unknown-unknown` with no
5//! feature gate. To avoid a circular dependency on `main.rs`'s clap `AdjustmentUnit`
6//! `ValueEnum`, this module defines its own minimal angular base (`ClickBase`) and factor
7//! table; `main.rs` maps its own `AdjustmentUnit` onto `ClickBase`/`adjustment_factor`
8//! locally (see `drop_to_adjustment` in `main.rs`).
9
10/// Angular base unit for a turret click graduation (MBA-1355). A click graduation is
11/// always expressed in mil, (true) MOA, or SMOA/IPHY — never in whole clicks itself.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum ClickBase {
14    Mil,
15    Moa,
16    Smoa,
17}
18
19/// The `(drop_yd / range_yd) * factor` conversion factor for a `ClickBase`:
20/// - `Mil`: 1000.0 (milliradians)
21/// - `Moa`: 3438.0 (true MOA; this crate's locked printed-table constant — see MBA-724 —
22///   deliberately not the exact-angle 3437.7467)
23/// - `Smoa`: 3600.0 ("shooter's MOA" / inches-per-hundred-yards; exact by definition)
24pub fn adjustment_factor(base: ClickBase) -> f64 {
25    match base {
26        ClickBase::Mil => 1000.0,
27        ClickBase::Moa => 3438.0,
28        ClickBase::Smoa => 3600.0,
29    }
30}
31
32/// A turret click graduation, parsed from suffixed CLI/profile syntax
33/// like "0.1mil" / "0.25moa" / "0.125smoa" (MBA-1355).
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct ClickValue {
36    pub size: f64,
37    pub base: ClickBase,
38}
39
40/// Parses a suffixed click graduation string. The unit suffix is mandatory — one of
41/// `mil`, `moa`, `smoa`, `iphy` (`iphy` is accepted as an alias for `smoa`, the identical
42/// unit) — and the magnitude must be a positive, finite number.
43pub fn parse_click_value(s: &str) -> Result<ClickValue, String> {
44    let t = s.trim().to_ascii_lowercase();
45    let (num, base) = if let Some(n) = t.strip_suffix("smoa") {
46        (n, ClickBase::Smoa)
47    } else if let Some(n) = t.strip_suffix("iphy") {
48        (n, ClickBase::Smoa)
49    } else if let Some(n) = t.strip_suffix("moa") {
50        (n, ClickBase::Moa)
51    } else if let Some(n) = t.strip_suffix("mil") {
52        (n, ClickBase::Mil)
53    } else {
54        return Err(format!(
55            "click value '{s}' needs a unit suffix: mil, moa, smoa, or iphy (e.g. 0.1mil, 0.25moa)"
56        ));
57    };
58    let size: f64 = num
59        .trim()
60        .parse()
61        .map_err(|_| format!("click value '{s}' has an invalid number '{num}'"))?;
62    if !size.is_finite() || size <= 0.0 {
63        return Err(format!("click value '{s}' must be a positive, finite graduation"));
64    }
65    Ok(ClickValue { size, base })
66}
67
68/// Whole-click adjustment for a drop at a range: the angular value in the graduation's
69/// own base unit divided by the graduation, rounded to the nearest click (ties away from
70/// zero). Sign is preserved. Ranges under 1 yard are defined as zero adjustment, matching
71/// the CLI's `drop_to_adjustment` short-range guard.
72pub fn clicks_for(drop_yd: f64, range_yd: f64, click: &ClickValue) -> i64 {
73    if range_yd < 1.0 {
74        return 0;
75    }
76    let angle = (drop_yd / range_yd) * adjustment_factor(click.base);
77    (angle / click.size).round() as i64
78}
79
80/// Zero-banner dial values `(MOA, mrad)` for a solved zero angle, corrected by the
81/// elevation tracking correction factor (MBA-1358).
82///
83/// Direction: the stored CF is the published tall-target ratio `actual measured travel
84/// / dialed travel` (0.95 = the scope under-tracks by 5%). To obtain a true angular
85/// need `N` on a scope whose dialed unit delivers `CF` true units, the number set on
86/// the dial must be `N / CF` — so dial-unit OUTPUTS are DIVIDED by the CF.
87///
88/// Replicates the WASM terminal's historical banner conversions bit-for-bit at
89/// `elevation_cf == 1.0`: `deg * 60.0` (true MOA — deliberately NOT this module's locked
90/// printed-table `Moa = 3438.0` factor; see the MBA-724 note in the wasm banner sites)
91/// and `rad * 1000.0`, each divided by exactly `1.0` (a bit-exact no-op). Lives here,
92/// in an ungated module, so the emit rule is host-testable (`wasm.rs` is wasm32-gated)
93/// and shared by all three banner sites instead of being edited in parallel.
94pub fn zero_banner_dial_values(angle_deg: f64, angle_rad: f64, elevation_cf: f64) -> (f64, f64) {
95    (
96        angle_deg * 60.0 / elevation_cf,
97        angle_rad * 1000.0 / elevation_cf,
98    )
99}
100
101/// MBA-1358: the ONE accepted band for a scope tracking correction factor, shared by the
102/// native CLI and the WASM terminal so the two surfaces cannot drift: strictly between
103/// 0.5 and 1.5 (a real scope does not mistrack by 50% — values outside the band mean the
104/// tall-target test or a hand edit went wrong) and finite.
105pub fn tracking_cf_in_range(value: f64) -> bool {
106    value.is_finite() && value > 0.5 && value < 1.5
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    // MBA-1358: the WASM zero-banner emit rule, host-tested here because wasm.rs is
114    // wasm32-gated (the drag_coefficient_json_value pattern).
115    #[test]
116    fn zero_banner_dial_values_divide_by_the_elevation_cf_and_are_exact_at_one() {
117        let deg = 0.0717_f64;
118        let rad = deg.to_radians();
119        // cf = 1.0 is bit-exact against the historical inline expressions.
120        let (moa, mrad) = zero_banner_dial_values(deg, rad, 1.0);
121        assert_eq!(moa.to_bits(), (deg * 60.0).to_bits());
122        assert_eq!(mrad.to_bits(), (rad * 1000.0).to_bits());
123        // an under-tracking scope (CF < 1) needs MORE dial: outputs divide by the CF.
124        let (moa_cf, mrad_cf) = zero_banner_dial_values(deg, rad, 0.95);
125        assert_eq!(moa_cf.to_bits(), (deg * 60.0 / 0.95).to_bits());
126        assert_eq!(mrad_cf.to_bits(), (rad * 1000.0 / 0.95).to_bits());
127        assert!(moa_cf > moa && mrad_cf > mrad);
128    }
129
130    #[test]
131    fn parse_click_value_accepts_suffixed_and_rejects_bare() {
132        let c = parse_click_value("0.25moa").unwrap();
133        assert!((c.size - 0.25).abs() < 1e-12);
134        assert!(matches!(c.base, ClickBase::Moa));
135        assert!(matches!(parse_click_value("0.1mil").unwrap().base, ClickBase::Mil));
136        assert!(matches!(parse_click_value("0.125smoa").unwrap().base, ClickBase::Smoa));
137        assert!(matches!(parse_click_value("0.125iphy").unwrap().base, ClickBase::Smoa));
138        for bad in ["0.25", "moa", "-0.1mil", "0mil", "0.1mils", ""] {
139            assert!(parse_click_value(bad).is_err(), "{bad:?} must be rejected");
140        }
141        // error message names the accepted suffixes
142        let e = parse_click_value("0.25").unwrap_err();
143        assert!(e.contains("mil") && e.contains("moa"), "{e}");
144    }
145
146    #[test]
147    fn clicks_round_to_nearest_whole_click() {
148        let quarter_moa = parse_click_value("0.25moa").unwrap();
149        // 2.6 TMOA of drop -> 10.4 clicks -> 10
150        let drop_yd = 2.6 / 3438.0 * 100.0;
151        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 10);
152        // negative (wind left) rounds symmetrically
153        assert_eq!(clicks_for(-drop_yd, 100.0, &quarter_moa), -10);
154        // 10.5 clicks rounds away from zero
155        let drop_yd = 2.625 / 3438.0 * 100.0;
156        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 11);
157    }
158}