Skip to main content

ballistics_engine/profile_import/
map.rs

1//! `.a7p` -> [`ProfileData`] mapping and the import report (moved verbatim from `main.rs`
2//! so the CLI's `profile import` and the bridge's `profile.import_a7p` share ONE mapping).
3//!
4//! The CLI keeps the presentation layer (`render_import_report`, the `--strict` refusal,
5//! `--name` sanitization notes, saving); everything that decides WHAT a `.a7p` becomes —
6//! field conversions, the honest unmapped/warning accounting, `--zero-click` click-count
7//! conversion — lives here, behind the same `profile-import` feature as the parser.
8
9use crate::adjustment::ClickValue;
10use crate::profile::{ProfileBcSegment, ProfileData, ProfileDragPoint, ProfileZeroSet};
11
12/// Get a timestamp string without chrono (same implementation as the CLI's own
13/// `timestamp_string`; duplicated rather than exported because a seconds-since-epoch
14/// formatter is not engine API).
15fn timestamp_string() -> String {
16    use std::time::{SystemTime, UNIX_EPOCH};
17    let secs = SystemTime::now()
18        .duration_since(UNIX_EPOCH)
19        .unwrap_or_default()
20        .as_secs();
21    format!("{}", secs)
22}
23
24/// Everything `profile import` produced: the profile to save plus the honest
25/// account of what mapped, what did not, and why.
26#[derive(Debug)]
27pub struct ImportReport {
28    /// (source field, raw value, converted value, destination field)
29    pub mapped: Vec<[String; 4]>,
30    /// (source field, human explanation) — data the profile store cannot hold.
31    pub unmapped: Vec<(String, String)>,
32    pub warnings: Vec<String>,
33}
34
35#[derive(Debug)]
36pub struct A7pImportOutcome {
37    pub profile: ProfileData,
38    pub report: ImportReport,
39}
40
41// Re-export of the shared constant under the name this module's a7p-mapping
42// call sites already use (MBA-1327: single source of truth for grain<->gram).
43use crate::constants::GRAMS_PER_GRAIN as GRAIN_TO_GRAM;
44const IN_TO_MM: f64 = 25.4;
45
46/// Restrict imported profile names to characters that are safe as file names
47/// in the profile store (`~/.ballistics/profiles/<name>.json`).
48pub fn sanitize_profile_name(raw: &str) -> String {
49    let cleaned: String = raw
50        .chars()
51        .map(|c| {
52            if c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-') {
53                c
54            } else {
55                '_'
56            }
57        })
58        .collect();
59    let trimmed = cleaned.trim().to_string();
60    if trimmed.is_empty() {
61        "imported-a7p".to_string()
62    } else {
63        trimmed
64    }
65}
66
67/// `ClickValue`'s canonical suffixed profile-field string (MBA-1348), e.g. `"0.1mil"` —
68/// exactly what `parse_click_value` parses back, produced via the type's own
69/// `Serialize` impl rather than a second, hand-rolled suffix match that could drift
70/// from it.
71fn click_value_to_profile_string(click: ClickValue) -> String {
72    serde_json::to_string(&click)
73        .expect("ClickValue serialization is infallible")
74        .trim_matches('"')
75        .to_string()
76}
77
78pub fn map_a7p_to_profile(
79    doc: &super::A7pDocument,
80    name_override: Option<&str>,
81    // MBA-1359: the source device's scope click graduation (`--zero-click`). The .a7p
82    // format stores zeroing state as raw device click counts WITHOUT the click size, so
83    // this is the only way to convert `zero_x`/`zero_y` into a linear POI offset. `None`
84    // keeps the historical behavior (the click counts are reported as unmapped).
85    zero_click: Option<ClickValue>,
86) -> Result<A7pImportOutcome, String> {
87    use super::{A7pBcType, EnvelopeStatus};
88    let src = &doc.profile;
89
90    let mut report = ImportReport {
91        mapped: Vec::new(),
92        unmapped: Vec::new(),
93        warnings: Vec::new(),
94    };
95    if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
96        report.warnings.push(format!(
97            "checksum mismatch (file says {expected}, payload hashes to {actual}) — file may be corrupted"
98        ));
99    }
100
101    let name = match name_override {
102        Some(n) => n.to_string(),
103        None => sanitize_profile_name(&src.profile_name),
104    };
105
106    let mut push = |field: &str, raw: String, converted: String, dest: &str| {
107        report
108            .mapped
109            .push([field.to_string(), raw, converted, dest.to_string()]);
110    };
111    push(
112        "profile_name",
113        src.profile_name.clone(),
114        name.clone(),
115        "name",
116    );
117    push(
118        "c_muzzle_velocity",
119        format!("{:.1} m/s", src.muzzle_velocity_mps),
120        format!("{:.1} m/s", src.muzzle_velocity_mps),
121        "velocity (muzzle velocity)",
122    );
123
124    // Resolve drag model + BC-related profile fields. Branches by bc_type because G1/G7 and
125    // CUSTOM disagree on what `coef_rows_raw` even means (velocity-BC rows vs Mach-Cd rows —
126    // see A7pProfile::bc_rows()/custom_rows()) and on what the scalar `bc` field should hold.
127    let (drag_model, bc, bc_segments, drag_curve): (
128        &str,
129        f64,
130        Option<Vec<ProfileBcSegment>>,
131        Option<Vec<ProfileDragPoint>>,
132    ) = match src.bc_type {
133        A7pBcType::G1 | A7pBcType::G7 => {
134            let drag_model = if matches!(src.bc_type, A7pBcType::G1) {
135                "G1"
136            } else {
137                "G7"
138            };
139            let rows = src.bc_rows();
140            // The row measured at the highest velocity is the muzzle-regime BC, retained as
141            // the scalar `bc` for back-compat with tools that only understand one BC.
142            let (bc, bc_row_velocity) = rows
143                .iter()
144                .copied()
145                .max_by(|a, b| a.1.total_cmp(&b.1))
146                .ok_or_else(|| {
147                    "no BC rows in file — cannot build a profile without a BC".to_string()
148                })?;
149            push(
150                "coef_rows[fastest]",
151                format!("BC {bc:.3} @ {bc_row_velocity:.0} m/s"),
152                format!("{bc:.3} ({drag_model})"),
153                "bc + drag_model",
154            );
155
156            let bc_segments = if rows.len() > 1 {
157                // Descending by velocity: matches bc_segments_from_profile's/the engine's
158                // "fastest row governs the muzzle regime" convention, and puts the back-compat
159                // scalar `bc` above (= sorted[0].bc) in visible agreement with this list.
160                let mut sorted = rows.clone();
161                sorted.sort_by(|a, b| b.1.total_cmp(&a.1));
162                push(
163                    "coef_rows[all]",
164                    format!(
165                        "{} row(s), fastest {bc:.3} @ {bc_row_velocity:.0} m/s",
166                        rows.len()
167                    ),
168                    format!("{} bc_segments (velocity-banded, descending)", sorted.len()),
169                    "bc_segments",
170                );
171                Some(
172                    sorted
173                        .into_iter()
174                        .map(|(bc, velocity_mps)| ProfileBcSegment { bc, velocity_mps })
175                        .collect(),
176                )
177            } else {
178                None
179            };
180            (drag_model, bc, bc_segments, None)
181        }
182        A7pBcType::Custom => {
183            let mut pairs = src.custom_rows(); // (Cd, Mach), file order
184            pairs.sort_by(|a, b| a.1.total_cmp(&b.1)); // ascending by Mach (DragTable requirement)
185            let mach_values: Vec<f64> = pairs.iter().map(|&(_, mach)| mach).collect();
186            let cd_values: Vec<f64> = pairs.iter().map(|&(cd, _)| cd).collect();
187            // Validate now (at import time) rather than only at first solve, so a malformed
188            // curve is a clear import-time error instead of a confusing later failure.
189            crate::drag::DragTable::try_new(mach_values.clone(), cd_values.clone())
190                .map_err(|e| format!("CUSTOM drag curve is invalid: {e}"))?;
191            push(
192                "coef_rows[custom]",
193                format!("{} Cd/Mach row(s)", pairs.len()),
194                format!(
195                    "{} drag_curve point(s), Mach {:.3}-{:.3}",
196                    pairs.len(),
197                    mach_values.first().copied().unwrap_or(0.0),
198                    mach_values.last().copied().unwrap_or(0.0)
199                ),
200                "drag_curve",
201            );
202            // No scalar BC applies to a full drag curve — see map_a7p_to_profile's module-level
203            // rationale in the report below. `bc: 0.0` is an intentionally-invalid sentinel:
204            //   * it is physically inert once drag_curve is consumed (custom_drag_table divides
205            //     by sectional density, not `bc_value` — BallisticInputs::custom_drag_denominator);
206            //   * commands that do not YET consume drag_curve (see CLI_USAGE.md's a7p import
207            //     section) will fail loudly (`bc_value must be finite and greater than zero`)
208            //     instead of silently running the wrong physics under an assumed G1 model. That
209            //     loud failure is the honest outcome for an unwired path, not a bug to paper over.
210            report.warnings.push(
211                "bc_type CUSTOM: no scalar BC applies to a full drag curve; the profile's 'bc' \
212                 field is set to 0.0 as an inert sentinel. It is unused once drag_curve is \
213                 consumed (a custom drag table replaces the BC-based retardation model \
214                 entirely); commands that do not yet consume drag_curve will fail loudly \
215                 (bc_value must be > 0) rather than silently assuming a G1 model — see \
216                 CLI_USAGE.md."
217                    .to_string(),
218            );
219            let drag_curve = Some(
220                pairs
221                    .into_iter()
222                    .map(|(cd, mach)| ProfileDragPoint { mach, cd })
223                    .collect(),
224            );
225            ("CUSTOM", 0.0, None, drag_curve)
226        }
227        A7pBcType::Other(v) => {
228            return Err(format!(
229                "unknown bc_type {v} — file newer than this importer"
230            ))
231        }
232    };
233
234    push(
235        "b_weight",
236        format!("{:.1} gr", src.bullet_weight_gr),
237        format!("{:.4} g", src.bullet_weight_gr * GRAIN_TO_GRAM),
238        "mass",
239    );
240    push(
241        "b_diameter",
242        format!("{:.3} in", src.bullet_diameter_in),
243        format!("{:.3} mm", src.bullet_diameter_in * IN_TO_MM),
244        "diameter",
245    );
246    push(
247        "b_length",
248        format!("{:.3} in", src.bullet_length_in),
249        format!("{:.2} mm", src.bullet_length_in * IN_TO_MM),
250        "bullet_length",
251    );
252    push(
253        "r_twist / twist_dir",
254        format!(
255            "{:.2} in/turn, {}",
256            src.twist_in_per_turn,
257            if src.twist_right { "RIGHT" } else { "LEFT" }
258        ),
259        format!("{:.1} mm/turn", src.twist_in_per_turn * IN_TO_MM),
260        "twist_rate + twist_right",
261    );
262    push(
263        "sc_height",
264        format!("{:.0} mm", src.sight_height_mm),
265        format!("{:.0} mm", src.sight_height_mm),
266        "sight_height",
267    );
268    if let Some(zd) = src.zero_distance_m {
269        push(
270            "distances[c_zero_distance_idx]",
271            format!("{zd:.1} m"),
272            format!("{zd:.1} m"),
273            "zero_distance + auto_zero",
274        );
275    }
276    push(
277        "c_zero_air_temperature",
278        format!("{:.1} C", src.air_temperature_c),
279        format!("{:.1} C", src.air_temperature_c),
280        "temperature",
281    );
282    push(
283        "c_zero_air_pressure",
284        format!("{:.1} hPa", src.air_pressure_hpa),
285        format!("{:.1} hPa", src.air_pressure_hpa),
286        "pressure",
287    );
288    push(
289        "c_zero_air_humidity",
290        format!("{:.0} %", src.air_humidity_pct),
291        format!("{:.0} %", src.air_humidity_pct),
292        "humidity",
293    );
294    if !src.bullet_name.is_empty() {
295        push(
296            "bullet_name",
297            src.bullet_name.clone(),
298            src.bullet_name.clone(),
299            "bullet_name",
300        );
301    }
302
303    // Honest non-mapping: things the profile store cannot hold today.
304    let tcoeff_mps_per_c = src.muzzle_velocity_mps * (src.temp_coeff_pct_per_15c / 100.0) / 15.0;
305    report.unmapped.push((
306        "c_t_coeff".to_string(),
307        format!(
308            "{:.3} %/15C = {:.3} m/s per C powder sensitivity — profile schema does not model \
309             powder sensitivity",
310            src.temp_coeff_pct_per_15c, tcoeff_mps_per_c
311        ),
312    ));
313    report.unmapped.push((
314        "c_zero_p_temperature".to_string(),
315        format!(
316            "{:.0} C powder temperature at zeroing",
317            src.powder_temperature_c
318        ),
319    ));
320    report.unmapped.push((
321        "c_zero_temperature".to_string(),
322        format!(
323            "{:.0} C ambient temperature when the zero was established",
324            src.zero_temperature_c
325        ),
326    ));
327    if src.w_pitch_raw != 0 {
328        report.unmapped.push((
329            "c_zero_w_pitch".to_string(),
330            format!(
331                "zeroing pitch value ({}) — base pitch is rifle-mount geometry outside \
332                 the turret model (OpticProfile has no base-pitch concept); its effect \
333                 is already absorbed by zeroing, not a turret setting left to store",
334                src.w_pitch_raw
335            ),
336        ));
337    }
338    // MBA-1359: `zero_x`/`zero_y` are the device's zeroing state in CLICK counts x 1000
339    // (upstream a7p spec: "zeroing h-clicks / v-clicks for specific device"). The file does
340    // NOT carry the device's click size, so conversion is only possible when the user
341    // supplies it (`--zero-click`). Axis conventions, confirmed against upstream tooling
342    // (a7p's own CLI negates X on entry: `zero_x += round(x_offset * -1000)`,
343    // `zero_y += round(y_offset * 1000)`): user-facing right-offset clicks = -zero_x/1000,
344    // up-offset clicks = zero_y/1000. Linear offset at the zero range =
345    // clicks x (click size / adjustment_factor(base)) [radians] x zero distance [m].
346    // MBA-1348: whenever the caller supplies the device's click graduation
347    // (--zero-click), that is itself a modeled fact regardless of whether the file's
348    // zero_x/zero_y counts can ALSO be converted to a POI offset below (which
349    // additionally needs a zero distance) -- so it is recorded as the profile's turret
350    // click graduation independently of that offset conversion. "only when not already
351    // set" is always satisfied here since map_a7p_to_profile always builds a fresh
352    // ProfileData (elevation_click starts unset), stated for the same defensive reason
353    // resolve_click_values documents its own precedence.
354    let click_from_zero_click: Option<String> = zero_click.map(click_value_to_profile_string);
355    if let Some(click_str) = &click_from_zero_click {
356        push(
357            "--zero-click",
358            format!("{click_str} (device click size, supplied on the command line)"),
359            click_str.clone(),
360            "elevation_click + windage_click",
361        );
362    }
363    let mut zero_poi_up_m: Option<f64> = None;
364    let mut zero_poi_right_m: Option<f64> = None;
365    let mut zero_sets: Option<Vec<ProfileZeroSet>> = None;
366    if src.zero_x_raw != 0 || src.zero_y_raw != 0 {
367        match (zero_click, src.zero_distance_m) {
368            (Some(click), Some(zero_distance_m)) if zero_distance_m > 0.0 => {
369                let click_rad = click.size / crate::adjustment::adjustment_factor(click.base);
370                let up_clicks = f64::from(src.zero_y_raw) / 1000.0;
371                let right_clicks = -f64::from(src.zero_x_raw) / 1000.0;
372                let up_m = up_clicks * click_rad * zero_distance_m;
373                let right_m = right_clicks * click_rad * zero_distance_m;
374                zero_poi_up_m = Some(up_m);
375                zero_poi_right_m = Some(right_m);
376                // MBA-1360: ALSO record the click state as a zero set named "a7p-zero",
377                // in DIAL-CORRECTION convention (the negated angular POI offset: a zero
378                // state that impacts high/right needs less up/right dial). The engine-
379                // field path above stays the primary consumer; see CLI_USAGE for why
380                // selecting this set only makes sense on a profile whose zero_poi_*
381                // fields have been cleared (both applied at once double-counts).
382                zero_sets = Some(vec![ProfileZeroSet {
383                    name: "a7p-zero".to_string(),
384                    zero_distance: None,
385                    poi_up_mil: Some(-(up_clicks * click_rad * 1000.0)),
386                    poi_right_mil: Some(-(right_clicks * click_rad * 1000.0)),
387                    notes: Some("imported .a7p zero_x/zero_y click state".to_string()),
388                }]);
389                push(
390                    "zero_x / zero_y",
391                    format!(
392                        "({}, {}) raw = {:.2} right / {:.2} up device clicks",
393                        src.zero_x_raw, src.zero_y_raw, right_clicks, up_clicks
394                    ),
395                    format!(
396                        "POI {:.2} cm up / {:.2} cm right at {zero_distance_m:.0} m \
397                         (--zero-click {}{})",
398                        up_m * 100.0,
399                        right_m * 100.0,
400                        click.size,
401                        match click.base {
402                            crate::adjustment::ClickBase::Mil => "mil",
403                            crate::adjustment::ClickBase::Moa => "moa",
404                            crate::adjustment::ClickBase::Smoa => "smoa",
405                        },
406                    ),
407                    "zero_poi_up_m + zero_poi_right_m + zero_sets[a7p-zero]",
408                );
409            }
410            (Some(_), _) => {
411                report.unmapped.push((
412                    "zero_x / zero_y".to_string(),
413                    format!(
414                        "scope zeroing click offsets ({}, {}) — the file stores no zero \
415                         distance, so --zero-click cannot convert them to a POI offset",
416                        src.zero_x_raw, src.zero_y_raw
417                    ),
418                ));
419            }
420            // No --zero-click: the historical report line, byte-identical (the .a7p
421            // format itself carries no click size to convert with).
422            (None, _) => {
423                report.unmapped.push((
424                    "zero_x / zero_y".to_string(),
425                    format!(
426                        "scope zeroing click offsets ({}, {}) — device click size not \
427                         supplied; pass --zero-click to record the profile's turret \
428                         graduation and convert this offset",
429                        src.zero_x_raw, src.zero_y_raw
430                    ),
431                ));
432            }
433        }
434    }
435    if !src.distances_m.is_empty() {
436        report.unmapped.push((
437            "distances".to_string(),
438            format!(
439                "{} range-card entries (device UI list)",
440                src.distances_m.len()
441            ),
442        ));
443    }
444    if src.switches_count > 0 {
445        report.unmapped.push((
446            "switches".to_string(),
447            format!("{} device UI switch entries", src.switches_count),
448        ));
449    }
450    for (field, value) in [
451        ("cartridge_name", &src.cartridge_name),
452        ("caliber", &src.caliber),
453        ("short_name_top", &src.short_name_top),
454        ("short_name_bot", &src.short_name_bot),
455        ("device_uuid", &src.device_uuid),
456    ] {
457        if !value.is_empty() {
458            report
459                .unmapped
460                .push((field.to_string(), format!("\"{}\"", value.trim())));
461        }
462    }
463    if !src.user_note.trim().is_empty() {
464        report.unmapped.push((
465            "user_note".to_string(),
466            format!("\"{}\"", src.user_note.trim()),
467        ));
468    }
469    for unknown in &doc.unknown_fields {
470        report.unmapped.push((
471            format!("{} field #{}", unknown.context, unknown.number),
472            "unknown field (file newer than this importer)".to_string(),
473        ));
474    }
475
476    let profile = ProfileData {
477        name,
478        velocity: src.muzzle_velocity_mps,
479        bc,
480        mass: src.bullet_weight_gr * GRAIN_TO_GRAM,
481        diameter: src.bullet_diameter_in * IN_TO_MM,
482        drag_model: drag_model.to_string(),
483        twist_rate: Some(src.twist_in_per_turn * IN_TO_MM),
484        sight_height: Some(src.sight_height_mm),
485        zero_distance: src.zero_distance_m,
486        units: "metric".to_string(),
487        temperature: src.air_temperature_c,
488        pressure: src.air_pressure_hpa,
489        humidity: src.air_humidity_pct,
490        altitude: 0.0,
491        bullet_name: if src.bullet_name.is_empty() {
492            None
493        } else {
494            Some(src.bullet_name.clone())
495        },
496        created: Some(timestamp_string()),
497        wind_speed: None,
498        wind_direction: None,
499        shooting_angle: None,
500        auto_zero: src.zero_distance_m,
501        twist_right: Some(src.twist_right),
502        // Left unset (not forced to Some(true)): consumers already treat "bc_segments
503        // present" as implying velocity-segment behavior is active (the existing
504        // `effective_use_bc_segments = use_bc_segments || bc_segments_data.is_some()`
505        // pattern), so this boolean keeps its original meaning rather than being
506        // overloaded by import.
507        use_bc_segments: None,
508        bullet_length: Some(src.bullet_length_in * IN_TO_MM),
509        // MBA-1348: populated only when --zero-click supplied a device click size (see
510        // the mapping above); otherwise left for `profile save
511        // --elevation-click/--windage-click` (or a later edit) to fill in.
512        elevation_click: click_from_zero_click.clone(),
513        windage_click: click_from_zero_click,
514        bc_segments,
515        drag_curve,
516        // .a7p carries no DSF concept; that's the `dsf` verb's job post-import.
517        dsf_points: None,
518        // .a7p carries no BC-reference-standard concept; imported BCs are treated as
519        // ICAO-referenced (the omitted-field default) unless the user later edits the
520        // saved profile with `--bc-reference`.
521        bc_reference: None,
522        // .a7p carries no QNH/pressure-reference concept; `air_pressure_hpa` is treated as
523        // absolute station pressure (the omitted-field default) unless the user later edits
524        // the saved profile with `--pressure-type`.
525        pressure_reference: None,
526        // .a7p carries no density-altitude concept; the imported temperature/pressure/altitude
527        // are used as-is unless the user later edits the saved profile with
528        // `--density-altitude`.
529        density_altitude: None,
530        // MBA-1359: populated only when --zero-click supplied a device click size to
531        // convert the file's zero_x/zero_y click counts with (see the mapping above).
532        zero_poi_up_m,
533        zero_poi_right_m,
534        // MBA-1396: .a7p has no lateral sight-offset concept; left for a hand edit.
535        sight_offset_lateral_m: None,
536        // MBA-1358: .a7p has no scope tracking-CF concept; derive with `tall-target`.
537        elevation_cf: None,
538        windage_cf: None,
539        // MBA-1360: Some only when --zero-click converted zero_x/zero_y (see above).
540        zero_sets,
541        // MBA-1361: .a7p carries no reticle description; attach one after import with
542        // `profile save --reticle-json`.
543        reticle: None,
544        // MBA-1348: .a7p carries no turret-mechanics or reticle-hold-bounds concept
545        // beyond the click graduation above; these are for `profile save` (or a hand
546        // edit) to fill in after import.
547        clicks_per_revolution: None,
548        zero_stop: None,
549        elevation_travel_up_mil: None,
550        elevation_travel_down_mil: None,
551        windage_travel_left_mil: None,
552        windage_travel_right_mil: None,
553        turret_elevation_dialed_mil: None,
554        turret_windage_dialed_mil: None,
555        hold_bound_up_mil: None,
556        hold_bound_down_mil: None,
557        hold_bound_left_mil: None,
558        hold_bound_right_mil: None,
559    };
560
561    Ok(A7pImportOutcome { profile, report })
562}