Skip to main content

ballistics_engine/
profile.rs

1//! Saved ballistic profile data model.
2//!
3//! `ProfileData` is the schema of `~/.ballistics/profiles/<name>.json` — the CLI's saved
4//! profiles — and, since the bridge grew `profile.validate`/`profile.normalize`/
5//! `profile.import_a7p`, also the profile document the JSON command bridge exchanges with
6//! embedding apps. It lived in `main.rs` from its introduction; it moved here VERBATIM so
7//! the bridge and the CLI share one definition. The serde wire shape is a compatibility
8//! contract: every stored profile on disk and every fixture in the test suite must keep
9//! loading, so field names, defaults, `skip_serializing_if` decisions, and the ABSENCE of
10//! `deny_unknown_fields` (unknown keys are tolerated and dropped, the documented
11//! forward-compat behavior) are all deliberately unchanged. See the round-trip test at the
12//! bottom of this file before touching any attribute.
13//!
14//! fs-free by design: this module must compile for wasm32, so file persistence
15//! (`save_profile`/`load_profile`) and unit conversion of loaded profiles (`converted_to`,
16//! which rides on the CLI's `UnitConverter`) stay in `main.rs`.
17
18use serde::{Deserialize, Serialize};
19
20use crate::adjustment::parse_click_value;
21use crate::cli_api::UnitSystem;
22use crate::optic::{HoldBounds, OpticProfile, TravelLimits, TurretState};
23use crate::reticle::ReticleDescription;
24use crate::truing_dsf::DsfPoint;
25
26/// Saved ballistic profile for quick recall
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ProfileData {
29    pub name: String,
30    pub velocity: f64,
31    pub bc: f64,
32    pub mass: f64,
33    pub diameter: f64,
34    pub drag_model: String,
35    #[serde(default)]
36    pub twist_rate: Option<f64>,
37    #[serde(default)]
38    pub sight_height: Option<f64>,
39    #[serde(default)]
40    pub zero_distance: Option<f64>,
41    #[serde(default = "default_unit_system")]
42    pub units: String,
43    #[serde(default = "default_temperature")]
44    pub temperature: f64,
45    #[serde(default = "default_pressure")]
46    pub pressure: f64,
47    /// True when the stored temperature was a supplied reading (0.49.0+). Absent/false = unknown:
48    /// files written before 0.49.0 cannot tell a typed 59 °F from the default, so they keep the
49    /// legacy rule (59 °F / 15 °C at a real altitude flies the ICAO standard temperature).
50    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51    pub temperature_supplied: bool,
52    /// True when the stored pressure was a supplied reading (0.49.0+). Absent/false = unknown:
53    /// files written before 0.49.0 cannot tell a typed 29.92 inHg from the default, so they keep
54    /// the legacy rule (29.92 inHg / 1013.25 hPa at a real altitude flies ICAO standard pressure).
55    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56    pub pressure_supplied: bool,
57    #[serde(default = "default_humidity")]
58    pub humidity: f64,
59    #[serde(default)]
60    pub altitude: f64,
61    #[serde(default)]
62    pub bullet_name: Option<String>,
63    #[serde(default)]
64    pub created: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub wind_speed: Option<f64>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub wind_direction: Option<f64>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub shooting_angle: Option<f64>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub auto_zero: Option<f64>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub twist_right: Option<bool>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub use_bc_segments: Option<bool>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub bullet_length: Option<f64>,
79    /// Turret elevation click graduation for `--adjustment-unit clicks` (MBA-1355), e.g. "0.1mil" or
80    /// "0.25moa" — parsed by `parse_click_value` at both save-time (validation) and
81    /// resolve-time (`resolve_click_values`). Unit-invariant (an angular graduation, not a
82    /// linear measurement), so `converted_to` leaves it untouched — see the `bc_segments`/
83    /// `drag_curve` comment below for why.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub elevation_click: Option<String>,
86    /// Turret windage click graduation for `--adjustment-unit clicks` (MBA-1355). Falls back to the
87    /// resolved elevation graduation when unset — see `resolve_click_values`.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub windage_click: Option<String>,
90    /// Velocity-banded BC breakpoints (MBA-1323 Phase 2: multi-row `.a7p` G1/G7 import).
91    /// `velocity_mps` in each entry is ALWAYS meters/second regardless of this profile's
92    /// `units` field — see [`ProfileBcSegment`]. The scalar `bc` field above is retained as
93    /// the highest-velocity row for tools that only understand a single BC; this list is the
94    /// authoritative full schedule once present. `None` (the pre-Phase-2 shape) means "no
95    /// velocity-banded schedule was captured" and callers fall back to the scalar `bc`.
96    ///
97    /// FORWARD-COMPAT WARNING (one-way): `#[serde(default)]` means this field round-trips
98    /// safely through readers that predate Phase 2, but "safely" only means *deserialization*
99    /// doesn't error — a pre-Phase-2 (or otherwise un-updated, e.g. stale WASM/bindings) reader
100    /// silently drops this key and solves with only the scalar `bc` above. That is a materially
101    /// different, unwarned answer whenever the schedule's non-fastest bands matter (empirically
102    /// confirmed: ~639 m/s vs. ~411 m/s impact velocity for the same imported profile — see
103    /// CLI_USAGE.md's "Multi-BC and CUSTOM drag curves" section). There is no sentinel trick
104    /// available here the way there is for `drag_curve`/CUSTOM below (a real, plausible-looking
105    /// `bc` value is unavoidable for back-compat with single-BC tools), so this direction of
106    /// version skew degrades silently by design and must stay documented rather than "fixed".
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub bc_segments: Option<Vec<ProfileBcSegment>>,
109    /// Full Mach/Cd drag curve (MBA-1323 Phase 2: `.a7p` `bc_type == CUSTOM` import). When
110    /// present, the scalar `bc`/`drag_model` fields are not physically meaningful for the
111    /// solve (`drag_model` reads "CUSTOM"; see `map_a7p_to_profile`'s CUSTOM handling for why
112    /// `bc` is an inert `0.0` sentinel rather than a real coefficient).
113    ///
114    /// FORWARD-COMPAT NOTE: unlike `bc_segments` above, a reader that predates Phase 2 (or
115    /// otherwise doesn't consume this field) is safe by construction, not just by omission: it
116    /// still sees `bc == 0.0` and `drag_model == "CUSTOM"`, so `BallisticInputs::validate_for_solve`
117    /// rejects the solve loudly ("bc_value must be finite and greater than zero") instead of
118    /// silently running physics under a fabricated coefficient.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub drag_curve: Option<Vec<ProfileDragPoint>>,
121    /// Mach-keyed drop-scale-factor table (MBA-1357), accumulated one point at a time by
122    /// the `dsf` verb. `None` for every profile with no DSF calibration yet — including
123    /// every profile saved before this field existed, which loads clean and solves
124    /// untrued (same `#[serde(default)]` forward-compat pattern as `bc_segments`/
125    /// `drag_curve` above: an old reader that predates this field silently drops it on
126    /// re-save, degrading to untrued drop with no error).
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub dsf_points: Option<Vec<DsfPoint>>,
129    /// Which standard atmosphere `bc`/`bc_segments` are referenced to (MBA-1365): `None`
130    /// (the omitted-field default, and every profile saved before this field existed) or
131    /// `"icao"` mean ICAO; `"army-standard-metro"` declares the older Army Standard Metro
132    /// reference some vendor-published BCs use instead. Parsed by
133    /// `parse_bc_reference_profile_field`, written by `bc_reference_profile_field` (which
134    /// never writes `"icao"` — it stays the omitted default so an untouched profile
135    /// round-trips with no new key). Unit-invariant, like `bc_segments`/`drag_curve` above,
136    /// so `converted_to` leaves it untouched.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub bc_reference: Option<String>,
139    /// Whether `pressure` is absolute station pressure or a sea-level-corrected altimeter
140    /// setting (QNH), mirroring `bc_reference` (MBA-1397): `None` (the omitted-field default,
141    /// and every profile saved before this field existed) or `"absolute"` mean absolute;
142    /// `"qnh"` declares a QNH pressure that must be reduced to station pressure before use.
143    /// Parsed by `parse_pressure_reference_profile_field`, written by
144    /// `pressure_reference_profile_field` (which never writes `"absolute"` -- it stays the
145    /// omitted default so an untouched profile round-trips with no new key).
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub pressure_reference: Option<String>,
148    /// Density altitude (MBA-1366), feet imperial / meters metric per `units` (same convention
149    /// as `altitude`). `None` (the omitted-field default, and every profile saved before this
150    /// field existed) means no density-altitude override is stored; a saved value supersedes
151    /// `altitude`/`pressure` when the profile is loaded (see `trajectory`'s
152    /// `--density-altitude` for the full precedence rule). `converted_to` rescales it exactly
153    /// like `altitude` since it shares the same feet/meters convention.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub density_altitude: Option<f64>,
156    /// Deliberate vertical POI offset AT the zero range (MBA-1359, Kestrel "zero height"):
157    /// positive = the rifle is deliberately zeroed to impact HIGH by this much at the zero
158    /// distance. ALWAYS meters regardless of this profile's `units` field (same unit-fixed
159    /// convention as [`ProfileBcSegment::velocity_mps`]), so `converted_to` leaves it
160    /// untouched. `None` (the omitted-field default, and every profile saved before this
161    /// field existed) means no offset; an old reader silently drops it on re-save (the
162    /// `bc_segments` forward-compat pattern).
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub zero_poi_up_m: Option<f64>,
165    /// Deliberate horizontal POI offset AT the zero range (MBA-1359, Kestrel "zero
166    /// offset"): positive = impacts RIGHT. ALWAYS meters, like `zero_poi_up_m` above.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub zero_poi_right_m: Option<f64>,
169    /// Lateral sight-to-bore mount offset (MBA-1396, offset-mounted optics): positive =
170    /// sight RIGHT of bore. ALWAYS meters (unit-fixed like the zero POI fields above), so
171    /// `converted_to` leaves it untouched; same `#[serde(default)]` forward-compat
172    /// pattern (an old reader silently drops it on re-save).
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub sight_offset_lateral_m: Option<f64>,
175    /// Elevation-axis scope tracking correction factor from a tall-target test
176    /// (MBA-1358, Litz), stored as the published ratio `actual measured travel /
177    /// dialed travel` (0.95 = the scope under-tracks by 5%). Elevation dial-unit
178    /// outputs (mil/MOA/SMOA/IPHY/clicks) are DIVIDED by this factor — an
179    /// under-tracking scope needs more dial — and dialed truing observations are
180    /// MULTIPLIED by it (scope-dial -> true angular); raw drop inches never scale.
181    /// NOTE on conventions: Kestrel's "Scope Cal" MULTIPLIES its factor into the
182    /// solution because it stores the reciprocal (dialed/actual); we divide because we
183    /// store the published actual/dialed — same physics, opposite bookkeeping.
184    /// Dimensionless, so `converted_to` leaves it untouched. Validated on load: must
185    /// be strictly between 0.5 and 1.5 (a factor outside that band means the
186    /// tall-target test went wrong, not that the scope does). `None` = 1.0 = no
187    /// correction. Derive with `ballistics tall-target`; overridden by
188    /// `--elevation-cf`.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub elevation_cf: Option<f64>,
191    /// Windage-axis scope tracking correction factor (MBA-1358), same contract and
192    /// direction as `elevation_cf`: windage-axis dial-unit outputs (including mover
193    /// lead/ring) are divided by it; overridden by `--windage-cf`.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub windage_cf: Option<f64>,
196    /// Named zero sets (MBA-1360): alternate zero distances and per-load dial
197    /// corrections (Lapua Sight-In POI / ATrag zero zones / Strelok multi-zero class).
198    /// Managed by `profile zero-set add|remove|list`; selected at solve time with
199    /// `--zero-set NAME`. Nothing here applies unless a set is explicitly selected —
200    /// the profile's own `zero_distance`/`auto_zero` remain the master zero.
201    ///
202    /// FORWARD-COMPAT (the `bc_segments` pattern, deliberately): `#[serde(default)]`
203    /// means a reader that predates this field loads the profile cleanly and solves
204    /// with the master zero — which is exactly what a CURRENT reader does when no
205    /// `--zero-set` is selected, so an old reader can never silently produce a
206    /// different default answer. Requesting an alternate set on an old binary fails
207    /// loudly at the flag (`--zero-set` is an unknown argument there). The one-way
208    /// skew is re-SAVING: an old reader that rewrites the profile silently drops this
209    /// key (documented, like `bc_segments`; there is no sentinel trick available that
210    /// wouldn't corrupt the master-zero fields old readers rely on).
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub zero_sets: Option<Vec<ProfileZeroSet>>,
213    /// The optic's reticle (MBA-1361), so `reticle hold --profile NAME` can place a
214    /// firing solution without being handed a description every time. Set with
215    /// `profile save --reticle-json <file>`; carried forward untouched by a re-save.
216    ///
217    /// Angular data only (milliradians from the optical center), so
218    /// `ProfileData::converted_to` (in `main.rs`) leaves it alone for the same reason it leaves
219    /// `elevation_click` alone — a subtension is not a linear measurement.
220    ///
221    /// FORWARD-COMPAT (the `bc_segments` pattern): `#[serde(default)]` means a reader that
222    /// predates this field loads the profile cleanly. Nothing about a trajectory depends
223    /// on it — it is a display/hold aid consumed only by the `reticle` command — so an old
224    /// reader cannot produce a different ballistic answer because of it; it simply has no
225    /// `reticle` verb. The one-way skew is re-SAVING, which drops the key, exactly as
226    /// documented for `bc_segments` and `zero_sets`.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub reticle: Option<ReticleDescription>,
229    /// Turret mechanics and reticle hold bounds (MBA-1348): twelve fields (this one
230    /// through `hold_bound_right_mil` below) assembled by `ProfileData::optic_profile`
231    /// into a `ballistics_engine::optic::OpticProfile` for the dial/hold/hybrid
232    /// correction planner. Every one is independently `Option` and, like
233    /// `elevation_click`/`windage_click` above, ALWAYS stored in mil (or its own natural
234    /// type for `clicks_per_revolution`/`zero_stop`) regardless of this profile's `units`
235    /// field — angular turret/reticle geometry, not a linear measurement — so
236    /// `converted_to` leaves all twelve untouched. Set with `profile save
237    /// --clicks-per-rev/--zero-stop/--travel-up/--travel-down/--windage-travel-left/
238    /// --windage-travel-right/--turret-elev/--turret-wind/--hold-up/--hold-down/
239    /// --hold-left/--hold-right`; validated at save time via `optic_profile()` +
240    /// `OpticProfile::validate()` (a profile can never be saved with, say, a dialed
241    /// turret state outside its own declared travel, or a non-positive click size).
242    ///
243    /// FORWARD-COMPAT (the `bc_segments` pattern, deliberately): `#[serde(default)]`
244    /// means a reader that predates these fields loads the profile cleanly with every one
245    /// of them absent — identical to what a CURRENT reader does for a profile that never
246    /// set them, so an old reader can never silently produce a different ballistic
247    /// answer because of them (nothing about a trajectory solve reads them; only a later
248    /// dial/hold planner does). The one-way skew is re-SAVING on an old binary, which
249    /// silently drops all twelve keys, exactly as documented for `bc_segments`/
250    /// `zero_sets`/`reticle` above.
251    ///
252    /// This field specifically: click detents per full turret revolution, for turrets
253    /// whose cap marks revolutions at all (many hunting turrets do not). `None` means
254    /// unknown/not applicable, never a specific count.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub clicks_per_revolution: Option<u32>,
257    /// Whether the elevation turret hard-stops at its lowest travel so it cannot be
258    /// dialed below zero (MBA-1348) — purely descriptive metadata, never read by
259    /// `plan_corrections` (see `OpticProfile::zero_stop`'s own doc comment for why).
260    /// `None` (the omitted-field default) means not recorded; `optic_profile()` treats
261    /// that the same as `Some(false)`.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub zero_stop: Option<bool>,
264    /// Elevation travel remaining UP from the current zero (not the turret's mechanical
265    /// bottom), DIAL-space mil (MBA-1348). Required together with
266    /// `elevation_travel_down_mil` — `optic_profile()` returns a named-field `Err` if
267    /// only one of the pair is set, rather than silently treating the unset half as zero
268    /// travel (a specific, likely-false physical claim, not an honest "unknown").
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub elevation_travel_up_mil: Option<f64>,
271    /// Elevation travel remaining DOWN from the current zero, DIAL-space mil (MBA-1348).
272    /// See `elevation_travel_up_mil`.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub elevation_travel_down_mil: Option<f64>,
275    /// Windage travel remaining LEFT from the current zero, DIAL-space mil (MBA-1348) —
276    /// `TravelLimits::down_mil` on the windage axis (see that type's doc comment for the
277    /// left/down convention). Required together with `windage_travel_right_mil`, like
278    /// `elevation_travel_up_mil`/`_down_mil` above.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub windage_travel_left_mil: Option<f64>,
281    /// Windage travel remaining RIGHT from the current zero, DIAL-space mil (MBA-1348) —
282    /// `TravelLimits::up_mil` on the windage axis. See `windage_travel_left_mil`.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub windage_travel_right_mil: Option<f64>,
285    /// The elevation turret's current dialed offset from zero, DIAL-space mil, signed:
286    /// positive is dialed UP (MBA-1348). Required together with
287    /// `turret_windage_dialed_mil` — `optic_profile()` returns a named-field `Err` if
288    /// only one axis of the pair is set, rather than silently assuming the other reads
289    /// zero.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub turret_elevation_dialed_mil: Option<f64>,
292    /// The windage turret's current dialed offset from zero, DIAL-space mil, signed:
293    /// positive is dialed RIGHT (MBA-1348). See `turret_elevation_dialed_mil`.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub turret_windage_dialed_mil: Option<f64>,
296    /// The reticle's usable hold extent ABOVE center, TRUE angular mil (MBA-1348) — see
297    /// `HoldBounds`, an explicit spec input (manufacturer spec sheet or bench
298    /// measurement), never derived from this profile's own `reticle` field. Required
299    /// together with `hold_bound_down_mil`/`hold_bound_left_mil`/`hold_bound_right_mil`
300    /// — all four or none.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub hold_bound_up_mil: Option<f64>,
303    /// The reticle's usable hold extent BELOW center, TRUE angular mil (MBA-1348). See
304    /// `hold_bound_up_mil`.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub hold_bound_down_mil: Option<f64>,
307    /// The reticle's usable hold extent LEFT of center, TRUE angular mil (MBA-1348). See
308    /// `hold_bound_up_mil`.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub hold_bound_left_mil: Option<f64>,
311    /// The reticle's usable hold extent RIGHT of center, TRUE angular mil (MBA-1348). See
312    /// `hold_bound_up_mil`.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub hold_bound_right_mil: Option<f64>,
315}
316
317/// One named zero condition / per-load dial correction (MBA-1360).
318///
319/// `zero_distance` uses the SAME display-unit convention as [`ProfileData::zero_distance`]
320/// (yards imperial / meters metric per the profile's `units` field), and
321/// `ProfileData::converted_to` (in `main.rs`) rescales it identically. `poi_up_mil`/`poi_right_mil`
322/// are constant ANGULAR dial corrections in MILs (unit-invariant, untouched by
323/// `converted_to`), ADDED to the dial outputs (elevation/windage adjustments) when the
324/// set is selected — positive = dial UP/RIGHT more; a load that impacts high/right
325/// relative to the master zero therefore stores negative values. This is deliberately
326/// dial-side (a constant angular correction per the ticket), unlike the MBA-1359
327/// linear-at-zero-range POI offsets, which bias the solved zero itself; the two compose.
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
329pub struct ProfileZeroSet {
330    pub name: String,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub zero_distance: Option<f64>,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub poi_up_mil: Option<f64>,
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub poi_right_mil: Option<f64>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub notes: Option<String>,
339}
340
341/// One velocity-banded BC breakpoint (profile schema v2, MBA-1323 Phase 2). Stored as a raw
342/// breakpoint, NOT a pre-computed `velocity_min`/`velocity_max` band — banding into the
343/// engine's [`crate::BCSegmentData`] shape happens at solve time (`bc_segments_from_profile`), so the
344/// stored JSON stays a simple, order-independent list that round-trips cleanly.
345///
346/// `velocity_mps` is ALWAYS meters/second, independent of [`ProfileData::units`]. This is
347/// intentional (matches the engine's internal BC-segment plumbing, which is also always in
348/// engine units) and is why `ProfileData::converted_to` (in `main.rs`) leaves this field untouched — see
349/// the comment there.
350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
351pub struct ProfileBcSegment {
352    pub bc: f64,
353    pub velocity_mps: f64,
354}
355
356/// One Mach/Cd point of a saved custom drag curve (profile schema v2, MBA-1323 Phase 2).
357/// Both fields are unit-invariant (Mach is dimensionless; Cd is dimensionless), so
358/// `ProfileData::converted_to` (in `main.rs`) leaves `drag_curve` untouched too.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360pub struct ProfileDragPoint {
361    pub mach: f64,
362    pub cd: f64,
363}
364
365fn default_unit_system() -> String {
366    "imperial".to_string()
367}
368fn default_temperature() -> f64 {
369    59.0
370}
371fn default_pressure() -> f64 {
372    29.92
373}
374fn default_humidity() -> f64 {
375    50.0
376}
377
378/// Assembles an all-or-nothing pair of angular profile fields — `OpticProfile`'s
379/// `TravelLimits`/`TurretState` axes — from two independently-`Option` values (MBA-1348).
380/// `None` only when NEITHER is set; `Some` only when BOTH are; a named-field `Err` when
381/// exactly one is. A silently-defaulted `0.0` for the missing half would assert a
382/// specific, likely-false physical fact (e.g. "zero down travel from zero") rather than
383/// "not recorded" — exactly the silent fabrication `OpticProfile`'s own doc comments
384/// warn against — so an incomplete pair is rejected by name instead of guessed at.
385///
386/// `a_flag`/`b_flag` name the CLI FLAGS (e.g. `"--travel-up"`), not the internal
387/// `ProfileData` struct fields (MBA-1348 review fix I2): the field↔flag mapping is not
388/// mechanical (`--travel-up` drops the axis prefix `elevation_travel_up_mil` carries;
389/// `--turret-elev` abbreviates `turret_elevation_dialed_mil`), and a user who typed
390/// `--travel-up` has no reason to recognize `elevation_travel_up_mil` in an error. This
391/// matches the convention two call sites above already use
392/// (`"--elevation-click '{v}' is invalid"`).
393pub fn require_angular_pair(
394    a_flag: &'static str,
395    a: Option<f64>,
396    b_flag: &'static str,
397    b: Option<f64>,
398) -> Result<Option<(f64, f64)>, String> {
399    match (a, b) {
400        (None, None) => Ok(None),
401        (Some(a), Some(b)) => Ok(Some((a, b))),
402        (Some(_), None) => Err(format!(
403            "{a_flag} is set but {b_flag} is not — both are required together (or neither)"
404        )),
405        (None, Some(_)) => Err(format!(
406            "{b_flag} is set but {a_flag} is not — both are required together (or neither)"
407        )),
408    }
409}
410
411/// The `require_angular_pair` all-or-nothing rule extended to `HoldBounds`' four fields
412/// (MBA-1348): all four or none, any other combination is a named-field `Err` (same
413/// no-silent-fabrication rationale — see `require_angular_pair`). Names the four CLI
414/// FLAGS in any error, not the internal struct fields (MBA-1348 review fix I2) — see
415/// `require_angular_pair`'s own doc comment for why that distinction matters.
416pub fn require_hold_bounds(
417    up: Option<f64>,
418    down: Option<f64>,
419    left: Option<f64>,
420    right: Option<f64>,
421) -> Result<Option<HoldBounds>, String> {
422    let flags = [
423        ("--hold-up", up),
424        ("--hold-down", down),
425        ("--hold-left", left),
426        ("--hold-right", right),
427    ];
428    let set_count = flags.iter().filter(|(_, v)| v.is_some()).count();
429    if set_count == 0 {
430        return Ok(None);
431    }
432    if set_count < 4 {
433        let missing: Vec<&str> = flags
434            .iter()
435            .filter(|(_, v)| v.is_none())
436            .map(|(name, _)| *name)
437            .collect();
438        return Err(format!(
439            "reticle hold bounds are incomplete — missing {} (all four of --hold-up/\
440             --hold-down/--hold-left/--hold-right are required together, or none)",
441            missing.join(", ")
442        ));
443    }
444    Ok(Some(HoldBounds {
445        up_mil: up.unwrap(),
446        down_mil: down.unwrap(),
447        left_mil: left.unwrap(),
448        right_mil: right.unwrap(),
449    }))
450}
451
452/// MBA-1358: a scope tracking correction factor must be strictly between 0.5 and 1.5.
453/// Values outside that band mean the tall-target test (or a hand edit) went wrong — a
454/// real scope does not mistrack by 50% — so they are a hard error naming the offending
455/// field/flag rather than a silently applied scale. The band itself lives in the shared
456/// `crate::adjustment::tracking_cf_in_range` so the WASM terminal enforces
457/// the identical bound.
458pub fn validate_tracking_cf(value: f64, source: &str) -> Result<(), String> {
459    if crate::adjustment::tracking_cf_in_range(value) {
460        Ok(())
461    } else {
462        Err(format!(
463            "{source} must be a tracking correction factor strictly between 0.5 and 1.5 \
464             (got {value}); derive it from a tall-target test with `ballistics tall-target`"
465        ))
466    }
467}
468
469impl ProfileData {
470    /// Assembles this profile's `OpticProfile` (MBA-1348) from `elevation_click`/
471    /// `windage_click` (parsed via `parse_click_value`, windage falling back to the
472    /// resolved elevation graduation — the same precedence `resolve_click_values` uses)
473    /// plus the twelve turret/hold fields declared alongside `reticle` above.
474    ///
475    /// Returns `Ok(None)` only when NONE of the twelve fields are set: the profile has
476    /// never been given any turret model at all. Every other combination either succeeds
477    /// (`Ok(Some(..))`) or is a named-field `Err` — including `elevation_click` itself
478    /// being unset while ANY of the other eleven fields IS set, since those eleven are
479    /// meaningless without a click graduation (`OpticProfile` cannot be constructed
480    /// without one, structurally) and silently ignoring them would let a save persist
481    /// turret/hold data no downstream code could ever use.
482    ///
483    /// Does NOT call `OpticProfile::validate()` itself — callers that need a
484    /// pre-validated profile (`profile save`) call it explicitly, so a validation
485    /// failure is attributed to the operation asking for it rather than to assembly.
486    pub fn optic_profile(&self) -> Result<Option<OpticProfile>, String> {
487        // Shape/pairing checks run FIRST and UNCONDITIONALLY -- before the elevation_click
488        // gate below -- so an incomplete travel/turret-state/hold-bound pair is rejected by
489        // name even on a profile that never set elevation_click at all (MBA-1348 review
490        // fix: a save with, e.g., only --travel-up and no --elevation-click previously
491        // skipped every one of these checks via the early `Ok(None)` return, silently
492        // persisting an incomplete pair no downstream code could ever have used anyway).
493        let elevation_travel = require_angular_pair(
494            "--travel-up",
495            self.elevation_travel_up_mil,
496            "--travel-down",
497            self.elevation_travel_down_mil,
498        )?
499        .map(|(up, down)| TravelLimits {
500            up_mil: up,
501            down_mil: down,
502        });
503
504        let windage_travel = require_angular_pair(
505            "--windage-travel-left",
506            self.windage_travel_left_mil,
507            "--windage-travel-right",
508            self.windage_travel_right_mil,
509        )?
510        .map(|(left, right)| TravelLimits {
511            down_mil: left,
512            up_mil: right,
513        });
514
515        let turret_state = require_angular_pair(
516            "--turret-elev",
517            self.turret_elevation_dialed_mil,
518            "--turret-wind",
519            self.turret_windage_dialed_mil,
520        )?
521        .map(|(elevation_mil, windage_mil)| TurretState {
522            elevation_mil,
523            windage_mil,
524        });
525
526        let reticle_hold_bounds = require_hold_bounds(
527            self.hold_bound_up_mil,
528            self.hold_bound_down_mil,
529            self.hold_bound_left_mil,
530            self.hold_bound_right_mil,
531        )?;
532
533        let Some(elev_str) = &self.elevation_click else {
534            // MBA-1348 review fix: every one of the other eleven fields is meaningless
535            // without a click graduation -- `OpticProfile` cannot even be constructed
536            // without one, structurally -- so silently accepting them here (the way a
537            // bare `Ok(None)` would) lets a save persist turret/hold data that no
538            // downstream code, including this profile's own future re-saves, could ever
539            // use. A profile that has genuinely never touched any of the twelve fields
540            // still returns Ok(None), unchanged.
541            if self.clicks_per_revolution.is_some()
542                || self.zero_stop.is_some()
543                || elevation_travel.is_some()
544                || windage_travel.is_some()
545                || turret_state.is_some()
546                || reticle_hold_bounds.is_some()
547            {
548                return Err(
549                    "turret/hold fields are set but --elevation-click is not -- a turret \
550                     model needs a click graduation to be usable at all; supply \
551                     --elevation-click, or also pass --clear-turret to clear the other \
552                     fields (e.g. after --clear-click removed the click alone)"
553                        .to_string(),
554                );
555            }
556            return Ok(None);
557        };
558        let elevation_click = parse_click_value(elev_str)?;
559        let windage_click = match &self.windage_click {
560            Some(s) => parse_click_value(s)?,
561            None => elevation_click,
562        };
563
564        Ok(Some(OpticProfile {
565            elevation_click,
566            windage_click,
567            clicks_per_revolution: self.clicks_per_revolution,
568            zero_stop: self.zero_stop.unwrap_or(false),
569            elevation_travel,
570            windage_travel,
571            turret_state,
572            reticle_hold_bounds,
573        }))
574    }
575
576    /// Parse this profile's `units` field into the CLI unit system it names.
577    ///
578    /// Factored out of the CLI's `converted_to` (which now calls this) so the bridge's
579    /// `profile.validate` applies the identical check with the identical message.
580    pub fn unit_system(&self) -> Result<UnitSystem, String> {
581        match self.units.trim().to_ascii_lowercase().as_str() {
582            "imperial" => Ok(UnitSystem::Imperial),
583            "metric" => Ok(UnitSystem::Metric),
584            other => Err(format!(
585                "Profile '{}' has unsupported units '{}'; expected 'imperial' or 'metric'",
586                self.name, other
587            )),
588        }
589    }
590
591    /// The cheap invariants the CLI already applies to a saved profile, collected instead
592    /// of short-circuited: the `units` string (`converted_to`'s gate), the MBA-1358
593    /// tracking-CF band (`load_profile`'s gate), and the MBA-1348 turret/optic assembly +
594    /// validation (`profile save`'s gate, including `parse_click_value` on the stored
595    /// click graduations). No new physics checks — this is exactly the existing load/save
596    /// surface, aggregated for the bridge's `profile.validate`. Empty means the profile
597    /// passes every one of those gates.
598    pub fn validation_warnings(&self) -> Vec<String> {
599        let mut warnings = Vec::new();
600        if let Err(err) = self.unit_system() {
601            warnings.push(err);
602        }
603        if let Some(cf) = self.elevation_cf {
604            if let Err(err) = validate_tracking_cf(cf, "profile field elevation_cf") {
605                warnings.push(err);
606            }
607        }
608        if let Some(cf) = self.windage_cf {
609            if let Err(err) = validate_tracking_cf(cf, "profile field windage_cf") {
610                warnings.push(err);
611            }
612        }
613        match self.optic_profile() {
614            Err(err) => warnings.push(format!("turret/optic model: {err}")),
615            Ok(Some(optic)) => {
616                if let Err(err) = optic.validate() {
617                    warnings.push(format!("turret/optic model: {err}"));
618                }
619            }
620            Ok(None) => {}
621        }
622        warnings
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    /// A literal on-disk profile document exercising the big wire surface: the five
631    /// mandatory fields, bc_segments, drag_curve, dsf_points, tracking CFs, named zero
632    /// sets with POI corrections, the click graduations plus all twelve turret/hold
633    /// fields, and a reticle — PLUS a top-level key this build has never heard of
634    /// ("future_field") and an unknown key nested inside a zero set, because tolerating
635    /// unknown keys (no `deny_unknown_fields`) is the documented forward-compat behavior
636    /// this struct has always had and the move into the library must not change.
637    const FIXTURE: &str = r#"{
638        "name": "wire-shape-fixture",
639        "velocity": 762.0,
640        "bc": 0.243,
641        "mass": 11.33980925,
642        "diameter": 7.8232,
643        "drag_model": "G7",
644        "twist_rate": 254.0,
645        "sight_height": 50.8,
646        "zero_distance": 91.44,
647        "units": "metric",
648        "temperature": 15.0,
649        "pressure": 1013.207888,
650        "humidity": 55.0,
651        "altitude": 304.8,
652        "bullet_name": "175gr SMK",
653        "created": "1755400000",
654        "wind_speed": 4.4704,
655        "wind_direction": 90.0,
656        "shooting_angle": -5.0,
657        "auto_zero": 91.44,
658        "twist_right": false,
659        "use_bc_segments": true,
660        "bullet_length": 30.48,
661        "elevation_click": "0.1mil",
662        "windage_click": "0.25moa",
663        "bc_segments": [
664            {"bc": 0.243, "velocity_mps": 792.0},
665            {"bc": 0.230, "velocity_mps": 400.0}
666        ],
667        "drag_curve": [
668            {"mach": 0.5, "cd": 0.23},
669            {"mach": 1.2, "cd": 0.45},
670            {"mach": 3.0, "cd": 0.28}
671        ],
672        "dsf_points": [
673            {"mach": 0.9, "dsf": 1.04}
674        ],
675        "bc_reference": "army-standard-metro",
676        "pressure_reference": "qnh",
677        "density_altitude": 500.0,
678        "zero_poi_up_m": 0.05,
679        "zero_poi_right_m": -0.02,
680        "sight_offset_lateral_m": 0.01,
681        "elevation_cf": 0.97,
682        "windage_cf": 1.02,
683        "zero_sets": [
684            {"name": "suppressed", "zero_distance": 200.0, "poi_up_mil": -0.3,
685             "poi_right_mil": 0.1, "notes": "suppressed load",
686             "zero_set_future_field": true},
687            {"name": "match", "poi_up_mil": 0.25}
688        ],
689        "reticle": {
690            "name": "mil-grid 0.5/10",
691            "focal_plane": "ffp",
692            "reference_magnification": 10.0,
693            "marks": [
694                {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
695                {"down_mil": 1.0, "right_mil": 0.0, "kind": "hash", "label": "1.0"}
696            ]
697        },
698        "clicks_per_revolution": 100,
699        "zero_stop": true,
700        "elevation_travel_up_mil": 26.0,
701        "elevation_travel_down_mil": 4.0,
702        "windage_travel_left_mil": 12.0,
703        "windage_travel_right_mil": 12.0,
704        "turret_elevation_dialed_mil": 5.4,
705        "turret_windage_dialed_mil": -0.2,
706        "hold_bound_up_mil": 5.0,
707        "hold_bound_down_mil": 10.0,
708        "hold_bound_left_mil": 6.0,
709        "hold_bound_right_mil": 6.0,
710        "future_field": {"nested": [1, 2, 3]}
711    }"#;
712
713    /// deserialize -> serialize -> deserialize is the identity on every stored field,
714    /// and the two serializations are byte-identical to each other (the engine's own
715    /// output is stable). Unknown input keys are tolerated on the way in — the
716    /// pre-existing behavior — and are NOT preserved on the way out (serde drops them;
717    /// also pre-existing, documented in the module doc).
718    #[test]
719    fn wire_shape_round_trips_field_for_field() {
720        let first: ProfileData = serde_json::from_str(FIXTURE).expect("fixture must load");
721        let serialized = serde_json::to_string(&first).expect("serialize");
722        let second: ProfileData = serde_json::from_str(&serialized).expect("reload");
723
724        // The engine re-serializes its own output identically.
725        assert_eq!(
726            serde_json::to_value(&first).unwrap(),
727            serde_json::to_value(&second).unwrap()
728        );
729
730        // Field-level equality across the whole surface (ProfileData does not derive
731        // PartialEq, deliberately — nothing in production compares whole profiles).
732        assert_eq!(second.name, "wire-shape-fixture");
733        assert_eq!(second.velocity.to_bits(), 762.0f64.to_bits());
734        assert_eq!(second.bc.to_bits(), 0.243f64.to_bits());
735        assert_eq!(second.mass.to_bits(), 11.33980925f64.to_bits());
736        assert_eq!(second.diameter.to_bits(), 7.8232f64.to_bits());
737        assert_eq!(second.drag_model, "G7");
738        assert_eq!(second.twist_rate, Some(254.0));
739        assert_eq!(second.sight_height, Some(50.8));
740        assert_eq!(second.zero_distance, Some(91.44));
741        assert_eq!(second.units, "metric");
742        assert_eq!(second.temperature.to_bits(), 15.0f64.to_bits());
743        assert_eq!(second.pressure.to_bits(), 1013.207888f64.to_bits());
744        assert_eq!(second.humidity.to_bits(), 55.0f64.to_bits());
745        assert_eq!(second.altitude.to_bits(), 304.8f64.to_bits());
746        assert_eq!(second.bullet_name.as_deref(), Some("175gr SMK"));
747        assert_eq!(second.created.as_deref(), Some("1755400000"));
748        assert_eq!(second.wind_speed, Some(4.4704));
749        assert_eq!(second.wind_direction, Some(90.0));
750        assert_eq!(second.shooting_angle, Some(-5.0));
751        assert_eq!(second.auto_zero, Some(91.44));
752        assert_eq!(second.twist_right, Some(false));
753        assert_eq!(second.use_bc_segments, Some(true));
754        assert_eq!(second.bullet_length, Some(30.48));
755        assert_eq!(second.elevation_click.as_deref(), Some("0.1mil"));
756        assert_eq!(second.windage_click.as_deref(), Some("0.25moa"));
757        assert_eq!(
758            second.bc_segments,
759            Some(vec![
760                ProfileBcSegment {
761                    bc: 0.243,
762                    velocity_mps: 792.0
763                },
764                ProfileBcSegment {
765                    bc: 0.230,
766                    velocity_mps: 400.0
767                },
768            ])
769        );
770        assert_eq!(
771            second.drag_curve,
772            Some(vec![
773                ProfileDragPoint {
774                    mach: 0.5,
775                    cd: 0.23
776                },
777                ProfileDragPoint {
778                    mach: 1.2,
779                    cd: 0.45
780                },
781                ProfileDragPoint {
782                    mach: 3.0,
783                    cd: 0.28
784                },
785            ])
786        );
787        assert_eq!(
788            second.dsf_points,
789            Some(vec![DsfPoint {
790                mach: 0.9,
791                dsf: 1.04
792            }])
793        );
794        assert_eq!(second.bc_reference.as_deref(), Some("army-standard-metro"));
795        assert_eq!(second.pressure_reference.as_deref(), Some("qnh"));
796        assert_eq!(second.density_altitude, Some(500.0));
797        assert_eq!(second.zero_poi_up_m, Some(0.05));
798        assert_eq!(second.zero_poi_right_m, Some(-0.02));
799        assert_eq!(second.sight_offset_lateral_m, Some(0.01));
800        assert_eq!(second.elevation_cf, Some(0.97));
801        assert_eq!(second.windage_cf, Some(1.02));
802        let sets = second.zero_sets.as_deref().expect("zero_sets kept");
803        assert_eq!(sets.len(), 2);
804        assert_eq!(sets[0].name, "suppressed");
805        assert_eq!(sets[0].zero_distance, Some(200.0));
806        assert_eq!(sets[0].poi_up_mil, Some(-0.3));
807        assert_eq!(sets[0].poi_right_mil, Some(0.1));
808        assert_eq!(sets[0].notes.as_deref(), Some("suppressed load"));
809        assert_eq!(sets[1].name, "match");
810        assert_eq!(sets[1].zero_distance, None);
811        assert_eq!(sets[1].poi_up_mil, Some(0.25));
812        assert_eq!(sets[1].poi_right_mil, None);
813        let reticle = second.reticle.as_ref().expect("reticle kept");
814        assert_eq!(reticle.name, "mil-grid 0.5/10");
815        assert_eq!(reticle.marks.len(), 2);
816        assert_eq!(reticle.marks[1].label.as_deref(), Some("1.0"));
817        assert_eq!(second.clicks_per_revolution, Some(100));
818        assert_eq!(second.zero_stop, Some(true));
819        assert_eq!(second.elevation_travel_up_mil, Some(26.0));
820        assert_eq!(second.elevation_travel_down_mil, Some(4.0));
821        assert_eq!(second.windage_travel_left_mil, Some(12.0));
822        assert_eq!(second.windage_travel_right_mil, Some(12.0));
823        assert_eq!(second.turret_elevation_dialed_mil, Some(5.4));
824        assert_eq!(second.turret_windage_dialed_mil, Some(-0.2));
825        assert_eq!(second.hold_bound_up_mil, Some(5.0));
826        assert_eq!(second.hold_bound_down_mil, Some(10.0));
827        assert_eq!(second.hold_bound_left_mil, Some(6.0));
828        assert_eq!(second.hold_bound_right_mil, Some(6.0));
829
830        // Unknown keys were tolerated (this test got this far) and dropped on re-save
831        // (pre-existing serde behavior, documented in the module doc): no invented keys.
832        let reserialized: serde_json::Value = serde_json::from_str(&serialized).unwrap();
833        assert!(reserialized.get("future_field").is_none());
834
835        // This fixture also passes every load/save invariant `validation_warnings`
836        // aggregates (units, CF band, optic assembly + validation).
837        assert_eq!(second.validation_warnings(), Vec::<String>::new());
838    }
839
840    /// The minimal legacy (pre-v2) document: only the five mandatory fields. The four
841    /// defaulted scalars fill in (imperial / 59 F / 29.92 inHg / 50%), every `Option`
842    /// stays `None`, and — load-bearing for on-disk compatibility — re-serialization
843    /// keeps the non-`skip_serializing_if` optional keys (`twist_rate`, `sight_height`,
844    /// `zero_distance`, `bullet_name`, `created`) present as JSON `null` while every
845    /// `skip_serializing_if` key stays absent.
846    #[test]
847    fn legacy_minimal_document_defaults_and_reserializes_with_stable_keys() {
848        let legacy = r#"{
849            "name": "legacy",
850            "velocity": 2500.0,
851            "bc": 0.475,
852            "mass": 175.0,
853            "diameter": 0.308,
854            "drag_model": "G1"
855        }"#;
856        let profile: ProfileData = serde_json::from_str(legacy).expect("legacy must load");
857        assert_eq!(profile.units, "imperial");
858        assert_eq!(profile.temperature.to_bits(), 59.0f64.to_bits());
859        assert_eq!(profile.pressure.to_bits(), 29.92f64.to_bits());
860        assert_eq!(profile.humidity.to_bits(), 50.0f64.to_bits());
861        assert_eq!(profile.altitude.to_bits(), 0.0f64.to_bits());
862        assert!(profile.bc_segments.is_none());
863        assert!(profile.zero_sets.is_none());
864
865        let out: serde_json::Value =
866            serde_json::from_str(&serde_json::to_string(&profile).unwrap()).unwrap();
867        // Plain-`default` options serialize as explicit null (the historical shape)...
868        assert!(out
869            .get("twist_rate")
870            .is_some_and(serde_json::Value::is_null));
871        assert!(out
872            .get("bullet_name")
873            .is_some_and(serde_json::Value::is_null));
874        assert!(out.get("created").is_some_and(serde_json::Value::is_null));
875        // ...while `skip_serializing_if` options stay absent entirely.
876        for absent in [
877            "wind_speed",
878            "bc_segments",
879            "drag_curve",
880            "dsf_points",
881            "zero_sets",
882            "reticle",
883            "elevation_click",
884            "elevation_cf",
885            "clicks_per_revolution",
886        ] {
887            assert!(out.get(absent).is_none(), "{absent} must stay absent");
888        }
889        assert_eq!(profile.validation_warnings(), Vec::<String>::new());
890    }
891
892    /// `validation_warnings` reports exactly the CLI's own gates: an out-of-band
893    /// tracking CF (load gate), a bad units string (`converted_to` gate), and an
894    /// incomplete turret pair / unparseable click (save gate) — and nothing for a
895    /// profile that merely leaves everything optional unset.
896    #[test]
897    fn validation_warnings_mirror_the_cli_gates() {
898        let mut profile: ProfileData = serde_json::from_str(FIXTURE).unwrap();
899        profile.elevation_cf = Some(0.4); // outside the (0.5, 1.5) band
900        profile.units = "nautical".to_string();
901        profile.elevation_travel_down_mil = None; // breaks the all-or-nothing pair
902        let warnings = profile.validation_warnings();
903        assert_eq!(warnings.len(), 3, "{warnings:?}");
904        assert!(
905            warnings.iter().any(|w| w.contains("elevation_cf")),
906            "{warnings:?}"
907        );
908        assert!(
909            warnings.iter().any(|w| w.contains("unsupported units")),
910            "{warnings:?}"
911        );
912        assert!(
913            warnings.iter().any(|w| w.contains("--travel-down")),
914            "{warnings:?}"
915        );
916    }
917}