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