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
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12/// Angular base unit for a turret click graduation (MBA-1355). A click graduation is
13/// always expressed in mil, (true) MOA, or SMOA/IPHY — never in whole clicks itself.
14///
15/// Does not itself derive `Serialize`/`Deserialize`: `ClickValue` serializes as ONE
16/// suffixed string (`"0.1mil"`, `"0.25moa"`, ...) via its own hand-written impls below,
17/// not as a structural `{size, base}` object, so nothing needs this type's wire form on
18/// its own (MBA-1348).
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum ClickBase {
21    Mil,
22    Moa,
23    Smoa,
24}
25
26/// The `(drop_yd / range_yd) * factor` conversion factor for a `ClickBase`:
27/// - `Mil`: 1000.0 (milliradians)
28/// - `Moa`: 3438.0 (true MOA; this crate's locked printed-table constant — see MBA-724 —
29///   deliberately not the exact-angle 3437.7467)
30/// - `Smoa`: 3600.0 ("shooter's MOA" / inches-per-hundred-yards; exact by definition)
31pub fn adjustment_factor(base: ClickBase) -> f64 {
32    match base {
33        ClickBase::Mil => 1000.0,
34        ClickBase::Moa => 3438.0,
35        ClickBase::Smoa => 3600.0,
36    }
37}
38
39/// A turret click graduation, parsed from suffixed CLI/profile syntax
40/// like "0.1mil" / "0.25moa" / "0.125smoa" (MBA-1355).
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct ClickValue {
43    pub size: f64,
44    pub base: ClickBase,
45}
46
47/// Parses a suffixed click graduation string. The unit suffix is mandatory — one of
48/// `mil`, `moa`, `smoa`, `iphy` (`iphy` is accepted as an alias for `smoa`, the identical
49/// unit) — and the magnitude must be a positive, finite number.
50pub fn parse_click_value(s: &str) -> Result<ClickValue, String> {
51    let t = s.trim().to_ascii_lowercase();
52    let (num, base) = if let Some(n) = t.strip_suffix("smoa") {
53        (n, ClickBase::Smoa)
54    } else if let Some(n) = t.strip_suffix("iphy") {
55        (n, ClickBase::Smoa)
56    } else if let Some(n) = t.strip_suffix("moa") {
57        (n, ClickBase::Moa)
58    } else if let Some(n) = t.strip_suffix("mil") {
59        (n, ClickBase::Mil)
60    } else {
61        return Err(format!(
62            "click value '{s}' needs a unit suffix: mil, moa, smoa, or iphy (e.g. 0.1mil, 0.25moa)"
63        ));
64    };
65    let size: f64 = num
66        .trim()
67        .parse()
68        .map_err(|_| format!("click value '{s}' has an invalid number '{num}'"))?;
69    if !size.is_finite() || size <= 0.0 {
70        return Err(format!("click value '{s}' must be a positive, finite graduation"));
71    }
72    Ok(ClickValue { size, base })
73}
74
75/// Serializes as the crate's one existing suffixed-string representation — `"0.1mil"`,
76/// `"0.25moa"`, `"1smoa"` — the exact shape `parse_click_value` parses and a CLI
77/// `--elevation-click` flag already accepts (MBA-1348). Deliberately NOT the structural
78/// `{"size": 0.1, "base": "mil"}` a plain derive would produce: the string form is stable
79/// against internal field changes and makes a profile file's click entries identical to
80/// the CLI flag syntax a shooter already types. `size` uses `f64`'s default `Display`,
81/// which is Rust's shortest string that round-trips back to the same value (so `1.0`
82/// prints as `"1"`, matching `parse_click_value`'s own accepted syntax).
83impl Serialize for ClickValue {
84    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
85    where
86        S: Serializer,
87    {
88        let suffix = match self.base {
89            ClickBase::Mil => "mil",
90            ClickBase::Moa => "moa",
91            ClickBase::Smoa => "smoa",
92        };
93        let size = self.size;
94        serializer.serialize_str(&format!("{size}{suffix}"))
95    }
96}
97
98/// Deserializes via `parse_click_value` — the ONLY parser for this syntax, not a second,
99/// divergent implementation of it — so a malformed string is rejected with the identical
100/// message the CLI already gives, and `parse_click_value`'s positive-and-finite rule
101/// applies on every deserialize (MBA-1348).
102impl<'de> Deserialize<'de> for ClickValue {
103    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
104    where
105        D: Deserializer<'de>,
106    {
107        let raw = String::deserialize(deserializer)?;
108        parse_click_value(&raw).map_err(serde::de::Error::custom)
109    }
110}
111
112/// One quantized dial setting: the detent count nearest `angle`, and what remains.
113/// `residual` is in the same angular unit as `angle` (the click's base unit) and is
114/// DEFINED by the exact reconstruction identity
115/// `angle == clicks as f64 * click.size + residual` (bit-for-bit, by construction).
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub struct Quantized {
118    pub clicks: i64,
119    pub residual: f64,
120}
121
122/// Quantize an angular value (in `click.base` units) onto a click detent.
123/// Rounding is nearest, ties away from zero — identical to `clicks_for`, which delegates here.
124pub fn quantize_angle(angle: f64, click: &ClickValue) -> Quantized {
125    let clicks_f = (angle / click.size).round();
126    Quantized { clicks: clicks_f as i64, residual: angle - clicks_f * click.size }
127}
128
129/// This click's size in milliradians, the crate's true-angular unit. `adjustment_factor`
130/// maps one physical ratio into each display unit, so conversion is a factor ratio —
131/// using the LOCKED printed-table MOA constant (3438; MBA-724), deliberately not 3437.7467.
132pub fn click_size_mil(click: &ClickValue) -> f64 {
133    click.size * adjustment_factor(ClickBase::Mil) / adjustment_factor(click.base)
134}
135
136/// Whole-click adjustment for a drop at a range: the angular value in the graduation's
137/// own base unit divided by the graduation, rounded to the nearest click (ties away from
138/// zero). Sign is preserved. Ranges under 1 yard are defined as zero adjustment, matching
139/// the CLI's `drop_to_adjustment` short-range guard.
140pub fn clicks_for(drop_yd: f64, range_yd: f64, click: &ClickValue) -> i64 {
141    if range_yd < 1.0 {
142        return 0;
143    }
144    let angle = (drop_yd / range_yd) * adjustment_factor(click.base);
145    quantize_angle(angle, click).clicks
146}
147
148/// Zero-banner dial values `(MOA, mrad)` for a solved zero angle, corrected by the
149/// elevation tracking correction factor (MBA-1358).
150///
151/// Direction: the stored CF is the published tall-target ratio `actual measured travel
152/// / dialed travel` (0.95 = the scope under-tracks by 5%). To obtain a true angular
153/// need `N` on a scope whose dialed unit delivers `CF` true units, the number set on
154/// the dial must be `N / CF` — so dial-unit OUTPUTS are DIVIDED by the CF.
155///
156/// Replicates the WASM terminal's historical banner conversions bit-for-bit at
157/// `elevation_cf == 1.0`: `deg * 60.0` (true MOA — deliberately NOT this module's locked
158/// printed-table `Moa = 3438.0` factor; see the MBA-724 note in the wasm banner sites)
159/// and `rad * 1000.0`, each divided by exactly `1.0` (a bit-exact no-op). Lives here,
160/// in an ungated module, so the emit rule is host-testable (`wasm.rs` is wasm32-gated)
161/// and shared by all three banner sites instead of being edited in parallel.
162pub fn zero_banner_dial_values(angle_deg: f64, angle_rad: f64, elevation_cf: f64) -> (f64, f64) {
163    (
164        angle_deg * 60.0 / elevation_cf,
165        angle_rad * 1000.0 / elevation_cf,
166    )
167}
168
169/// MBA-1358: the ONE accepted band for a scope tracking correction factor, shared by the
170/// native CLI and the WASM terminal so the two surfaces cannot drift: strictly between
171/// 0.5 and 1.5 (a real scope does not mistrack by 50% — values outside the band mean the
172/// tall-target test or a hand edit went wrong) and finite.
173pub fn tracking_cf_in_range(value: f64) -> bool {
174    value.is_finite() && value > 0.5 && value < 1.5
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    // MBA-1358: the WASM zero-banner emit rule, host-tested here because wasm.rs is
182    // wasm32-gated (the drag_coefficient_json_value pattern).
183    #[test]
184    fn zero_banner_dial_values_divide_by_the_elevation_cf_and_are_exact_at_one() {
185        let deg = 0.0717_f64;
186        let rad = deg.to_radians();
187        // cf = 1.0 is bit-exact against the historical inline expressions.
188        let (moa, mrad) = zero_banner_dial_values(deg, rad, 1.0);
189        assert_eq!(moa.to_bits(), (deg * 60.0).to_bits());
190        assert_eq!(mrad.to_bits(), (rad * 1000.0).to_bits());
191        // an under-tracking scope (CF < 1) needs MORE dial: outputs divide by the CF.
192        let (moa_cf, mrad_cf) = zero_banner_dial_values(deg, rad, 0.95);
193        assert_eq!(moa_cf.to_bits(), (deg * 60.0 / 0.95).to_bits());
194        assert_eq!(mrad_cf.to_bits(), (rad * 1000.0 / 0.95).to_bits());
195        assert!(moa_cf > moa && mrad_cf > mrad);
196    }
197
198    #[test]
199    fn parse_click_value_accepts_suffixed_and_rejects_bare() {
200        let c = parse_click_value("0.25moa").unwrap();
201        assert!((c.size - 0.25).abs() < 1e-12);
202        assert!(matches!(c.base, ClickBase::Moa));
203        assert!(matches!(parse_click_value("0.1mil").unwrap().base, ClickBase::Mil));
204        assert!(matches!(parse_click_value("0.125smoa").unwrap().base, ClickBase::Smoa));
205        assert!(matches!(parse_click_value("0.125iphy").unwrap().base, ClickBase::Smoa));
206        for bad in ["0.25", "moa", "-0.1mil", "0mil", "0.1mils", ""] {
207            assert!(parse_click_value(bad).is_err(), "{bad:?} must be rejected");
208        }
209        // error message names the accepted suffixes
210        let e = parse_click_value("0.25").unwrap_err();
211        assert!(e.contains("mil") && e.contains("moa"), "{e}");
212    }
213
214    #[test]
215    fn clicks_round_to_nearest_whole_click() {
216        let quarter_moa = parse_click_value("0.25moa").unwrap();
217        // 2.6 TMOA of drop -> 10.4 clicks -> 10
218        let drop_yd = 2.6 / 3438.0 * 100.0;
219        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 10);
220        // negative (wind left) rounds symmetrically
221        assert_eq!(clicks_for(-drop_yd, 100.0, &quarter_moa), -10);
222        // 10.5 clicks rounds away from zero
223        let drop_yd = 2.625 / 3438.0 * 100.0;
224        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 11);
225    }
226
227    #[test]
228    fn quantize_reconstruction_identity_is_bit_exact() {
229        // residual is DEFINED as angle - clicks*size, so reconstruction is exact by construction.
230        let c = ClickValue { size: 0.25, base: ClickBase::Moa };
231        for angle in [0.0, 0.24, 0.25, 0.26, -std::f64::consts::PI, 7.4499999, 1234.567] {
232            let q = quantize_angle(angle, &c);
233            assert_eq!(q.clicks as f64 * c.size + q.residual, angle, "identity broke at {angle}");
234            assert!(q.residual.abs() <= c.size / 2.0 + f64::EPSILON, "residual beyond half-click");
235        }
236    }
237
238    #[test]
239    fn quantize_exact_multiples_have_residual_exactly_zero() {
240        let c = ClickValue { size: 0.1, base: ClickBase::Mil };
241        let q = quantize_angle(0.1 * 27.0, &c);
242        assert_eq!(q.clicks, 27);
243        assert_eq!(q.residual, 0.0); // bit-exact, not approx
244    }
245
246    #[test]
247    fn quantize_ties_round_away_from_zero_matching_clicks_for() {
248        let c = ClickValue { size: 0.5, base: ClickBase::Mil };
249        assert_eq!(quantize_angle(0.25, &c).clicks, 1);   // f64::round: ties away from zero
250        assert_eq!(quantize_angle(-0.25, &c).clicks, -1);
251    }
252
253    #[test]
254    fn clicks_for_is_unchanged_and_delegates() {
255        // Historical behavior pinned: sub-1yd guard + rounding. Values chosen off any tie.
256        let c = ClickValue { size: 0.25, base: ClickBase::Moa };
257        assert_eq!(clicks_for(10.0, 0.5, &c), 0);                       // range_yd < 1.0 guard
258        let angle = (7.2 / 300.0) * adjustment_factor(ClickBase::Moa);
259        assert_eq!(clicks_for(7.2, 300.0, &c), (angle / 0.25_f64).round() as i64);
260    }
261
262    #[test]
263    fn click_size_mil_converts_via_the_locked_factor_table() {
264        let moa = ClickValue { size: 0.25, base: ClickBase::Moa };
265        // 0.25 MOA in mil via the LOCKED 3438 constant (MBA-724), not 3437.7467.
266        assert_eq!(click_size_mil(&moa), 0.25 * 1000.0 / 3438.0);
267        let mil = ClickValue { size: 0.1, base: ClickBase::Mil };
268        assert_eq!(click_size_mil(&mil), 0.1);
269    }
270
271    // MBA-1348: ClickValue's JSON wire form is the same suffixed string
272    // `parse_click_value` already parses, not a structural `{size, base}` object.
273    #[test]
274    fn click_value_json_is_pinned_to_the_suffixed_string() {
275        let quarter_moa = ClickValue { size: 0.25, base: ClickBase::Moa };
276        assert_eq!(serde_json::to_string(&quarter_moa).unwrap(), "\"0.25moa\"");
277
278        let tenth_mil = ClickValue { size: 0.1, base: ClickBase::Mil };
279        assert_eq!(serde_json::to_string(&tenth_mil).unwrap(), "\"0.1mil\"");
280
281        // A whole-number size prints without a trailing ".0" -- `f64`'s shortest Display.
282        let one_smoa = ClickValue { size: 1.0, base: ClickBase::Smoa };
283        assert_eq!(serde_json::to_string(&one_smoa).unwrap(), "\"1smoa\"");
284    }
285
286    #[test]
287    fn click_value_round_trips_through_json_including_smoa() {
288        let cases = [
289            ClickValue { size: 0.1, base: ClickBase::Mil },
290            ClickValue { size: 0.25, base: ClickBase::Moa },
291            ClickValue { size: 1.0, base: ClickBase::Smoa },
292            ClickValue { size: 0.125, base: ClickBase::Smoa },
293        ];
294        for click in cases {
295            let json = serde_json::to_string(&click).unwrap();
296            let parsed: ClickValue = serde_json::from_str(&json).unwrap();
297            assert_eq!(parsed, click, "round trip broke for {json}");
298        }
299    }
300
301    #[test]
302    fn click_value_deserialize_rejects_what_parse_click_value_rejects() {
303        // The Deserialize impl IS parse_click_value -- a non-positive or malformed
304        // string must fail to deserialize exactly as it fails to parse.
305        for bad in ["\"0mil\"", "\"-0.1mil\"", "\"0.25\"", "\"nonsense\""] {
306            assert!(
307                serde_json::from_str::<ClickValue>(bad).is_err(),
308                "{bad} must be rejected"
309            );
310        }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Adjustment display layer (moved from main.rs for the bridge card service).
316//
317// Everything below was previously private to the CLI. The bridge's card
318// commands need the exact same unit conversion, zero-set bias, tracking-CF,
319// and click-quantization ordering the CLI prints, so the shared boundary now
320// lives here and `main.rs` re-imports it. The module doc's historical note
321// about avoiding a circular dependency on main.rs's AdjustmentUnit no longer
322// applies: the enum itself moved here, and the CLI derives its clap face via
323// cfg_attr.
324// ---------------------------------------------------------------------------
325
326/// Printed adjustment unit for dial columns (MIL/MOA/SMOA/IPHY or whole clicks).
327#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "lowercase")]
329#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
330pub enum AdjustmentUnit {
331    /// Milliradians (1 MIL = 3.6 inches at 100 yards)
332    #[default]
333    Mil,
334    /// Minutes of Angle, true MOA (1 MOA = 1.047 inches at 100 yards)
335    Moa,
336    /// Shooter's MOA (exactly 1 inch per 100 yards)
337    Smoa,
338    /// Inches per hundred yards (numerically identical to SMOA)
339    Iphy,
340    /// Whole turret clicks; requires an elevation click graduation
341    /// (--elevation-click-value or the profile's elevation_click)
342    Clicks,
343}
344
345/// Convert drop to adjustment unit (MIL, MOA, or SMOA/IPHY). `unit` maps onto
346/// `ClickBase`/`adjustment_factor` (MBA-1355) so the CLI, the WASM terminal, and the
347/// bridge card service share one conversion table.
348///
349/// `AdjustmentUnit::Clicks` has no fixed factor of its own — clicks depend on a graduation
350/// (`ClickValue`) supplied separately — so callers MUST resolve clicks via `clicks_for()`
351/// before ever reaching this function with `unit == Clicks`. That arm exists only as a
352/// release-safe fallback (falls back to MIL) guarded by a `debug_assert!`.
353pub fn drop_to_adjustment(drop_yd: f64, range_yd: f64, unit: AdjustmentUnit) -> f64 {
354    if range_yd < 1.0 {
355        return 0.0;
356    }
357    let factor = match unit {
358        AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
359        AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
360        AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
361        AdjustmentUnit::Clicks => {
362            // Callers resolve clicks via clicks_for() BEFORE display; reaching this
363            // arm is a scoping bug. Fall back to MIL so release builds stay sane.
364            debug_assert!(false, "Clicks must be resolved via clicks_for()");
365            adjustment_factor(ClickBase::Mil)
366        }
367    };
368    (drop_yd / range_yd) * factor
369}
370
371/// The `adjustment_factor` a non-clicks [`AdjustmentUnit`] renders in, for rescaling a
372/// MIL-denominated zero-set bias into the active display unit (MBA-1360). `Clicks`
373/// never reaches this (the clicks arm below works on raw drop); the MIL fallback
374/// mirrors `drop_to_adjustment`'s own release-safe arm.
375pub fn unit_factor_for_bias(unit: AdjustmentUnit) -> f64 {
376    match unit {
377        AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
378        AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
379        AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
380        AdjustmentUnit::Clicks => {
381            debug_assert!(false, "Clicks bias is applied on raw drop, not here");
382            adjustment_factor(ClickBase::Mil)
383        }
384    }
385}
386
387/// An adjustment value plus the click-quantization detail (Plan B Task 2). `value` is
388/// the whole-click count as an `f64` on the clicks arm, the plain angle otherwise.
389/// `quantized` carries the sub-click `residual` so a caller that needs it doesn't have
390/// to re-derive the click math a second time; `None` on the angular arm, where nothing
391/// is quantized.
392#[derive(Debug, Clone, Copy, PartialEq)]
393pub struct AdjustmentDisplay {
394    pub value: f64,
395    pub quantized: Option<Quantized>,
396}
397
398/// The one boundary where zero-set bias, tracking CF, and click quantization compose —
399/// the order is load-bearing (see main.rs's `zero_set_bias_applies_before_the_cf_division`
400/// and `clicks_arm_order_of_operations_is_load_bearing` tests) and must not move.
401pub fn adjustment_display(
402    drop_yd: f64,
403    range_yd: f64,
404    unit: AdjustmentUnit,
405    click: Option<ClickValue>,
406    bias_mil: f64,
407    cf: f64,
408) -> AdjustmentDisplay {
409    match click {
410        // Clicks convert from RAW drop (not from the angular value), so the CF is
411        // applied to the input drop — correcting the angle before click quantization.
412        // The zero-set bias joins as its drop-equivalent at this range (1 mil =
413        // range/1000), keeping the ordering: (true + bias) first, / CF second, then
414        // quantize into whole clicks.
415        Some(c) => {
416            let biased_drop_yd = if bias_mil != 0.0 {
417                drop_yd + bias_mil / 1000.0 * range_yd
418            } else {
419                drop_yd
420            };
421            let drop_for_clicks = biased_drop_yd / cf;
422            // Inlined from `clicks_for` (guard, then angle formula) so this boundary can
423            // quantize once and keep the residual, instead of calling `clicks_for` and
424            // throwing the sub-click remainder away.
425            if range_yd < 1.0 {
426                AdjustmentDisplay {
427                    value: 0.0,
428                    quantized: Some(Quantized { clicks: 0, residual: 0.0 }),
429                }
430            } else {
431                let angle = (drop_for_clicks / range_yd) * adjustment_factor(c.base);
432                let q = quantize_angle(angle, &c);
433                AdjustmentDisplay { value: q.clicks as f64, quantized: Some(q) }
434            }
435        }
436        None => {
437            let true_need = drop_to_adjustment(drop_yd, range_yd, unit);
438            let biased = if bias_mil != 0.0 {
439                // Rescale the mil bias into the display unit via the shared factor
440                // table (MIL -> unit), so MOA/SMOA/IPHY columns get the same angular
441                // correction the MIL column does.
442                true_need
443                    + bias_mil * unit_factor_for_bias(unit) / adjustment_factor(ClickBase::Mil)
444            } else {
445                true_need
446            };
447            AdjustmentDisplay { value: biased / cf, quantized: None }
448        }
449    }
450}
451
452/// Windage-axis adjustment display (Tier 2 review C1 fix): only pass the click
453/// graduation through to `adjustment_display` when the windage axis itself is `Clicks`;
454/// otherwise this collapses to the angular path. All windage columns (range-table,
455/// wind-card, compare, the PDF dope card's wind_adj/lead_adj, and the bridge card
456/// service) go through this one function so the guard cannot be dropped again.
457pub fn windage_adjustment_display(
458    drift_yd: f64,
459    range_yd: f64,
460    windage_unit: AdjustmentUnit,
461    windage_click: Option<ClickValue>,
462    windage_bias_mil: f64,
463    windage_cf: f64,
464) -> AdjustmentDisplay {
465    let click = if windage_unit == AdjustmentUnit::Clicks {
466        windage_click
467    } else {
468        None
469    };
470    adjustment_display(drift_yd, range_yd, windage_unit, click, windage_bias_mil, windage_cf)
471}
472
473/// Display label for an [`AdjustmentUnit`] header/column name (MBA-1355/MBA-1410):
474/// every surface that prints one shares this, so the "MIL"/"MOA"/"SMOA"/"IPHY"/"CLICKS"
475/// spelling cannot drift between commands.
476pub fn adjustment_unit_label(unit: AdjustmentUnit) -> String {
477    match unit {
478        AdjustmentUnit::Mil => "MIL".to_string(),
479        AdjustmentUnit::Moa => "MOA".to_string(),
480        AdjustmentUnit::Smoa => "SMOA".to_string(),
481        AdjustmentUnit::Iphy => "IPHY".to_string(),
482        AdjustmentUnit::Clicks => "CLICKS".to_string(),
483    }
484}