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