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}