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.iter().copied().max_by(|a, b| a.1.total_cmp(&b.1)).ok_or_else(
143                || "no BC rows in file — cannot build a profile without a BC".to_string(),
144            )?;
145            push(
146                "coef_rows[fastest]",
147                format!("BC {bc:.3} @ {bc_row_velocity:.0} m/s"),
148                format!("{bc:.3} ({drag_model})"),
149                "bc + drag_model",
150            );
151
152            let bc_segments = if rows.len() > 1 {
153                // Descending by velocity: matches bc_segments_from_profile's/the engine's
154                // "fastest row governs the muzzle regime" convention, and puts the back-compat
155                // scalar `bc` above (= sorted[0].bc) in visible agreement with this list.
156                let mut sorted = rows.clone();
157                sorted.sort_by(|a, b| b.1.total_cmp(&a.1));
158                push(
159                    "coef_rows[all]",
160                    format!("{} row(s), fastest {bc:.3} @ {bc_row_velocity:.0} m/s", rows.len()),
161                    format!("{} bc_segments (velocity-banded, descending)", sorted.len()),
162                    "bc_segments",
163                );
164                Some(
165                    sorted
166                        .into_iter()
167                        .map(|(bc, velocity_mps)| ProfileBcSegment { bc, velocity_mps })
168                        .collect(),
169                )
170            } else {
171                None
172            };
173            (drag_model, bc, bc_segments, None)
174        }
175        A7pBcType::Custom => {
176            let mut pairs = src.custom_rows(); // (Cd, Mach), file order
177            pairs.sort_by(|a, b| a.1.total_cmp(&b.1)); // ascending by Mach (DragTable requirement)
178            let mach_values: Vec<f64> = pairs.iter().map(|&(_, mach)| mach).collect();
179            let cd_values: Vec<f64> = pairs.iter().map(|&(cd, _)| cd).collect();
180            // Validate now (at import time) rather than only at first solve, so a malformed
181            // curve is a clear import-time error instead of a confusing later failure.
182            crate::drag::DragTable::try_new(mach_values.clone(), cd_values.clone())
183                .map_err(|e| format!("CUSTOM drag curve is invalid: {e}"))?;
184            push(
185                "coef_rows[custom]",
186                format!("{} Cd/Mach row(s)", pairs.len()),
187                format!(
188                    "{} drag_curve point(s), Mach {:.3}-{:.3}",
189                    pairs.len(),
190                    mach_values.first().copied().unwrap_or(0.0),
191                    mach_values.last().copied().unwrap_or(0.0)
192                ),
193                "drag_curve",
194            );
195            // No scalar BC applies to a full drag curve — see map_a7p_to_profile's module-level
196            // rationale in the report below. `bc: 0.0` is an intentionally-invalid sentinel:
197            //   * it is physically inert once drag_curve is consumed (custom_drag_table divides
198            //     by sectional density, not `bc_value` — BallisticInputs::custom_drag_denominator);
199            //   * commands that do not YET consume drag_curve (see CLI_USAGE.md's a7p import
200            //     section) will fail loudly (`bc_value must be finite and greater than zero`)
201            //     instead of silently running the wrong physics under an assumed G1 model. That
202            //     loud failure is the honest outcome for an unwired path, not a bug to paper over.
203            report.warnings.push(
204                "bc_type CUSTOM: no scalar BC applies to a full drag curve; the profile's 'bc' \
205                 field is set to 0.0 as an inert sentinel. It is unused once drag_curve is \
206                 consumed (a custom drag table replaces the BC-based retardation model \
207                 entirely); commands that do not yet consume drag_curve will fail loudly \
208                 (bc_value must be > 0) rather than silently assuming a G1 model — see \
209                 CLI_USAGE.md."
210                    .to_string(),
211            );
212            let drag_curve = Some(
213                pairs
214                    .into_iter()
215                    .map(|(cd, mach)| ProfileDragPoint { mach, cd })
216                    .collect(),
217            );
218            ("CUSTOM", 0.0, None, drag_curve)
219        }
220        A7pBcType::Other(v) => {
221            return Err(format!("unknown bc_type {v} — file newer than this importer"))
222        }
223    };
224
225    push(
226        "b_weight",
227        format!("{:.1} gr", src.bullet_weight_gr),
228        format!("{:.4} g", src.bullet_weight_gr * GRAIN_TO_GRAM),
229        "mass",
230    );
231    push(
232        "b_diameter",
233        format!("{:.3} in", src.bullet_diameter_in),
234        format!("{:.3} mm", src.bullet_diameter_in * IN_TO_MM),
235        "diameter",
236    );
237    push(
238        "b_length",
239        format!("{:.3} in", src.bullet_length_in),
240        format!("{:.2} mm", src.bullet_length_in * IN_TO_MM),
241        "bullet_length",
242    );
243    push(
244        "r_twist / twist_dir",
245        format!(
246            "{:.2} in/turn, {}",
247            src.twist_in_per_turn,
248            if src.twist_right { "RIGHT" } else { "LEFT" }
249        ),
250        format!("{:.1} mm/turn", src.twist_in_per_turn * IN_TO_MM),
251        "twist_rate + twist_right",
252    );
253    push(
254        "sc_height",
255        format!("{:.0} mm", src.sight_height_mm),
256        format!("{:.0} mm", src.sight_height_mm),
257        "sight_height",
258    );
259    if let Some(zd) = src.zero_distance_m {
260        push(
261            "distances[c_zero_distance_idx]",
262            format!("{zd:.1} m"),
263            format!("{zd:.1} m"),
264            "zero_distance + auto_zero",
265        );
266    }
267    push(
268        "c_zero_air_temperature",
269        format!("{:.1} C", src.air_temperature_c),
270        format!("{:.1} C", src.air_temperature_c),
271        "temperature",
272    );
273    push(
274        "c_zero_air_pressure",
275        format!("{:.1} hPa", src.air_pressure_hpa),
276        format!("{:.1} hPa", src.air_pressure_hpa),
277        "pressure",
278    );
279    push(
280        "c_zero_air_humidity",
281        format!("{:.0} %", src.air_humidity_pct),
282        format!("{:.0} %", src.air_humidity_pct),
283        "humidity",
284    );
285    if !src.bullet_name.is_empty() {
286        push(
287            "bullet_name",
288            src.bullet_name.clone(),
289            src.bullet_name.clone(),
290            "bullet_name",
291        );
292    }
293
294    // Honest non-mapping: things the profile store cannot hold today.
295    let tcoeff_mps_per_c =
296        src.muzzle_velocity_mps * (src.temp_coeff_pct_per_15c / 100.0) / 15.0;
297    report.unmapped.push((
298        "c_t_coeff".to_string(),
299        format!(
300            "{:.3} %/15C = {:.3} m/s per C powder sensitivity — profile schema does not model \
301             powder sensitivity",
302            src.temp_coeff_pct_per_15c, tcoeff_mps_per_c
303        ),
304    ));
305    report.unmapped.push((
306        "c_zero_p_temperature".to_string(),
307        format!("{:.0} C powder temperature at zeroing", src.powder_temperature_c),
308    ));
309    report.unmapped.push((
310        "c_zero_temperature".to_string(),
311        format!(
312            "{:.0} C ambient temperature when the zero was established",
313            src.zero_temperature_c
314        ),
315    ));
316    if src.w_pitch_raw != 0 {
317        report.unmapped.push((
318            "c_zero_w_pitch".to_string(),
319            format!(
320                "zeroing pitch value ({}) — base pitch is rifle-mount geometry outside \
321                 the turret model (OpticProfile has no base-pitch concept); its effect \
322                 is already absorbed by zeroing, not a turret setting left to store",
323                src.w_pitch_raw
324            ),
325        ));
326    }
327    // MBA-1359: `zero_x`/`zero_y` are the device's zeroing state in CLICK counts x 1000
328    // (upstream a7p spec: "zeroing h-clicks / v-clicks for specific device"). The file does
329    // NOT carry the device's click size, so conversion is only possible when the user
330    // supplies it (`--zero-click`). Axis conventions, confirmed against upstream tooling
331    // (a7p's own CLI negates X on entry: `zero_x += round(x_offset * -1000)`,
332    // `zero_y += round(y_offset * 1000)`): user-facing right-offset clicks = -zero_x/1000,
333    // up-offset clicks = zero_y/1000. Linear offset at the zero range =
334    // clicks x (click size / adjustment_factor(base)) [radians] x zero distance [m].
335    // MBA-1348: whenever the caller supplies the device's click graduation
336    // (--zero-click), that is itself a modeled fact regardless of whether the file's
337    // zero_x/zero_y counts can ALSO be converted to a POI offset below (which
338    // additionally needs a zero distance) -- so it is recorded as the profile's turret
339    // click graduation independently of that offset conversion. "only when not already
340    // set" is always satisfied here since map_a7p_to_profile always builds a fresh
341    // ProfileData (elevation_click starts unset), stated for the same defensive reason
342    // resolve_click_values documents its own precedence.
343    let click_from_zero_click: Option<String> = zero_click.map(click_value_to_profile_string);
344    if let Some(click_str) = &click_from_zero_click {
345        push(
346            "--zero-click",
347            format!("{click_str} (device click size, supplied on the command line)"),
348            click_str.clone(),
349            "elevation_click + windage_click",
350        );
351    }
352    let mut zero_poi_up_m: Option<f64> = None;
353    let mut zero_poi_right_m: Option<f64> = None;
354    let mut zero_sets: Option<Vec<ProfileZeroSet>> = None;
355    if src.zero_x_raw != 0 || src.zero_y_raw != 0 {
356        match (zero_click, src.zero_distance_m) {
357            (Some(click), Some(zero_distance_m)) if zero_distance_m > 0.0 => {
358                let click_rad =
359                    click.size / crate::adjustment::adjustment_factor(click.base);
360                let up_clicks = f64::from(src.zero_y_raw) / 1000.0;
361                let right_clicks = -f64::from(src.zero_x_raw) / 1000.0;
362                let up_m = up_clicks * click_rad * zero_distance_m;
363                let right_m = right_clicks * click_rad * zero_distance_m;
364                zero_poi_up_m = Some(up_m);
365                zero_poi_right_m = Some(right_m);
366                // MBA-1360: ALSO record the click state as a zero set named "a7p-zero",
367                // in DIAL-CORRECTION convention (the negated angular POI offset: a zero
368                // state that impacts high/right needs less up/right dial). The engine-
369                // field path above stays the primary consumer; see CLI_USAGE for why
370                // selecting this set only makes sense on a profile whose zero_poi_*
371                // fields have been cleared (both applied at once double-counts).
372                zero_sets = Some(vec![ProfileZeroSet {
373                    name: "a7p-zero".to_string(),
374                    zero_distance: None,
375                    poi_up_mil: Some(-(up_clicks * click_rad * 1000.0)),
376                    poi_right_mil: Some(-(right_clicks * click_rad * 1000.0)),
377                    notes: Some("imported .a7p zero_x/zero_y click state".to_string()),
378                }]);
379                push(
380                    "zero_x / zero_y",
381                    format!(
382                        "({}, {}) raw = {:.2} right / {:.2} up device clicks",
383                        src.zero_x_raw, src.zero_y_raw, right_clicks, up_clicks
384                    ),
385                    format!(
386                        "POI {:.2} cm up / {:.2} cm right at {zero_distance_m:.0} m \
387                         (--zero-click {}{})",
388                        up_m * 100.0,
389                        right_m * 100.0,
390                        click.size,
391                        match click.base {
392                            crate::adjustment::ClickBase::Mil => "mil",
393                            crate::adjustment::ClickBase::Moa => "moa",
394                            crate::adjustment::ClickBase::Smoa => "smoa",
395                        },
396                    ),
397                    "zero_poi_up_m + zero_poi_right_m + zero_sets[a7p-zero]",
398                );
399            }
400            (Some(_), _) => {
401                report.unmapped.push((
402                    "zero_x / zero_y".to_string(),
403                    format!(
404                        "scope zeroing click offsets ({}, {}) — the file stores no zero \
405                         distance, so --zero-click cannot convert them to a POI offset",
406                        src.zero_x_raw, src.zero_y_raw
407                    ),
408                ));
409            }
410            // No --zero-click: the historical report line, byte-identical (the .a7p
411            // format itself carries no click size to convert with).
412            (None, _) => {
413                report.unmapped.push((
414                    "zero_x / zero_y".to_string(),
415                    format!(
416                        "scope zeroing click offsets ({}, {}) — device click size not \
417                         supplied; pass --zero-click to record the profile's turret \
418                         graduation and convert this offset",
419                        src.zero_x_raw, src.zero_y_raw
420                    ),
421                ));
422            }
423        }
424    }
425    if !src.distances_m.is_empty() {
426        report.unmapped.push((
427            "distances".to_string(),
428            format!("{} range-card entries (device UI list)", src.distances_m.len()),
429        ));
430    }
431    if src.switches_count > 0 {
432        report.unmapped.push((
433            "switches".to_string(),
434            format!("{} device UI switch entries", src.switches_count),
435        ));
436    }
437    for (field, value) in [
438        ("cartridge_name", &src.cartridge_name),
439        ("caliber", &src.caliber),
440        ("short_name_top", &src.short_name_top),
441        ("short_name_bot", &src.short_name_bot),
442        ("device_uuid", &src.device_uuid),
443    ] {
444        if !value.is_empty() {
445            report
446                .unmapped
447                .push((field.to_string(), format!("\"{}\"", value.trim())));
448        }
449    }
450    if !src.user_note.trim().is_empty() {
451        report
452            .unmapped
453            .push(("user_note".to_string(), format!("\"{}\"", src.user_note.trim())));
454    }
455    for unknown in &doc.unknown_fields {
456        report.unmapped.push((
457            format!("{} field #{}", unknown.context, unknown.number),
458            "unknown field (file newer than this importer)".to_string(),
459        ));
460    }
461
462    let profile = ProfileData {
463        name,
464        velocity: src.muzzle_velocity_mps,
465        bc,
466        mass: src.bullet_weight_gr * GRAIN_TO_GRAM,
467        diameter: src.bullet_diameter_in * IN_TO_MM,
468        drag_model: drag_model.to_string(),
469        twist_rate: Some(src.twist_in_per_turn * IN_TO_MM),
470        sight_height: Some(src.sight_height_mm),
471        zero_distance: src.zero_distance_m,
472        units: "metric".to_string(),
473        temperature: src.air_temperature_c,
474        pressure: src.air_pressure_hpa,
475        humidity: src.air_humidity_pct,
476        altitude: 0.0,
477        bullet_name: if src.bullet_name.is_empty() {
478            None
479        } else {
480            Some(src.bullet_name.clone())
481        },
482        created: Some(timestamp_string()),
483        wind_speed: None,
484        wind_direction: None,
485        shooting_angle: None,
486        auto_zero: src.zero_distance_m,
487        twist_right: Some(src.twist_right),
488        // Left unset (not forced to Some(true)): consumers already treat "bc_segments
489        // present" as implying velocity-segment behavior is active (the existing
490        // `effective_use_bc_segments = use_bc_segments || bc_segments_data.is_some()`
491        // pattern), so this boolean keeps its original meaning rather than being
492        // overloaded by import.
493        use_bc_segments: None,
494        bullet_length: Some(src.bullet_length_in * IN_TO_MM),
495        // MBA-1348: populated only when --zero-click supplied a device click size (see
496        // the mapping above); otherwise left for `profile save
497        // --elevation-click/--windage-click` (or a later edit) to fill in.
498        elevation_click: click_from_zero_click.clone(),
499        windage_click: click_from_zero_click,
500        bc_segments,
501        drag_curve,
502        // .a7p carries no DSF concept; that's the `dsf` verb's job post-import.
503        dsf_points: None,
504        // .a7p carries no BC-reference-standard concept; imported BCs are treated as
505        // ICAO-referenced (the omitted-field default) unless the user later edits the
506        // saved profile with `--bc-reference`.
507        bc_reference: None,
508        // .a7p carries no QNH/pressure-reference concept; `air_pressure_hpa` is treated as
509        // absolute station pressure (the omitted-field default) unless the user later edits
510        // the saved profile with `--pressure-type`.
511        pressure_reference: None,
512        // .a7p carries no density-altitude concept; the imported temperature/pressure/altitude
513        // are used as-is unless the user later edits the saved profile with
514        // `--density-altitude`.
515        density_altitude: None,
516        // MBA-1359: populated only when --zero-click supplied a device click size to
517        // convert the file's zero_x/zero_y click counts with (see the mapping above).
518        zero_poi_up_m,
519        zero_poi_right_m,
520        // MBA-1396: .a7p has no lateral sight-offset concept; left for a hand edit.
521        sight_offset_lateral_m: None,
522        // MBA-1358: .a7p has no scope tracking-CF concept; derive with `tall-target`.
523        elevation_cf: None,
524        windage_cf: None,
525        // MBA-1360: Some only when --zero-click converted zero_x/zero_y (see above).
526        zero_sets,
527        // MBA-1361: .a7p carries no reticle description; attach one after import with
528        // `profile save --reticle-json`.
529        reticle: None,
530        // MBA-1348: .a7p carries no turret-mechanics or reticle-hold-bounds concept
531        // beyond the click graduation above; these are for `profile save` (or a hand
532        // edit) to fill in after import.
533        clicks_per_revolution: None,
534        zero_stop: None,
535        elevation_travel_up_mil: None,
536        elevation_travel_down_mil: None,
537        windage_travel_left_mil: None,
538        windage_travel_right_mil: None,
539        turret_elevation_dialed_mil: None,
540        turret_windage_dialed_mil: None,
541        hold_bound_up_mil: None,
542        hold_bound_down_mil: None,
543        hold_bound_left_mil: None,
544        hold_bound_right_mil: None,
545    };
546
547    Ok(A7pImportOutcome { profile, report })
548}