#[cfg(all(
feature = "jemalloc",
not(feature = "mimalloc"),
not(target_env = "msvc")
))]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
#[cfg(all(feature = "mimalloc", not(target_env = "msvc")))]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
mod mcp_command;
mod solve_json_command;
use ballistics_engine::adjustment::{
adjustment_display, adjustment_factor, adjustment_unit_label, click_size_mil, clicks_for,
parse_click_value, windage_adjustment_display, AdjustmentUnit, ClickBase, ClickValue,
};
#[cfg(test)]
use ballistics_engine::adjustment::drop_to_adjustment;
use ballistics_engine::optic::{OpticProfile, TravelLimits, TurretState};
use ballistics_engine::profile::{
require_angular_pair, require_hold_bounds, validate_tracking_cf, ProfileBcSegment, ProfileData,
ProfileDragPoint, ProfileZeroSet,
};
#[cfg(feature = "profile-import")]
use ballistics_engine::profile_import::{map_a7p_to_profile, sanitize_profile_name, ImportReport};
use ballistics_engine::optic::{
plan_corrections, AngularCorrection, Axis, DialPlanReportV1, Direction, LimitKind, Preferences,
Strategy,
};
#[cfg(feature = "pdf")]
use ballistics_engine::pdf_dope_card::{
calculate_density_altitude, DopeCardConfig, FontSizePreset, RangeUnit,
};
use ballistics_engine::terminal_plot;
#[cfg(feature = "online")]
use ballistics_engine::api_client::{ApiClient, TrajectoryRequestBuilder, TrueVelocityRequest};
use ballistics_engine::bc_table::BcCorrectionTable;
use ballistics_engine::bc_table_5d::{Bc5dError, Bc5dTableManager};
#[cfg(feature = "online")]
use ballistics_engine::bc_table_download::Bc5dDownloader;
use ballistics_engine::cli_api::UnitSystem;
use ballistics_engine::constants::{
DEFAULT_POWDER_REFERENCE_TEMP_C, DEFAULT_POWDER_REFERENCE_TEMP_F, FPS_TO_MPS, GRAINS_PER_GRAM,
GRAINS_TO_KG, GRAMS_PER_GRAIN,
};
use ballistics_engine::mc_stats::ConfidenceLevel;
use ballistics_engine::truing::{
calculate_true_velocity_local, correct_chrono_velocity_fps, dsf_window_start,
fallback_bullet_length_m, mv_calibration_window, parse_truing_observation,
run_multi_observation_truing_core, validate_truing_observations, DragModelArg, DropUnit,
MultiTruingReport, TruingEarthFrame, TruingModelInputsV1, TruingObservation, TruingTwist,
};
use ballistics_engine::truing_dsf::{apply_dsf, DsfPoint, DsfTable, UpsertOutcome};
use ballistics_engine::truing_plan::{
plan_truing_experiment_v1, TruingExperimentPlanRequestV1, TruingExperimentPlanV1,
TruingPlanModeV1,
};
use ballistics_engine::truing_uncertainty::{
run_uncertainty_truing_v1, NormalPriorV1, TruingApproximationV1, TruingPredictionRequestV1,
TruingPriorsV1, UncertaintyTruingReportV1, UncertaintyTruingRequestV1,
WeightedTruingObservationV1,
};
use ballistics_engine::truing_wind::{
format_wind_truing_report, parse_wind_observation, solve_wind_truing, WindTruingOutput,
WindTruingRequest, MPH_TO_MPS,
};
use ballistics_engine::truing_service::{
derive_dsf_point_from_solve_v1, tall_target_v1, DsfServiceErrorV1, TallTargetErrorV1,
TallTargetRequestV1,
};
use ballistics_engine::wez::{compute_wez, parse_target_size, TargetSizeMetric, WezResult, WezRow};
use ballistics_engine::atmosphere::{
resolve_station_conditions_with_pressure_mode, PressureReferenceMode,
};
use ballistics_engine::power_factor::{evaluate_all as pf_evaluate_all, scored_power_factor};
use ballistics_engine::recoil::{
free_recoil, FirearmType, FreeRecoilInputs, GasVelocityModel, POUNDS_TO_KG,
};
use ballistics_engine::{
run_monte_carlo_adaptive_seeded, trajectory_sampling, AdaptiveMcReportV1,
AtmosphericConditions, BCSegmentData, BallisticInputs, BcReferenceStandard, DragModel,
McConvergence, McStopReason, MonteCarloParams, TrajectorySolver, WindConditions,
};
use ballistics_engine::hold_curve::{
build_trajectory_components, card_sample_max_range_m, range_not_sampled_message,
run_sampled_flight, run_sampled_trajectory, sample_at_range, CardTruncation, HoldCurve,
HoldCurveLoad, MarkToRangeOutcome, CARD_SAMPLE_INTERVAL_M,
};
use ballistics_engine::card::CardRow;
use ballistics_engine::card::{
adaptive_card, AdaptiveBudget, AdaptiveCardReportV1, AdaptiveRequest, CardAdjustmentUnit,
CardError,
};
use ballistics_engine::wind::{parse_wind_direction_standalone, ParsedWindDirection};
use ballistics_engine::wind_scenarios::{
format_robust_hold_report, parse_target_spec, parse_wind_scenario_set, solve_robust_hold,
CorridorLoad, RobustHoldFormat, RobustHoldRequest, TargetSpec,
};
use ballistics_engine::error_budget::{
error_budget_with_target, ErrorBudgetReportV1, TargetGeometryV1,
};
use ballistics_engine::explain::{explain_difference, DeltaV1, SolutionDiffReportV1};
use ballistics_engine::perturbation::{axis_meta, AxisKind, DifferenceScheme, InputAxis};
use ballistics_engine::solve_json::{
decode_solve_request_v1, ResolvedSolveRequestV1, SolveErrorEnvelopeV1,
};
use ballistics_engine::tolerance::{tolerance_envelope, LimitingBoundaryV1, ToleranceReportV1};
use ballistics_engine::reticle::{
format_reticle_description, format_reticle_hold, hold_point_in_reticle, FocalPlane,
ReticleDescription, ReticleFormat,
};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use strsim::levenshtein;
const WIND_DIRECTION_HELP: &str = "Wind direction: degrees (wind-FROM: 0=headwind, \
90=from right, 180=tailwind, 270=from left) or a clock position (3oc, 10h30, \
10:30; 12oc = headwind)";
fn parse_wind_direction_arg(
s: &str,
) -> Result<ParsedWindDirection, ballistics_engine::wind::WindDirectionParseError> {
parse_wind_direction_standalone(s)
}
#[cfg(feature = "online")]
const TOS_URL: &str = "https://ballistics.rs/terms.txt";
#[cfg(feature = "online")]
const TOS_VERSION: &str = "2026-01-26";
#[cfg(feature = "online")]
#[derive(Debug, Serialize, Deserialize)]
struct TosAcceptance {
accepted: bool,
accepted_version: String,
accepted_at: String,
terms_hash: String,
}
#[cfg(feature = "online")]
fn get_tos_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".ballistics").join("tos.json"))
}
#[cfg(feature = "online")]
fn check_tos_accepted() -> bool {
let Some(tos_path) = get_tos_file_path() else {
return false;
};
if !tos_path.exists() {
return false;
}
match fs::read_to_string(&tos_path) {
Ok(content) => {
match serde_json::from_str::<TosAcceptance>(&content) {
Ok(acceptance) => {
acceptance.accepted && acceptance.accepted_version == TOS_VERSION
}
Err(_) => false,
}
}
Err(_) => false,
}
}
#[cfg(feature = "online")]
fn fetch_tos_text() -> Result<String, String> {
let mut response = ureq::get(TOS_URL)
.config()
.timeout_global(Some(std::time::Duration::from_secs(10)))
.build()
.call()
.map_err(|e| format!("Failed to fetch Terms of Service: {}", e))?;
response
.body_mut()
.read_to_string()
.map_err(|e| format!("Failed to read Terms of Service: {}", e))
}
#[cfg(feature = "online")]
fn fnv1a_hash(data: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325;
for &byte in data {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[cfg(feature = "online")]
fn hash_tos(text: &str) -> String {
format!("{:x}", fnv1a_hash(text.as_bytes()))
}
#[cfg(feature = "online")]
fn save_tos_acceptance(accepted: bool, terms_hash: &str) -> Result<(), String> {
let Some(tos_path) = get_tos_file_path() else {
return Err("Could not determine home directory".to_string());
};
if let Some(parent) = tos_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create .ballistics directory: {}", e))?;
}
let acceptance = TosAcceptance {
accepted,
accepted_version: TOS_VERSION.to_string(),
accepted_at: chrono_lite_timestamp(),
terms_hash: terms_hash.to_string(),
};
let json = serde_json::to_string_pretty(&acceptance)
.map_err(|e| format!("Failed to serialize TOS acceptance: {}", e))?;
fs::write(&tos_path, json)
.map_err(|e| format!("Failed to write TOS acceptance file: {}", e))?;
Ok(())
}
#[cfg(feature = "online")]
fn chrono_lite_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
}
#[cfg(feature = "online")]
fn prompt_tos_acceptance() -> Result<bool, String> {
eprintln!("\n");
eprintln!("================================================================================");
eprintln!(" TERMS OF SERVICE ACCEPTANCE REQUIRED");
eprintln!("================================================================================");
eprintln!();
eprintln!("The --online feature sends data to a proprietary cloud service.");
eprintln!("Before using this feature, you must accept the Terms of Service.");
eprintln!();
eprintln!("Fetching Terms of Service from {}...", TOS_URL);
eprintln!();
let tos_text = fetch_tos_text()?;
let terms_hash = hash_tos(&tos_text);
eprintln!("{}", tos_text);
eprintln!();
eprintln!("================================================================================");
eprintln!();
eprint!("Do you accept the Terms of Service? [y/N]: ");
io::stderr().flush().ok();
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| format!("Failed to read input: {}", e))?;
let accepted = matches!(input.trim().to_lowercase().as_str(), "y" | "yes");
save_tos_acceptance(accepted, &terms_hash)?;
if accepted {
eprintln!();
eprintln!(
"Terms of Service accepted. This will not be shown again unless the terms change."
);
eprintln!();
} else {
eprintln!();
eprintln!("Terms of Service not accepted. The --online feature cannot be used.");
eprintln!("You can still use local calculations by omitting the --online flag.");
eprintln!();
}
Ok(accepted)
}
#[cfg(feature = "online")]
fn ensure_tos_accepted() -> Result<bool, String> {
if check_tos_accepted() {
return Ok(true);
}
prompt_tos_acceptance()
}
fn f64_range(min: f64, max: f64) -> clap::builder::ValueParser {
clap::builder::ValueParser::new(F64RangeParser { min, max })
}
#[derive(Clone, Debug)]
struct F64RangeParser {
min: f64,
max: f64,
}
impl clap::builder::TypedValueParser for F64RangeParser {
type Value = f64;
fn parse_ref(
&self,
_cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let s = value.to_str().ok_or_else(|| {
let mut err = clap::Error::new(clap::error::ErrorKind::InvalidUtf8);
if let Some(a) = arg {
err.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String(a.get_id().to_string()),
);
}
err
})?;
let inner: f64 = s.parse().map_err(|_| {
let mut err = clap::Error::new(clap::error::ErrorKind::ValueValidation);
if let Some(a) = arg {
err.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String(a.get_id().to_string()),
);
}
err.insert(
clap::error::ContextKind::InvalidValue,
clap::error::ContextValue::String(format!("'{}' is not a valid number", s)),
);
err
})?;
if !inner.is_finite() || inner < self.min || inner > self.max {
let mut err = clap::Error::new(clap::error::ErrorKind::ValueValidation);
if let Some(a) = arg {
err.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String(a.get_id().to_string()),
);
}
err.insert(
clap::error::ContextKind::InvalidValue,
clap::error::ContextValue::String(format!(
"{} is not in range {:.6}..={:.6}",
inner, self.min, self.max
)),
);
return Err(err);
}
Ok(inner)
}
}
fn parse_confidence_level(s: &str) -> Result<ConfidenceLevel, String> {
match s {
"90" => Ok(ConfidenceLevel::P90),
"95" => Ok(ConfidenceLevel::P95),
"99" => Ok(ConfidenceLevel::P99),
_ => Err(format!("confidence level '{s}' must be one of: 90, 95, 99")),
}
}
fn parse_positive_half_width(s: &str) -> Result<f64, String> {
let v: f64 = s
.parse()
.map_err(|_| format!("half-width '{s}' is not a valid number"))?;
if !v.is_finite() || v <= 0.0 {
return Err(format!(
"half-width '{s}' must be a finite value greater than zero (probability units, e.g. 0.02 = +-2 percentage points)"
));
}
Ok(v)
}
#[derive(Parser)]
#[command(name = "ballistics")]
#[command(author = "Ballistics Engine Team")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "High-performance ballistics trajectory calculator", long_about = None)]
struct Cli {
#[arg(short = 'u', long, default_value = "imperial", global = true)]
units: UnitSystem,
#[command(subcommand)]
command: Commands,
}
#[allow(
clippy::large_enum_variant,
reason = "variants intentionally mirror the stable flat Clap command shape"
)]
#[derive(Subcommand)]
enum Commands {
SolveJson,
Mcp,
#[cfg(feature = "online")]
Login {
#[arg(long)]
token: Option<String>,
},
#[cfg(feature = "online")]
Logout,
#[cfg(feature = "online")]
RecommendPowder {
#[arg(long)]
cartridge: String,
#[arg(long)]
bullet_weight: f64,
#[arg(long)]
desired_velocity: f64,
#[arg(long)]
barrel_length: Option<f64>,
#[arg(long)]
velocity_tolerance: Option<i64>,
#[arg(long, default_value = "https://api.ballistics.7.62x51mm.sh")]
api_url: String,
},
#[cfg(feature = "online")]
RecommendTwist {
#[arg(long)]
caliber: f64,
#[arg(long)]
weight: f64,
#[arg(long)]
overall_length: f64,
#[arg(long)]
nose_length: f64,
#[arg(long)]
muzzle_velocity: Option<f64>,
#[arg(long, default_value = "https://api.ballistics.7.62x51mm.sh")]
api_url: String,
},
#[cfg(feature = "online")]
RecommendCol {
#[arg(long)]
cartridge: String,
#[arg(long)]
bullet_weight: Option<i64>,
#[arg(long)]
bullet_type: Option<String>,
#[arg(long, default_value = "https://api.ballistics.7.62x51mm.sh")]
api_url: String,
},
#[cfg(feature = "online")]
CalibrateBc {
#[arg(long)]
diameter: f64,
#[arg(long)]
length: f64,
#[arg(long)]
weight: f64,
#[arg(long)]
bullet_type: Option<String>,
#[arg(long, default_value = "https://api.ballistics.7.62x51mm.sh")]
api_url: String,
},
Trajectory {
#[arg(long, value_name = "FILE", requires = "profile_row")]
profile: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
profile_row: Option<String>,
#[arg(long, value_name = "NAME")]
saved_profile: Option<String>,
#[arg(long, value_name = "FILE", requires = "site")]
location: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
site: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(long, default_value = "0.0", allow_hyphen_values = true)]
velocity_adjustment: f64,
#[arg(short = 'a', long, default_value = "0.0")]
angle: f64,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(long, default_value = "1.0")]
bc_adjustment: f64,
#[arg(long, value_enum)]
bc_reference: Option<BcReferenceStandard>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long, default_value = "1000.0")]
max_range: f64,
#[arg(long, default_value = "0.001", value_parser = f64_range(0.00001, 0.1))]
time_step: f64,
#[arg(long, default_value = "0.0")]
wind_speed: f64,
#[arg(long, value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: Option<ParsedWindDirection>,
#[arg(long, value_enum, default_value = "shooter")]
wind_ref: WindReferenceArg,
#[arg(
long,
value_parser = f64_range(-100.0, 100.0),
default_value_t = 0.0,
allow_hyphen_values = true
)]
wind_vertical: f64,
#[arg(long = "wind-segment", value_name = "SPEED:ANGLE:DIST[:VERTICAL]", action = clap::ArgAction::Append)]
wind_segment: Vec<String>,
#[arg(long = "bc-segment", value_name = "VMIN:VMAX:BC", action = clap::ArgAction::Append)]
bc_segment: Vec<String>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, allow_hyphen_values = true, value_parser = f64_range(-5000.0, 120000.0))]
density_altitude: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
#[arg(long)]
full: bool,
#[arg(long, value_enum, num_args = 0..=1, default_missing_value = "braille")]
plot: Option<PlotStyle>,
#[arg(long)]
auto_zero: Option<f64>,
#[arg(long, value_name = "NAME")]
zero_set: Option<String>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_up: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_right: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
sight_offset: Option<f64>,
#[arg(long, visible_alias = "muzzle-height")]
bore_height: Option<f64>,
#[arg(long)]
ignore_ground_impact: bool,
#[arg(long)]
use_bc_segments: bool,
#[arg(long, value_name = "FILE")]
drag_table: Option<PathBuf>,
#[arg(long, value_name = "FACTOR")]
cd_scale: Option<f64>,
#[arg(
long,
alias = "cant-angle",
value_name = "DEGREES",
default_value_t = 0.0
)]
cant: f64,
#[arg(long)]
print_bc_segments: bool,
#[arg(long)]
enable_magnus: bool,
#[arg(long)]
enable_coriolis: bool,
#[arg(long)]
use_euler: bool,
#[arg(long)]
use_rk4_fixed: bool,
#[arg(long)]
enable_spin_drift: bool,
#[arg(long)]
enable_aerodynamic_jump: bool,
#[arg(long)]
enable_wind_shear: bool,
#[arg(long)]
sample_trajectory: bool,
#[arg(long, default_value = "10.0")]
sample_interval: f64,
#[arg(long, value_enum, default_value_t = DropsReferenceArg::Los)]
drops_reference: DropsReferenceArg,
#[arg(long)]
enable_pitch_damping: bool,
#[arg(long)]
enable_precession: bool,
#[arg(long)]
use_cluster_bc: bool,
#[arg(
long,
value_name = "FILE",
help = "Use precomputed BC correction table instead of online API"
)]
bc_table: Option<PathBuf>,
#[arg(
long,
value_name = "DIR",
help = "Use caliber-specific 5D BC correction tables"
)]
bc_table_dir: Option<PathBuf>,
#[cfg(feature = "online")]
#[arg(long, help = "Auto-download BC5D tables when needed")]
bc_table_auto: bool,
#[cfg(feature = "online")]
#[arg(
long,
default_value = "https://ballistics.tools/downloads/bc5d",
help = "Base URL for BC5D table downloads"
)]
bc_table_url: String,
#[cfg(feature = "online")]
#[arg(long, help = "Force re-download even if cached")]
bc_table_refresh: bool,
#[arg(long)]
bullet_length: Option<f64>,
#[arg(long)]
twist_rate: Option<f64>,
#[arg(
long,
num_args = 0..=1,
default_value_t = true,
default_missing_value = "true",
action = clap::ArgAction::Set
)]
twist_right: bool,
#[arg(long, value_parser = f64_range(-90.0, 90.0), allow_hyphen_values = true)]
latitude: Option<f64>,
#[arg(long, value_parser = f64_range(-180.0, 180.0), allow_hyphen_values = true)]
longitude: Option<f64>,
#[arg(long)]
shot_direction: Option<f64>,
#[arg(long, default_value = "0.0", allow_hyphen_values = true)]
shooting_angle: f64,
#[arg(long)]
use_powder_sensitivity: bool,
#[arg(long)]
powder_temp_sensitivity: Option<f64>,
#[arg(long)]
powder_temp: Option<f64>,
#[arg(long = "powder-temp-curve", value_name = "TEMP:VEL,...")]
powder_temp_curve: Option<String>,
#[arg(long, value_parser = f64_range(0.0, 6000.0))]
zero_velocity: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
zero_temperature: Option<f64>,
#[arg(long)]
zero_pressure: Option<f64>,
#[arg(long, value_enum)]
zero_pressure_type: Option<PressureReferenceMode>,
#[arg(long, value_parser = f64_range(0.0, 100.0))]
zero_humidity: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
zero_altitude: Option<f64>,
#[arg(long, allow_hyphen_values = true, value_parser = f64_range(-5000.0, 120000.0))]
zero_density_altitude: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
zero_powder_temp: Option<f64>,
#[cfg(feature = "online")]
#[arg(
long,
help = "Route calculations through Flask API for ML enhancements"
)]
online: bool,
#[cfg(feature = "online")]
#[arg(
long,
default_value = "https://api.ballistics.7.62x51mm.sh",
help = "API endpoint URL"
)]
api_url: String,
#[cfg(feature = "online")]
#[arg(long, help = "Use local calculation if API fails")]
offline_fallback: bool,
#[cfg(feature = "online")]
#[arg(long, help = "Show comparison between local and API results")]
compare: bool,
#[cfg(feature = "online")]
#[arg(long, default_value = "10", help = "API timeout in seconds")]
api_timeout: u64,
#[cfg(feature = "online")]
#[arg(long, help = "Enable automatic weather zone generation")]
enable_weather_zones: bool,
#[cfg(feature = "online")]
#[arg(long, help = "Enable 3D weather with altitude corrections")]
enable_3d_weather: bool,
#[arg(
long,
value_enum,
default_value = "power_law",
help = "Wind shear model: none, logarithmic, power_law, ekman_spiral, custom_layers"
)]
wind_shear_model: WindShearModelArg,
#[cfg(feature = "online")]
#[arg(
long,
default_value = "linear",
help = "Weather zone interpolation: linear, cubic, step"
)]
weather_zone_interpolation: String,
#[arg(long, default_value = "0.0", value_parser = f64_range(0.0, 300.0))]
target_speed: f64,
#[arg(long)]
with_drag_coefficient: bool,
#[arg(long)]
powder: Option<String>,
#[arg(long)]
bullet_name: Option<String>,
#[arg(long)]
location_name: Option<String>,
#[arg(long, value_name = "FILE")]
output_file: Option<PathBuf>,
#[arg(long, default_value = "1.0")]
font_scale: f32,
#[arg(long, value_name = "SIZE")]
font_preset: Option<String>,
#[arg(long)]
bold_data: bool,
#[arg(long, value_enum, default_value = "mil")]
adjustment_unit: AdjustmentUnit,
#[arg(long, value_enum)]
windage_unit: Option<AdjustmentUnit>,
#[arg(long)]
elevation_click_value: Option<String>,
#[arg(long)]
windage_click_value: Option<String>,
#[arg(long, value_name = "FACTOR")]
elevation_cf: Option<f64>,
#[arg(long, value_name = "FACTOR")]
windage_cf: Option<f64>,
},
MonteCarlo {
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: f64,
#[arg(short = 'a', long, default_value = "0.0")]
angle: f64,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: f64,
#[arg(long, value_enum, default_value = "icao")]
bc_reference: BcReferenceStandard,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: f64,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: f64,
#[arg(short = 'n', long, default_value = "1000")]
num_sims: usize,
#[arg(long, default_value = "1.0")]
velocity_std: f64,
#[arg(long, default_value = "0.1")]
angle_std: f64,
#[arg(long, default_value = "0.01")]
bc_std: f64,
#[arg(long, default_value = "1.0")]
wind_std: f64,
#[arg(long, visible_alias = "wind-dir-std", default_value = "0.0")]
wind_direction_std: f64,
#[arg(long, default_value = "0.0")]
wind_speed: f64,
#[arg(long, default_value = "0.0", value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: ParsedWindDirection,
#[arg(long, value_enum, default_value = "shooter")]
wind_ref: WindReferenceArg,
#[arg(long, value_name = "DEG")]
shot_direction: Option<f64>,
#[arg(
long,
value_parser = f64_range(-100.0, 100.0),
default_value_t = 0.0,
allow_hyphen_values = true
)]
wind_vertical: f64,
#[arg(long)]
target_distance: Option<f64>,
#[arg(long)]
target_radius: Option<f64>,
#[arg(long, value_name = "FILE")]
drag_table: Option<std::path::PathBuf>,
#[arg(long, value_name = "FACTOR")]
cd_scale: Option<f64>,
#[arg(
long,
alias = "cant-angle",
value_name = "DEGREES",
default_value_t = 0.0
)]
cant: f64,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
sight_offset: Option<f64>,
#[arg(long)]
wez: bool,
#[arg(long, value_name = "WxH|R", requires = "wez")]
target_size: Option<String>,
#[arg(long, default_value = "0.0", requires = "wez")]
wind_call_error: f64,
#[arg(long, default_value = "200.0", requires = "wez")]
wez_start: f64,
#[arg(long, default_value = "1000.0", requires = "wez")]
wez_end: f64,
#[arg(long, default_value = "100.0", requires = "wez")]
wez_step: f64,
#[arg(long, conflicts_with = "wez")]
adaptive: bool,
#[arg(long, value_parser = parse_confidence_level)]
confidence: Option<ConfidenceLevel>,
#[arg(
long,
default_value = "0.02",
requires = "adaptive",
allow_hyphen_values = true,
value_parser = parse_positive_half_width
)]
target_ci_half_width: f64,
#[arg(long, requires = "adaptive")]
min_samples: Option<u64>,
#[arg(
long,
requires = "adaptive",
value_parser = clap::value_parser!(u64).range(1..)
)]
max_samples: Option<u64>,
#[arg(
long,
default_value = "500",
requires = "adaptive",
value_parser = clap::value_parser!(u64).range(1..)
)]
mc_batch_size: u64,
#[arg(long)]
seed: Option<u64>,
#[arg(short = 'o', long, default_value = "summary")]
output: MonteCarloOutput,
},
Zero {
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: f64,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: f64,
#[arg(long, value_enum, default_value = "icao")]
bc_reference: BcReferenceStandard,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: f64,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: f64,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(
long,
required_unless_present = "from_angle",
conflicts_with = "from_angle"
)]
target_distance: Option<f64>,
#[arg(
long,
value_name = "DEGREES",
required_unless_present = "target_distance",
conflicts_with = "target_distance",
allow_hyphen_values = true
)]
from_angle: Option<f64>,
#[arg(long, default_value = "0.0", allow_hyphen_values = true)]
target_height: f64,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum, default_value = "absolute")]
pressure_type: PressureReferenceMode,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, allow_hyphen_values = true, value_parser = f64_range(-5000.0, 120000.0))]
density_altitude: Option<f64>,
#[arg(long, value_name = "FILE")]
drag_table: Option<std::path::PathBuf>,
#[arg(long, value_name = "FACTOR")]
cd_scale: Option<f64>,
#[arg(long, value_name = "FACTOR")]
elevation_cf: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
EstimateBC {
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: f64,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: f64,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: f64,
#[arg(long)]
data: Option<String>,
#[arg(long)]
velocity_data: Option<String>,
#[arg(long, default_value = "both")]
drag_model: String,
#[arg(long)]
zero_range: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long)]
distance1: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
drop1: Option<f64>,
#[arg(long)]
distance2: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
drop2: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
BcConvert {
#[arg(long, value_enum, ignore_case = true)]
source_model: BcConvertModelArg,
#[arg(long, value_enum, ignore_case = true)]
target_model: BcConvertModelArg,
#[arg(
short = 'b',
long,
value_parser = f64_range(0.001, 2.0),
conflicts_with = "bc_segment",
required_unless_present = "bc_segment"
)]
bc: Option<f64>,
#[arg(
long,
value_parser = f64_range(0.0, 5.0),
conflicts_with_all = ["velocity", "bc_segment", "speed_of_sound"]
)]
mach: Option<f64>,
#[arg(
long,
value_parser = f64_range(0.001, 6000.0),
conflicts_with_all = ["mach", "bc_segment"]
)]
velocity: Option<f64>,
#[arg(
long = "bc-segment",
value_name = "VMIN:VMAX:BC",
action = clap::ArgAction::Append,
conflicts_with_all = ["bc", "mach", "velocity"],
required_unless_present = "bc"
)]
bc_segment: Vec<String>,
#[arg(
long,
value_parser = f64_range(0.001, 6000.0),
conflicts_with = "mach"
)]
speed_of_sound: Option<f64>,
#[arg(short = 'o', long, default_value = "table", ignore_case = true)]
output: BcConversionOutputFormat,
},
GenerateBCSegments {
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: f64,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: f64,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: f64,
#[arg(long, default_value = "")]
model: String,
#[arg(long, default_value = "G1")]
drag_model: String,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
TrueVelocity {
#[arg(long)]
measured_drop: f64,
#[arg(long)]
range: f64,
#[arg(long = "observed", value_name = "RANGE:DROP", action = clap::ArgAction::Append)]
observed: Vec<String>,
#[arg(long, value_name = "SIGMA")]
observation_sigma: Option<f64>,
#[arg(long, value_name = "MEAN:SIGMA")]
mv_prior: Option<String>,
#[arg(long, value_name = "MEAN:SIGMA")]
bc_prior: Option<String>,
#[arg(long, value_name = "RANGE", action = clap::ArgAction::Append)]
predict_range: Vec<f64>,
#[arg(long, value_name = "SIGMA")]
prediction_sigma: Option<f64>,
#[arg(long = "drop-unit", default_value = "mil")]
drop_unit: DropUnit,
#[arg(long, value_name = "FACTOR")]
elevation_cf: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: f64,
#[arg(long, default_value = "g1")]
drag_model: DragModelArg,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: f64,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: f64,
#[arg(long)]
chrono_velocity: Option<f64>,
#[arg(long, value_name = "DISTANCE")]
chrono_distance: Option<f64>,
#[arg(long, default_value = "100.0")]
zero_distance: f64,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, default_value = "imperial")]
units: UnitSystem,
#[arg(
long,
value_name = "DIR",
help = "Use caliber-specific 5D BC correction tables"
)]
bc_table_dir: Option<PathBuf>,
#[cfg(feature = "online")]
#[arg(long, help = "Auto-download BC5D tables when needed")]
bc_table_auto: bool,
#[cfg(feature = "online")]
#[arg(
long,
default_value = "https://ballistics.tools/downloads/bc5d",
help = "Base URL for BC5D table downloads"
)]
bc_table_url: String,
#[arg(long, help = "Force offline mode using local calculation")]
offline: bool,
#[cfg(feature = "online")]
#[arg(long, help = "Use local calculation if API fails")]
offline_fallback: bool,
#[cfg(feature = "online")]
#[arg(long, default_value = "https://api.ballistics.7.62x51mm.sh")]
api_url: String,
#[cfg(feature = "online")]
#[arg(long, default_value = "10")]
api_timeout: u64,
#[arg(long)]
bullet_length: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
#[command(verbatim_doc_comment)]
TrueWind {
#[arg(
long = "miss",
value_name = "RANGE:RIGHT[:SIGMA]",
action = clap::ArgAction::Append,
required = true,
allow_hyphen_values = true
)]
miss: Vec<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: f64,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: f64,
#[arg(long, default_value = "g1")]
drag_model: DragModelArg,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: f64,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: f64,
#[arg(long, value_parser = f64_range(0.1, 1000.0))]
twist_rate: f64,
#[arg(
long,
num_args = 0..=1,
default_value_t = true,
default_missing_value = "true",
action = clap::ArgAction::Set
)]
twist_right: bool,
#[arg(long, value_parser = f64_range(-90.0, 90.0), allow_hyphen_values = true)]
latitude: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
shot_direction: Option<f64>,
#[arg(long, value_name = "SPEED", allow_hyphen_values = true)]
called_wind: Option<f64>,
#[arg(long, default_value = "100.0")]
zero_distance: f64,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0", allow_hyphen_values = true)]
altitude: f64,
#[arg(long, default_value = "imperial")]
units: UnitSystem,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
PlanTruing {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(long)]
drag_model: Option<DragModelArg>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(
long,
value_name = "R1,R2,...",
value_delimiter = ',',
num_args = 1..,
conflicts_with = "range_grid",
required_unless_present = "range_grid"
)]
candidate_ranges: Vec<f64>,
#[arg(
long,
value_name = "START:END:STEP",
conflicts_with = "candidate_ranges",
required_unless_present = "candidate_ranges"
)]
range_grid: Option<String>,
#[arg(long, default_value_t = 3)]
observation_count: usize,
#[arg(long, default_value_t = 0.0, value_parser = f64_range(0.0, 100000.0))]
minimum_separation: f64,
#[arg(long, value_parser = f64_range(f64::MIN_POSITIVE, 1000000.0))]
measurement_resolution: f64,
#[arg(long, default_value = "mil")]
drop_unit: DropUnit,
#[arg(long)]
zero_distance: Option<f64>,
#[arg(long, value_name = "NAME")]
zero_set: Option<String>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, value_parser = f64_range(0.0, 100.0))]
humidity: Option<f64>,
#[arg(long)]
altitude: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
TallTarget {
#[arg(long, value_name = "DIAL")]
dialed: f64,
#[arg(long, value_name = "TRAVEL")]
measured: f64,
#[arg(long, value_name = "RANGE")]
range: f64,
#[arg(long, value_enum, default_value = "mil")]
unit: AdjustmentUnit,
},
Mpbr {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long, default_value = "8.0")]
vital_zone: f64,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_up: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_right: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
sight_offset: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
ComeUps {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
zero_distance: f64,
#[arg(long, value_name = "NAME")]
zero_set: Option<String>,
#[arg(long, default_value = "100.0")]
start: f64,
#[arg(long, default_value = "1200.0")]
end: f64,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.001, 100000.0))]
step: f64,
#[arg(long, default_value = "mil")]
adjustment_unit: AdjustmentUnit,
#[arg(long)]
elevation_click_value: Option<String>,
#[arg(long)]
windage_click_value: Option<String>,
#[arg(long, value_name = "FACTOR")]
elevation_cf: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_up: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_right: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
sight_offset: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, default_value = "0.0")]
wind_speed: f64,
#[arg(long, default_value = "0.0", value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: ParsedWindDirection,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Dsf {
#[arg(long, value_name = "NAME")]
saved_profile: String,
#[arg(long)]
range: f64,
#[arg(long, value_name = "VALUEUNIT")]
observed_drop: String,
#[arg(long, value_name = "NAME")]
zero_set: Option<String>,
},
Lead {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, default_value = "0.0")]
wind_speed: f64,
#[arg(long, default_value = "0.0", value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: ParsedWindDirection,
#[arg(long)]
use_powder_sensitivity: bool,
#[arg(long)]
powder_temp_sensitivity: Option<f64>,
#[arg(long)]
powder_temp: Option<f64>,
#[arg(long = "powder-temp-curve", value_name = "TEMP:VEL,...")]
powder_temp_curve: Option<String>,
#[arg(long, value_parser = f64_range(0.0, 300.0))]
target_speed: f64,
#[arg(long, default_value = "90.0")]
target_angle: f64,
#[arg(long, value_parser = f64_range(0.001, 10000.0))]
target_length: Option<f64>,
#[arg(long, default_value = "100.0")]
start: f64,
#[arg(long, default_value = "600.0")]
end: f64,
#[arg(long, default_value = "100.0", value_parser = f64_range(0.001, 100000.0))]
step: f64,
#[arg(long, default_value = "mil")]
adjustment_unit: AdjustmentUnit,
#[arg(long)]
windage_click_value: Option<String>,
#[arg(long, value_name = "FACTOR")]
windage_cf: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Powder {
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
powder_temp_sensitivity: Option<f64>,
#[arg(long)]
powder_temp: Option<f64>,
#[arg(long = "powder-temp-curve", value_name = "TEMP:VEL,...")]
powder_temp_curve: Option<String>,
#[arg(long, value_name = "START:END:STEP")]
sweep: Option<String>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Recoil {
#[arg(short = 'b', long, value_parser = f64_range(0.1, 2000.0))]
bullet_weight: f64,
#[arg(short = 'c', long, value_parser = f64_range(0.0, 1000.0))]
charge_weight: f64,
#[arg(short = 'v', long, value_parser = f64_range(1.0, 6000.0))]
velocity: f64,
#[arg(short = 'f', long, value_parser = f64_range(0.1, 500.0))]
firearm_weight: f64,
#[arg(long, default_value = "rifle")]
firearm_type: RecoilFirearmTypeArg,
#[arg(long, value_parser = f64_range(0.0, 20.0))]
gas_velocity_factor: Option<f64>,
#[arg(long, value_parser = f64_range(0.0, 20000.0))]
gas_velocity: Option<f64>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
PowerFactor {
#[arg(short = 'w', long, value_parser = f64_range(0.1, 2000.0))]
weight: f64,
#[arg(short = 'v', long, value_parser = f64_range(1.0, 6000.0))]
velocity: f64,
#[arg(long)]
organization: Option<String>,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
WindCard {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
zero_distance: f64,
#[arg(long, value_name = "NAME")]
zero_set: Option<String>,
#[arg(long, default_value = "5,10,15,20")]
wind_speeds: String,
#[arg(long, value_parser = f64_range(0.0, 360.0), conflicts_with = "wind_angles")]
wind_angle: Option<f64>,
#[arg(long, conflicts_with = "wind_angle")]
wind_angles: Option<String>,
#[arg(long, default_value = "100.0")]
start: f64,
#[arg(long, default_value = "1000.0")]
end: f64,
#[arg(long, default_value = "100.0", value_parser = f64_range(0.001, 100000.0))]
step: f64,
#[arg(long, default_value = "mil")]
adjustment_unit: AdjustmentUnit,
#[arg(long)]
windage_click_value: Option<String>,
#[arg(long, value_name = "FACTOR")]
windage_cf: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_up: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_right: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
sight_offset: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0")]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Stability {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'm', long)]
mass: Option<f64>,
#[arg(short = 'd', long)]
diameter: Option<f64>,
#[arg(short = 'l', long)]
length: f64,
#[arg(short = 't', long)]
twist_rate: f64,
#[arg(short = 'v', long)]
velocity: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
RangeTable {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long)]
velocity: Option<f64>,
#[arg(short = 'b', long)]
bc: Option<f64>,
#[arg(short = 'm', long)]
mass: Option<f64>,
#[arg(short = 'd', long)]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
zero_distance: f64,
#[arg(long, value_name = "NAME")]
zero_set: Option<String>,
#[arg(long, default_value = "100.0")]
start: f64,
#[arg(long, default_value = "1200.0")]
end: f64,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.001, 100000.0))]
step: f64,
#[arg(long, default_value = "10.0")]
wind_speed: f64,
#[arg(long, default_value = "90.0", value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: ParsedWindDirection,
#[arg(long, default_value = "mil")]
adjustment_unit: AdjustmentUnit,
#[arg(long, value_enum)]
windage_unit: Option<AdjustmentUnit>,
#[arg(long)]
elevation_click_value: Option<String>,
#[arg(long)]
windage_click_value: Option<String>,
#[arg(long, value_name = "FACTOR")]
elevation_cf: Option<f64>,
#[arg(long, value_name = "FACTOR")]
windage_cf: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_up: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_right: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
sight_offset: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Compare {
#[arg(long = "load", value_name = "SPEC")]
loads: Vec<String>,
#[arg(long = "profile", value_name = "NAME")]
profiles: Vec<String>,
#[arg(long)]
zero_distance: f64,
#[arg(long, default_value = "100.0")]
start: f64,
#[arg(long, default_value = "500.0")]
end: f64,
#[arg(long, default_value = "100.0", value_parser = f64_range(0.001, 100000.0))]
step: f64,
#[arg(long, default_value = "10.0")]
wind_speed: f64,
#[arg(long, default_value = "90.0", value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: ParsedWindDirection,
#[arg(long, default_value = "mil")]
adjustment_unit: AdjustmentUnit,
#[arg(long, value_enum)]
windage_unit: Option<AdjustmentUnit>,
#[arg(long)]
elevation_click_value: Option<String>,
#[arg(long)]
windage_click_value: Option<String>,
#[arg(long, value_name = "FACTOR")]
elevation_cf: Option<f64>,
#[arg(long, value_name = "FACTOR")]
windage_cf: Option<f64>,
#[arg(long, default_value = "1.5")]
sight_height: f64,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_up: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
zero_poi_right: Option<f64>,
#[arg(long, value_name = "OFFSET", allow_hyphen_values = true)]
sight_offset: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0")]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Profile {
#[command(subcommand)]
action: ProfileAction,
},
DragCurve {
#[arg(long, default_value = "g7")]
drag_model: DragModel,
#[arg(short, long, default_value = "table")]
output: OutputFormat,
},
HoldCorridor {
#[arg(long, value_name = "FILE")]
scenarios: PathBuf,
#[arg(long, value_name = "R1,R2,...")]
ranges: String,
#[arg(long, value_name = "SPEC")]
target: Option<String>,
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
zero_distance: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
MarkToRange {
#[arg(long = "mark", value_name = "MIL", action = clap::ArgAction::Append, allow_hyphen_values = true)]
marks: Vec<f64>,
#[arg(long, value_name = "FILE")]
reticle_json: Option<PathBuf>,
#[arg(long, default_value = "10.0", value_parser = f64_range(0.0001, 1000.0))]
mag: f64,
#[arg(long, default_value = "ffp")]
focal_plane: FocalPlaneArg,
#[arg(long, default_value = "1.0", value_parser = f64_range(0.0001, 1000.0))]
reference_mag: f64,
#[arg(long)]
zero_distance: Option<f64>,
#[arg(long, default_value = "1500.0", value_parser = f64_range(1.0, 10000.0))]
max_range: f64,
#[command(flatten)]
load: InverseSolverLoadArgs,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
BdcMatch {
#[arg(long = "mark-range", value_name = "MIL:RANGE", action = clap::ArgAction::Append, required = true)]
mark_ranges: Vec<String>,
#[arg(long, default_value = "1.0", value_parser = f64_range(0.0001, 1000.0))]
reference_mag: f64,
#[arg(long, default_value = "sfp")]
focal_plane: FocalPlaneArg,
#[arg(long, value_name = "MIL", default_value = "0.2", value_parser = f64_range(0.0, 100.0))]
residual_warn: f64,
#[arg(long)]
zero_distance: Option<f64>,
#[command(flatten)]
load: InverseSolverLoadArgs,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
OptimalZero {
#[arg(long = "target", value_name = "RANGE[:HEIGHT]", action = clap::ArgAction::Append, required = true)]
targets: Vec<String>,
#[arg(long, value_name = "SIZE", value_parser = f64_range(0.0, 1000.0))]
vital: Option<f64>,
#[command(flatten)]
load: InverseSolverLoadArgs,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Explain {
#[arg(long, value_name = "FILE")]
a: PathBuf,
#[arg(long, value_name = "FILE")]
b: PathBuf,
#[arg(long, value_name = "R1,R2,...", value_delimiter = ',', num_args = 1.., required = true)]
ranges: Vec<f64>,
#[arg(short, long, default_value = "table")]
output: OutputFormat,
},
Tolerance {
#[arg(long, value_name = "FILE")]
request: PathBuf,
#[arg(long)]
range: f64,
#[arg(long)]
target: String,
#[arg(long = "axis", value_name = "AXIS", action = clap::ArgAction::Append, required = true)]
axes: Vec<String>,
#[arg(long = "domain", value_name = "AXIS=LO:HI", action = clap::ArgAction::Append)]
domains: Vec<String>,
#[arg(short, long, default_value = "table")]
output: OutputFormat,
},
ErrorBudget {
#[arg(long, value_name = "FILE")]
request: PathBuf,
#[arg(
long,
value_name = "R1,R2,...",
value_delimiter = ',',
num_args = 1..,
required = true
)]
ranges: Vec<f64>,
#[arg(
long = "sigma",
value_name = "AXIS=VALUE",
action = clap::ArgAction::Append,
required = true
)]
sigmas: Vec<String>,
#[arg(long)]
target: Option<String>,
#[arg(short, long, default_value = "table")]
output: OutputFormat,
},
Reticle {
#[command(subcommand)]
action: ReticleAction,
},
DialPlan {
#[arg(long, allow_hyphen_values = true, value_parser = parse_angular_mil)]
elevation: Option<f64>,
#[arg(long, allow_hyphen_values = true, value_parser = parse_angular_mil)]
windage: Option<f64>,
#[arg(long)]
range: f64,
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(long)]
elevation_click: Option<String>,
#[arg(long)]
windage_click: Option<String>,
#[arg(long)]
clicks_per_rev: Option<u32>,
#[arg(long)]
zero_stop: Option<bool>,
#[arg(long, value_parser = parse_angular_mil)]
travel_up: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
travel_down: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
windage_travel_left: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
windage_travel_right: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_up: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_down: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_left: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_right: Option<f64>,
#[arg(long, allow_hyphen_values = true, value_parser = parse_angular_mil)]
turret_elev: Option<f64>,
#[arg(long, allow_hyphen_values = true, value_parser = parse_angular_mil)]
turret_wind: Option<f64>,
#[arg(long)]
prefer_hold: bool,
#[arg(long, value_name = "MIL", value_parser = parse_angular_mil)]
max_hold: Option<f64>,
#[arg(short, long, default_value = "table")]
output: OutputFormat,
},
AdaptiveCard {
#[command(flatten)]
load: InverseSolverLoadArgs,
#[arg(long)]
zero_distance: Option<f64>,
#[arg(long)]
start: f64,
#[arg(long)]
end: f64,
#[arg(long = "anchor", value_name = "RANGE", action = clap::ArgAction::Append)]
anchors: Vec<f64>,
#[arg(long, value_name = "VALUEUNIT", value_parser = parse_angular_mil)]
elevation_budget: Option<f64>,
#[arg(long, value_name = "VALUEUNIT", value_parser = parse_angular_mil)]
windage_budget: Option<f64>,
#[arg(long, default_value = "25")]
max_rows: usize,
#[arg(long, value_enum, default_value = "mil")]
adjustment: AdaptiveCardAdjustmentArg,
#[arg(long, value_name = "FILE")]
output_file: Option<PathBuf>,
#[arg(short, long, default_value = "table")]
output: OutputFormat,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[allow(
clippy::large_enum_variant,
reason = "variants intentionally mirror the stable flat Clap command shape"
)]
#[derive(Subcommand)]
enum ReticleAction {
Hold {
#[arg(long, value_name = "FILE")]
reticle_json: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(long, value_name = "MIL", allow_hyphen_values = true)]
drop_mil: Option<f64>,
#[arg(long, value_name = "MIL", allow_hyphen_values = true)]
wind_mil: Option<f64>,
#[arg(long)]
range: Option<f64>,
#[arg(long, default_value = "10.0", value_parser = f64_range(0.0001, 1000.0))]
mag: f64,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
zero_distance: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, default_value = "0.0")]
wind_speed: f64,
#[arg(long, default_value = "0.0", value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: ParsedWindDirection,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
Generate {
#[command(subcommand)]
layout: ReticleLayout,
},
Import {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
},
}
#[derive(Subcommand)]
enum ReticleLayout {
MilGrid {
#[arg(long, default_value = "1.0")]
spacing: f64,
#[arg(long, default_value = "10.0")]
extent: f64,
#[command(flatten)]
common: ReticleGenerateCommon,
},
Tree {
#[arg(long, default_value = "5")]
rows: usize,
#[arg(long, default_value = "1.0")]
row_spacing: f64,
#[arg(long, default_value = "0.5")]
spread_step: f64,
#[command(flatten)]
common: ReticleGenerateCommon,
},
Bdc {
#[arg(long = "drop", value_name = "RANGE:DROP_MIL", action = clap::ArgAction::Append, required = true, allow_hyphen_values = true)]
drops: Vec<String>,
#[command(flatten)]
common: ReticleGenerateCommon,
},
}
#[derive(clap::Args)]
struct ReticleGenerateCommon {
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = "ffp")]
focal_plane: FocalPlaneArg,
#[arg(long, default_value = "1.0", value_parser = f64_range(0.0001, 1000.0))]
reference_mag: f64,
#[arg(short = 'o', long, default_value = "table")]
output: OutputFormat,
}
#[derive(clap::Args, Debug, Clone)]
struct InverseSolverLoadArgs {
#[arg(long, value_name = "NAME")]
profile: Option<String>,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: Option<f64>,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: Option<f64>,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: Option<f64>,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: Option<f64>,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum)]
pressure_type: Option<PressureReferenceMode>,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, default_value = "0.0")]
wind_speed: f64,
#[arg(long, default_value = "0.0", value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: ParsedWindDirection,
}
impl InverseSolverLoadArgs {
fn resolve(
&self,
zero_distance: Option<f64>,
units: UnitSystem,
) -> Result<(HoldCurveLoad, Option<ProfileData>), Box<dyn Error>> {
let profile = match self.profile.as_deref() {
Some(name) => Some(load_profile(name)?),
None => None,
};
let require = |value: Option<f64>, flag: &str| -> Result<f64, Box<dyn Error>> {
value.ok_or_else(|| format!("{flag} is required (directly or from --profile)").into())
};
let velocity = require(
resolve_param(self.velocity, &profile, |p| p.velocity),
"--velocity",
)?;
let bc = require(resolve_param(self.bc, &profile, |p| p.bc), "--bc")?;
let mass = require(resolve_param(self.mass, &profile, |p| p.mass), "--mass")?;
let diameter = require(
resolve_param(self.diameter, &profile, |p| p.diameter),
"--diameter",
)?;
let zero_distance = require(
zero_distance.or_else(|| profile.as_ref().and_then(|p| p.zero_distance)),
"--zero-distance",
)?;
if zero_distance <= 0.0 {
return Err("--zero-distance must be greater than zero".into());
}
let sight_height = self
.sight_height
.or_else(|| profile.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let temperature = UnitConverter::resolve_temperature(self.temperature, units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(self.pressure, units)?,
self.altitude,
self.pressure_type,
units,
);
Ok((
HoldCurveLoad {
velocity_mps: UnitConverter::velocity_to_metric(velocity, units),
bc,
mass_kg: UnitConverter::mass_to_metric(mass, units),
diameter_m: UnitConverter::diameter_to_metric(diameter, units),
drag_model: self.drag_model,
sight_height_m: UnitConverter::sight_height_to_metric(sight_height, units),
zero_distance_m: UnitConverter::distance_to_metric(zero_distance, units),
temperature_c: UnitConverter::temperature_to_metric(temperature, units),
pressure_hpa: UnitConverter::pressure_to_metric(pressure, units),
humidity: self.humidity,
altitude_m: UnitConverter::altitude_to_metric(self.altitude, units),
wind_speed_mps: UnitConverter::wind_to_metric(self.wind_speed, units),
wind_direction_deg: self.wind_direction.degrees,
},
profile,
))
}
}
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
enum FocalPlaneArg {
#[default]
Ffp,
Sfp,
}
impl From<FocalPlaneArg> for FocalPlane {
fn from(arg: FocalPlaneArg) -> Self {
match arg {
FocalPlaneArg::Ffp => FocalPlane::First,
FocalPlaneArg::Sfp => FocalPlane::Second,
}
}
}
#[allow(
clippy::large_enum_variant,
reason = "variants intentionally mirror the stable flat Clap command shape"
)]
#[derive(Subcommand)]
enum ProfileAction {
Save {
name: String,
#[arg(short = 'v', long, value_parser = f64_range(0.0, 6000.0))]
velocity: f64,
#[arg(short = 'b', long, value_parser = f64_range(0.001, 2.0))]
bc: f64,
#[arg(long, value_enum, default_value = "icao")]
bc_reference: BcReferenceStandard,
#[arg(short = 'm', long, value_parser = f64_range(0.1, 2000.0))]
mass: f64,
#[arg(short = 'd', long, value_parser = f64_range(0.01, 60.0))]
diameter: f64,
#[arg(long, default_value = "g1")]
drag_model: DragModel,
#[arg(long)]
twist_rate: Option<f64>,
#[arg(long)]
sight_height: Option<f64>,
#[arg(long)]
zero_distance: Option<f64>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
pressure: Option<f64>,
#[arg(long, value_enum, default_value = "absolute")]
pressure_type: PressureReferenceMode,
#[arg(long, default_value = "50.0", value_parser = f64_range(0.0, 100.0))]
humidity: f64,
#[arg(long, default_value = "0.0")]
altitude: f64,
#[arg(long, allow_hyphen_values = true, value_parser = f64_range(-5000.0, 120000.0))]
density_altitude: Option<f64>,
#[arg(long)]
bullet_name: Option<String>,
#[arg(long)]
wind_speed: Option<f64>,
#[arg(long, value_parser = parse_wind_direction_arg, help = WIND_DIRECTION_HELP)]
wind_direction: Option<ParsedWindDirection>,
#[arg(long, allow_hyphen_values = true)]
shooting_angle: Option<f64>,
#[arg(long)]
auto_zero: Option<f64>,
#[arg(long)]
twist_right: Option<bool>,
#[arg(long)]
use_bc_segments: Option<bool>,
#[arg(long)]
bullet_length: Option<f64>,
#[arg(long)]
elevation_click: Option<String>,
#[arg(long)]
windage_click: Option<String>,
#[arg(long)]
clear_click: bool,
#[arg(long)]
clear_dsf: bool,
#[arg(long, value_name = "FILE")]
reticle_json: Option<PathBuf>,
#[arg(long)]
clear_reticle: bool,
#[arg(long)]
clicks_per_rev: Option<u32>,
#[arg(long)]
zero_stop: Option<bool>,
#[arg(long, value_parser = parse_angular_mil)]
travel_up: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
travel_down: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
windage_travel_left: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
windage_travel_right: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_up: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_down: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_left: Option<f64>,
#[arg(long, value_parser = parse_angular_mil)]
hold_right: Option<f64>,
#[arg(long, allow_hyphen_values = true, value_parser = parse_angular_mil)]
turret_elev: Option<f64>,
#[arg(long, allow_hyphen_values = true, value_parser = parse_angular_mil)]
turret_wind: Option<f64>,
#[arg(long)]
clear_turret: bool,
},
#[cfg(feature = "profile-import")]
Import {
file: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
strict: bool,
#[arg(long, value_name = "CLICK")]
zero_click: Option<String>,
},
List,
Show {
name: String,
},
Delete {
name: String,
},
ZeroSet {
#[command(subcommand)]
action: ZeroSetAction,
},
}
#[derive(Subcommand)]
enum ZeroSetAction {
Add {
profile: String,
#[arg(long)]
name: String,
#[arg(long)]
zero_distance: Option<f64>,
#[arg(long, value_name = "MIL", allow_hyphen_values = true)]
poi_up: Option<f64>,
#[arg(long, value_name = "MIL", allow_hyphen_values = true)]
poi_right: Option<f64>,
#[arg(long)]
notes: Option<String>,
},
Remove {
profile: String,
#[arg(long)]
name: String,
},
List {
profile: String,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Json,
Csv,
Table,
Pdf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum BcConvertModelArg {
G1,
G7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum BcConversionOutputFormat {
Json,
Csv,
Table,
}
impl From<BcConvertModelArg> for DragModel {
fn from(value: BcConvertModelArg) -> Self {
match value {
BcConvertModelArg::G1 => DragModel::G1,
BcConvertModelArg::G7 => DragModel::G7,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum, Default)]
enum RecoilFirearmTypeArg {
#[default]
Rifle,
Pistol,
#[value(name = "shotgun-average", alias = "shotgun")]
ShotgunAverage,
#[value(name = "shotgun-long")]
ShotgunLong,
}
impl RecoilFirearmTypeArg {
fn to_engine(self) -> FirearmType {
match self {
RecoilFirearmTypeArg::Rifle => FirearmType::Rifle,
RecoilFirearmTypeArg::Pistol => FirearmType::Pistol,
RecoilFirearmTypeArg::ShotgunAverage => FirearmType::ShotgunAverage,
RecoilFirearmTypeArg::ShotgunLong => FirearmType::ShotgunLong,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum MonteCarloOutput {
Summary,
#[value(name = "json", alias = "full")]
Full,
Statistics,
}
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
enum WindShearModelArg {
None,
#[value(name = "logarithmic")]
Logarithmic,
#[value(
name = "power_law",
alias = "power-law",
alias = "powerlaw",
alias = "exponential"
)]
PowerLaw,
#[value(name = "ekman_spiral", alias = "ekman-spiral", alias = "ekman")]
EkmanSpiral,
#[value(name = "custom_layers", alias = "custom-layers", alias = "custom")]
CustomLayers,
}
impl WindShearModelArg {
fn as_engine_str(self) -> &'static str {
match self {
WindShearModelArg::None => "none",
WindShearModelArg::Logarithmic => "logarithmic",
WindShearModelArg::PowerLaw => "power_law",
WindShearModelArg::EkmanSpiral => "ekman_spiral",
WindShearModelArg::CustomLayers => "custom_layers",
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
enum WindReferenceArg {
#[default]
Shooter,
Compass,
}
fn resolve_wind_reference(
wind_ref: WindReferenceArg,
direction_deg: f64,
entered_as_clock: bool,
shot_direction: Option<f64>,
) -> Result<f64, String> {
match wind_ref {
WindReferenceArg::Shooter => Ok(direction_deg),
WindReferenceArg::Compass => {
if entered_as_clock {
return Err(
"clock positions are shooter-relative by definition; use degrees \
with --wind-ref compass"
.to_string(),
);
}
let Some(azimuth) = shot_direction else {
return Err(
"--wind-ref compass requires --shot-direction (earth-fixed wind \
bearings are re-referenced against the shot azimuth; omitting it \
would silently treat bearings as shooter-relative)"
.to_string(),
);
};
Ok(
ballistics_engine::wind::compass_bearing_to_shooter_relative_deg(
direction_deg,
azimuth,
),
)
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
enum DropsReferenceArg {
#[default]
Los,
Target,
}
impl DropsReferenceArg {
fn to_engine(self) -> ballistics_engine::DropsReference {
match self {
DropsReferenceArg::Los => ballistics_engine::DropsReference::Los,
DropsReferenceArg::Target => ballistics_engine::DropsReference::Target,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum PlotStyle {
Braille,
Ascii,
}
impl PlotStyle {
fn as_canvas_style(self) -> terminal_plot::CanvasStyle {
match self {
PlotStyle::Braille => terminal_plot::CanvasStyle::Braille,
PlotStyle::Ascii => terminal_plot::CanvasStyle::Ascii,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct TrajectoryPoint {
time: f64,
x: f64,
y: f64,
z: f64,
velocity: f64,
energy: f64,
#[serde(skip_serializing_if = "Option::is_none")]
mover_ring_m: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
mover_ring_mil: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
drag_coefficient: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TrajectoryResult {
units: String,
max_range: f64,
max_height: f64,
time_of_flight: f64,
impact_velocity: f64,
impact_energy: f64,
stability_coefficient: Option<f64>,
spin_drift: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
zero_angle_degrees: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
min_pitch_damping: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
transonic_mach: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
max_yaw_angle: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
max_precession_angle: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
final_pitch_angle: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
final_yaw_angle: Option<f64>,
trajectory: Vec<TrajectoryPoint>,
legend: TrajectoryLegend,
}
#[derive(Debug, Serialize, Deserialize)]
struct TrajectoryLegend {
units: TrajectoryLegendUnits,
axes: TrajectoryLegendAxes,
}
#[derive(Debug, Serialize, Deserialize)]
struct TrajectoryLegendUnits {
distance: String,
velocity: String,
energy: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct TrajectoryLegendAxes {
x: String,
y: String,
z: String,
}
impl TrajectoryLegend {
fn for_units(units: UnitSystem) -> Self {
let (distance, velocity, energy) = match units {
UnitSystem::Imperial => ("yd", "fps", "ft-lb"),
UnitSystem::Metric => ("m", "m/s", "J"),
};
TrajectoryLegend {
units: TrajectoryLegendUnits {
distance: distance.to_string(),
velocity: velocity.to_string(),
energy: energy.to_string(),
},
axes: TrajectoryLegendAxes {
x: "lateral offset from the muzzle's initial aiming direction; positive means \
to the shooter's right (e.g. a crosswind FROM the left, --wind-direction \
270, drifts x positive; FROM the right, --wind-direction 90, drifts x \
negative). Zero at the muzzle."
.to_string(),
y: "height above the ground in the world frame; positive means up. Starts near \
bore height and reaches 0 at ground impact. This is NOT height above the \
line of sight (compare solve-json v1's LOS-relative drop_m)."
.to_string(),
z: "downrange distance from the muzzle; zero at the muzzle, always increasing."
.to_string(),
},
}
}
}
struct TrajectoryConfig {
velocity: f64,
angle: f64,
bc: f64,
bc_reference_standard: BcReferenceStandard,
mass: f64,
diameter: f64,
bullet_length: f64,
drag_model: DragModel,
max_range: f64,
time_step: f64,
temperature: f64,
pressure: f64,
pressure_type: PressureReferenceMode,
humidity: f64,
altitude: f64,
density_altitude_active: bool,
wind_speed: f64,
wind_direction: f64, wind_vertical: f64, wind_segments: Vec<ballistics_engine::wind::WindSegment>,
output: OutputFormat,
full: bool,
plot: Option<PlotStyle>,
units: UnitSystem,
sight_height: f64,
bore_height: f64,
ignore_ground_impact: bool,
cant: f64,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
zero_solve_distance_m: Option<f64>,
use_bc_segments: bool,
use_cluster_bc: bool,
bc_segments_data: Option<Vec<BCSegmentData>>,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
cd_scale: f64,
enable_magnus: bool,
enable_coriolis: bool,
enable_spin_drift: bool,
enable_aerodynamic_jump: bool,
enable_wind_shear: bool,
wind_shear_model: WindShearModelArg,
enable_pitch_damping: bool,
enable_precession: bool,
sample_trajectory: bool,
sample_interval: f64,
drops_reference: DropsReferenceArg,
elevation_cf: f64,
windage_cf: f64,
zero_set_elevation_bias_mil: f64,
zero_set_windage_bias_mil: f64,
use_rk4: bool,
use_rk45: bool,
twist_rate: Option<f64>,
twist_right: bool,
latitude: Option<f64>,
shot_direction: Option<f64>, shooting_angle: f64,
use_powder_sensitivity: bool,
powder_temp_sensitivity: f64,
powder_temp: f64,
powder_temp_curve: Option<Vec<(f64, f64)>>,
powder_curve_temp_c: Option<f64>,
target_speed: f64,
with_drag_coefficient: bool,
adjustment_unit: AdjustmentUnit,
elevation_click: Option<ClickValue>,
windage_click: Option<ClickValue>,
pdf_metadata: Option<PdfMetadata>,
dsf_table: Option<DsfTable>,
solved_zero_angle_deg: Option<f64>,
}
const MC_FIXED_COUNT_CI_METHOD: &str = "wilson_fixed_n";
#[derive(Debug, Serialize, Deserialize)]
struct HitProbabilityCi {
method: String,
confidence_percent: u32,
low: f64,
high: f64,
samples: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct MonteCarloResult {
mean_range: f64,
std_range: f64,
mean_impact_velocity: f64,
std_impact_velocity: f64,
cep: Option<f64>,
target_shortfall_fraction: f64,
hit_probability: Option<f64>,
hit_probability_ci: Option<HitProbabilityCi>,
}
struct UnitConverter;
impl UnitConverter {
fn velocity_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 0.3048, }
}
fn mass_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val * 0.001, UnitSystem::Imperial => val * GRAINS_TO_KG, }
}
fn distance_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 0.9144, }
}
fn diameter_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val * 0.001, UnitSystem::Imperial => val * 0.0254, }
}
fn sight_height_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val * 0.001, UnitSystem::Imperial => val * 0.0254, }
}
fn wind_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 0.44704, }
}
fn temperature_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => (val - 32.0) * 5.0 / 9.0, }
}
fn temperature_delta_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 5.0 / 9.0, }
}
fn pressure_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 33.8639, }
}
fn resolve_pressure(val: Option<f64>, units: UnitSystem) -> Result<f64, String> {
match (val, units) {
(None, UnitSystem::Imperial) => Ok(29.92),
(None, UnitSystem::Metric) => Ok(1013.25),
(Some(v), UnitSystem::Imperial) => {
if !(8.0..=33.0).contains(&v) {
Err(format!(
"--pressure {v} inHg is out of range (expected ~8..33 inHg for imperial units)"
))
} else {
Ok(v)
}
}
(Some(v), UnitSystem::Metric) => {
if !(250.0..=1100.0).contains(&v) {
Err(format!(
"--pressure {v} hPa is out of range (expected ~250..1100 hPa for metric units)"
))
} else {
Ok(v)
}
}
}
}
fn resolve_temperature(val: Option<f64>, units: UnitSystem) -> Result<f64, String> {
match (val, units) {
(None, UnitSystem::Imperial) => Ok(59.0),
(None, UnitSystem::Metric) => Ok(15.0),
(Some(v), UnitSystem::Imperial) => {
if !(-148.0..=392.0).contains(&v) {
Err(format!(
"--temperature {v} F is out of range (expected ~-148..392 F for imperial units)"
))
} else {
Ok(v)
}
}
(Some(v), UnitSystem::Metric) => {
if !(-100.0..=200.0).contains(&v) {
Err(format!(
"--temperature {v} C is out of range (expected ~-100..200 C for metric units)"
))
} else {
Ok(v)
}
}
}
}
fn altitude_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 0.3048, }
}
fn velocity_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val / 0.3048, }
}
fn mass_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val * 1000.0, UnitSystem::Imperial => val / GRAINS_TO_KG, }
}
fn diameter_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val * 1000.0, UnitSystem::Imperial => val / 0.0254, }
}
fn sight_height_from_metric(val: f64, units: UnitSystem) -> f64 {
Self::diameter_from_metric(val, units)
}
fn distance_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val / 0.9144, }
}
fn wind_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val / 0.44704, }
}
fn temperature_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 9.0 / 5.0 + 32.0, }
}
fn pressure_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val / 33.8639, }
}
fn altitude_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val / 0.3048, }
}
fn energy_from_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val,
UnitSystem::Imperial => val * 0.737562, }
}
}
fn parse_angular_mil(s: &str) -> Result<f64, String> {
let t = s.trim().to_ascii_lowercase();
let (num, base) = if let Some(n) = t.strip_suffix("smoa") {
(n, ClickBase::Smoa)
} else if let Some(n) = t.strip_suffix("iphy") {
(n, ClickBase::Smoa)
} else if let Some(n) = t.strip_suffix("moa") {
(n, ClickBase::Moa)
} else if let Some(n) = t.strip_suffix("mil") {
(n, ClickBase::Mil)
} else {
return Err(format!(
"angular value '{s}' needs a unit suffix: mil, moa, smoa, or iphy (e.g. 28mil, 96moa)"
));
};
let value: f64 = num
.trim()
.parse()
.map_err(|_| format!("angular value '{s}' has an invalid number '{num}'"))?;
if !value.is_finite() {
return Err(format!("angular value '{s}' must be finite"));
}
Ok(value * adjustment_factor(ClickBase::Mil) / adjustment_factor(base))
}
fn carry_turret_field<T: Copy>(
flag: Option<T>,
existing: Option<T>,
clear_turret: bool,
) -> Option<T> {
if clear_turret {
None
} else {
flag.or(existing)
}
}
trait ProfileDataUnitExt: Sized {
fn converted_to(self, target: UnitSystem) -> Result<Self, Box<dyn Error>>;
}
impl ProfileDataUnitExt for ProfileData {
fn converted_to(mut self, target: UnitSystem) -> Result<Self, Box<dyn Error>> {
let source = self.unit_system()?;
if source == target {
return Ok(self);
}
self.velocity = UnitConverter::velocity_from_metric(
UnitConverter::velocity_to_metric(self.velocity, source),
target,
);
self.mass = UnitConverter::mass_from_metric(
UnitConverter::mass_to_metric(self.mass, source),
target,
);
self.diameter = UnitConverter::diameter_from_metric(
UnitConverter::diameter_to_metric(self.diameter, source),
target,
);
self.twist_rate = self.twist_rate.map(|value| {
UnitConverter::diameter_from_metric(
UnitConverter::diameter_to_metric(value, source),
target,
)
});
self.sight_height = self.sight_height.map(|value| {
UnitConverter::sight_height_from_metric(
UnitConverter::sight_height_to_metric(value, source),
target,
)
});
self.zero_distance = self.zero_distance.map(|value| {
UnitConverter::distance_from_metric(
UnitConverter::distance_to_metric(value, source),
target,
)
});
self.temperature = UnitConverter::temperature_from_metric(
UnitConverter::temperature_to_metric(self.temperature, source),
target,
);
self.pressure = UnitConverter::pressure_from_metric(
UnitConverter::pressure_to_metric(self.pressure, source),
target,
);
self.altitude = UnitConverter::altitude_from_metric(
UnitConverter::altitude_to_metric(self.altitude, source),
target,
);
self.density_altitude = self.density_altitude.map(|value| {
UnitConverter::altitude_from_metric(
UnitConverter::altitude_to_metric(value, source),
target,
)
});
self.wind_speed = self.wind_speed.map(|value| {
UnitConverter::wind_from_metric(UnitConverter::wind_to_metric(value, source), target)
});
self.auto_zero = self.auto_zero.map(|value| {
UnitConverter::distance_from_metric(
UnitConverter::distance_to_metric(value, source),
target,
)
});
self.bullet_length = self.bullet_length.map(|value| {
UnitConverter::diameter_from_metric(
UnitConverter::diameter_to_metric(value, source),
target,
)
});
if let Some(sets) = self.zero_sets.as_mut() {
for set in sets.iter_mut() {
set.zero_distance = set.zero_distance.map(|value| {
UnitConverter::distance_from_metric(
UnitConverter::distance_to_metric(value, source),
target,
)
});
}
}
self.units = match target {
UnitSystem::Imperial => "imperial",
UnitSystem::Metric => "metric",
}
.to_string();
Ok(self)
}
}
const KNOWN_COLUMNS: &[&str] = &[
"RIFLE_NAME",
"BULLET_NAME",
"CALIBER",
"BULLET_LENGTH",
"VELOCITY",
"MUZZLE_VELOCITY",
"MV",
"ZERO_TEMP",
"ZERO_ALT",
"ZERO_RANGE",
"ZERO_DISTANCE",
"ZERO",
"BC",
"BC_TYPE",
"BC_ADJ",
"BC_ADJUSTMENT",
"BULLET_WEIGHT",
"WIND_SPEED",
"TARGET_SPEED",
"WIND_ANGLE",
"SIGHT_HEIGHT",
"BARREL_TWIST",
"TWIST_RATE",
"TWIST",
"VELOCITY_ADJ",
"VEL_ADJ",
"RANGE_MIN",
"RANGE_MAX",
"RANGE_INCR",
"GUN_MPH",
"COA",
"POWDER_NAME",
"POWDER_CHARGE",
"PLASTIC_TIP_LENGTH",
"JBM_BULLET_ID",
"SPIN_DRIFT",
"CHART_TYPE",
"REGENERATE",
"V_OFFSET_MIL",
"H_OFFSET_MIL",
"LATITUDE",
"LONGITUDE",
"DRAG_MODEL",
"LOCATION_NAME",
"ALTITUDE",
"ALT",
"PRESSURE",
"PRESSURE(HPA OR INHG)",
"TARGET_TEMP",
"TEMPERATURE",
"TEMP",
"HUMIDITY",
"DA",
"DENSITY_ALTITUDE",
"WIND_DIR",
"WIND_DIRECTION",
];
fn find_closest_column(unknown: &str, max_distance: usize) -> Option<(&'static str, usize)> {
let unknown_upper = unknown.to_uppercase();
for &known in KNOWN_COLUMNS {
if known == unknown_upper {
return Some((known, 0));
}
}
KNOWN_COLUMNS
.iter()
.filter_map(|&known| {
let dist = levenshtein(&unknown_upper, known);
if dist <= max_distance && dist > 0 {
Some((known, dist))
} else {
None
}
})
.min_by_key(|(_, dist)| *dist)
}
fn normalize_column_header(header: &str) -> (String, bool, String) {
let upper = header.trim().to_uppercase();
if let Some((known, dist)) = find_closest_column(&upper, 2) {
if dist == 0 {
(known.to_string(), false, header.to_string())
} else {
(known.to_string(), true, header.to_string())
}
} else {
(upper, false, header.to_string())
}
}
fn load_csv_row(path: &PathBuf, row_name: &str) -> Result<HashMap<String, String>, Box<dyn Error>> {
use csv::ReaderBuilder;
let mut reader = ReaderBuilder::new()
.has_headers(true)
.comment(Some(b'#'))
.flexible(true)
.from_path(path)?;
let mut corrections: Vec<(String, String)> = Vec::new();
let headers: Vec<String> = reader
.headers()?
.iter()
.map(|h| {
let (normalized, was_corrected, original) = normalize_column_header(h);
if was_corrected {
corrections.push((original, normalized.clone()));
}
normalized
})
.collect();
for (original, corrected) in &corrections {
eprintln!(
"Warning: Column '{}' not recognized - did you mean '{}'? Using {}.",
original.trim(),
corrected,
corrected
);
}
if headers.is_empty() {
return Err("CSV file has no header row".into());
}
for record in reader.records() {
let record = record?;
if let Some(name) = record.get(0) {
if name.eq_ignore_ascii_case(row_name) {
let mut map = HashMap::new();
for (i, header) in headers.iter().enumerate() {
if let Some(val) = record.get(i) {
if !val.is_empty() {
map.insert(header.clone(), val.to_string());
}
}
}
return Ok(map);
}
}
}
Err(format!("Row '{}' not found in CSV", row_name).into())
}
fn csv_has(map: &HashMap<String, String>, keys: &[&str]) -> bool {
keys.iter().any(|key| {
map.get(&key.to_uppercase())
.is_some_and(|val| val.parse::<f64>().is_ok())
})
}
fn csv_get_f64(map: &HashMap<String, String>, keys: &[&str], default: f64) -> f64 {
for key in keys {
if let Some(val) = map.get(&key.to_uppercase()) {
if let Ok(v) = val.parse::<f64>() {
return v;
}
}
}
default
}
fn get_profiles_dir() -> Result<PathBuf, Box<dyn Error>> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
let dir = home.join(".ballistics").join("profiles");
fs::create_dir_all(&dir)?;
Ok(dir)
}
fn save_profile(profile: &ProfileData) -> Result<PathBuf, Box<dyn Error>> {
let dir = get_profiles_dir()?;
let path = dir.join(format!("{}.json", profile.name));
let json = serde_json::to_string_pretty(profile)?;
fs::write(&path, json)?;
Ok(path)
}
fn load_reticle_description(path: &std::path::Path) -> Result<ReticleDescription, Box<dyn Error>> {
let content = fs::read_to_string(path)
.map_err(|e| format!("could not read reticle file {}: {e}", path.display()))?;
let reticle: ReticleDescription = serde_json::from_str(&content)
.map_err(|e| format!("invalid reticle JSON in {}: {e}", path.display()))?;
reticle
.validate()
.map_err(|e| format!("invalid reticle in {}: {e}", path.display()))?;
Ok(reticle)
}
fn load_profile(name: &str) -> Result<ProfileData, Box<dyn Error>> {
let dir = get_profiles_dir()?;
let path = dir.join(format!("{}.json", name));
if !path.exists() {
return Err(format!(
"Profile '{}' not found. Use 'ballistics profile list' to see available profiles.",
name
)
.into());
}
let content = fs::read_to_string(&path)?;
let profile: ProfileData = serde_json::from_str(&content)?;
if let Some(cf) = profile.elevation_cf {
validate_tracking_cf(cf, "profile field elevation_cf")?;
}
if let Some(cf) = profile.windage_cf {
validate_tracking_cf(cf, "profile field windage_cf")?;
}
Ok(profile)
}
fn resolve_tracking_cf(
flag: Option<f64>,
profile_value: Option<f64>,
flag_name: &str,
) -> Result<f64, String> {
let value = flag.or(profile_value).unwrap_or(1.0);
validate_tracking_cf(value, flag_name)?;
Ok(value)
}
fn load_profile_for_units(name: &str, units: UnitSystem) -> Result<ProfileData, Box<dyn Error>> {
load_profile(name)?.converted_to(units)
}
struct ZeroSelection {
zero_distance: Option<f64>,
elevation_bias_mil: f64,
windage_bias_mil: f64,
}
fn resolve_zero_selection(
zero_set_name: Option<&str>,
profile: Option<&ProfileData>,
extra_set: Option<&ProfileZeroSet>,
explicit_zero: Option<f64>,
legacy_zero: Option<f64>,
) -> Result<ZeroSelection, String> {
let Some(wanted) = zero_set_name else {
return Ok(ZeroSelection {
zero_distance: explicit_zero.or(legacy_zero),
elevation_bias_mil: 0.0,
windage_bias_mil: 0.0,
});
};
let stored: &[ProfileZeroSet] = profile.and_then(|p| p.zero_sets.as_deref()).unwrap_or(&[]);
let candidates: Vec<&ProfileZeroSet> = stored.iter().chain(extra_set).collect();
if candidates.is_empty() {
return Err(match profile {
Some(p) => format!(
"--zero-set '{wanted}': profile '{}' has no zero sets (add one with \
`profile zero-set add {} --name {wanted} ...`)",
p.name, p.name
),
None => format!(
"--zero-set '{wanted}' requires a profile to look the set up in \
(pass the command's profile flag)"
),
});
}
let Some(set) = candidates.iter().find(|s| s.name == wanted) else {
let names: Vec<&str> = candidates.iter().map(|s| s.name.as_str()).collect();
return Err(format!(
"zero set '{wanted}' not found; available zero sets: {}",
names.join(", ")
));
};
Ok(ZeroSelection {
zero_distance: explicit_zero.or(set.zero_distance).or(legacy_zero),
elevation_bias_mil: set.poi_up_mil.unwrap_or(0.0),
windage_bias_mil: set.poi_right_mil.unwrap_or(0.0),
})
}
fn zero_set_from_profile_csv(
csv: &HashMap<String, String>,
row_name: Option<&str>,
) -> Option<ProfileZeroSet> {
let has_up = csv_has(csv, &["V_OFFSET_MIL"]);
let has_right = csv_has(csv, &["H_OFFSET_MIL"]);
if !has_up && !has_right {
return None;
}
Some(ProfileZeroSet {
name: row_name.unwrap_or("imported").to_string(),
zero_distance: None,
poi_up_mil: has_up.then(|| csv_get_f64(csv, &["V_OFFSET_MIL"], 0.0)),
poi_right_mil: has_right.then(|| csv_get_f64(csv, &["H_OFFSET_MIL"], 0.0)),
notes: None,
})
}
fn list_profiles() -> Result<Vec<ProfileData>, Box<dyn Error>> {
let dir = get_profiles_dir()?;
let mut profiles = Vec::new();
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(profile) = serde_json::from_str::<ProfileData>(&content) {
profiles.push(profile);
}
}
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
fn delete_profile(name: &str) -> Result<(), Box<dyn Error>> {
let dir = get_profiles_dir()?;
let path = dir.join(format!("{}.json", name));
if !path.exists() {
return Err(format!("Profile '{}' not found.", name).into());
}
fs::remove_file(&path)?;
Ok(())
}
#[cfg(feature = "profile-import")]
fn render_import_report(report: &ImportReport) -> String {
let mut out = String::new();
out.push_str("Imported fields:\n");
out.push_str(&format!(
" {:<32} {:<26} {:<22} {}\n",
"SOURCE (.a7p)", "VALUE", "CONVERTED", "DESTINATION"
));
for [field, raw, converted, dest] in &report.mapped {
out.push_str(&format!(" {field:<32} {raw:<26} {converted:<22} {dest}\n"));
}
if !report.unmapped.is_empty() {
out.push_str("\nNOT imported (no destination in the profile store):\n");
for (field, why) in &report.unmapped {
out.push_str(&format!(" {field:<32} {why}\n"));
}
}
if !report.warnings.is_empty() {
out.push_str("\nWarnings:\n");
for w in &report.warnings {
out.push_str(&format!(" ! {w}\n"));
}
}
out
}
fn smoa_per_mil() -> f64 {
adjustment_factor(ClickBase::Smoa) / adjustment_factor(ClickBase::Mil)
}
fn resolve_click_values(
flag_elev: Option<&str>,
flag_wind: Option<&str>,
profile: Option<&ProfileData>,
) -> Result<Option<(ClickValue, ClickValue)>, String> {
let elev_str = flag_elev
.map(str::to_string)
.or_else(|| profile.and_then(|p| p.elevation_click.clone()));
let Some(elev_str) = elev_str else {
return Err(
"--adjustment-unit clicks requires a turret elevation graduation: pass \
--elevation-click-value <SIZE><UNIT> (e.g. 0.25moa or 0.1mil), or save one on \
the profile with `profile save --elevation-click`"
.to_string(),
);
};
let elevation = parse_click_value(&elev_str)?;
let wind_str = flag_wind
.map(str::to_string)
.or_else(|| profile.and_then(|p| p.windage_click.clone()));
let windage = match wind_str {
Some(s) => parse_click_value(&s)?,
None => elevation,
};
Ok(Some((elevation, windage)))
}
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
enum AdaptiveCardAdjustmentArg {
#[default]
Mil,
Moa,
}
impl AdaptiveCardAdjustmentArg {
fn to_engine(self) -> CardAdjustmentUnit {
match self {
Self::Mil => CardAdjustmentUnit::Mil,
Self::Moa => CardAdjustmentUnit::Moa,
}
}
fn label(self) -> &'static str {
match self {
Self::Mil => "MIL",
Self::Moa => "MOA",
}
}
}
fn resolve_windage_unit(
elevation_unit: AdjustmentUnit,
windage_unit: Option<AdjustmentUnit>,
) -> Result<AdjustmentUnit, String> {
let resolved = windage_unit.unwrap_or(elevation_unit);
if matches!(resolved, AdjustmentUnit::Clicks)
&& !matches!(elevation_unit, AdjustmentUnit::Clicks)
{
return Err(
"--windage-unit clicks requires --adjustment-unit clicks (turret click output \
needs the resolved elevation graduation)"
.to_string(),
);
}
Ok(resolved)
}
fn resolve_single_axis_click_value(
flag_windage: Option<&str>,
profile: Option<&ProfileData>,
) -> Result<ClickValue, String> {
let s = flag_windage
.map(str::to_string)
.or_else(|| profile.and_then(|p| p.windage_click.clone()))
.or_else(|| profile.and_then(|p| p.elevation_click.clone()));
let Some(s) = s else {
return Err(
"--adjustment-unit clicks requires a turret click graduation: pass \
--windage-click-value <SIZE><UNIT> (e.g. 0.25moa or 0.1mil), or save one on \
the profile with `profile save --windage-click` (or --elevation-click)"
.to_string(),
);
};
parse_click_value(&s)
}
fn timestamp_string() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}", secs)
}
fn drag_model_arg_warning(s: &str) -> Option<String> {
if DragModel::from_str(s).is_some() {
None
} else {
Some(format!(
"warning: drag model '{s}' is not recognized; using G1. Supported models: \
G1, G2, G5, G6, G7, G8, GI, GS, RA4."
))
}
}
fn parse_drag_model_arg(s: &str) -> DragModel {
if let Some(w) = drag_model_arg_warning(s) {
eprintln!("{w}");
}
DragModel::from_str(s).unwrap_or(DragModel::G1)
}
fn parse_bc_reference_profile_field(value: Option<&str>) -> Result<BcReferenceStandard, String> {
match value.map(str::trim) {
None | Some("") => Ok(BcReferenceStandard::Icao),
Some(s) => match s.to_ascii_lowercase().as_str() {
"icao" => Ok(BcReferenceStandard::Icao),
"army-standard-metro" => Ok(BcReferenceStandard::ArmyStandardMetro),
other => Err(format!(
"invalid bc_reference '{other}' (expected 'icao' or 'army-standard-metro')"
)),
},
}
}
fn bc_reference_profile_field(value: BcReferenceStandard) -> Option<String> {
match value {
BcReferenceStandard::Icao => None,
BcReferenceStandard::ArmyStandardMetro => Some("army-standard-metro".to_string()),
}
}
fn parse_pressure_reference_profile_field(
value: Option<&str>,
) -> Result<PressureReferenceMode, String> {
match value.map(str::trim) {
None | Some("") => Ok(PressureReferenceMode::Absolute),
Some(s) => match s.to_ascii_lowercase().as_str() {
"absolute" => Ok(PressureReferenceMode::Absolute),
"qnh" => Ok(PressureReferenceMode::Qnh),
other => Err(format!(
"invalid pressure_reference '{other}' (expected 'absolute' or 'qnh')"
)),
},
}
}
fn pressure_reference_profile_field(value: PressureReferenceMode) -> Option<String> {
match value {
PressureReferenceMode::Absolute => None,
PressureReferenceMode::Qnh => Some("qnh".to_string()),
}
}
fn resolve_bc_for_reference_standard(bc: f64, standard: BcReferenceStandard) -> f64 {
match standard {
BcReferenceStandard::Icao => bc,
BcReferenceStandard::ArmyStandardMetro => bc * ballistics_engine::constants::ASM_TO_ICAO_BC,
}
}
fn truing_drag_model_warning(s: &str) -> Option<String> {
match s.to_uppercase().as_str() {
"G1" | "G7" => None,
other => Some(format!(
"warning: drag model '{other}' is not supported by truing's forward model \
(G1/G7 only); using G1."
)),
}
}
fn parse_drag_model_arg_for_truing(s: &str) -> DragModelArg {
if let Some(w) = truing_drag_model_warning(s) {
eprintln!("{w}");
}
match s.to_uppercase().as_str() {
"G7" => DragModelArg::G7,
_ => DragModelArg::G1,
}
}
fn aux_g1_coercion_warning(feature: &str, model: DragModel) -> Option<String> {
match model {
DragModel::G7 | DragModel::G1 => None,
other => Some(format!(
"warning: {feature} supports G1/G7 only; treating drag model '{other}' as G1"
)),
}
}
fn warn_aux_g1_coercion(feature: &str, model: DragModel) -> &'static str {
if let Some(w) = aux_g1_coercion_warning(feature, model) {
eprintln!("{w}");
}
match model {
DragModel::G7 => "G7",
_ => "G1",
}
}
fn cd_scale_range_warning(value: f64) -> Option<String> {
if (0.5..=2.0).contains(&value) {
None
} else {
Some(format!(
"warning: --cd-scale {value} is far outside the typical truing range (0.90-1.10)"
))
}
}
fn adjustment_unit_noop_warning(
elevation_unit: AdjustmentUnit,
windage_unit_explicit: Option<AdjustmentUnit>,
target_speed: f64,
is_pdf: bool,
) -> Option<String> {
if is_pdf {
return None;
}
let elevation_consumed = target_speed > 0.0 || elevation_unit == AdjustmentUnit::Mil;
let windage_consumed = windage_unit_explicit.is_none_or(|u| u == AdjustmentUnit::Mil);
let inert_flag = match (elevation_consumed, windage_consumed) {
(false, false) => "--adjustment-unit and --windage-unit",
(false, true) => "--adjustment-unit",
(true, false) => "--windage-unit",
(true, true) => return None,
};
Some(format!(
"warning: {inert_flag} only affects the mover Ring column and the PDF dope card; \
add --target-speed or use -o pdf to see it"
))
}
fn windage_click_value_noop_warning(windage_click_value: Option<&str>) -> Option<String> {
windage_click_value.map(|_| {
"warning: --windage-click-value has no effect on come-ups (it has no windage \
column); did you mean --elevation-click-value?"
.to_string()
})
}
fn resolve_cd_scale(
cd_scale: Option<f64>,
has_custom_drag_table: bool,
has_bc_adjustment: bool,
) -> Result<f64, String> {
match cd_scale {
Some(_) if !has_custom_drag_table => {
let mut msg = "--cd-scale requires --drag-table".to_string();
if has_bc_adjustment {
msg.push_str(" (for G1/G7 use --bc-adjustment instead)");
}
Err(msg)
}
Some(scale) => {
if let Some(w) = cd_scale_range_warning(scale) {
eprintln!("{w}");
}
Ok(scale)
}
None => Ok(1.0),
}
}
fn parse_powder_temp_curve(s: &str, units: UnitSystem) -> Result<Vec<(f64, f64)>, String> {
let mut pts: Vec<(f64, f64)> = Vec::new();
for (i, raw) in s.split(',').enumerate() {
let part = raw.trim();
if part.is_empty() {
continue;
}
let (t_str, v_str) = part.split_once(':').ok_or_else(|| {
format!(
"--powder-temp-curve point {} '{}' must be TEMP:VELOCITY",
i + 1,
part
)
})?;
let t: f64 = t_str.trim().parse().map_err(|_| {
format!(
"invalid temperature '{}' in --powder-temp-curve",
t_str.trim()
)
})?;
let v: f64 = v_str
.trim()
.parse()
.map_err(|_| format!("invalid velocity '{}' in --powder-temp-curve", v_str.trim()))?;
if v.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
return Err(format!(
"--powder-temp-curve velocity must be positive (got {})",
v
));
}
pts.push((
UnitConverter::temperature_to_metric(t, units),
UnitConverter::velocity_to_metric(v, units),
));
}
if pts.len() < 2 {
return Err(format!(
"--powder-temp-curve needs at least 2 TEMP:VELOCITY points (got {})",
pts.len()
));
}
pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
for w in pts.windows(2) {
if (w[0].0 - w[1].0).abs() < f64::EPSILON {
return Err("--powder-temp-curve has duplicate temperatures".to_string());
}
}
Ok(pts)
}
fn parse_bc_segment(s: &str, units: UnitSystem) -> Result<BCSegmentData, String> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return Err(format!(
"--bc-segment expects VMIN:VMAX:BC (e.g. 1500:1800:0.243), got '{}'",
s
));
}
let vmin: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("--bc-segment: invalid VMIN in '{}'", s))?;
let vmax: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("--bc-segment: invalid VMAX in '{}'", s))?;
let bc: f64 = parts[2]
.trim()
.parse()
.map_err(|_| format!("--bc-segment: invalid BC in '{}'", s))?;
if vmin.partial_cmp(&vmax) != Some(std::cmp::Ordering::Less) {
return Err(format!("--bc-segment: VMIN must be < VMAX in '{}'", s));
}
if bc <= 0.0 {
return Err(format!("--bc-segment: BC must be > 0 in '{}'", s));
}
let to_fps = if matches!(units, UnitSystem::Imperial) {
1.0
} else {
3.280_839_895
};
Ok(BCSegmentData {
velocity_min: vmin * to_fps,
velocity_max: vmax * to_fps,
bc_value: bc,
})
}
fn load_drag_table(path: &std::path::Path) -> Result<ballistics_engine::drag::DragTable, String> {
let is_drg = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("drg"));
if !is_drg {
return ballistics_engine::drag::DragTable::from_file(path);
}
let text = std::fs::read_to_string(path)
.map_err(|e| format!("could not read drag table {}: {e}", path.display()))?;
let curve = ballistics_engine::drag_file::parse_drg(&text)
.map_err(|e| format!("{} (.drg format): {e}", path.display()))?;
let (mach, cd): (Vec<f64>, Vec<f64>) = curve.points.into_iter().unzip();
ballistics_engine::drag::DragTable::try_new(mach, cd)
.map_err(|e| format!("{}: {e}", path.display()))
}
fn load_drag_table_or_exit(path: &std::path::Path) -> ballistics_engine::drag::DragTable {
match load_drag_table(path) {
Ok(t) => t,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
fn drag_table_from_profile(
points: &[ProfileDragPoint],
) -> Result<ballistics_engine::drag::DragTable, String> {
let mach: Vec<f64> = points.iter().map(|p| p.mach).collect();
let cd: Vec<f64> = points.iter().map(|p| p.cd).collect();
ballistics_engine::drag::DragTable::try_new(mach, cd)
}
fn bc_segments_from_profile(rows: &[ProfileBcSegment]) -> Vec<BCSegmentData> {
const MPS_TO_FPS: f64 = 3.280_839_895;
const TOP_SENTINEL_FPS: f64 = 5000.0;
let mut sorted: Vec<(f64, f64)> = rows
.iter()
.map(|r| (r.bc, r.velocity_mps * MPS_TO_FPS))
.collect();
sorted.sort_by(|a, b| b.1.total_cmp(&a.1)); let n = sorted.len();
sorted
.iter()
.enumerate()
.map(|(i, &(bc, v))| {
let velocity_max = if i == 0 {
v.max(TOP_SENTINEL_FPS) + 1.0
} else {
sorted[i - 1].1
};
let velocity_min = if i + 1 == n { 0.0 } else { v };
BCSegmentData {
velocity_min,
velocity_max,
bc_value: bc,
}
})
.collect()
}
fn resolve_velocity_bc_segments(
provided: Option<&[BCSegmentData]>,
use_estimator: bool,
bc: f64,
diameter_m: f64,
mass_kg: f64,
drag_model: DragModel,
) -> Option<Vec<BCSegmentData>> {
if let Some(segments) = provided {
return Some(segments.to_vec());
}
if !use_estimator {
return None;
}
Some(
ballistics_engine::bc_estimation::BCSegmentEstimator::estimate_bc_segments(
bc,
diameter_m / 0.0254,
mass_kg / GRAINS_TO_KG,
"",
warn_aux_g1_coercion("--use-bc-segments' estimator", drag_model),
),
)
}
fn dsf_table_summary(points: &[DsfPoint]) -> String {
let lo = points.first().map(|p| p.mach).unwrap_or(0.0);
let hi = points.last().map(|p| p.mach).unwrap_or(0.0);
format!("{} points, Mach {:.2}-{:.2}", points.len(), lo, hi)
}
fn parse_observed_drop(s: &str) -> Result<(f64, DropUnit), String> {
let t = s.trim().to_ascii_lowercase();
let (num, unit) = if let Some(n) = t.strip_suffix("mil") {
(n, DropUnit::Mil)
} else if let Some(n) = t.strip_suffix("moa") {
(n, DropUnit::Moa)
} else if let Some(n) = t.strip_suffix("in") {
(n, DropUnit::In)
} else {
return Err(format!(
"--observed-drop '{s}' needs a unit suffix: mil, moa, or in (e.g. 5.1mil, 17.4moa, 42.0in)"
));
};
let value: f64 = num
.trim()
.parse()
.map_err(|_| format!("--observed-drop '{s}' has an invalid number '{num}'"))?;
if !value.is_finite() {
return Err(format!("--observed-drop '{s}' must be a finite number"));
}
Ok((value, unit))
}
fn solve_profile_for_dsf(
profile: &ProfileData,
units: UnitSystem,
max_range_m: f64,
zero_distance_display: Option<f64>,
) -> Result<ballistics_engine::cli_api::TrajectoryResult, Box<dyn Error>> {
let zero_distance_yd = zero_distance_display.map(|zero_distance_display| match units {
UnitSystem::Imperial => zero_distance_display,
UnitSystem::Metric => zero_distance_display / 0.9144,
});
let velocity_m = UnitConverter::velocity_to_metric(profile.velocity, units);
let mass_kg = UnitConverter::mass_to_metric(profile.mass, units);
let diameter_m = UnitConverter::diameter_to_metric(profile.diameter, units);
let drag_model = parse_drag_model_arg(&profile.drag_model);
let sight_height_default = match units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
};
let sight_height_m = UnitConverter::sight_height_to_metric(
profile.sight_height.unwrap_or(sight_height_default),
units,
);
let bore_height_default_display = match units {
UnitSystem::Imperial => 60.0,
UnitSystem::Metric => 1500.0,
};
let bore_height_m = match units {
UnitSystem::Imperial => bore_height_default_display * 0.0254,
UnitSystem::Metric => bore_height_default_display * 0.001,
};
let temperature_c = UnitConverter::temperature_to_metric(profile.temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(profile.pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(profile.altitude, units);
let wind_speed_mps = profile
.wind_speed
.map(|w| UnitConverter::wind_to_metric(w, units));
let wind_direction_rad = profile.wind_direction.map(|d| d.to_radians());
let shooting_angle_rad = profile.shooting_angle.map(|a| a.to_radians());
let bc_segments = profile
.bc_segments
.as_ref()
.map(|rows| bc_segments_from_profile(rows));
let custom_drag_table = profile
.drag_curve
.as_ref()
.map(|pts| drag_table_from_profile(pts))
.transpose()
.map_err(|e| format!("saved profile's drag curve is invalid: {e}"))?;
let bc_reference_standard = Some(parse_bc_reference_profile_field(
profile.bc_reference.as_deref(),
)?);
let inputs = ballistics_engine::truing_dsf::DsfSolveInputs {
muzzle_velocity_fps: velocity_m / 0.3048,
ballistic_coefficient: profile.bc,
drag_model,
mass_gr: mass_kg / GRAINS_TO_KG,
diameter_in: diameter_m / 0.0254,
sight_height_in: sight_height_m / 0.0254,
temperature_f: temperature_c * 9.0 / 5.0 + 32.0,
pressure_inhg: pressure_hpa / 33.8639,
humidity_pct: profile.humidity,
altitude_ft: altitude_m / 0.3048,
wind_speed_mps,
wind_direction_rad,
shooting_angle_rad,
zero_poi_vertical_m: profile.zero_poi_up_m,
zero_poi_horizontal_m: profile.zero_poi_right_m,
sight_offset_lateral_m: profile.sight_offset_lateral_m,
bc_reference_standard,
use_bc_segments: profile.use_bc_segments.unwrap_or(false),
bc_segments,
custom_drag_table,
zero_distance_yd,
bore_height_m,
};
Ok(ballistics_engine::truing_dsf::solve_for_dsf(
&inputs,
max_range_m,
)?)
}
const DSF_DEFAULT_SOLVE_ENVELOPE: f64 = 1000.0;
fn dsf_supersonic_error(mach: f64) -> String {
format!(
"error: observation is supersonic (Mach {mach:.2}); calibrate muzzle velocity first \
(true-velocity), then collect DSF points at Mach <= 1.2"
)
}
fn dsf_beyond_90pct_warning(range_display: f64, dist_unit: &str) -> String {
format!(
"warning: observation at {range_display:.0} {dist_unit} is beyond 90% of the solved \
range; solution reliability degrades past this point"
)
}
fn dsf_table_missing_transonic_coverage(points: &[DsfPoint]) -> bool {
points.last().map(|p| p.mach < 0.9).unwrap_or(true)
}
fn dsf_transonic_gap_warning() -> String {
"warning: no DSF point in the transonic band (Mach 1.2-0.9); transonic drops remain uncorrected"
.to_string()
}
#[cfg(feature = "online")]
fn online_client(api_url: &str) -> ApiClient {
ApiClient::new(api_url, 30).with_token(ballistics_engine::credentials::load_token())
}
#[cfg(feature = "online")]
fn online_render(result: Result<serde_json::Value, ballistics_engine::api_client::ApiError>) {
match result {
Ok(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()),
Err(e) => {
eprintln!(
"{}",
e.cli_hint()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Online request failed: {}", e))
);
std::process::exit(1);
}
}
}
fn main() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
match cli.command {
Commands::SolveJson => std::process::exit(solve_json_command::run_stdio()),
Commands::Mcp => std::process::exit(mcp_command::run_stdio()),
#[cfg(feature = "online")]
Commands::Login { token } => {
let token = match token {
Some(t) => t,
None => {
eprintln!("Paste your CLI token from https://ballisticsinsight.com/account:");
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
line.trim().to_string()
}
};
if token.is_empty() {
eprintln!("No token provided.");
std::process::exit(1);
}
match ballistics_engine::credentials::save_token(&token) {
Ok(()) => {
let where_ = ballistics_engine::credentials::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the credentials file".to_string());
println!("Saved CLI token to {}", where_);
}
Err(e) => {
eprintln!("Failed to save token: {}", e);
std::process::exit(1);
}
}
}
#[cfg(feature = "online")]
Commands::Logout => match ballistics_engine::credentials::clear_token() {
Ok(()) => println!("Removed saved CLI token."),
Err(e) => {
eprintln!("Failed to remove token: {}", e);
std::process::exit(1);
}
},
#[cfg(feature = "online")]
Commands::RecommendPowder {
cartridge,
bullet_weight,
desired_velocity,
barrel_length,
velocity_tolerance,
api_url,
} => {
let mut body = serde_json::json!({
"cartridge": cartridge,
"bullet_weight": bullet_weight,
"desired_velocity": desired_velocity,
});
if let Some(bl) = barrel_length {
body["barrel_length"] = serde_json::Value::from(bl);
}
if let Some(vt) = velocity_tolerance {
body["velocity_tolerance"] = serde_json::Value::from(vt);
}
online_render(online_client(&api_url).post_json("/v1/recommend_powders", &body));
}
#[cfg(feature = "online")]
Commands::RecommendTwist {
caliber,
weight,
overall_length,
nose_length,
muzzle_velocity,
api_url,
} => {
let mut body = serde_json::json!({
"caliber": caliber,
"weight": weight,
"overall_length": overall_length,
"nose_length": nose_length,
});
if let Some(mv) = muzzle_velocity {
body["muzzle_velocity"] = serde_json::Value::from(mv);
}
online_render(online_client(&api_url).post_json("/v1/recommend_twist", &body));
}
#[cfg(feature = "online")]
Commands::RecommendCol {
cartridge,
bullet_weight,
bullet_type,
api_url,
} => {
let mut params: Vec<(String, String)> = vec![("cartridge".into(), cartridge)];
if let Some(bw) = bullet_weight {
params.push(("bullet_weight".into(), bw.to_string()));
}
if let Some(bt) = bullet_type {
params.push(("bullet_type".into(), bt));
}
online_render(online_client(&api_url).get_json("/v1/recommend_col", ¶ms));
}
#[cfg(feature = "online")]
Commands::CalibrateBc {
diameter,
length,
weight,
bullet_type,
api_url,
} => {
let mut body = serde_json::json!({
"diameter": diameter,
"length": length,
"weight": weight,
});
if let Some(bt) = bullet_type {
body["bullet_type"] = serde_json::Value::from(bt);
}
online_render(online_client(&api_url).post_json("/v1/calibrate_bc", &body));
}
Commands::Trajectory {
profile,
profile_row,
saved_profile,
location,
site,
velocity,
velocity_adjustment,
angle,
bc,
bc_adjustment,
bc_reference,
mass,
diameter,
drag_model,
max_range,
time_step,
wind_speed,
wind_direction,
wind_ref,
wind_vertical,
wind_segment,
bc_segment,
temperature,
pressure,
pressure_type,
humidity,
altitude,
density_altitude,
output,
full,
plot,
auto_zero,
zero_set,
zero_poi_up,
zero_poi_right,
sight_height,
sight_offset,
bore_height,
ignore_ground_impact,
use_bc_segments,
drag_table,
cd_scale,
cant,
print_bc_segments,
enable_magnus,
enable_coriolis,
enable_spin_drift,
enable_aerodynamic_jump,
enable_wind_shear,
sample_trajectory,
sample_interval,
drops_reference,
enable_pitch_damping,
enable_precession,
use_cluster_bc,
bc_table,
bc_table_dir,
#[cfg(feature = "online")]
bc_table_auto,
#[cfg(feature = "online")]
bc_table_url,
#[cfg(feature = "online")]
bc_table_refresh,
bullet_length,
twist_rate,
twist_right,
latitude,
longitude,
shot_direction,
use_euler,
use_rk4_fixed,
shooting_angle,
use_powder_sensitivity,
powder_temp_sensitivity,
powder_temp,
powder_temp_curve,
zero_velocity,
zero_temperature,
zero_pressure,
zero_pressure_type,
zero_humidity,
zero_altitude,
zero_density_altitude,
zero_powder_temp,
#[cfg(feature = "online")]
online,
#[cfg(feature = "online")]
api_url,
#[cfg(feature = "online")]
offline_fallback,
#[cfg(feature = "online")]
compare,
#[cfg(feature = "online")]
api_timeout,
#[cfg(feature = "online")]
enable_weather_zones,
#[cfg(feature = "online")]
enable_3d_weather,
wind_shear_model,
#[cfg(feature = "online")]
weather_zone_interpolation,
target_speed,
with_drag_coefficient,
powder,
bullet_name,
location_name,
output_file,
font_scale,
font_preset,
bold_data,
adjustment_unit,
windage_unit,
elevation_click_value,
windage_click_value,
elevation_cf,
windage_cf,
} => {
let profile_data: HashMap<String, String> =
if let (Some(path), Some(row)) = (&profile, &profile_row) {
match load_csv_row(path, row) {
Ok(data) => {
eprintln!("Loaded profile '{}' from {:?}", row, path);
data
}
Err(e) => {
return Err(format!(
"failed to load profile row '{row}' from {path:?}: {e}"
)
.into());
}
}
} else {
HashMap::new()
};
let saved_profile_data: Option<ProfileData> = saved_profile.as_ref().map(|name| {
load_profile_for_units(name, cli.units).unwrap_or_else(|e| {
eprintln!("Error loading saved profile: {}", e);
std::process::exit(1);
})
});
if let Some(ref sp) = saved_profile_data {
eprintln!("Loaded saved profile '{}'", sp.name);
}
let dsf_table: Option<DsfTable> = saved_profile_data
.as_ref()
.and_then(|p| p.dsf_points.clone())
.map(|points| {
DsfTable::from_points(points).unwrap_or_else(|e| {
eprintln!("error: saved profile's DSF table is invalid: {e}");
std::process::exit(1);
})
});
if let Some(w) = adjustment_unit_noop_warning(
adjustment_unit,
windage_unit,
target_speed,
matches!(output, OutputFormat::Pdf),
) {
eprintln!("{w}");
}
let windage_unit_resolved = resolve_windage_unit(adjustment_unit, windage_unit)
.unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
let (elevation_click, windage_click): (Option<ClickValue>, Option<ClickValue>) =
if matches!(adjustment_unit, AdjustmentUnit::Clicks) {
match resolve_click_values(
elevation_click_value.as_deref(),
windage_click_value.as_deref(),
saved_profile_data.as_ref(),
) {
Ok(Some((el, wi))) => (Some(el), Some(wi)),
Ok(None) => (None, None),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
(None, None)
};
let location_data: HashMap<String, String> =
if let (Some(path), Some(site_name)) = (&location, &site) {
match load_csv_row(path, site_name) {
Ok(data) => {
eprintln!("Loaded location '{}' from {:?}", site_name, path);
data
}
Err(e) => {
return Err(format!(
"failed to load location '{site_name}' from {path:?}: {e}"
)
.into());
}
}
} else {
HashMap::new()
};
let final_velocity = velocity
.or_else(|| {
let v = csv_get_f64(&profile_data, &["VELOCITY", "MV", "MUZZLE_VELOCITY"], 0.0);
if v > 0.0 {
Some(v)
} else {
None
}
})
.or_else(|| saved_profile_data.as_ref().map(|p| p.velocity))
.unwrap_or(2800.0);
let final_velocity_adj = if velocity_adjustment != 0.0 {
velocity_adjustment
} else {
csv_get_f64(&profile_data, &["VELOCITY_ADJ", "VEL_ADJ"], 0.0)
};
let bc_came_from_saved_profile = bc.is_none()
&& csv_get_f64(&profile_data, &["BC"], 0.0) <= 0.0
&& saved_profile_data.is_some();
let final_bc = bc
.or_else(|| {
let v = csv_get_f64(&profile_data, &["BC"], 0.0);
if v > 0.0 {
Some(v)
} else {
None
}
})
.or_else(|| saved_profile_data.as_ref().map(|p| p.bc))
.unwrap_or(0.5);
let bc_reference_standard = match bc_reference {
Some(v) => v,
None if bc_came_from_saved_profile => parse_bc_reference_profile_field(
saved_profile_data
.as_ref()
.and_then(|p| p.bc_reference.as_deref()),
)?,
None => BcReferenceStandard::Icao,
};
let final_bc_adj = if bc_adjustment != 1.0 {
bc_adjustment
} else {
csv_get_f64(&profile_data, &["BC_ADJ", "BC_ADJUSTMENT"], 1.0)
};
let final_mass = mass
.or_else(|| saved_profile_data.as_ref().map(|p| p.mass))
.unwrap_or_else(|| {
eprintln!("Error: --mass is required (or use --saved-profile)");
std::process::exit(1);
});
let final_diameter = diameter
.or_else(|| saved_profile_data.as_ref().map(|p| p.diameter))
.unwrap_or_else(|| {
eprintln!("Error: --diameter is required (or use --saved-profile)");
std::process::exit(1);
});
let final_max_range = max_range;
let final_wind_speed = if wind_speed != 0.0 {
wind_speed
} else {
saved_profile_data
.as_ref()
.and_then(|p| p.wind_speed)
.unwrap_or(0.0)
};
let final_wind_direction = match wind_direction {
Some(d) => d.degrees,
None => csv_get_f64(&location_data, &["WIND_DIR", "WIND_DIRECTION"], 0.0),
};
let final_wind_direction = resolve_wind_reference(
wind_ref,
final_wind_direction,
wind_direction.map(|d| d.was_clock).unwrap_or(false),
shot_direction,
)?;
let std_temperature = UnitConverter::resolve_temperature(None, cli.units)?;
let std_pressure = UnitConverter::resolve_pressure(None, cli.units)?;
let saved_atmo = saved_profile_data.as_ref();
let profile_supplied_pressure = pressure.is_none()
&& !csv_has(&location_data, &["PRESSURE", "PRESSURE(HPA OR INHG)"])
&& saved_atmo.is_some();
let final_temperature = match temperature {
Some(t) => UnitConverter::resolve_temperature(Some(t), cli.units)?,
None => csv_get_f64(
&location_data,
&["TARGET_TEMP", "TEMPERATURE", "TEMP"],
csv_get_f64(
&profile_data,
&["ZERO_TEMP"],
saved_atmo.map_or(std_temperature, |p| p.temperature),
),
),
};
let final_pressure = match pressure {
Some(p) => UnitConverter::resolve_pressure(Some(p), cli.units)?,
None => csv_get_f64(
&location_data,
&["PRESSURE", "PRESSURE(HPA OR INHG)"],
saved_atmo.map_or(std_pressure, |p| p.pressure),
),
};
let final_pressure_type = match pressure_type {
Some(mode) => mode,
None if profile_supplied_pressure => parse_pressure_reference_profile_field(
saved_atmo.and_then(|p| p.pressure_reference.as_deref()),
)?,
None => PressureReferenceMode::Absolute,
};
let final_humidity = if humidity != 50.0 {
humidity
} else {
csv_get_f64(
&location_data,
&["HUMIDITY"],
saved_atmo.map_or(50.0, |p| p.humidity),
)
};
let final_altitude = if altitude != 0.0 {
altitude
} else {
csv_get_f64(
&location_data,
&["ALTITUDE", "ALT"],
csv_get_f64(
&profile_data,
&["ZERO_ALT"],
saved_atmo.map_or(0.0, |p| p.altitude),
),
)
};
let final_density_altitude: Option<f64> = match density_altitude {
Some(da) => Some(da),
None => {
let da_csv = csv_get_f64(&location_data, &["DA", "DENSITY_ALTITUDE"], f64::NAN);
if !da_csv.is_nan() {
Some(da_csv)
} else {
let run_supplied_its_own_atmosphere = pressure.is_some()
|| altitude != 0.0
|| csv_has(&location_data, &["PRESSURE", "PRESSURE(HPA OR INHG)"])
|| csv_has(&location_data, &["ALTITUDE", "ALT"]);
saved_atmo
.filter(|_| !run_supplied_its_own_atmosphere)
.and_then(|p| p.density_altitude)
}
}
};
if final_density_altitude.is_some() && (pressure.is_some() || pressure_type.is_some()) {
eprintln!(
"note: --density-altitude supersedes --pressure/--pressure-type (station \
pressure is derived from density altitude instead)"
);
}
if final_density_altitude.is_some() && altitude != 0.0 {
eprintln!(
"note: --density-altitude supersedes --altitude (the station altitude is \
back-solved from density altitude instead)"
);
}
let (final_temperature, final_pressure, final_altitude, final_pressure_type) =
match final_density_altitude {
Some(da_display) => {
let da_m = UnitConverter::altitude_to_metric(da_display, cli.units);
let explicit_temp_c = temperature.map(|_| {
UnitConverter::temperature_to_metric(final_temperature, cli.units)
});
let (da_altitude_m, da_temp_c, da_pressure_hpa) =
ballistics_engine::atmosphere::resolve_atmosphere_for_density_altitude(
da_m,
explicit_temp_c,
);
(
UnitConverter::temperature_from_metric(da_temp_c, cli.units),
UnitConverter::pressure_from_metric(da_pressure_hpa, cli.units),
UnitConverter::altitude_from_metric(da_altitude_m, cli.units),
PressureReferenceMode::Absolute,
)
}
None => (
final_temperature,
final_pressure,
final_altitude,
final_pressure_type,
),
};
let csv_zero_set = zero_set_from_profile_csv(&profile_data, profile_row.as_deref());
let legacy_zero: Option<f64> = {
let zero_from_csv =
csv_get_f64(&profile_data, &["ZERO_RANGE", "ZERO_DISTANCE", "ZERO"], 0.0);
if zero_from_csv > 0.0 {
Some(zero_from_csv)
} else {
saved_profile_data
.as_ref()
.and_then(|p| p.auto_zero.or(p.zero_distance))
}
};
let zero_selection = resolve_zero_selection(
zero_set.as_deref(),
saved_profile_data.as_ref(),
csv_zero_set.as_ref(),
auto_zero,
legacy_zero,
)
.map_err(|e| -> Box<dyn Error> { e.into() })?;
let final_auto_zero: Option<f64> = zero_selection.zero_distance;
let drag_model = if saved_profile_data.is_some() && velocity.is_none() && bc.is_none() {
saved_profile_data
.as_ref()
.map(|p| parse_drag_model_arg(&p.drag_model))
.unwrap_or(drag_model)
} else {
drag_model
};
let use_bc_segments = use_bc_segments
|| saved_profile_data
.as_ref()
.and_then(|p| p.use_bc_segments)
.unwrap_or(false);
let twist_right = saved_profile_data
.as_ref()
.and_then(|p| p.twist_right)
.unwrap_or(twist_right);
let shooting_angle = if shooting_angle != 0.0 {
shooting_angle
} else {
saved_profile_data
.as_ref()
.and_then(|p| p.shooting_angle)
.unwrap_or(0.0)
};
let bullet_length =
bullet_length.or_else(|| saved_profile_data.as_ref().and_then(|p| p.bullet_length));
let sight_height =
sight_height.or_else(|| saved_profile_data.as_ref().and_then(|p| p.sight_height));
let zero_poi_vertical_metric = zero_poi_up
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| saved_profile_data.as_ref().and_then(|p| p.zero_poi_up_m))
.unwrap_or(0.0);
let zero_poi_horizontal_metric = zero_poi_right
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| saved_profile_data.as_ref().and_then(|p| p.zero_poi_right_m))
.unwrap_or(0.0);
let sight_offset_lateral_metric = sight_offset
.map(|v| sight_offset_display_to_metric(v, cli.units))
.or_else(|| {
saved_profile_data
.as_ref()
.and_then(|p| p.sight_offset_lateral_m)
})
.unwrap_or(0.0);
let elevation_cf = resolve_tracking_cf(
elevation_cf,
saved_profile_data.as_ref().and_then(|p| p.elevation_cf),
"--elevation-cf",
)?;
let windage_cf = resolve_tracking_cf(
windage_cf,
saved_profile_data.as_ref().and_then(|p| p.windage_cf),
"--windage-cf",
)?;
let twist_rate =
twist_rate.or_else(|| saved_profile_data.as_ref().and_then(|p| p.twist_rate));
let twist_rate = twist_rate.map(|t| match cli.units {
UnitSystem::Imperial => t,
UnitSystem::Metric => t / 25.4, });
let powder_curve_temp_c: Option<f64> =
powder_temp.map(|t| UnitConverter::temperature_to_metric(t, cli.units));
let powder_temp = powder_temp.unwrap_or(match cli.units {
UnitSystem::Imperial => DEFAULT_POWDER_REFERENCE_TEMP_F,
UnitSystem::Metric => DEFAULT_POWDER_REFERENCE_TEMP_C,
});
let powder_temp_sensitivity = powder_temp_sensitivity.unwrap_or(match cli.units {
UnitSystem::Imperial => 1.0,
UnitSystem::Metric => 0.3048 / (5.0 / 9.0),
});
let powder_temp_curve_si: Option<Vec<(f64, f64)>> = match powder_temp_curve.as_deref() {
Some(s) => Some(parse_powder_temp_curve(s, cli.units)?),
None => None,
};
let trued_velocity = final_velocity + final_velocity_adj;
let pre_table_bc = final_bc * final_bc_adj;
let mut trued_bc = pre_table_bc;
let trued_velocity_fps = match cli.units {
UnitSystem::Imperial => trued_velocity,
UnitSystem::Metric => trued_velocity / 0.3048,
};
let caliber_in = match cli.units {
UnitSystem::Imperial => final_diameter,
UnitSystem::Metric => final_diameter / 25.4,
};
let mut bc_table_segments: Option<Vec<BCSegmentData>> = None;
let bc_table_correction: Option<f64> = if let Some(table_path) = &bc_table {
match BcCorrectionTable::load(table_path) {
Ok(table) => {
let bullet_length_in = bullet_length
.map(|l| match cli.units {
UnitSystem::Imperial => l,
UnitSystem::Metric => l / 25.4,
})
.or_else(|| {
let len =
csv_get_f64(&profile_data, &["BULLET_LENGTH", "LENGTH"], 0.0);
if len > 0.0 {
Some(match cli.units {
UnitSystem::Imperial => len,
UnitSystem::Metric => len / 25.4,
})
} else {
None
}
})
.unwrap_or_else(|| {
let mass_gr = match cli.units {
UnitSystem::Imperial => final_mass,
UnitSystem::Metric => final_mass * 15.4324,
};
let est_m = ballistics_engine::stability::estimate_bullet_length_m(
caliber_in * 0.0254,
mass_gr * GRAINS_TO_KG,
);
if est_m > 0.0 {
est_m / 0.0254
} else {
caliber_in * 3.5
}
});
let mass_grains = match cli.units {
UnitSystem::Imperial => final_mass, UnitSystem::Metric => final_mass * 15.4324, };
let bc_type_str = warn_aux_g1_coercion("--bc-table correction", drag_model);
let velocity_breakpoints: Vec<f64> = vec![
trued_velocity_fps, 2700.0,
2500.0,
2300.0,
2100.0,
2000.0,
1900.0,
1800.0,
1700.0,
1600.0,
1500.0,
1400.0,
1350.0,
1300.0,
1250.0,
1200.0,
1150.0,
1100.0,
1050.0,
1000.0,
950.0,
900.0,
850.0,
800.0,
700.0,
600.0,
500.0,
];
let mut valid_velocities: Vec<f64> = velocity_breakpoints
.into_iter()
.filter(|&v| v <= trued_velocity_fps && v >= 500.0)
.collect();
valid_velocities.sort_by(|a, b| b.partial_cmp(a).unwrap());
valid_velocities.dedup();
let mut segments: Vec<BCSegmentData> = Vec::new();
for i in 0..valid_velocities.len().saturating_sub(1) {
let vel_max = valid_velocities[i];
let vel_min = valid_velocities[i + 1];
let vel_mid = (vel_max + vel_min) / 2.0;
let correction = table.lookup(
final_bc,
bc_type_str,
mass_grains,
bullet_length_in,
vel_mid,
);
let segment_bc = trued_bc * correction;
segments.push(BCSegmentData {
velocity_min: vel_min,
velocity_max: vel_max,
bc_value: segment_bc,
});
}
let muzzle_correction = table.lookup(
final_bc,
bc_type_str,
mass_grains,
bullet_length_in,
trued_velocity_fps,
);
eprintln!(
"BC Table: Loaded {} (v{}, {})",
table_path.display(),
table.version(),
table.dimensions_str()
);
eprintln!(
"BC Table: Generated {} velocity-dependent BC segments",
segments.len()
);
eprintln!("BC Table: Muzzle correction={:.4} for BC={:.3} {} {}gr {:.3}\" @ {:.0}fps",
muzzle_correction, final_bc, bc_type_str, mass_grains, bullet_length_in, trued_velocity_fps);
if !segments.is_empty() {
let min_bc = segments
.iter()
.map(|s| s.bc_value)
.fold(f64::INFINITY, f64::min);
let max_bc = segments
.iter()
.map(|s| s.bc_value)
.fold(f64::NEG_INFINITY, f64::max);
eprintln!(
"BC Table: BC range {:.5} - {:.5} across velocity envelope",
min_bc, max_bc
);
bc_table_segments = Some(segments);
}
trued_bc *= muzzle_correction;
Some(muzzle_correction)
}
Err(e) => {
eprintln!("Warning: Failed to load BC table: {}", e);
eprintln!(" Continuing without BC correction table.");
None
}
}
} else {
None
};
#[cfg(feature = "online")]
let effective_bc_table_dir: Option<PathBuf> = if bc_table_dir.is_some() {
bc_table_dir.clone()
} else if bc_table_auto {
match Bc5dDownloader::new(&bc_table_url, bc_table_refresh) {
Ok(mut downloader) => {
match downloader.ensure_table(caliber_in) {
Ok(table_path) => {
table_path.parent().map(|p| p.to_path_buf())
}
Err(e) => {
eprintln!("Warning: {}", e);
eprintln!(" Continuing without BC5D correction table.");
None
}
}
}
Err(e) => {
eprintln!("Warning: BC5D table download failed: {}", e);
eprintln!(" Continuing without BC5D correction table.");
None
}
}
} else {
None
};
#[cfg(not(feature = "online"))]
let effective_bc_table_dir: Option<PathBuf> = bc_table_dir.clone();
let bc_table_5d_correction: Option<f64> = if bc_table_correction.is_none() {
if let Some(table_dir) = &effective_bc_table_dir {
let mut manager = Bc5dTableManager::new(table_dir);
reject_bc5d_caliber_mismatch(&mut manager, caliber_in)?;
let mass_grains = match cli.units {
UnitSystem::Imperial => final_mass,
UnitSystem::Metric => final_mass * 15.4324,
};
let bc_type_str = warn_aux_g1_coercion("BC5D correction tables", drag_model);
if let Ok(table) = manager.get_table(caliber_in) {
eprintln!(
"BC5D Table: Loaded caliber {:.3} (v{}, API {}, {})",
table.caliber(),
table.version(),
table.api_version(),
table.dimensions_str()
);
}
let bullet_length_user: Option<f64> = bullet_length
.map(|l| match cli.units {
UnitSystem::Imperial => l,
UnitSystem::Metric => l / 25.4,
})
.or_else(|| {
let csv_len =
csv_get_f64(&profile_data, &["BULLET_LENGTH", "LENGTH"], 0.0);
if csv_len > 0.0 {
Some(match cli.units {
UnitSystem::Imperial => csv_len,
UnitSystem::Metric => csv_len / 25.4,
})
} else {
None
}
});
let length_is_user = bullet_length_user.is_some();
let bullet_length_in = bullet_length_user.unwrap_or_else(|| {
let est_m = ballistics_engine::stability::estimate_bullet_length_m(
caliber_in * 0.0254,
mass_grains * GRAINS_TO_KG,
);
if est_m > 0.0 {
est_m / 0.0254
} else {
caliber_in * 3.5
}
});
let table_base_bc = trued_bc;
if let Some(segments) = generate_bc5d_segments(
&mut manager,
caliber_in,
table_base_bc,
bc_type_str,
mass_grains,
Some(trued_velocity_fps),
Some(bullet_length_in),
length_is_user,
if print_bc_segments {
Some(cli.units)
} else {
None
},
) {
bc_table_segments = Some(segments);
let muzzle_correction = manager
.lookup(
caliber_in,
mass_grains,
table_base_bc,
trued_velocity_fps,
trued_velocity_fps,
bc_type_str,
)
.unwrap_or(1.0);
eprintln!(
"BC5D Table: Muzzle correction={:.4} for BC={:.3} {} {}gr @ {:.0}fps",
muzzle_correction,
table_base_bc,
bc_type_str,
mass_grains,
trued_velocity_fps
);
trued_bc = table_base_bc * muzzle_correction;
Some(muzzle_correction)
} else {
None
}
} else {
None
}
} else {
None };
let combined_bc_correction = bc_table_correction.or(bc_table_5d_correction);
if !profile_data.is_empty()
|| !location_data.is_empty()
|| saved_profile_data.is_some()
|| combined_bc_correction.is_some()
{
let bc_info = if let Some(corr) = combined_bc_correction {
format!(
"BC={:.3} (table-corrected={:.4}, factor={:.4})",
final_bc, trued_bc, corr
)
} else {
format!("BC={:.3} (trued={:.4})", final_bc, trued_bc)
};
eprintln!(
"Effective values: velocity={:.1} (trued={:.1}), {}",
final_velocity, trued_velocity, bc_info
);
if !location_data.is_empty() {
eprintln!(
" altitude={:.0}, temp={:.1}, pressure={:.2}",
final_altitude, final_temperature, final_pressure
);
}
}
let bullet_mass = final_mass;
let bullet_diameter = final_diameter;
let velocity_metric = UnitConverter::velocity_to_metric(trued_velocity, cli.units);
let mass_metric = UnitConverter::mass_to_metric(bullet_mass, cli.units);
let diameter_metric = UnitConverter::diameter_to_metric(bullet_diameter, cli.units);
let max_range_metric = UnitConverter::distance_to_metric(final_max_range, cli.units);
let wind_speed_metric = UnitConverter::wind_to_metric(final_wind_speed, cli.units);
let wind_vertical_metric = UnitConverter::wind_to_metric(wind_vertical, cli.units);
let temperature_metric =
UnitConverter::temperature_to_metric(final_temperature, cli.units);
let pressure_metric = UnitConverter::pressure_to_metric(final_pressure, cli.units);
let altitude_metric = UnitConverter::altitude_to_metric(final_altitude, cli.units);
let sight_height_default = match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
};
let sight_height_value = sight_height.unwrap_or(sight_height_default);
let sight_height_metric =
UnitConverter::sight_height_to_metric(sight_height_value, cli.units);
let bore_height_default = match cli.units {
UnitSystem::Imperial => 60.0, UnitSystem::Metric => 1500.0, };
let bore_height_value = bore_height.unwrap_or(bore_height_default);
let bore_height_metric = match cli.units {
UnitSystem::Imperial => bore_height_value * 0.0254, UnitSystem::Metric => bore_height_value * 0.001, };
if bore_height_metric > 1000.0 {
eprintln!(
"warning: --bore-height puts the bore {bore_height_metric:.0} m above ground; \
air density is computed at trajectory altitude, so this thins the air for \
the entire flight. If you meant site elevation use --altitude; to disable \
ground-impact truncation use --ignore-ground-impact."
);
}
let pdf_metadata = if matches!(output, OutputFormat::Pdf) {
let rifle_name = profile_row.clone().unwrap_or_else(|| "Rifle".to_string());
let loc_name = location_name
.clone()
.or_else(|| site.clone())
.unwrap_or_else(|| "Field".to_string());
let powder_name = powder
.clone()
.or_else(|| profile_data.get("POWDER_NAME").cloned())
.unwrap_or_default();
let bullet_display = bullet_name
.clone()
.or_else(|| profile_data.get("BULLET_NAME").cloned())
.unwrap_or_default();
#[cfg(feature = "pdf")]
let effective_font_scale = if font_scale != 1.0 {
font_scale
} else if let Some(ref preset) = font_preset {
FontSizePreset::from_str(preset)
.map(|p| p.scale())
.unwrap_or_else(|| {
eprintln!("Warning: Unknown font preset '{}', using medium", preset);
1.0
})
} else {
1.0
};
#[cfg(not(feature = "pdf"))]
let effective_font_scale = font_scale;
Some(PdfMetadata {
rifle_name,
location_name: loc_name,
powder: powder_name,
bullet_name: bullet_display,
target_speed_mph: match cli.units {
UnitSystem::Imperial => target_speed,
UnitSystem::Metric => target_speed / 0.44704,
},
output_file: output_file.clone(),
velocity_fps: match cli.units {
UnitSystem::Imperial => trued_velocity,
UnitSystem::Metric => trued_velocity / 0.3048,
},
temperature_f: match cli.units {
UnitSystem::Imperial => final_temperature,
UnitSystem::Metric => final_temperature * 9.0 / 5.0 + 32.0,
},
pressure_inhg: match cli.units {
UnitSystem::Imperial => final_pressure,
UnitSystem::Metric => final_pressure / 33.8639,
},
altitude_ft: match cli.units {
UnitSystem::Imperial => final_altitude,
UnitSystem::Metric => final_altitude / 0.3048,
},
wind_speed_mph: match cli.units {
UnitSystem::Imperial => final_wind_speed,
UnitSystem::Metric => final_wind_speed / 0.44704,
},
weight_gr: match cli.units {
UnitSystem::Imperial => bullet_mass,
UnitSystem::Metric => bullet_mass * 15.4324,
},
font_scale: effective_font_scale,
bold_data,
adjustment_unit,
windage_unit: windage_unit_resolved,
})
} else {
None
};
let manual_bc_segments: Vec<BCSegmentData> = bc_segment
.iter()
.map(|s| parse_bc_segment(s, cli.units))
.collect::<Result<Vec<_>, String>>()?;
if !manual_bc_segments.is_empty() {
bc_table_segments = Some(manual_bc_segments.clone());
trued_bc = pre_table_bc;
}
if bc_table_segments.is_none() {
if let Some(rows) = saved_profile_data
.as_ref()
.and_then(|p| p.bc_segments.as_ref())
{
bc_table_segments = Some(bc_segments_from_profile(rows));
}
}
let bc_segments_data = resolve_velocity_bc_segments(
bc_table_segments.as_deref(),
use_bc_segments,
trued_bc,
diameter_metric,
mass_metric,
drag_model,
);
let effective_use_bc_segments = use_bc_segments || bc_segments_data.is_some();
let custom_drag_table =
drag_table
.as_deref()
.map(load_drag_table_or_exit)
.or_else(|| {
saved_profile_data
.as_ref()
.and_then(|p| p.drag_curve.as_ref())
.map(|pts| {
drag_table_from_profile(pts).unwrap_or_else(|e| {
eprintln!("Error: saved profile's drag curve is invalid: {e}");
std::process::exit(1);
})
})
});
if custom_drag_table.is_some() && effective_use_bc_segments {
eprintln!(
"Warning: --drag-table and BC segments were both provided; the drag table takes \
precedence and BC segments are ignored."
);
}
let cd_scale = resolve_cd_scale(cd_scale, custom_drag_table.is_some(), true)
.unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
if custom_drag_table.is_some()
&& matches!(
bc_reference_standard,
BcReferenceStandard::ArmyStandardMetro
)
{
eprintln!(
"{}",
ballistics_engine::cli_api::BC_REFERENCE_STANDARD_INERT_WARNING
);
}
let muzzle_angle = if let Some(zero_distance) = final_auto_zero {
let zero_distance_metric =
UnitConverter::distance_to_metric(zero_distance, cli.units);
let zero_velocity_metric = match zero_velocity {
Some(v) => UnitConverter::velocity_to_metric(v, cli.units),
None => velocity_metric,
};
let zero_temperature_metric = match zero_temperature {
Some(t) => UnitConverter::temperature_to_metric(t, cli.units),
None => temperature_metric,
};
let zero_pressure_metric = match zero_pressure {
Some(p) => UnitConverter::pressure_to_metric(p, cli.units),
None => pressure_metric,
};
let zero_pressure_type_resolved = match zero_pressure_type {
Some(mode)
if (final_density_altitude.is_some()
|| zero_density_altitude.is_some())
&& zero_pressure.is_none() =>
{
if mode != PressureReferenceMode::Absolute {
let source = if zero_density_altitude.is_some() {
"--zero-density-altitude"
} else {
"--density-altitude"
};
eprintln!(
"warning: --zero-pressure-type is ignored because {source} supplies the zero-day station pressure directly; pass --zero-pressure to declare a zero-day pressure of your own"
);
}
PressureReferenceMode::Absolute
}
Some(mode) => mode,
None => final_pressure_type,
};
let zero_humidity_value = zero_humidity.unwrap_or(final_humidity);
let zero_altitude_metric = match zero_altitude {
Some(a) => UnitConverter::altitude_to_metric(a, cli.units),
None => altitude_metric,
};
let (zero_temperature_metric, zero_pressure_metric, zero_altitude_metric) =
match zero_density_altitude {
Some(da_display) => {
let da_m = UnitConverter::altitude_to_metric(da_display, cli.units);
let explicit_zero_temp_c = zero_temperature
.map(|t| UnitConverter::temperature_to_metric(t, cli.units));
let (da_altitude_m, da_temp_c, da_pressure_hpa) =
ballistics_engine::atmosphere::resolve_atmosphere_for_density_altitude(
da_m,
explicit_zero_temp_c,
);
(da_temp_c, da_pressure_hpa, da_altitude_m)
}
None => (
zero_temperature_metric,
zero_pressure_metric,
zero_altitude_metric,
),
};
if zero_velocity.is_some()
|| zero_temperature.is_some()
|| zero_pressure.is_some()
|| zero_humidity.is_some()
|| zero_altitude.is_some()
|| zero_density_altitude.is_some()
{
eprintln!(
"Solving zero angle under supplied zero-day conditions (velocity/atmosphere \
may differ from the shot-day trajectory)."
);
}
let apply_zero_linear = zero_velocity.is_none() && use_powder_sensitivity;
let zero_inputs = BallisticInputs {
muzzle_velocity: zero_velocity_metric,
bc_value: trued_bc,
bc_type: drag_model,
bc_reference_standard,
bullet_mass: mass_metric,
bullet_diameter: diameter_metric,
caliber_inches: diameter_metric / 0.0254,
weight_grains: mass_metric / GRAINS_TO_KG,
bullet_length: bullet_length
.map(|l| match cli.units {
UnitSystem::Imperial => l * 0.0254,
UnitSystem::Metric => l * 0.001,
})
.unwrap_or_else(|| fallback_bullet_length_m(diameter_metric, mass_metric)),
sight_height: sight_height_metric,
muzzle_height: bore_height_metric,
ground_threshold: 0.0,
altitude: zero_altitude_metric,
temperature: zero_temperature_metric,
pressure: zero_pressure_metric,
humidity: zero_humidity_value,
use_powder_sensitivity: apply_zero_linear,
powder_temp_sensitivity: if apply_zero_linear {
UnitConverter::velocity_to_metric(powder_temp_sensitivity, cli.units)
/ UnitConverter::temperature_delta_to_metric(1.0, cli.units)
} else {
0.0
},
powder_temp: UnitConverter::temperature_to_metric(powder_temp, cli.units),
powder_temp_curve: if zero_velocity.is_some() {
None
} else {
powder_temp_curve_si.clone()
},
powder_curve_temp_c: if let Some(t) = zero_powder_temp {
Some(UnitConverter::temperature_to_metric(t, cli.units))
} else if zero_temperature.is_some() {
None
} else {
powder_curve_temp_c
},
use_bc_segments: effective_use_bc_segments,
bc_segments_data: bc_segments_data.clone(),
use_cluster_bc,
custom_drag_table: custom_drag_table.clone(),
cd_scale,
zero_poi_vertical_m: zero_poi_vertical_metric,
zero_poi_horizontal_m: zero_poi_horizontal_metric,
sight_offset_lateral_m: sight_offset_lateral_metric,
..Default::default()
};
let zero_wind = WindConditions {
speed: wind_speed_metric,
direction: final_wind_direction.to_radians(),
vertical_speed: wind_vertical_metric,
};
let zero_angle = match zero_pressure_type_resolved {
PressureReferenceMode::Absolute => {
let zero_atmosphere = AtmosphericConditions {
temperature: zero_temperature_metric,
pressure: zero_pressure_metric,
humidity: zero_humidity_value,
altitude: zero_altitude_metric,
};
ballistics_engine::calculate_zero_angle_with_conditions(
zero_inputs,
zero_distance_metric,
bore_height_metric + sight_height_metric,
zero_wind,
zero_atmosphere,
)?
}
PressureReferenceMode::Qnh => {
let (resolved_temp_c, resolved_pressure_hpa) =
resolve_station_conditions_with_pressure_mode(
zero_temperature_metric,
zero_pressure_metric,
zero_altitude_metric,
PressureReferenceMode::Qnh,
);
let zero_atmosphere = AtmosphericConditions {
temperature: resolved_temp_c,
pressure: resolved_pressure_hpa,
humidity: zero_humidity_value,
altitude: zero_altitude_metric,
};
ballistics_engine::calculate_zero_angle_with_resolved_conditions(
zero_inputs,
zero_distance_metric,
bore_height_metric + sight_height_metric,
zero_wind,
zero_atmosphere,
)?
}
};
zero_angle.to_degrees()
} else {
angle
};
let wind_segments: Vec<ballistics_engine::wind::WindSegment> = wind_segment
.iter()
.map(|s| {
let (mut segment, was_clock) =
ballistics_engine::wind::parse_wind_segment_str_detailed(
s,
matches!(cli.units, UnitSystem::Imperial),
)?;
segment.angle_deg = resolve_wind_reference(
wind_ref,
segment.angle_deg,
was_clock,
shot_direction,
)?;
Ok(segment)
})
.collect::<Result<Vec<_>, String>>()?;
let traj_config = TrajectoryConfig {
velocity: velocity_metric,
angle: muzzle_angle,
bc: trued_bc,
bc_reference_standard,
mass: mass_metric,
diameter: diameter_metric,
bullet_length: bullet_length
.map(|l| match cli.units {
UnitSystem::Imperial => l * 0.0254,
UnitSystem::Metric => l * 0.001,
})
.unwrap_or_else(|| fallback_bullet_length_m(diameter_metric, mass_metric)),
drag_model,
max_range: max_range_metric,
time_step,
temperature: temperature_metric,
pressure: pressure_metric,
pressure_type: final_pressure_type,
humidity: final_humidity,
altitude: altitude_metric,
density_altitude_active: final_density_altitude.is_some(),
wind_speed: wind_speed_metric,
wind_direction: final_wind_direction,
wind_vertical: wind_vertical_metric,
wind_segments,
output,
full,
plot,
units: cli.units,
sight_height: sight_height_metric,
bore_height: bore_height_metric,
ignore_ground_impact,
cant,
zero_poi_vertical_m: zero_poi_vertical_metric,
zero_poi_horizontal_m: zero_poi_horizontal_metric,
sight_offset_lateral_m: sight_offset_lateral_metric,
zero_solve_distance_m: final_auto_zero
.map(|zd| UnitConverter::distance_to_metric(zd, cli.units)),
use_bc_segments: effective_use_bc_segments,
use_cluster_bc,
bc_segments_data: bc_segments_data.clone(),
custom_drag_table: custom_drag_table.clone(),
cd_scale,
enable_magnus,
enable_coriolis,
enable_spin_drift,
enable_aerodynamic_jump,
enable_wind_shear,
wind_shear_model,
enable_pitch_damping,
enable_precession,
sample_trajectory,
sample_interval,
drops_reference,
elevation_cf,
windage_cf,
zero_set_elevation_bias_mil: zero_selection.elevation_bias_mil,
zero_set_windage_bias_mil: zero_selection.windage_bias_mil,
use_rk4: !use_euler,
use_rk45: !use_rk4_fixed,
twist_rate,
twist_right,
latitude,
shot_direction,
shooting_angle,
use_powder_sensitivity,
powder_temp_sensitivity,
powder_temp,
powder_temp_curve: powder_temp_curve_si.clone(),
powder_curve_temp_c,
target_speed,
with_drag_coefficient,
adjustment_unit,
elevation_click,
windage_click,
pdf_metadata: pdf_metadata.clone(),
dsf_table: dsf_table.clone(),
solved_zero_angle_deg: final_auto_zero.map(|_| muzzle_angle),
};
#[cfg(feature = "online")]
{
if online || compare {
match ensure_tos_accepted() {
Ok(true) => {
}
Ok(false) => {
return Err(
"Cannot use --online without accepting Terms of Service.".into()
);
}
Err(e) => {
eprintln!("Warning: Could not verify TOS acceptance: {}", e);
eprintln!("Proceeding with online mode...");
}
}
let zero_range_metric = final_auto_zero
.map(|d| UnitConverter::distance_to_metric(d, cli.units))
.unwrap_or_else(|| UnitConverter::distance_to_metric(100.0, cli.units));
let api_request = TrajectoryRequestBuilder::new()
.bc_value(trued_bc)
.bc_type(warn_aux_g1_coercion("--online mode", drag_model))
.bullet_mass(mass_metric * 1000.0) .muzzle_velocity(velocity_metric)
.target_distance(max_range_metric)
.zero_range(zero_range_metric)
.wind_speed(wind_speed_metric)
.wind_angle(final_wind_direction)
.temperature(temperature_metric)
.pressure(pressure_metric)
.humidity(final_humidity)
.altitude(altitude_metric)
.shooting_angle(shooting_angle)
.bullet_diameter(diameter_metric)
.ground_threshold(if ignore_ground_impact {
f64::NEG_INFINITY
} else {
0.0
})
.enable_weather_zones(enable_weather_zones)
.enable_3d_weather(enable_3d_weather)
.wind_shear_model(wind_shear_model.as_engine_str())
.weather_zone_interpolation(&weather_zone_interpolation)
.sample_interval(sample_interval)
.build()
.map_err(|e| format!("Failed to build API request: {}", e))?;
let mut request = api_request;
if let Some(lat) = latitude {
request.latitude = Some(lat);
}
if let Some(lon) = longitude {
request.longitude = Some(lon);
}
if let Some(dir) = shot_direction {
request.shot_direction = Some(dir);
}
if let Some(twist) = twist_rate {
request.twist_rate = Some(twist);
}
let api_client = ApiClient::new(&api_url, api_timeout)
.with_token(ballistics_engine::credentials::load_token());
let api_result = api_client.calculate_trajectory(&request);
match (&api_result, compare) {
(Ok(api_response), true) => {
eprintln!("Running comparison between local and API calculations...\n");
let local_result = {
let drag_model_enum = drag_model;
let local_wind_direction_rad = final_wind_direction.to_radians();
let mut local_inputs = BallisticInputs {
bc_value: trued_bc,
bc_type: drag_model_enum,
bc_reference_standard,
bullet_mass: mass_metric,
muzzle_velocity: velocity_metric,
bullet_diameter: diameter_metric,
bullet_length: bullet_length
.map(|l| match cli.units {
UnitSystem::Imperial => l * 0.0254,
UnitSystem::Metric => l * 0.001,
})
.unwrap_or_else(|| {
fallback_bullet_length_m(diameter_metric, mass_metric)
}),
muzzle_angle: muzzle_angle.to_radians(),
target_distance: max_range_metric,
azimuth_angle: 0.0,
shot_azimuth: shot_direction
.map(|d| d.to_radians())
.unwrap_or(0.0),
shooting_angle: shooting_angle.to_radians(),
cant_angle: cant.to_radians(),
sight_height: sight_height_metric,
muzzle_height: bore_height_metric,
target_height: 0.0,
ground_threshold: if ignore_ground_impact {
f64::NEG_INFINITY
} else {
0.0
},
altitude: altitude_metric,
temperature: temperature_metric,
pressure: pressure_metric,
humidity: final_humidity,
latitude,
wind_speed: wind_speed_metric,
wind_angle: local_wind_direction_rad,
twist_rate: twist_rate.unwrap_or_else(|| {
ballistics_engine::stability::default_twist_inches(
diameter_metric,
mass_metric,
velocity_metric,
)
}),
is_twist_right: twist_right,
caliber_inches: diameter_metric / 0.0254,
weight_grains: mass_metric / GRAINS_TO_KG,
manufacturer: None,
bullet_model: None,
bullet_id: None,
bullet_cluster: None,
use_rk4: !use_euler,
use_adaptive_rk45: !use_rk4_fixed,
enable_advanced_effects: enable_magnus || enable_coriolis,
enable_magnus,
enable_coriolis,
use_powder_sensitivity,
powder_temp_sensitivity: if use_powder_sensitivity {
UnitConverter::velocity_to_metric(
powder_temp_sensitivity,
cli.units,
) / UnitConverter::temperature_delta_to_metric(
1.0, cli.units,
)
} else {
0.0
},
powder_temp: UnitConverter::temperature_to_metric(
powder_temp,
cli.units,
),
powder_temp_curve: powder_temp_curve_si.clone(),
powder_curve_temp_c,
tipoff_yaw: 0.0,
cd_delta2: 7.5,
tipoff_decay_distance: 50.0,
use_bc_segments: effective_use_bc_segments,
bc_segments: None,
bc_segments_data: bc_segments_data.clone(),
use_enhanced_spin_drift: enable_spin_drift,
enable_aerodynamic_jump,
use_form_factor: false,
enable_wind_shear,
wind_shear_model: if enable_wind_shear {
wind_shear_model.as_engine_str().to_string()
} else {
"none".to_string()
},
enable_trajectory_sampling: sample_trajectory,
sample_interval,
drops_reference: drops_reference.to_engine(),
enable_pitch_damping,
enable_precession_nutation: enable_precession,
use_cluster_bc,
bc_type_str: None,
custom_drag_table: custom_drag_table.clone(),
cd_scale,
zero_poi_vertical_m: zero_poi_vertical_metric,
zero_poi_horizontal_m: zero_poi_horizontal_metric,
sight_offset_lateral_m: sight_offset_lateral_metric,
};
let local_wind = WindConditions {
speed: wind_speed_metric,
direction: local_wind_direction_rad,
vertical_speed: wind_vertical_metric,
};
let local_atmo = AtmosphericConditions {
temperature: temperature_metric,
pressure: pressure_metric,
humidity: final_humidity,
altitude: altitude_metric,
};
if final_auto_zero.is_none() {
local_inputs.muzzle_angle =
ballistics_engine::calculate_zero_angle_with_conditions(
local_inputs.clone(),
zero_range_metric,
bore_height_metric + sight_height_metric,
local_wind.clone(),
local_atmo.clone(),
)?;
}
local_inputs.azimuth_angle +=
local_inputs.windage_zero_bias_rad(zero_range_metric);
let mut local_solver =
TrajectorySolver::new(local_inputs, local_wind, local_atmo);
local_solver.set_max_range(max_range_metric);
local_solver.set_time_step(time_step);
local_solver.solve().ok()
};
println!("╔════════════════════════════════════════╗");
println!("║ COMPARISON: LOCAL vs API ║");
println!("╠════════════════════════════════════════╣");
if let Some(ref corrections) = api_response.ml_corrections_applied {
if !corrections.is_empty() {
println!("║ ML Corrections Applied: ║");
for correction in corrections {
println!("║ - {:32} ║", correction);
}
println!("╠════════════════════════════════════════╣");
}
}
if let Some(confidence) = api_response.bc_confidence {
println!(
"║ BC Confidence: {:>8.1}% ║",
confidence * 100.0
);
println!("╠════════════════════════════════════════╣");
}
let (dist_unit, _drop_unit, _vel_unit) = match cli.units {
UnitSystem::Metric => ("m", "m", "m/s"),
UnitSystem::Imperial => ("yd", "in", "fps"),
};
println!("║ Range {} │ API Drop │Local Drop│ Δ Drop ║", dist_unit);
println!("╠══════════╪══════════╪══════════╪══════════╣");
for point in api_response.trajectory.iter().take(10) {
let range_display =
UnitConverter::distance_from_metric(point.range, cli.units);
let api_drop_display = if cli.units == UnitSystem::Imperial {
point.drop * 39.3701
} else {
point.drop
};
if let Some(ref local_res) = local_result {
if let Some(pos) = local_res.position_at_range(point.range) {
let local_drop =
pos.y - (bore_height_metric + sight_height_metric);
let local_drop_display =
if cli.units == UnitSystem::Imperial {
local_drop * 39.3701
} else {
local_drop
};
let delta = api_drop_display - local_drop_display;
println!(
"║ {:>8.1} │ {:>8.2} │ {:>8.2} │ {:>+8.2} ║",
range_display,
api_drop_display,
local_drop_display,
delta
);
} else {
println!(
"║ {:>8.1} │ {:>8.2} │ --- │ --- ║",
range_display, api_drop_display
);
}
} else {
println!(
"║ {:>8.1} │ {:>8.2} │ error │ --- ║",
range_display, api_drop_display
);
}
}
println!("╚══════════╧══════════╧══════════╧══════════╝");
println!();
}
(Ok(api_response), false) => {
display_api_trajectory_result(api_response, output, cli.units, full)?;
}
(Err(e), _) => {
if offline_fallback {
eprintln!("Warning: API request failed: {}", e);
eprintln!("Falling back to local calculation...\n");
run_trajectory(&traj_config)?;
} else {
eprintln!("Error: API request failed: {}", e);
eprintln!("Hint: Use --offline-fallback to use local calculation on API failure");
std::process::exit(1);
}
}
}
} else {
run_trajectory(&traj_config)?;
}
}
#[cfg(not(feature = "online"))]
{
run_trajectory(&traj_config)?;
}
}
Commands::MonteCarlo {
velocity,
angle,
bc,
bc_reference,
mass,
diameter,
num_sims,
velocity_std,
angle_std,
bc_std,
wind_std,
wind_direction_std,
wind_speed,
wind_direction,
wind_ref,
shot_direction,
wind_vertical,
target_distance,
target_radius,
drag_table,
cd_scale,
cant,
sight_offset,
wez,
target_size,
wind_call_error,
wez_start,
wez_end,
wez_step,
adaptive,
confidence,
target_ci_half_width,
min_samples,
max_samples,
mc_batch_size,
seed,
output,
} => {
let bullet_mass = mass;
let bullet_diameter = diameter;
let sight_offset_lateral_m = sight_offset
.map(|v| sight_offset_display_to_metric(v, cli.units))
.unwrap_or(0.0);
let velocity_metric = UnitConverter::velocity_to_metric(velocity, cli.units);
let mass_metric = UnitConverter::mass_to_metric(bullet_mass, cli.units);
let diameter_metric = UnitConverter::diameter_to_metric(bullet_diameter, cli.units);
let velocity_std_metric = UnitConverter::velocity_to_metric(velocity_std, cli.units);
let wind_std_metric = UnitConverter::wind_to_metric(wind_std, cli.units);
let wind_speed_metric = UnitConverter::wind_to_metric(wind_speed, cli.units);
let wind_vertical_metric = UnitConverter::wind_to_metric(wind_vertical, cli.units);
let target_distance_metric =
target_distance.map(|d| UnitConverter::distance_to_metric(d, cli.units));
let target_radius_metric = target_radius
.map(|r| UnitConverter::distance_to_metric(r, cli.units))
.unwrap_or(ballistics_engine::DEFAULT_HIT_RADIUS_M);
let custom_drag_table = drag_table.as_deref().map(load_drag_table_or_exit);
let cd_scale = resolve_cd_scale(cd_scale, custom_drag_table.is_some(), false)
.unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
let wind_direction_relative_deg = resolve_wind_reference(
wind_ref,
wind_direction.degrees,
wind_direction.was_clock,
shot_direction,
)?;
if custom_drag_table.is_some()
&& matches!(bc_reference, BcReferenceStandard::ArmyStandardMetro)
{
eprintln!(
"{}",
ballistics_engine::cli_api::BC_REFERENCE_STANDARD_INERT_WARNING
);
}
if wez {
if confidence.is_some() {
eprintln!(
"note: --confidence has no effect with --wez (the sweep reports a bare hit probability, no confidence interval)"
);
}
if seed.is_some() {
eprintln!(
"note: --seed has no effect with --wez (the sweep seeds itself internally with a fixed per-range-step seed, not --seed)"
);
}
let target_size_spec = target_size
.as_deref()
.ok_or("--target-size is required with --wez (e.g. --target-size 18x30)")?;
let target_size_parsed = parse_target_size(target_size_spec)
.map_err(|e| format!("--target-size: {e}"))?;
let target_size_metric = target_size_parsed.to_metric(cli.units);
let wind_call_error_metric =
UnitConverter::wind_to_metric(wind_call_error, cli.units);
let wez_start_metric = UnitConverter::distance_to_metric(wez_start, cli.units);
let wez_end_metric = UnitConverter::distance_to_metric(wez_end, cli.units);
let wez_step_metric = UnitConverter::distance_to_metric(wez_step, cli.units);
let bc_wez = resolve_bc_for_reference_standard(bc, bc_reference);
let bc_std_wez = resolve_bc_for_reference_standard(bc_std, bc_reference);
run_monte_carlo_wez(
velocity_metric,
angle,
bc_wez,
mass_metric,
diameter_metric,
num_sims,
velocity_std_metric,
angle_std,
bc_std_wez,
wind_std_metric,
wind_direction_std,
wind_speed_metric,
wind_direction_relative_deg,
wind_vertical_metric,
wind_call_error_metric,
target_size_metric,
wez_start_metric,
wez_end_metric,
wez_step_metric,
custom_drag_table,
cd_scale,
cant,
sight_offset_lateral_m,
output,
cli.units,
)?;
} else if adaptive {
run_monte_carlo_adaptive(
velocity_metric,
angle,
bc,
bc_reference,
mass_metric,
diameter_metric,
velocity_std_metric,
angle_std,
bc_std,
wind_std_metric,
wind_speed_metric,
wind_direction_relative_deg,
wind_vertical_metric,
target_distance_metric,
target_radius_metric,
custom_drag_table,
cd_scale,
cant,
sight_offset_lateral_m,
AdaptiveMcCliControls {
confidence,
target_half_width: target_ci_half_width,
min_samples,
max_samples,
batch_size: mc_batch_size,
seed,
},
output,
)?;
} else {
run_monte_carlo(
velocity_metric,
angle,
bc,
bc_reference,
mass_metric,
diameter_metric,
num_sims,
velocity_std_metric,
angle_std,
bc_std,
wind_std_metric,
wind_direction_std,
wind_speed_metric,
wind_direction_relative_deg,
wind_vertical_metric,
target_distance_metric,
target_radius_metric,
custom_drag_table,
cd_scale,
cant,
sight_offset_lateral_m,
seed,
confidence.unwrap_or(ConfidenceLevel::P95),
output,
)?;
}
}
Commands::Zero {
velocity,
bc,
bc_reference,
mass,
diameter,
drag_model,
target_distance,
from_angle,
target_height,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
density_altitude,
drag_table,
cd_scale,
elevation_cf,
output,
} => {
let elevation_cf = resolve_tracking_cf(elevation_cf, None, "--elevation-cf")?;
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = UnitConverter::resolve_pressure(pressure, cli.units)?;
let bullet_mass = mass;
let bullet_diameter = diameter;
let velocity_metric = UnitConverter::velocity_to_metric(velocity, cli.units);
let mass_metric = UnitConverter::mass_to_metric(bullet_mass, cli.units);
let diameter_metric = UnitConverter::diameter_to_metric(bullet_diameter, cli.units);
let target_height_metric = UnitConverter::distance_to_metric(target_height, cli.units);
let sight_height_default = match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
};
let sight_height_value = sight_height.unwrap_or(sight_height_default);
let sight_height_metric =
UnitConverter::sight_height_to_metric(sight_height_value, cli.units);
let temperature_metric = UnitConverter::temperature_to_metric(temperature, cli.units);
let pressure_metric = UnitConverter::pressure_to_metric(pressure, cli.units);
let altitude_metric = UnitConverter::altitude_to_metric(altitude, cli.units);
let (temperature_metric, pressure_metric, altitude_metric, pressure_type) =
match density_altitude {
Some(da_display) => {
let da_m = UnitConverter::altitude_to_metric(da_display, cli.units);
let std_temperature_metric = UnitConverter::temperature_to_metric(
UnitConverter::resolve_temperature(None, cli.units)?,
cli.units,
);
let explicit_temp_c = (temperature_metric != std_temperature_metric)
.then_some(temperature_metric);
if pressure_type != PressureReferenceMode::Absolute {
eprintln!(
"warning: --pressure-type is ignored because --density-altitude supplies the station pressure directly"
);
}
let (da_altitude_m, da_temp_c, da_pressure_hpa) =
ballistics_engine::atmosphere::resolve_atmosphere_for_density_altitude(
da_m,
explicit_temp_c,
);
(
da_temp_c,
da_pressure_hpa,
da_altitude_m,
PressureReferenceMode::Absolute,
)
}
None => (
temperature_metric,
pressure_metric,
altitude_metric,
pressure_type,
),
};
let custom_drag_table = drag_table.as_deref().map(load_drag_table_or_exit);
let cd_scale = resolve_cd_scale(cd_scale, custom_drag_table.is_some(), false)
.unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
if custom_drag_table.is_some()
&& matches!(bc_reference, BcReferenceStandard::ArmyStandardMetro)
{
eprintln!(
"{}",
ballistics_engine::cli_api::BC_REFERENCE_STANDARD_INERT_WARNING
);
}
match from_angle {
Some(angle_deg) => {
run_zero_range_calculation(
velocity_metric,
bc,
bc_reference,
mass_metric,
diameter_metric,
drag_model,
angle_deg.to_radians(),
target_height_metric,
sight_height_metric,
temperature_metric,
pressure_metric,
pressure_type,
humidity,
altitude_metric,
custom_drag_table,
cd_scale,
elevation_cf,
output,
cli.units,
)?;
}
None => {
let target_distance = target_distance
.expect("clap requires --target-distance when --from-angle is absent");
let target_distance_metric =
UnitConverter::distance_to_metric(target_distance, cli.units);
run_zero_calculation(
velocity_metric,
bc,
bc_reference,
mass_metric,
diameter_metric,
drag_model,
target_distance_metric,
target_height_metric,
sight_height_metric,
temperature_metric,
pressure_metric,
pressure_type,
humidity,
altitude_metric,
custom_drag_table,
cd_scale,
elevation_cf,
output,
cli.units,
)?;
}
}
}
Commands::EstimateBC {
velocity,
mass,
diameter,
data,
velocity_data,
drag_model,
zero_range,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
distance1,
drop1,
distance2,
drop2,
output,
} => {
let velocity_metric = UnitConverter::velocity_to_metric(velocity, cli.units);
let mass_metric = UnitConverter::mass_to_metric(mass, cli.units);
let diameter_metric = UnitConverter::diameter_to_metric(diameter, cli.units);
let atmosphere = AtmosphericConditions {
temperature: temperature
.map(|t| UnitConverter::temperature_to_metric(t, cli.units))
.unwrap_or(15.0),
pressure: pressure
.map(|p| {
apply_pressure_mode(
UnitConverter::pressure_to_metric(p, cli.units),
altitude,
pressure_type,
cli.units,
)
})
.unwrap_or(1013.25),
humidity,
altitude: match cli.units {
UnitSystem::Imperial => altitude * 0.3048,
UnitSystem::Metric => altitude,
},
};
let zero_range_m = zero_range.map(|z| UnitConverter::distance_to_metric(z, cli.units));
let sight_height_m = sight_height
.map(|s| match cli.units {
UnitSystem::Imperial => s * 0.0254,
UnitSystem::Metric => s / 1000.0,
})
.unwrap_or(0.05);
let drop_raw: Vec<(f64, f64)> = if let Some(s) = &data {
parse_data_pairs(s)?
} else if let (Some(d1), Some(dr1), Some(d2), Some(dr2)) =
(distance1, drop1, distance2, drop2)
{
vec![(d1, dr1), (d2, dr2)]
} else {
Vec::new()
};
let vel_raw: Vec<(f64, f64)> = match &velocity_data {
Some(s) => parse_data_pairs(s)?,
None => Vec::new(),
};
if drop_raw.is_empty() && vel_raw.is_empty() {
return Err(
"No data provided. Supply drop data (--data \"d,drop;...\" or \
--distance1/--drop1/--distance2/--drop2) and/or velocity data \
(--velocity-data \"d,vel;...\")."
.into(),
);
}
let drop_metric: Vec<(f64, f64)> = drop_raw
.iter()
.map(|(d, drop)| {
(
UnitConverter::distance_to_metric(*d, cli.units),
match cli.units {
UnitSystem::Imperial => *drop * 0.0254,
UnitSystem::Metric => *drop,
},
)
})
.collect();
let vel_metric: Vec<(f64, f64)> = vel_raw
.iter()
.map(|(d, v)| {
(
UnitConverter::distance_to_metric(*d, cli.units),
UnitConverter::velocity_to_metric(*v, cli.units),
)
})
.collect();
let models = parse_drag_models(&drag_model)?;
if zero_range_m.is_none()
&& drop_metric.iter().any(|(_, dr)| dr.abs() < 0.05)
&& drop_metric.iter().any(|(_, dr)| dr.abs() > 0.25)
{
let zd = drop_metric
.iter()
.min_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap())
.map(|(d, _)| match cli.units {
UnitSystem::Imperial => *d / 0.9144,
UnitSystem::Metric => *d,
})
.unwrap_or(0.0);
let unit = if cli.units == UnitSystem::Imperial {
"yd"
} else {
"m"
};
eprintln!(
"Warning: your drop data looks zeroed near {zd:.0} {unit} (a point has ~0 drop), \
but --zero-range was not given."
);
eprintln!(
" Dope-card drops are below line of sight; pass --zero-range {zd:.0} \
for an accurate BC. Without it, drop is treated as bore-referenced (flat fire)."
);
}
run_bc_estimation_multi(
velocity_metric,
mass_metric,
diameter_metric,
&drop_metric,
&vel_metric,
&models,
atmosphere,
zero_range_m,
sight_height_m,
cli.units,
output,
)?;
}
Commands::BcConvert {
source_model,
target_model,
bc,
mach,
velocity,
bc_segment,
speed_of_sound,
output,
} => {
run_bc_convert(
source_model,
target_model,
bc,
mach,
velocity,
&bc_segment,
speed_of_sound,
output,
cli.units,
)?;
}
Commands::GenerateBCSegments {
bc,
mass,
diameter,
model,
drag_model,
output,
} => {
let bullet_mass = mass;
let bullet_diameter = diameter;
let mass_metric = UnitConverter::mass_to_metric(bullet_mass, cli.units);
let diameter_metric = UnitConverter::diameter_to_metric(bullet_diameter, cli.units);
run_bc_segment_generation(
bc,
mass_metric,
diameter_metric,
&model,
&drag_model,
output,
cli.units,
)?;
}
Commands::TallTarget {
dialed,
measured,
range,
unit,
} => {
let (measured_label, range_label) = match cli.units {
UnitSystem::Imperial => ("in", "yd"),
UnitSystem::Metric => ("cm", "m"),
};
let metric = matches!(cli.units, UnitSystem::Metric);
let result = tall_target_v1(&TallTargetRequestV1 {
dialed,
measured,
range,
unit,
metric,
})
.map_err(|e| {
match e {
TallTargetErrorV1::ClicksNotAngular => {
"--unit clicks is not an angular unit; enter the dialed travel in mil, moa, smoa, or iphy".to_string()
}
TallTargetErrorV1::InvalidDialed => {
"--dialed must be a positive angular travel".to_string()
}
TallTargetErrorV1::InvalidMeasured => {
"--measured must be a positive measured travel".to_string()
}
TallTargetErrorV1::InvalidRange => {
"--range must be at least 1 yard/meter".to_string()
}
}
})?;
let unit_label = adjustment_unit_label(unit);
println!();
println!("Tall-Target Test Result");
println!(" Dialed travel: {:.2} {}", dialed, unit_label);
println!(
" Actual travel: {:.2} {} ({:.2} {} at {:.0} {})",
result.actual, unit_label, measured, measured_label, range, range_label
);
println!(
" Correction factor (actual / dialed): {:.4}",
result.correction_factor
);
if !result.within_accepted_band {
println!(
" warning: {:.4} is outside the accepted (0.5, 1.5) band — the engine \
will reject it as a CF; re-check the tall-target measurement",
result.correction_factor
);
} else {
println!(
" Enter this as --elevation-cf / profile elevation_cf (or the windage \
equivalents); dial solutions are divided by it."
);
}
println!();
}
Commands::TrueVelocity {
measured_drop,
range,
observed,
observation_sigma,
mv_prior,
bc_prior,
predict_range,
prediction_sigma,
drop_unit,
elevation_cf,
bc,
drag_model,
mass,
diameter,
chrono_velocity,
chrono_distance,
zero_distance,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
units,
bc_table_dir,
#[cfg(feature = "online")]
bc_table_auto,
#[cfg(feature = "online")]
bc_table_url,
offline,
#[cfg(feature = "online")]
offline_fallback,
#[cfg(feature = "online")]
api_url,
#[cfg(feature = "online")]
api_timeout,
bullet_length,
output,
} => {
let elevation_cf = resolve_tracking_cf(elevation_cf, None, "--elevation-cf")?;
let observation_cf = if observed.is_empty() || drop_unit != DropUnit::In {
elevation_cf
} else {
1.0
};
let measured_drop = measured_drop * observation_cf;
let temperature = UnitConverter::resolve_temperature(temperature, units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, units)?,
altitude,
pressure_type,
units,
);
let range_yd = match units {
UnitSystem::Imperial => range,
UnitSystem::Metric => range / 0.9144, };
let weight_gr = match units {
UnitSystem::Imperial => mass,
UnitSystem::Metric => mass / GRAMS_PER_GRAIN, };
let caliber_in = match units {
UnitSystem::Imperial => diameter,
UnitSystem::Metric => diameter / 25.4, };
let chrono_fps = chrono_velocity.map(|v| match units {
UnitSystem::Imperial => v,
UnitSystem::Metric => v / 0.3048, });
let zero_yd = match units {
UnitSystem::Imperial => zero_distance,
UnitSystem::Metric => zero_distance / 0.9144,
};
let sight_height_default = match units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
};
let sight_height = sight_height.unwrap_or(sight_height_default);
let sight_in = match units {
UnitSystem::Imperial => sight_height,
UnitSystem::Metric => sight_height / 25.4, };
let temp_f = match units {
UnitSystem::Imperial => temperature,
UnitSystem::Metric => temperature * 9.0 / 5.0 + 32.0, };
let press_inhg = match units {
UnitSystem::Imperial => pressure,
UnitSystem::Metric => pressure / 33.8639, };
let alt_ft = match units {
UnitSystem::Imperial => altitude,
UnitSystem::Metric => altitude / 0.3048, };
let drag_str = match drag_model {
DragModelArg::G1 => "G1",
DragModelArg::G7 => "G7",
};
#[cfg(feature = "online")]
let effective_bc_table_dir: Option<PathBuf> = if bc_table_dir.is_some() {
bc_table_dir.clone()
} else if bc_table_auto {
match Bc5dDownloader::new(&bc_table_url, false) {
Ok(mut downloader) => {
match downloader.ensure_table(caliber_in) {
Ok(table_path) => {
table_path.parent().map(|p| p.to_path_buf())
}
Err(e) => {
eprintln!("Warning: {}", e);
eprintln!(" Continuing without BC5D correction table.");
None
}
}
}
Err(e) => {
eprintln!("Warning: BC5D table download failed: {}", e);
eprintln!(" Continuing without BC5D correction table.");
None
}
}
} else {
None
};
#[cfg(not(feature = "online"))]
let effective_bc_table_dir: Option<PathBuf> = bc_table_dir.clone();
let bc_segments: Option<Vec<BCSegmentData>> =
if let Some(table_dir) = &effective_bc_table_dir {
let mut manager = Bc5dTableManager::new(table_dir);
reject_bc5d_caliber_mismatch(&mut manager, caliber_in)?;
generate_bc5d_segments(
&mut manager,
caliber_in,
bc,
drag_str,
weight_gr,
None,
bullet_length,
bullet_length.is_some(),
None,
)
} else {
None
};
if chrono_distance.is_some_and(|d| d != 0.0) && chrono_fps.is_none() {
return Err("--chrono-distance requires --chrono-velocity".into());
}
let chrono_fps = match (chrono_fps, chrono_distance) {
(Some(measured), Some(d)) if d != 0.0 => {
let screen_distance_m = match units {
UnitSystem::Imperial => d * 0.3048, UnitSystem::Metric => d,
};
let correction = correct_chrono_velocity_fps(
measured,
screen_distance_m,
bc,
drag_model,
weight_gr,
caliber_in,
temp_f,
press_inhg,
humidity,
alt_ft,
&bc_segments,
)?;
Some(correction.muzzle_velocity_fps)
}
_ => chrono_fps,
};
#[cfg(feature = "online")]
let use_local = offline;
#[cfg(not(feature = "online"))]
let use_local = true;
let uncertainty_requested = observation_sigma.is_some()
|| mv_prior.is_some()
|| bc_prior.is_some()
|| !predict_range.is_empty()
|| prediction_sigma.is_some()
|| observed.iter().any(|token| token.matches(':').count() == 2);
if uncertainty_requested {
let default_sigma = observation_sigma.ok_or(
"--observation-sigma is required for uncertainty-aware truing (it supplies the primary observation's known 1-sigma error)",
)?;
if !default_sigma.is_finite() || default_sigma <= 0.0 {
return Err("--observation-sigma must be positive and finite".into());
}
if observed.is_empty() {
return Err(
"uncertainty-aware joint MV+BC truing requires at least one --observed RANGE:DROP[:SIGMA] in addition to the primary observation"
.into(),
);
}
if bc_segments
.as_ref()
.is_some_and(|segments| !segments.is_empty())
{
return Err(
"uncertainty-aware scalar-BC truing does not support velocity-banded BC tables; omit --bc-table-dir/--bc-table-auto or fit an explicit table scale model"
.into(),
);
}
let mut weighted_observations = Vec::with_capacity(observed.len() + 1);
weighted_observations.push(WeightedTruingObservationV1 {
range_yd,
drop: measured_drop,
sigma: default_sigma * observation_cf,
});
for token in &observed {
let (observation, sigma) =
parse_uncertain_truing_observation(token, units, default_sigma)?;
weighted_observations.push(WeightedTruingObservationV1 {
range_yd: observation.range_yd,
drop: observation.drop * observation_cf,
sigma: sigma * observation_cf,
});
}
let mv_prior = mv_prior
.as_deref()
.map(|token| {
let (mean, sigma) = parse_normal_prior(token, "--mv-prior")?;
let to_fps = match units {
UnitSystem::Imperial => 1.0,
UnitSystem::Metric => 1.0 / 0.3048,
};
Ok::<NormalPriorV1, String>(NormalPriorV1 {
mean: mean * to_fps,
sigma: sigma * to_fps,
})
})
.transpose()?;
let bc_prior = bc_prior
.as_deref()
.map(|token| {
let (mean, sigma) = parse_normal_prior(token, "--bc-prior")?;
Ok::<NormalPriorV1, String>(NormalPriorV1 { mean, sigma })
})
.transpose()?;
if prediction_sigma.is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0) {
return Err("--prediction-sigma must be positive and finite".into());
}
if prediction_sigma.is_some() && predict_range.is_empty() {
return Err("--prediction-sigma requires at least one --predict-range".into());
}
let predictions = predict_range
.iter()
.map(|range| TruingPredictionRequestV1 {
range_yd: match units {
UnitSystem::Imperial => *range,
UnitSystem::Metric => *range / 0.9144,
},
future_observation_sigma: prediction_sigma,
})
.collect();
let initial_mv_fps = mv_prior.map_or(3000.0, |prior| prior.mean);
let model = TruingModelInputsV1 {
muzzle_velocity_fps: initial_mv_fps,
ballistic_coefficient: bc,
drag_model,
mass_gr: weight_gr,
diameter_in: caliber_in,
zero_distance_yd: zero_yd,
sight_height_in: sight_in,
temperature_f: temp_f,
pressure_inhg: press_inhg,
humidity_pct: humidity,
altitude_ft: alt_ft,
};
model
.validate()
.map_err(|error| format!("invalid uncertainty-truing model: {error}"))?;
let request = UncertaintyTruingRequestV1 {
model,
drop_unit,
observations: weighted_observations,
priors: TruingPriorsV1 {
muzzle_velocity_fps: mv_prior,
ballistic_coefficient: bc_prior,
},
predictions,
};
eprintln!(
"Fitting {} weighted observations (uncertainty-aware joint MV+BC MAP)...",
request.observations.len()
);
if observation_cf != 1.0 {
eprintln!(
"note: --elevation-cf {observation_cf} converted the dialed observations \
to true angular units (dialed × CF); uncertainty-report values are in \
true (scope-corrected) angular units"
);
}
let report = run_uncertainty_truing_v1(&request)?;
display_uncertainty_truing_result(&report, units, chrono_fps, output)?;
} else if !observed.is_empty() {
run_multi_observation_truing(
measured_drop,
range_yd,
&observed,
drop_unit,
observation_cf,
bc,
drag_model,
weight_gr,
caliber_in,
zero_yd,
sight_in,
temp_f,
press_inhg,
humidity,
alt_ft,
&bc_segments,
chrono_fps,
units,
output,
)?;
} else if use_local {
eprintln!("Calculating effective velocity locally...");
match calculate_true_velocity_local(
measured_drop,
range_yd,
bc,
drag_model,
weight_gr,
caliber_in,
zero_yd,
sight_in,
temp_f,
press_inhg,
humidity,
alt_ft,
&bc_segments,
) {
Ok(result) => {
let velocity_adjustment =
chrono_fps.map(|c| result.effective_velocity_fps - c);
let adjustment_percent =
chrono_fps.map(|c| (result.effective_velocity_fps - c) / c * 100.0);
let effective_vel = match units {
UnitSystem::Imperial => result.effective_velocity_fps,
UnitSystem::Metric => result.effective_velocity_fps * 0.3048,
};
let vel_unit = match units {
UnitSystem::Imperial => "fps",
UnitSystem::Metric => "m/s",
};
display_true_velocity_result(
effective_vel,
vel_unit,
velocity_adjustment,
adjustment_percent,
&result.confidence,
result.iterations,
result.final_error_mil / observation_cf,
result.calculated_drop_mil / observation_cf,
measured_drop / observation_cf,
units,
output,
bc_segments.is_some(),
)?;
}
Err(e) => {
eprintln!("Error: Local calculation failed: {}", e);
std::process::exit(1);
}
}
} else {
#[cfg(feature = "online")]
{
if !check_tos_accepted() && !prompt_tos_acceptance()? {
eprintln!("Cannot use online features without accepting Terms of Service.");
std::process::exit(1);
}
let request = TrueVelocityRequest {
measured_drop_mil: measured_drop,
range_yd,
bc,
drag_model: drag_str.to_string(),
weight_gr,
caliber: caliber_in,
zero_range_yd: Some(zero_yd),
chrono_velocity_fps: chrono_fps,
altitude_ft: Some(alt_ft),
temperature_f: Some(temp_f),
pressure_inhg: Some(press_inhg),
humidity: Some(humidity),
sight_height_in: Some(sight_in),
use_bc_enhancement: Some(true),
};
let api_client = ApiClient::new(&api_url, api_timeout)
.with_token(ballistics_engine::credentials::load_token());
eprintln!("Calculating effective velocity via API...");
match api_client.true_velocity(&request) {
Ok(response) => {
let effective_vel = match units {
UnitSystem::Imperial => response.effective_velocity_fps,
UnitSystem::Metric => response.effective_velocity_fps * 0.3048,
};
let vel_unit = match units {
UnitSystem::Imperial => "fps",
UnitSystem::Metric => "m/s",
};
display_true_velocity_result(
effective_vel,
vel_unit,
response.velocity_adjustment_fps,
response.adjustment_percent,
&response.confidence,
response.iterations,
response.final_error_mil / observation_cf,
response.calculated_drop_mil / observation_cf,
measured_drop / observation_cf,
units,
output,
false,
)?;
}
Err(e) => {
if offline_fallback {
eprintln!("Warning: API request failed: {}", e);
eprintln!("Falling back to local calculation...\n");
match calculate_true_velocity_local(
measured_drop,
range_yd,
bc,
drag_model,
weight_gr,
caliber_in,
zero_yd,
sight_in,
temp_f,
press_inhg,
humidity,
alt_ft,
&bc_segments,
) {
Ok(result) => {
let velocity_adjustment =
chrono_fps.map(|c| result.effective_velocity_fps - c);
let adjustment_percent = chrono_fps.map(|c| {
(result.effective_velocity_fps - c) / c * 100.0
});
let effective_vel = match units {
UnitSystem::Imperial => result.effective_velocity_fps,
UnitSystem::Metric => {
result.effective_velocity_fps * 0.3048
}
};
let vel_unit = match units {
UnitSystem::Imperial => "fps",
UnitSystem::Metric => "m/s",
};
display_true_velocity_result(
effective_vel,
vel_unit,
velocity_adjustment,
adjustment_percent,
&result.confidence,
result.iterations,
result.final_error_mil / observation_cf,
result.calculated_drop_mil / observation_cf,
measured_drop / observation_cf,
units,
output,
bc_segments.is_some(),
)?;
}
Err(fallback_err) => {
eprintln!(
"Error: Fallback calculation also failed: {}",
fallback_err
);
std::process::exit(1);
}
}
} else {
eprintln!("API Error: {}", e);
eprintln!("Hint: If the service rejected your credentials, run `ballistics login` (create a token at https://ballisticsinsight.com/account)");
eprintln!("Hint: Use --offline for local calculation or --offline-fallback for automatic fallback");
std::process::exit(1);
}
}
}
}
#[cfg(not(feature = "online"))]
{
eprintln!("Error: Online mode not available (compile with --features online)");
std::process::exit(1);
}
}
}
Commands::TrueWind {
miss,
velocity,
bc,
drag_model,
mass,
diameter,
twist_rate,
twist_right,
latitude,
shot_direction,
called_wind,
zero_distance,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
units,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, units)?,
altitude,
pressure_type,
units,
);
let velocity_fps = match units {
UnitSystem::Imperial => velocity,
UnitSystem::Metric => velocity / 0.3048,
};
let weight_gr = match units {
UnitSystem::Imperial => mass,
UnitSystem::Metric => mass / GRAMS_PER_GRAIN,
};
let caliber_in = match units {
UnitSystem::Imperial => diameter,
UnitSystem::Metric => diameter / 25.4,
};
let zero_yd = match units {
UnitSystem::Imperial => zero_distance,
UnitSystem::Metric => zero_distance / 0.9144,
};
let sight_height_default = match units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
};
let sight_height = sight_height.unwrap_or(sight_height_default);
let sight_in = match units {
UnitSystem::Imperial => sight_height,
UnitSystem::Metric => sight_height / 25.4,
};
let temp_f = match units {
UnitSystem::Imperial => temperature,
UnitSystem::Metric => temperature * 9.0 / 5.0 + 32.0,
};
let press_inhg = match units {
UnitSystem::Imperial => pressure,
UnitSystem::Metric => pressure / 33.8639,
};
let alt_ft = match units {
UnitSystem::Imperial => altitude,
UnitSystem::Metric => altitude / 0.3048,
};
let twist_in = match units {
UnitSystem::Imperial => twist_rate,
UnitSystem::Metric => twist_rate / 25.4,
};
let called_mph = called_wind.map(|v| match units {
UnitSystem::Imperial => v,
UnitSystem::Metric => v / MPH_TO_MPS,
});
let earth =
match (latitude, shot_direction) {
(Some(latitude_deg), Some(shot_azimuth_deg)) => Some(TruingEarthFrame {
latitude_deg,
shot_azimuth_deg,
}),
(None, None) => None,
(Some(_), None) => {
return Err(
"--latitude also needs --shot-direction before Coriolis can be \
modelled (Earth rotation depends on which way downrange points)"
.into(),
)
}
(None, Some(_)) => return Err(
"--shot-direction also needs --latitude before Coriolis can be modelled"
.into(),
),
};
let observations = miss
.iter()
.map(|token| parse_wind_observation(token, units))
.collect::<Result<Vec<_>, _>>()?;
let request = WindTruingRequest {
observations,
muzzle_velocity_fps: velocity_fps,
bc,
drag_model,
mass_gr: weight_gr,
diameter_in: caliber_in,
zero_distance_yd: zero_yd,
sight_height_in: sight_in,
temperature_f: temp_f,
pressure_inhg: press_inhg,
humidity_pct: humidity,
altitude_ft: alt_ft,
twist: TruingTwist {
rate_in: twist_in,
right_hand: twist_right,
},
earth,
called_crosswind_mph: called_mph,
};
request.validate()?;
let wind_output = match output {
OutputFormat::Json => WindTruingOutput::Json,
OutputFormat::Csv => WindTruingOutput::Csv,
OutputFormat::Table => WindTruingOutput::Table,
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for wind truing results.");
eprintln!("Hint: Use --output json, --output csv, or --output table instead.");
std::process::exit(1);
}
};
eprintln!(
"Back-solving effective wind from {} observed miss{}...",
request.observations.len(),
if request.observations.len() == 1 {
""
} else {
"es"
}
);
let report = solve_wind_truing(&request)?;
print!("{}", format_wind_truing_report(&report, units, wind_output));
}
Commands::PlanTruing {
profile,
velocity,
bc,
drag_model,
mass,
diameter,
candidate_ranges,
range_grid,
observation_count,
minimum_separation,
measurement_resolution,
drop_unit,
zero_distance,
zero_set,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
output,
} => {
let profile_data = profile
.as_deref()
.map(|name| load_profile_for_units(name, cli.units))
.transpose()?;
if let Some(profile) = &profile_data {
let has_segments = profile
.bc_segments
.as_ref()
.is_some_and(|segments| !segments.is_empty())
|| profile.use_bc_segments.unwrap_or(false);
let has_custom_drag = profile
.drag_curve
.as_ref()
.is_some_and(|curve| !curve.is_empty())
|| profile.drag_model.eq_ignore_ascii_case("custom");
if has_segments || has_custom_drag {
return Err(format!(
"profile '{}' uses {} and cannot be reduced to the scalar G1/G7 BC parameter designed by plan-truing; use a scalar-BC profile or define a separate drag-deck scale model",
profile.name,
if has_custom_drag {
"a custom drag curve"
} else {
"velocity-banded BC values"
}
)
.into());
}
}
let velocity = velocity
.or_else(|| profile_data.as_ref().map(|profile| profile.velocity))
.ok_or("--velocity is required unless --profile supplies it")?;
let bc = bc
.or_else(|| profile_data.as_ref().map(|profile| profile.bc))
.ok_or("--bc is required unless --profile supplies it")?;
let mass = mass
.or_else(|| profile_data.as_ref().map(|profile| profile.mass))
.ok_or("--mass is required unless --profile supplies it")?;
let diameter = diameter
.or_else(|| profile_data.as_ref().map(|profile| profile.diameter))
.ok_or("--diameter is required unless --profile supplies it")?;
let drag_model = drag_model
.or_else(|| {
profile_data
.as_ref()
.map(|profile| parse_drag_model_arg_for_truing(&profile.drag_model))
})
.unwrap_or(DragModelArg::G1);
let zero_distance = resolve_zero_selection(
zero_set.as_deref(),
profile_data.as_ref(),
None,
zero_distance,
profile_data
.as_ref()
.and_then(|profile| profile.zero_distance.or(profile.auto_zero)),
)?
.zero_distance
.unwrap_or(100.0);
let sight_height = sight_height
.or_else(|| {
profile_data
.as_ref()
.and_then(|profile| profile.sight_height)
})
.unwrap_or(match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let temperature = UnitConverter::resolve_temperature(
temperature.or_else(|| profile_data.as_ref().map(|profile| profile.temperature)),
cli.units,
)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(
pressure.or_else(|| profile_data.as_ref().map(|profile| profile.pressure)),
cli.units,
)?,
altitude.unwrap_or(0.0),
pressure_type,
cli.units,
);
let humidity = humidity
.or_else(|| profile_data.as_ref().map(|profile| profile.humidity))
.unwrap_or(50.0);
let altitude = altitude
.or_else(|| profile_data.as_ref().map(|profile| profile.altitude))
.unwrap_or(0.0);
let candidate_ranges = match range_grid.as_deref() {
Some(grid) => parse_truing_range_grid(grid)?,
None => candidate_ranges,
};
let to_yards = |value: f64| match cli.units {
UnitSystem::Imperial => value,
UnitSystem::Metric => value / 0.9144,
};
let model = TruingModelInputsV1 {
muzzle_velocity_fps: match cli.units {
UnitSystem::Imperial => velocity,
UnitSystem::Metric => velocity / 0.3048,
},
ballistic_coefficient: bc,
drag_model,
mass_gr: match cli.units {
UnitSystem::Imperial => mass,
UnitSystem::Metric => mass / GRAMS_PER_GRAIN,
},
diameter_in: match cli.units {
UnitSystem::Imperial => diameter,
UnitSystem::Metric => diameter / 25.4,
},
zero_distance_yd: to_yards(zero_distance),
sight_height_in: match cli.units {
UnitSystem::Imperial => sight_height,
UnitSystem::Metric => sight_height / 25.4,
},
temperature_f: match cli.units {
UnitSystem::Imperial => temperature,
UnitSystem::Metric => temperature * 9.0 / 5.0 + 32.0,
},
pressure_inhg: match cli.units {
UnitSystem::Imperial => pressure,
UnitSystem::Metric => pressure / 33.8639,
},
humidity_pct: humidity,
altitude_ft: match cli.units {
UnitSystem::Imperial => altitude,
UnitSystem::Metric => altitude / 0.3048,
},
};
let request = TruingExperimentPlanRequestV1 {
model,
candidate_ranges_yd: candidate_ranges.into_iter().map(to_yards).collect(),
observation_count,
minimum_separation_yd: to_yards(minimum_separation),
measurement_sigma_1sd: measurement_resolution,
drop_unit,
};
eprintln!(
"Evaluating {} candidate ranges for a {}-station truing plan...",
request.candidate_ranges_yd.len(),
request.observation_count
);
let plan = plan_truing_experiment_v1(&request)?;
display_truing_experiment_plan(&plan, cli.units, output)?;
}
Commands::Mpbr {
profile,
velocity,
bc,
mass,
diameter,
drag_model,
vital_zone,
sight_height,
zero_poi_up,
zero_poi_right,
sight_offset,
temperature,
pressure,
pressure_type,
humidity,
altitude,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, cli.units)?,
altitude,
pressure_type,
cli.units,
);
let profile_data = profile.as_ref().map(|name| {
load_profile_for_units(name, cli.units).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
})
});
let final_velocity = resolve_param(velocity, &profile_data, |p| p.velocity)
.unwrap_or_else(|| {
eprintln!("Error: --velocity is required (or use --profile)");
std::process::exit(1);
});
let final_bc = resolve_param(bc, &profile_data, |p| p.bc).unwrap_or_else(|| {
eprintln!("Error: --bc is required (or use --profile)");
std::process::exit(1);
});
let final_mass = resolve_param(mass, &profile_data, |p| p.mass).unwrap_or_else(|| {
eprintln!("Error: --mass is required (or use --profile)");
std::process::exit(1);
});
let final_diameter = resolve_param(diameter, &profile_data, |p| p.diameter)
.unwrap_or_else(|| {
eprintln!("Error: --diameter is required (or use --profile)");
std::process::exit(1);
});
let final_sight_height = sight_height
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let final_drag_model = match (profile_data.as_ref(), velocity) {
(Some(profile), None) => parse_drag_model_arg(&profile.drag_model),
_ => drag_model,
};
let zero_poi_vertical_m = zero_poi_up
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_up_m))
.unwrap_or(0.0);
let zero_poi_horizontal_m = zero_poi_right
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_right_m))
.unwrap_or(0.0);
let sight_offset_lateral_m = sight_offset
.map(|v| sight_offset_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_offset_lateral_m))
.unwrap_or(0.0);
handle_mpbr(
final_velocity,
final_bc,
final_mass,
final_diameter,
final_drag_model,
vital_zone,
final_sight_height,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
temperature,
pressure,
humidity,
altitude,
cli.units,
output,
)?;
}
Commands::ComeUps {
profile,
velocity,
bc,
mass,
diameter,
drag_model,
zero_distance,
zero_set,
start,
end,
step,
adjustment_unit,
elevation_click_value,
windage_click_value,
elevation_cf,
sight_height,
zero_poi_up,
zero_poi_right,
sight_offset,
temperature,
pressure,
pressure_type,
humidity,
altitude,
wind_speed,
wind_direction,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, cli.units)?,
altitude,
pressure_type,
cli.units,
);
let profile_data = profile.as_ref().map(|name| {
load_profile_for_units(name, cli.units).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
})
});
if let Some(w) = windage_click_value_noop_warning(windage_click_value.as_deref()) {
eprintln!("{w}");
}
let elevation_cf = resolve_tracking_cf(
elevation_cf,
profile_data.as_ref().and_then(|p| p.elevation_cf),
"--elevation-cf",
)?;
let zero_selection = resolve_zero_selection(
zero_set.as_deref(),
profile_data.as_ref(),
None,
Some(zero_distance),
None,
)?;
let elevation_click: Option<ClickValue> =
if matches!(adjustment_unit, AdjustmentUnit::Clicks) {
match resolve_click_values(
elevation_click_value.as_deref(),
windage_click_value.as_deref(),
profile_data.as_ref(),
) {
Ok(Some((el, _wi))) => Some(el),
Ok(None) => None,
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
None
};
let final_velocity = resolve_param(velocity, &profile_data, |p| p.velocity)
.unwrap_or_else(|| {
eprintln!("Error: --velocity is required (or use --profile)");
std::process::exit(1);
});
let final_bc = resolve_param(bc, &profile_data, |p| p.bc).unwrap_or_else(|| {
eprintln!("Error: --bc is required (or use --profile)");
std::process::exit(1);
});
let final_mass = resolve_param(mass, &profile_data, |p| p.mass).unwrap_or_else(|| {
eprintln!("Error: --mass is required (or use --profile)");
std::process::exit(1);
});
let final_diameter = resolve_param(diameter, &profile_data, |p| p.diameter)
.unwrap_or_else(|| {
eprintln!("Error: --diameter is required (or use --profile)");
std::process::exit(1);
});
let final_sight_height = sight_height
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let final_drag_model = match (profile_data.as_ref(), velocity) {
(Some(profile), None) => parse_drag_model_arg(&profile.drag_model),
_ => drag_model,
};
let bc_segments_data = profile_data
.as_ref()
.and_then(|p| p.bc_segments.as_ref())
.map(|rows| bc_segments_from_profile(rows));
let custom_drag_table = profile_data
.as_ref()
.and_then(|p| p.drag_curve.as_ref())
.map(|pts| {
drag_table_from_profile(pts).unwrap_or_else(|e| {
eprintln!("Error: saved profile's drag curve is invalid: {e}");
std::process::exit(1);
})
});
let dsf_table: Option<DsfTable> = profile_data
.as_ref()
.and_then(|p| p.dsf_points.clone())
.map(|points| {
DsfTable::from_points(points).unwrap_or_else(|e| {
eprintln!("error: saved profile's DSF table is invalid: {e}");
std::process::exit(1);
})
});
let zero_poi_vertical_m = zero_poi_up
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_up_m))
.unwrap_or(0.0);
let zero_poi_horizontal_m = zero_poi_right
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_right_m))
.unwrap_or(0.0);
let sight_offset_lateral_m = sight_offset
.map(|v| sight_offset_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_offset_lateral_m))
.unwrap_or(0.0);
handle_come_ups(
final_velocity,
final_bc,
final_mass,
final_diameter,
final_drag_model,
zero_distance,
start,
end,
step,
adjustment_unit,
elevation_click,
elevation_cf,
zero_selection.elevation_bias_mil,
final_sight_height,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
temperature,
pressure,
humidity,
altitude,
wind_speed,
wind_direction.degrees,
cli.units,
output,
bc_segments_data,
custom_drag_table,
dsf_table,
)?;
}
Commands::Dsf {
saved_profile,
range,
observed_drop,
zero_set,
} => {
let (observed_value, drop_unit) =
parse_observed_drop(&observed_drop).unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
if !range.is_finite() || range <= 0.0 {
eprintln!("error: --range must be a positive, finite distance");
std::process::exit(1);
}
let mut profile =
load_profile_for_units(&saved_profile, cli.units).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
let dist_unit = match cli.units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
let range_m = UnitConverter::distance_to_metric(range, cli.units);
let default_envelope_m =
UnitConverter::distance_to_metric(DSF_DEFAULT_SOLVE_ENVELOPE, cli.units);
let solve_max_range_m = if range_m > default_envelope_m {
range_m * 1.05
} else {
default_envelope_m
};
let dsf_zero_selection = resolve_zero_selection(
zero_set.as_deref(),
Some(&profile),
None,
None,
profile.auto_zero.or(profile.zero_distance),
)?;
let result = solve_profile_for_dsf(
&profile,
cli.units,
solve_max_range_m,
dsf_zero_selection.zero_distance,
)?;
let derived =
derive_dsf_point_from_solve_v1(&result, range_m, observed_value, drop_unit)
.unwrap_or_else(|e| {
match e {
DsfServiceErrorV1::Supersonic { mach, .. } => {
eprintln!("{}", dsf_supersonic_error(mach));
}
DsfServiceErrorV1::OutOfRange { .. } => {
let solved_range_display = UnitConverter::distance_from_metric(
result.max_range,
cli.units,
);
eprintln!(
"error: saved profile '{}' trajectory does not reach {range:.0} \
{dist_unit} (solved to {solved_range_display:.0} {dist_unit})",
profile.name
);
}
DsfServiceErrorV1::DegenerateDrop { .. } => {
eprintln!(
"error: predicted drop at {range:.0} {dist_unit} is zero or \
non-finite; cannot compute a DSF ratio"
);
}
DsfServiceErrorV1::InvalidInput(_)
| DsfServiceErrorV1::ForwardModel(_) => {
eprintln!("error: {e}");
}
}
std::process::exit(1);
});
if !derived.warnings.is_empty() {
eprintln!("{}", dsf_beyond_90pct_warning(range, dist_unit));
}
let mach = derived.mach;
let dsf = derived.dsf;
let mut table = DsfTable::from_points(profile.dsf_points.clone().unwrap_or_default())
.unwrap_or_else(|e| {
eprintln!("error: saved profile's existing DSF table is invalid: {e}");
std::process::exit(1);
});
match table.upsert(DsfPoint { mach, dsf }) {
Ok(UpsertOutcome::Appended) => {
println!("Added DSF point: Mach {mach:.2} -> DSF {dsf:.4}");
}
Ok(UpsertOutcome::Replaced { old }) => {
println!(
"Superseded DSF point (Mach {:.2} -> DSF {:.4}) with Mach {mach:.2} -> DSF {dsf:.4}",
old.mach, old.dsf
);
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
if dsf_table_missing_transonic_coverage(table.points()) {
eprintln!("{}", dsf_transonic_gap_warning());
}
match dsf_window_start(result.mach_0_9_distance_m) {
Some(start_m) => {
let start_display = UnitConverter::distance_from_metric(start_m, cli.units);
println!(
"note: DSF window: at or beyond {start_display:.1} {dist_unit} (90% of \
the Mach 0.9 distance)"
);
}
None => {
println!("note: no subsonic window inside the solved range");
}
}
profile.dsf_points = Some(table.points().to_vec());
let path = save_profile(&profile)?;
eprintln!("Profile '{}' saved to {:?}", profile.name, path);
println!(
"DSF table for '{}' ({}):",
profile.name,
dsf_table_summary(table.points())
);
for p in table.points() {
println!(" Mach {:.2} DSF {:.4}", p.mach, p.dsf);
}
}
Commands::Lead {
profile,
velocity,
bc,
mass,
diameter,
drag_model,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
wind_speed,
wind_direction,
use_powder_sensitivity,
powder_temp_sensitivity,
powder_temp,
powder_temp_curve,
target_speed,
target_angle,
target_length,
start,
end,
step,
adjustment_unit,
windage_click_value,
windage_cf,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, cli.units)?,
altitude,
pressure_type,
cli.units,
);
let profile_data = profile.as_ref().map(|name| {
load_profile_for_units(name, cli.units).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
})
});
let windage_click: Option<ClickValue> =
if matches!(adjustment_unit, AdjustmentUnit::Clicks) {
match resolve_single_axis_click_value(
windage_click_value.as_deref(),
profile_data.as_ref(),
) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
None
};
let final_velocity = resolve_param(velocity, &profile_data, |p| p.velocity)
.unwrap_or_else(|| {
eprintln!("Error: --velocity is required (or use --profile)");
std::process::exit(1);
});
let final_bc = resolve_param(bc, &profile_data, |p| p.bc).unwrap_or_else(|| {
eprintln!("Error: --bc is required (or use --profile)");
std::process::exit(1);
});
let final_mass = resolve_param(mass, &profile_data, |p| p.mass).unwrap_or_else(|| {
eprintln!("Error: --mass is required (or use --profile)");
std::process::exit(1);
});
let final_diameter = resolve_param(diameter, &profile_data, |p| p.diameter)
.unwrap_or_else(|| {
eprintln!("Error: --diameter is required (or use --profile)");
std::process::exit(1);
});
let final_sight_height = sight_height
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let final_drag_model = match (&profile_data, velocity) {
(Some(p), None) => parse_drag_model_arg(&p.drag_model),
_ => drag_model,
};
let powder_curve_temp_c: Option<f64> =
powder_temp.map(|t| UnitConverter::temperature_to_metric(t, cli.units));
let powder_temp = powder_temp.unwrap_or(match cli.units {
UnitSystem::Imperial => DEFAULT_POWDER_REFERENCE_TEMP_F,
UnitSystem::Metric => DEFAULT_POWDER_REFERENCE_TEMP_C,
});
let powder_temp_sensitivity = powder_temp_sensitivity.unwrap_or(match cli.units {
UnitSystem::Imperial => 1.0,
UnitSystem::Metric => 0.3048 / (5.0 / 9.0),
});
let powder_temp_curve_si: Option<Vec<(f64, f64)>> = match powder_temp_curve.as_deref() {
Some(s) => Some(parse_powder_temp_curve(s, cli.units)?),
None => None,
};
let bc_segments_data = profile_data
.as_ref()
.and_then(|p| p.bc_segments.as_ref())
.map(|rows| bc_segments_from_profile(rows));
let custom_drag_table = profile_data
.as_ref()
.and_then(|p| p.drag_curve.as_ref())
.map(|pts| {
drag_table_from_profile(pts).unwrap_or_else(|e| {
eprintln!("Error: saved profile's drag curve is invalid: {e}");
std::process::exit(1);
})
});
let windage_cf = resolve_tracking_cf(
windage_cf,
profile_data.as_ref().and_then(|p| p.windage_cf),
"--windage-cf",
)?;
handle_lead(
final_velocity,
final_bc,
final_mass,
final_diameter,
final_drag_model,
final_sight_height,
temperature,
pressure,
humidity,
altitude,
wind_speed,
wind_direction.degrees,
use_powder_sensitivity,
powder_temp_sensitivity,
powder_temp,
powder_temp_curve_si,
powder_curve_temp_c,
target_speed,
target_angle,
target_length,
start,
end,
step,
adjustment_unit,
windage_click,
windage_cf,
cli.units,
output,
bc_segments_data,
custom_drag_table,
)?;
}
Commands::Powder {
velocity,
temperature,
powder_temp_sensitivity,
powder_temp,
powder_temp_curve,
sweep,
mass,
output,
} => {
let powder_temp_curve_si: Option<Vec<(f64, f64)>> = match powder_temp_curve.as_deref() {
Some(s) => Some(parse_powder_temp_curve(s, cli.units)?),
None => None,
};
handle_powder(
velocity,
temperature,
powder_temp_sensitivity,
powder_temp,
powder_temp_curve_si,
sweep,
mass,
cli.units,
output,
)?;
}
Commands::Recoil {
bullet_weight,
charge_weight,
velocity,
firearm_weight,
firearm_type,
gas_velocity_factor,
gas_velocity,
output,
} => {
handle_recoil(
bullet_weight,
charge_weight,
velocity,
firearm_weight,
firearm_type.to_engine(),
gas_velocity_factor,
gas_velocity,
cli.units,
output,
)?;
}
Commands::PowerFactor {
weight,
velocity,
organization,
output,
} => {
handle_power_factor(weight, velocity, organization, cli.units, output)?;
}
Commands::WindCard {
profile,
velocity,
bc,
mass,
diameter,
drag_model,
zero_distance,
zero_set,
wind_speeds,
wind_angle,
wind_angles,
start,
end,
step,
adjustment_unit,
windage_click_value,
windage_cf,
sight_height,
zero_poi_up,
zero_poi_right,
sight_offset,
temperature,
pressure,
pressure_type,
humidity,
altitude,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, cli.units)?,
altitude,
pressure_type,
cli.units,
);
let profile_data = profile.as_ref().map(|name| {
load_profile_for_units(name, cli.units).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
})
});
let windage_click: Option<ClickValue> =
if matches!(adjustment_unit, AdjustmentUnit::Clicks) {
match resolve_single_axis_click_value(
windage_click_value.as_deref(),
profile_data.as_ref(),
) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
None
};
let final_velocity = resolve_param(velocity, &profile_data, |p| p.velocity)
.unwrap_or_else(|| {
eprintln!("Error: --velocity is required (or use --profile)");
std::process::exit(1);
});
let final_bc = resolve_param(bc, &profile_data, |p| p.bc).unwrap_or_else(|| {
eprintln!("Error: --bc is required (or use --profile)");
std::process::exit(1);
});
let final_mass = resolve_param(mass, &profile_data, |p| p.mass).unwrap_or_else(|| {
eprintln!("Error: --mass is required (or use --profile)");
std::process::exit(1);
});
let final_diameter = resolve_param(diameter, &profile_data, |p| p.diameter)
.unwrap_or_else(|| {
eprintln!("Error: --diameter is required (or use --profile)");
std::process::exit(1);
});
let final_sight_height = sight_height
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let final_drag_model = match (profile_data.as_ref(), velocity) {
(Some(profile), None) => parse_drag_model_arg(&profile.drag_model),
_ => drag_model,
};
let ws_vec: Vec<f64> = wind_speeds
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
if ws_vec.is_empty() {
eprintln!("Error: --wind-speeds must contain at least one valid number (e.g., '5,10,15,20')");
std::process::exit(1);
}
let (wind_angle_vec, legacy_labels): (Vec<f64>, bool) = if let Some(angle) = wind_angle
{
(vec![angle], false)
} else if let Some(csv) = wind_angles.as_ref() {
let mut angles = Vec::new();
for tok in csv.split(',') {
let tok = tok.trim();
let angle: f64 = tok.parse().map_err(|_| {
format!("--wind-angles contains an invalid number: '{}'", tok)
})?;
if angle.is_nan() || !(0.0..=360.0).contains(&angle) {
return Err(
format!("--wind-angles value '{}' must be in [0, 360]", tok).into()
);
}
angles.push(angle);
}
(angles, false)
} else {
(vec![90.0], true)
};
let zero_poi_vertical_m = zero_poi_up
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_up_m))
.unwrap_or(0.0);
let zero_poi_horizontal_m = zero_poi_right
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_right_m))
.unwrap_or(0.0);
let sight_offset_lateral_m = sight_offset
.map(|v| sight_offset_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_offset_lateral_m))
.unwrap_or(0.0);
let windage_cf = resolve_tracking_cf(
windage_cf,
profile_data.as_ref().and_then(|p| p.windage_cf),
"--windage-cf",
)?;
let zero_selection = resolve_zero_selection(
zero_set.as_deref(),
profile_data.as_ref(),
None,
Some(zero_distance),
None,
)?;
handle_wind_card(
final_velocity,
final_bc,
final_mass,
final_diameter,
final_drag_model,
zero_distance,
&ws_vec,
&wind_angle_vec,
legacy_labels,
start,
end,
step,
adjustment_unit,
windage_click,
windage_cf,
zero_selection.windage_bias_mil,
final_sight_height,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
temperature,
pressure,
humidity,
altitude,
cli.units,
output,
)?;
}
Commands::Stability {
profile,
mass,
diameter,
length,
twist_rate,
velocity,
temperature,
pressure,
pressure_type,
altitude,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, cli.units)?,
altitude,
pressure_type,
cli.units,
);
let profile_data = profile.as_ref().map(|name| {
load_profile_for_units(name, cli.units).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
})
});
let final_mass = resolve_param(mass, &profile_data, |p| p.mass).unwrap_or_else(|| {
eprintln!("Error: --mass is required (or use --profile)");
std::process::exit(1);
});
let final_diameter = resolve_param(diameter, &profile_data, |p| p.diameter)
.unwrap_or_else(|| {
eprintln!("Error: --diameter is required (or use --profile)");
std::process::exit(1);
});
let final_velocity = velocity.unwrap_or(match cli.units {
UnitSystem::Imperial => 2700.0,
UnitSystem::Metric => 823.0,
});
handle_stability(
final_mass,
final_diameter,
length,
twist_rate,
final_velocity,
temperature,
pressure,
altitude,
cli.units,
output,
)?;
}
Commands::RangeTable {
profile,
velocity,
bc,
mass,
diameter,
drag_model,
zero_distance,
zero_set,
start,
end,
step,
wind_speed,
wind_direction,
adjustment_unit,
windage_unit,
elevation_click_value,
windage_click_value,
elevation_cf,
windage_cf,
sight_height,
zero_poi_up,
zero_poi_right,
sight_offset,
temperature,
pressure,
pressure_type,
humidity,
altitude,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, cli.units)?,
altitude,
pressure_type,
cli.units,
);
let profile_data = profile.as_ref().map(|name| {
load_profile_for_units(name, cli.units).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
})
});
let windage_unit_resolved = resolve_windage_unit(adjustment_unit, windage_unit)
.unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
let (elevation_click, windage_click): (Option<ClickValue>, Option<ClickValue>) =
if matches!(adjustment_unit, AdjustmentUnit::Clicks) {
match resolve_click_values(
elevation_click_value.as_deref(),
windage_click_value.as_deref(),
profile_data.as_ref(),
) {
Ok(Some((el, wi))) => (Some(el), Some(wi)),
Ok(None) => (None, None),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
(None, None)
};
let final_velocity = resolve_param(velocity, &profile_data, |p| p.velocity)
.unwrap_or_else(|| {
eprintln!("Error: --velocity is required (or use --profile)");
std::process::exit(1);
});
let final_bc = resolve_param(bc, &profile_data, |p| p.bc).unwrap_or_else(|| {
eprintln!("Error: --bc is required (or use --profile)");
std::process::exit(1);
});
let final_mass = resolve_param(mass, &profile_data, |p| p.mass).unwrap_or_else(|| {
eprintln!("Error: --mass is required (or use --profile)");
std::process::exit(1);
});
let final_diameter = resolve_param(diameter, &profile_data, |p| p.diameter)
.unwrap_or_else(|| {
eprintln!("Error: --diameter is required (or use --profile)");
std::process::exit(1);
});
let final_sight_height = sight_height
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match cli.units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let final_drag_model = match (profile_data.as_ref(), velocity) {
(Some(profile), None) => parse_drag_model_arg(&profile.drag_model),
_ => drag_model,
};
let zero_poi_vertical_m = zero_poi_up
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_up_m))
.unwrap_or(0.0);
let zero_poi_horizontal_m = zero_poi_right
.map(|v| zero_poi_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_poi_right_m))
.unwrap_or(0.0);
let sight_offset_lateral_m = sight_offset
.map(|v| sight_offset_display_to_metric(v, cli.units))
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_offset_lateral_m))
.unwrap_or(0.0);
let elevation_cf = resolve_tracking_cf(
elevation_cf,
profile_data.as_ref().and_then(|p| p.elevation_cf),
"--elevation-cf",
)?;
let windage_cf = resolve_tracking_cf(
windage_cf,
profile_data.as_ref().and_then(|p| p.windage_cf),
"--windage-cf",
)?;
let zero_selection = resolve_zero_selection(
zero_set.as_deref(),
profile_data.as_ref(),
None,
Some(zero_distance),
None,
)?;
handle_range_table(
final_velocity,
final_bc,
final_mass,
final_diameter,
final_drag_model,
zero_distance,
start,
end,
step,
wind_speed,
wind_direction.degrees,
adjustment_unit,
windage_unit_resolved,
elevation_click,
windage_click,
elevation_cf,
windage_cf,
zero_selection.elevation_bias_mil,
zero_selection.windage_bias_mil,
final_sight_height,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
temperature,
pressure,
humidity,
altitude,
cli.units,
output,
)?;
}
Commands::Compare {
loads,
profiles,
zero_distance,
start,
end,
step,
wind_speed,
wind_direction,
adjustment_unit,
windage_unit,
elevation_click_value,
windage_click_value,
elevation_cf,
windage_cf,
sight_height,
zero_poi_up,
zero_poi_right,
sight_offset,
temperature,
pressure,
pressure_type,
humidity,
altitude,
output,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, cli.units)?,
altitude,
pressure_type,
cli.units,
);
let windage_unit_resolved = resolve_windage_unit(adjustment_unit, windage_unit)
.unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
let (elevation_click, windage_click): (Option<ClickValue>, Option<ClickValue>) =
if matches!(adjustment_unit, AdjustmentUnit::Clicks) {
match resolve_click_values(
elevation_click_value.as_deref(),
windage_click_value.as_deref(),
None,
) {
Ok(Some((el, wi))) => (Some(el), Some(wi)),
Ok(None) => (None, None),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else {
(None, None)
};
let mut compare_loads: Vec<CompareLoadSpec> = Vec::new();
for spec in &loads {
compare_loads.push(parse_compare_load_spec(spec, cli.units)?);
}
for name in &profiles {
let p = load_profile_for_units(name, cli.units)?;
let bc_segments_data = p
.bc_segments
.as_ref()
.map(|rows| bc_segments_from_profile(rows));
let custom_drag_table = p
.drag_curve
.as_ref()
.map(|pts| {
drag_table_from_profile(pts).map_err(|e| {
format!("profile '{name}': saved drag curve is invalid: {e}")
})
})
.transpose()?;
compare_loads.push(CompareLoadSpec {
name: name.clone(),
drag_model: parse_drag_model_arg(&p.drag_model),
bc: p.bc,
mass: p.mass,
velocity: p.velocity,
diameter: p.diameter,
bc_segments_data,
custom_drag_table,
});
}
if compare_loads.len() < 2 {
return Err("compare needs at least 2 loads (via --load and/or --profile)".into());
}
if compare_loads.len() > 8 {
return Err("compare supports at most 8 loads".into());
}
if start >= end {
return Err("--start must be less than --end".into());
}
let zero_poi_vertical_m = zero_poi_up
.map(|v| zero_poi_display_to_metric(v, cli.units))
.unwrap_or(0.0);
let zero_poi_horizontal_m = zero_poi_right
.map(|v| zero_poi_display_to_metric(v, cli.units))
.unwrap_or(0.0);
let sight_offset_lateral_m = sight_offset
.map(|v| sight_offset_display_to_metric(v, cli.units))
.unwrap_or(0.0);
let elevation_cf = resolve_tracking_cf(elevation_cf, None, "--elevation-cf")?;
let windage_cf = resolve_tracking_cf(windage_cf, None, "--windage-cf")?;
handle_compare(
compare_loads,
zero_distance,
start,
end,
step,
wind_speed,
wind_direction.degrees,
adjustment_unit,
windage_unit_resolved,
elevation_click,
windage_click,
elevation_cf,
windage_cf,
sight_height,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
temperature,
pressure,
humidity,
altitude,
cli.units,
output,
)?;
}
Commands::Profile { action } => {
match action {
ProfileAction::Save {
name,
velocity,
bc,
bc_reference,
mass,
diameter,
drag_model,
twist_rate,
sight_height,
zero_distance,
temperature,
pressure,
pressure_type,
humidity,
altitude,
density_altitude,
bullet_name,
wind_speed,
wind_direction,
shooting_angle,
auto_zero,
twist_right,
use_bc_segments,
bullet_length,
elevation_click,
windage_click,
clear_click,
clear_dsf,
reticle_json,
clear_reticle,
clicks_per_rev,
zero_stop,
travel_up,
travel_down,
windage_travel_left,
windage_travel_right,
hold_up,
hold_down,
hold_left,
hold_right,
turret_elev,
turret_wind,
clear_turret,
} => {
let temperature = UnitConverter::resolve_temperature(temperature, cli.units)?;
let pressure = UnitConverter::resolve_pressure(pressure, cli.units)?;
let units_str = match cli.units {
UnitSystem::Imperial => "imperial",
UnitSystem::Metric => "metric",
};
let existing_profile = load_profile(&name).ok();
let carried_dsf_points = if clear_dsf {
None
} else {
existing_profile.as_ref().and_then(|p| p.dsf_points.clone())
};
let carried_zero_poi_up_m =
existing_profile.as_ref().and_then(|p| p.zero_poi_up_m);
let carried_zero_poi_right_m =
existing_profile.as_ref().and_then(|p| p.zero_poi_right_m);
let carried_sight_offset_lateral_m = existing_profile
.as_ref()
.and_then(|p| p.sight_offset_lateral_m);
let carried_elevation_cf =
existing_profile.as_ref().and_then(|p| p.elevation_cf);
let carried_windage_cf = existing_profile.as_ref().and_then(|p| p.windage_cf);
let carried_zero_sets =
existing_profile.as_ref().and_then(|p| p.zero_sets.clone());
if reticle_json.is_some() && clear_reticle {
return Err(
"--reticle-json and --clear-reticle are mutually exclusive".into()
);
}
let carried_reticle = match (&reticle_json, clear_reticle) {
(Some(path), _) => Some(load_reticle_description(path)?),
(None, true) => None,
(None, false) => existing_profile.as_ref().and_then(|p| p.reticle.clone()),
};
let any_turret_flag_given = clicks_per_rev.is_some()
|| zero_stop.is_some()
|| travel_up.is_some()
|| travel_down.is_some()
|| windage_travel_left.is_some()
|| windage_travel_right.is_some()
|| hold_up.is_some()
|| hold_down.is_some()
|| hold_left.is_some()
|| hold_right.is_some()
|| turret_elev.is_some()
|| turret_wind.is_some();
if clear_turret && any_turret_flag_given {
let mut conflicting = Vec::new();
if clicks_per_rev.is_some() {
conflicting.push("--clicks-per-rev");
}
if zero_stop.is_some() {
conflicting.push("--zero-stop");
}
if travel_up.is_some() {
conflicting.push("--travel-up");
}
if travel_down.is_some() {
conflicting.push("--travel-down");
}
if windage_travel_left.is_some() {
conflicting.push("--windage-travel-left");
}
if windage_travel_right.is_some() {
conflicting.push("--windage-travel-right");
}
if hold_up.is_some() {
conflicting.push("--hold-up");
}
if hold_down.is_some() {
conflicting.push("--hold-down");
}
if hold_left.is_some() {
conflicting.push("--hold-left");
}
if hold_right.is_some() {
conflicting.push("--hold-right");
}
if turret_elev.is_some() {
conflicting.push("--turret-elev");
}
if turret_wind.is_some() {
conflicting.push("--turret-wind");
}
return Err(format!(
"--clear-turret and {} are mutually exclusive",
conflicting.join(", ")
)
.into());
}
if clear_click && (elevation_click.is_some() || windage_click.is_some()) {
let mut conflicting = Vec::new();
if elevation_click.is_some() {
conflicting.push("--elevation-click");
}
if windage_click.is_some() {
conflicting.push("--windage-click");
}
return Err(format!(
"--clear-click and {} are mutually exclusive",
conflicting.join(", ")
)
.into());
}
let existing_optic = existing_profile.as_ref();
let carried_clicks_per_revolution = carry_turret_field(
clicks_per_rev,
existing_optic.and_then(|p| p.clicks_per_revolution),
clear_turret,
);
let carried_zero_stop = carry_turret_field(
zero_stop,
existing_optic.and_then(|p| p.zero_stop),
clear_turret,
);
let carried_elevation_travel_up_mil = carry_turret_field(
travel_up,
existing_optic.and_then(|p| p.elevation_travel_up_mil),
clear_turret,
);
let carried_elevation_travel_down_mil = carry_turret_field(
travel_down,
existing_optic.and_then(|p| p.elevation_travel_down_mil),
clear_turret,
);
let carried_windage_travel_left_mil = carry_turret_field(
windage_travel_left,
existing_optic.and_then(|p| p.windage_travel_left_mil),
clear_turret,
);
let carried_windage_travel_right_mil = carry_turret_field(
windage_travel_right,
existing_optic.and_then(|p| p.windage_travel_right_mil),
clear_turret,
);
let carried_turret_elevation_dialed_mil = carry_turret_field(
turret_elev,
existing_optic.and_then(|p| p.turret_elevation_dialed_mil),
clear_turret,
);
let carried_turret_windage_dialed_mil = carry_turret_field(
turret_wind,
existing_optic.and_then(|p| p.turret_windage_dialed_mil),
clear_turret,
);
let carried_hold_bound_up_mil = carry_turret_field(
hold_up,
existing_optic.and_then(|p| p.hold_bound_up_mil),
clear_turret,
);
let carried_hold_bound_down_mil = carry_turret_field(
hold_down,
existing_optic.and_then(|p| p.hold_bound_down_mil),
clear_turret,
);
let carried_hold_bound_left_mil = carry_turret_field(
hold_left,
existing_optic.and_then(|p| p.hold_bound_left_mil),
clear_turret,
);
let carried_hold_bound_right_mil = carry_turret_field(
hold_right,
existing_optic.and_then(|p| p.hold_bound_right_mil),
clear_turret,
);
if let Some(ref v) = elevation_click {
parse_click_value(v)
.map_err(|e| format!("--elevation-click '{v}' is invalid: {e}"))?;
}
if let Some(ref v) = windage_click {
parse_click_value(v)
.map_err(|e| format!("--windage-click '{v}' is invalid: {e}"))?;
}
let elevation_click = if clear_click {
None
} else {
elevation_click
.or_else(|| existing_optic.and_then(|p| p.elevation_click.clone()))
};
let windage_click = if clear_click {
None
} else {
windage_click
.or_else(|| existing_optic.and_then(|p| p.windage_click.clone()))
};
let profile = ProfileData {
name: name.clone(),
velocity,
bc,
mass,
diameter,
drag_model: drag_model.to_string(),
twist_rate,
sight_height,
zero_distance,
units: units_str.to_string(),
temperature,
pressure,
humidity,
altitude,
bullet_name,
created: Some(timestamp_string()),
wind_speed,
wind_direction: wind_direction.map(|d| d.degrees),
shooting_angle,
auto_zero,
twist_right,
use_bc_segments,
bullet_length,
elevation_click,
windage_click,
bc_segments: None,
drag_curve: None,
dsf_points: carried_dsf_points,
bc_reference: bc_reference_profile_field(bc_reference),
pressure_reference: pressure_reference_profile_field(pressure_type),
density_altitude,
zero_poi_up_m: carried_zero_poi_up_m,
zero_poi_right_m: carried_zero_poi_right_m,
sight_offset_lateral_m: carried_sight_offset_lateral_m,
elevation_cf: carried_elevation_cf,
windage_cf: carried_windage_cf,
zero_sets: carried_zero_sets,
reticle: carried_reticle,
clicks_per_revolution: carried_clicks_per_revolution,
zero_stop: carried_zero_stop,
elevation_travel_up_mil: carried_elevation_travel_up_mil,
elevation_travel_down_mil: carried_elevation_travel_down_mil,
windage_travel_left_mil: carried_windage_travel_left_mil,
windage_travel_right_mil: carried_windage_travel_right_mil,
turret_elevation_dialed_mil: carried_turret_elevation_dialed_mil,
turret_windage_dialed_mil: carried_turret_windage_dialed_mil,
hold_bound_up_mil: carried_hold_bound_up_mil,
hold_bound_down_mil: carried_hold_bound_down_mil,
hold_bound_left_mil: carried_hold_bound_left_mil,
hold_bound_right_mil: carried_hold_bound_right_mil,
};
if let Some(optic) = profile
.optic_profile()
.map_err(|e| format!("optic profile: {e}"))?
{
optic
.validate()
.map_err(|e| format!("optic profile: {e}"))?;
}
let path = save_profile(&profile)?;
eprintln!("Profile '{}' saved to {:?}", name, path);
}
#[cfg(feature = "profile-import")]
ProfileAction::Import {
file,
name,
dry_run,
strict,
zero_click,
} => {
use ballistics_engine::profile_import::{parse_a7p, EnvelopeStatus};
let zero_click: Option<ballistics_engine::adjustment::ClickValue> =
match zero_click.as_deref() {
Some(raw) => Some(
ballistics_engine::adjustment::parse_click_value(raw)
.map_err(|e| format!("--zero-click: {e}"))?,
),
None => None,
};
let bytes = fs::read(&file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
let doc =
parse_a7p(&bytes).map_err(|e| format!("not a usable .a7p file: {e}"))?;
if strict {
if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
return Err(format!(
"checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under --strict"
)
.into());
}
}
let sanitized_name = name.as_deref().map(|raw| {
let sanitized = sanitize_profile_name(raw);
if sanitized != raw {
println!(
"note: profile name sanitized to '{sanitized}' for the profile store"
);
}
sanitized
});
let outcome = map_a7p_to_profile(&doc, sanitized_name.as_deref(), zero_click)?;
print!("{}", render_import_report(&outcome.report));
if dry_run {
println!("\nDry run — nothing saved.");
} else {
let path = save_profile(&outcome.profile)?;
println!(
"\nSaved profile '{}' -> {}",
outcome.profile.name,
path.display()
);
}
}
ProfileAction::List => {
let profiles = list_profiles()?;
if profiles.is_empty() {
println!("No saved profiles. Use 'ballistics profile save <name> ...' to create one.");
} else {
println!("Saved Profiles:");
println!("┌────────────────────┬────────┬───────┬────────┬──────────┬──────────┐");
println!("│ Name │ Vel │ BC │ Mass │ Diameter │ Drag │");
println!("├────────────────────┼────────┼───────┼────────┼──────────┼──────────┤");
for p in &profiles {
println!(
"│ {:<18} │{:>7.0} │{:>6.3} │{:>7.1} │{:>9.3} │ {:<8} │",
p.name, p.velocity, p.bc, p.mass, p.diameter, p.drag_model
);
}
println!("└────────────────────┴────────┴───────┴────────┴──────────┴──────────┘");
}
}
ProfileAction::Show { name } => {
let profile = load_profile(&name)?;
println!();
println!("Profile: {}", profile.name);
println!("╔════════════════════════════════════════╗");
println!(
"║ Velocity: {:>10.1} {:<10} ║",
profile.velocity,
if profile.units == "metric" {
"m/s"
} else {
"fps"
}
);
if profile.drag_curve.is_some() {
println!("║ BC: {:<23}║", "not applicable (CUSTOM)");
} else {
println!("║ BC: {:>10.4} ║", profile.bc);
}
println!(
"║ Mass: {:>10.1} {:<10} ║",
profile.mass,
if profile.units == "metric" { "g" } else { "gr" }
);
println!(
"║ Diameter: {:>10.3} {:<10} ║",
profile.diameter,
if profile.units == "metric" {
"mm"
} else {
"in"
}
);
println!("║ Drag model: {:>10} ║", profile.drag_model);
if let Some(tw) = profile.twist_rate {
println!("║ Twist rate: {:>10.1} ║", tw);
}
if let Some(sh) = profile.sight_height {
println!(
"║ Sight height: {:>10.2} {:<10} ║",
sh,
if profile.units == "metric" {
"mm"
} else {
"in"
}
);
}
if let Some(zd) = profile.zero_distance {
println!(
"║ Zero distance: {:>10.0} {:<10} ║",
zd,
if profile.units == "metric" { "m" } else { "yd" }
);
}
if let Some(ref bn) = profile.bullet_name {
println!("║ Bullet: {:<24} ║", bn);
}
if let Some(ws) = profile.wind_speed {
println!(
"║ Wind speed: {:>10.1} {:<10} ║",
ws,
if profile.units == "metric" {
"m/s"
} else {
"mph"
}
);
}
if let Some(wd) = profile.wind_direction {
println!("║ Wind dir: {:>10.1}° ║", wd);
}
if let Some(sa) = profile.shooting_angle {
println!("║ Shoot angle: {:>10.1}° ║", sa);
}
if let Some(az) = profile.auto_zero {
println!(
"║ Auto-zero: {:>10.0} {:<10} ║",
az,
if profile.units == "metric" { "m" } else { "yd" }
);
}
if let Some(tr) = profile.twist_right {
println!(
"║ Twist: {:>10} ║",
if tr { "Right" } else { "Left" }
);
}
if let Some(ubc) = profile.use_bc_segments {
println!(
"║ BC segments: {:>10} ║",
if ubc { "Enabled" } else { "Disabled" }
);
}
if let Some(bl) = profile.bullet_length {
println!(
"║ Bullet length: {:>10.3} {:<10} ║",
bl,
if profile.units == "metric" {
"mm"
} else {
"in"
}
);
}
if let Some(ref ec) = profile.elevation_click {
println!("║ Elev click: {:<23}║", ec);
}
if let Some(ref wc) = profile.windage_click {
println!("║ Wind click: {:<23}║", wc);
}
if let Some(ref segs) = profile.bc_segments {
let vmin = segs
.iter()
.map(|s| s.velocity_mps)
.fold(f64::INFINITY, f64::min);
let vmax = segs
.iter()
.map(|s| s.velocity_mps)
.fold(f64::NEG_INFINITY, f64::max);
println!(
"║ BC bands: {:<23}║",
format!("{} rows, {:.0}-{:.0} m/s", segs.len(), vmin, vmax)
);
}
if let Some(ref curve) = profile.drag_curve {
let mmin = curve.iter().map(|p| p.mach).fold(f64::INFINITY, f64::min);
let mmax = curve
.iter()
.map(|p| p.mach)
.fold(f64::NEG_INFINITY, f64::max);
println!(
"║ Drag curve: {:<23}║",
format!("{} pts, Mach {:.2}-{:.2}", curve.len(), mmin, mmax)
);
}
println!(
"║ Temperature: {:>10.1} {:<10} ║",
profile.temperature,
if profile.units == "metric" {
"°C"
} else {
"°F"
}
);
println!(
"║ Pressure: {:>10.2} {:<10} ║",
profile.pressure,
if profile.units == "metric" {
"hPa"
} else {
"inHg"
}
);
println!("║ Humidity: {:>10.1}% ║", profile.humidity);
println!(
"║ Altitude: {:>10.0} {:<10} ║",
profile.altitude,
if profile.units == "metric" { "m" } else { "ft" }
);
println!("╚════════════════════════════════════════╝");
if let Some(ref points) = profile.dsf_points {
println!();
println!("DSF table ({}):", dsf_table_summary(points));
for p in points {
println!(" Mach {:.2} DSF {:.4}", p.mach, p.dsf);
}
}
}
ProfileAction::Delete { name } => {
delete_profile(&name)?;
eprintln!("Profile '{}' deleted.", name);
}
ProfileAction::ZeroSet { action } => match action {
ZeroSetAction::Add {
profile,
name,
zero_distance,
poi_up,
poi_right,
notes,
} => {
let mut data = load_profile(&profile)?;
for (flag, v) in [("--poi-up", poi_up), ("--poi-right", poi_right)] {
if let Some(v) = v {
if !v.is_finite() || v.abs() >= 100.0 {
return Err(format!(
"{flag} must be a finite dial correction smaller than \
100 mils (got {v})"
)
.into());
}
}
}
let profile_units = match data.units.trim().to_ascii_lowercase().as_str() {
"metric" => UnitSystem::Metric,
_ => UnitSystem::Imperial,
};
let zero_distance = zero_distance.map(|v| {
if !v.is_finite() || v <= 0.0 {
eprintln!(
"error: --zero-distance must be a positive, finite distance"
);
std::process::exit(1);
}
UnitConverter::distance_from_metric(
UnitConverter::distance_to_metric(v, cli.units),
profile_units,
)
});
let set = ProfileZeroSet {
name: name.clone(),
zero_distance,
poi_up_mil: poi_up,
poi_right_mil: poi_right,
notes,
};
let sets = data.zero_sets.get_or_insert_with(Vec::new);
if let Some(existing) = sets.iter_mut().find(|s| s.name == name) {
*existing = set;
eprintln!("Replaced zero set '{name}' on profile '{profile}'.");
} else {
sets.push(set);
eprintln!("Added zero set '{name}' to profile '{profile}'.");
}
save_profile(&data)?;
}
ZeroSetAction::Remove { profile, name } => {
let mut data = load_profile(&profile)?;
let sets = data.zero_sets.take().unwrap_or_default();
let before = sets.len();
let remaining: Vec<ProfileZeroSet> =
sets.into_iter().filter(|s| s.name != name).collect();
if remaining.len() == before {
let names: Vec<&str> =
remaining.iter().map(|s| s.name.as_str()).collect();
return Err(format!(
"zero set '{name}' not found on profile '{profile}'; {}",
if names.is_empty() {
"the profile has no zero sets".to_string()
} else {
format!("available zero sets: {}", names.join(", "))
}
)
.into());
}
data.zero_sets = if remaining.is_empty() {
None
} else {
Some(remaining)
};
save_profile(&data)?;
eprintln!("Removed zero set '{name}' from profile '{profile}'.");
}
ZeroSetAction::List { profile } => {
let data = load_profile(&profile)?;
let sets = data.zero_sets.as_deref().unwrap_or(&[]);
if sets.is_empty() {
println!("Profile '{profile}' has no zero sets.");
} else {
let dist_unit = if data.units.trim().eq_ignore_ascii_case("metric") {
"m"
} else {
"yd"
};
println!("Zero sets for '{profile}':");
for s in sets {
let mut parts: Vec<String> = Vec::new();
if let Some(zd) = s.zero_distance {
parts.push(format!("zero {zd:.0} {dist_unit}"));
}
if let Some(up) = s.poi_up_mil {
parts.push(format!("up {up:+.2} mil"));
}
if let Some(right) = s.poi_right_mil {
parts.push(format!("right {right:+.2} mil"));
}
if parts.is_empty() {
parts.push("(empty)".to_string());
}
match &s.notes {
Some(n) => {
println!(" {:<20} {} — {n}", s.name, parts.join(", "))
}
None => println!(" {:<20} {}", s.name, parts.join(", ")),
}
}
}
}
},
}
}
Commands::DragCurve { drag_model, output } => {
run_drag_curve(drag_model, output)?;
}
Commands::Reticle { action } => {
handle_reticle(action, cli.units)?;
}
Commands::HoldCorridor {
scenarios,
ranges,
target,
profile,
velocity,
bc,
mass,
diameter,
drag_model,
zero_distance,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
output,
} => {
handle_hold_corridor(
scenarios,
ranges,
target,
profile,
velocity,
bc,
mass,
diameter,
drag_model,
zero_distance,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
cli.units,
output,
)?;
}
Commands::MarkToRange {
marks,
reticle_json,
mag,
focal_plane,
reference_mag,
zero_distance,
max_range,
load,
output,
} => {
handle_mark_to_range(
marks,
reticle_json,
mag,
focal_plane,
reference_mag,
zero_distance,
max_range,
load,
cli.units,
output,
)?;
}
Commands::BdcMatch {
mark_ranges,
reference_mag,
focal_plane,
residual_warn,
zero_distance,
load,
output,
} => {
handle_bdc_match(
mark_ranges,
reference_mag,
focal_plane,
residual_warn,
zero_distance,
load,
cli.units,
output,
)?;
}
Commands::OptimalZero {
targets,
vital,
load,
output,
} => {
handle_optimal_zero(targets, vital, load, cli.units, output)?;
}
Commands::Explain {
a,
b,
ranges,
output,
} => {
handle_explain(a, b, ranges, output)?;
}
Commands::Tolerance {
request,
range,
target,
axes,
domains,
output,
} => {
handle_tolerance(request, range, target, axes, domains, cli.units, output)?;
}
Commands::ErrorBudget {
request,
ranges,
sigmas,
target,
output,
} => {
handle_error_budget(request, ranges, sigmas, target, cli.units, output)?;
}
Commands::DialPlan {
elevation,
windage,
range,
profile,
elevation_click,
windage_click,
clicks_per_rev,
zero_stop,
travel_up,
travel_down,
windage_travel_left,
windage_travel_right,
hold_up,
hold_down,
hold_left,
hold_right,
turret_elev,
turret_wind,
prefer_hold,
max_hold,
output,
} => {
handle_dial_plan(
elevation,
windage,
range,
profile,
elevation_click,
windage_click,
clicks_per_rev,
zero_stop,
travel_up,
travel_down,
windage_travel_left,
windage_travel_right,
hold_up,
hold_down,
hold_left,
hold_right,
turret_elev,
turret_wind,
prefer_hold,
max_hold,
cli.units,
output,
)?;
}
Commands::AdaptiveCard {
load,
zero_distance,
start,
end,
anchors,
elevation_budget,
windage_budget,
max_rows,
adjustment,
output_file,
output,
} => {
handle_adaptive_card(
load,
zero_distance,
start,
end,
anchors,
elevation_budget,
windage_budget,
max_rows,
adjustment,
output_file,
cli.units,
output,
)?;
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "ballistics", &mut std::io::stdout());
}
}
Ok(())
}
#[derive(Debug, Clone, Default)]
struct PdfMetadata {
rifle_name: String,
location_name: String,
powder: String,
bullet_name: String,
target_speed_mph: f64,
output_file: Option<PathBuf>,
velocity_fps: f64,
temperature_f: f64,
pressure_inhg: f64,
altitude_ft: f64,
wind_speed_mph: f64,
weight_gr: f64,
font_scale: f32,
bold_data: bool,
adjustment_unit: AdjustmentUnit,
windage_unit: AdjustmentUnit,
}
#[cfg(feature = "pdf")]
#[allow(clippy::too_many_arguments)]
fn dope_card_row_from_sample(
s: &trajectory_sampling::TrajectorySample,
pdf_meta: &PdfMetadata,
elevation_click: Option<ClickValue>,
windage_click: Option<ClickValue>,
zero_set_elevation_bias_mil: f64,
zero_set_windage_bias_mil: f64,
elevation_cf: f64,
windage_cf: f64,
) -> CardRow {
let range_yd = UnitConverter::distance_from_metric(s.distance_m, UnitSystem::Imperial);
let drop_yd = s.drop_m / 0.9144; let drift_yd = s.wind_drift_m / 0.9144;
let lead_m = ballistics_engine::lead_from_tof(
pdf_meta.target_speed_mph * 0.44704,
90.0,
s.time_s,
s.distance_m,
)
.lead_m;
let lead_yd = lead_m / 0.9144;
let elevation_unit = pdf_meta.adjustment_unit;
let windage_unit = pdf_meta.windage_unit;
CardRow {
range: range_yd.round(),
drop_adj: Some(
adjustment_display(
drop_yd,
range_yd,
elevation_unit,
elevation_click,
zero_set_elevation_bias_mil,
elevation_cf,
)
.value,
),
wind_adj: Some(
windage_adjustment_display(
drift_yd,
range_yd,
windage_unit,
windage_click,
zero_set_windage_bias_mil,
windage_cf,
)
.value,
),
lead_adj: Some(
windage_adjustment_display(
lead_yd,
range_yd,
windage_unit,
windage_click,
0.0,
windage_cf,
)
.value,
),
drop_linear: None,
come_up: None,
wind_linear: None,
velocity: None,
energy: None,
time: None,
wind_columns: Vec::new(),
}
}
fn reject_bc5d_caliber_mismatch(
manager: &mut Bc5dTableManager,
caliber_in: f64,
) -> Result<(), String> {
match manager.get_table(caliber_in) {
Err(error @ Bc5dError::CaliberMismatch { .. }) => Err(format!("--bc-table-dir: {error}")),
_ => Ok(()),
}
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the existing BC5D compatibility helper"
)]
fn generate_bc5d_segments(
manager: &mut Bc5dTableManager,
caliber: f64,
base_bc: f64,
drag_type: &str,
weight_grains: f64,
muzzle_velocity_fps: Option<f64>,
bullet_length_in: Option<f64>,
length_is_user: bool,
print_ladder_units: Option<UnitSystem>,
) -> Option<Vec<BCSegmentData>> {
let mut velocity_breakpoints: Vec<f64> = vec![
4000.0, 3500.0, 3000.0, 2700.0, 2500.0, 2300.0, 2100.0, 2000.0, 1900.0, 1800.0, 1700.0,
1600.0, 1500.0, 1400.0, 1350.0, 1300.0, 1250.0, 1200.0, 1150.0, 1100.0, 1050.0, 1000.0,
950.0, 900.0, 850.0, 800.0, 700.0, 600.0, 500.0,
];
if let Some(mv) = muzzle_velocity_fps {
velocity_breakpoints.push(mv);
}
let mut valid_velocities: Vec<f64> = velocity_breakpoints
.into_iter()
.filter(|&v| v >= 500.0 && muzzle_velocity_fps.is_none_or(|mv| v <= mv))
.collect();
valid_velocities.sort_by(|a, b| b.partial_cmp(a).unwrap());
valid_velocities.dedup();
let reference_muzzle_velocity = valid_velocities.first().copied().unwrap_or(3000.0);
let mut segments: Vec<BCSegmentData> = Vec::new();
let mut any_correction_found = false;
for i in 0..valid_velocities.len().saturating_sub(1) {
let vel_max = valid_velocities[i];
let vel_min = valid_velocities[i + 1];
let vel_mid = (vel_max + vel_min) / 2.0;
if let Ok(correction) = manager.lookup(
caliber,
weight_grains,
base_bc,
reference_muzzle_velocity,
vel_mid,
drag_type,
) {
any_correction_found = true;
let segment_bc = base_bc * correction;
segments.push(BCSegmentData {
velocity_min: vel_min,
velocity_max: vel_max,
bc_value: segment_bc,
});
}
}
if any_correction_found && !segments.is_empty() {
let length_display = bullet_length_in.unwrap_or_else(|| {
let est_m = ballistics_engine::stability::estimate_bullet_length_m(
caliber * 0.0254,
weight_grains * GRAINS_TO_KG,
);
if est_m > 0.0 {
est_m / 0.0254
} else {
caliber * 3.5
}
});
eprintln!(
"BC5D Table: Generated {} velocity-dependent BC segments",
segments.len()
);
eprintln!(
"BC5D Table: {:.3}\" caliber, {:.1}gr, {:.3}\" length ({}; not a lookup axis)",
caliber,
weight_grains,
length_display,
if length_is_user { "user" } else { "est" }
);
let min_bc = segments
.iter()
.map(|s| s.bc_value)
.fold(f64::INFINITY, f64::min);
let max_bc = segments
.iter()
.map(|s| s.bc_value)
.fold(f64::NEG_INFINITY, f64::max);
eprintln!(
"BC5D Table: BC range {:.5} - {:.5} across velocity envelope",
min_bc, max_bc
);
if let Some(units) = print_ladder_units {
let (to_unit, unit_label) = match units {
UnitSystem::Imperial => (1.0, "fps"),
UnitSystem::Metric => (0.3048, "m/s"),
};
eprintln!(
"BC5D Table: segment ladder (velocities in {unit_label}; paste as arguments):"
);
for seg in &segments {
let display_min = seg.velocity_min * to_unit;
let mut display_max = seg.velocity_max * to_unit;
if display_max <= display_min {
display_max = display_min.next_up();
}
eprintln!(
" --bc-segment {}:{}:{:.5}",
display_min, display_max, seg.bc_value
);
}
}
Some(segments)
} else {
eprintln!(
"Warning: No BC5D table data found for caliber {:.3}\"",
caliber
);
None
}
}
#[derive(Debug, Clone, Copy)]
enum RingUnit {
Factor(f64),
Clicks(ClickValue),
}
fn run_trajectory(config: &TrajectoryConfig) -> Result<(), Box<dyn Error>> {
let TrajectoryConfig {
velocity,
angle,
bc,
bc_reference_standard,
mass,
diameter,
bullet_length,
drag_model,
max_range,
time_step,
temperature,
pressure,
pressure_type,
humidity,
altitude,
density_altitude_active,
wind_speed,
wind_direction,
wind_vertical,
ref wind_segments,
output,
full,
plot,
units,
sight_height,
bore_height,
ignore_ground_impact,
cant,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
zero_solve_distance_m,
use_bc_segments,
use_cluster_bc,
ref bc_segments_data,
ref custom_drag_table,
cd_scale,
enable_magnus,
enable_coriolis,
enable_spin_drift,
enable_aerodynamic_jump,
enable_wind_shear,
wind_shear_model,
enable_pitch_damping,
enable_precession,
sample_trajectory,
sample_interval,
drops_reference,
elevation_cf,
windage_cf,
zero_set_elevation_bias_mil,
zero_set_windage_bias_mil,
use_rk4,
use_rk45,
twist_rate,
twist_right,
latitude,
shot_direction,
shooting_angle,
use_powder_sensitivity,
powder_temp_sensitivity,
powder_temp,
ref powder_temp_curve,
powder_curve_temp_c,
target_speed,
with_drag_coefficient,
adjustment_unit,
elevation_click,
windage_click,
ref pdf_metadata,
ref dsf_table,
solved_zero_angle_deg,
} = *config;
let target_speed_mps = UnitConverter::wind_to_metric(target_speed, units);
let ring_enabled = target_speed_mps > 0.0;
let (ring_hdr, ring_unit) = match adjustment_unit {
AdjustmentUnit::Mil => ("Ring(mil)", RingUnit::Factor(1.0)),
AdjustmentUnit::Moa => (
"Ring(moa)",
RingUnit::Factor(
ballistics_engine::moving_target::MOA_PER_UNIT_RATIO
/ ballistics_engine::moving_target::MIL_PER_UNIT_RATIO,
),
),
AdjustmentUnit::Smoa => ("Ring(smoa)", RingUnit::Factor(smoa_per_mil())),
AdjustmentUnit::Iphy => ("Ring(iphy)", RingUnit::Factor(smoa_per_mil())),
AdjustmentUnit::Clicks => (
"Ring(clicks)",
RingUnit::Clicks(elevation_click.expect(
"adjustment_unit == Clicks implies elevation_click was resolved in main()'s \
Trajectory dispatch before run_trajectory was ever called",
)),
),
};
let twist_assumed = twist_rate.is_none();
let drag_model_enum = drag_model;
let wind_direction_rad = wind_direction.to_radians();
let mut inputs = BallisticInputs {
bc_value: bc,
bc_type: drag_model_enum,
bc_reference_standard,
bullet_mass: mass,
muzzle_velocity: velocity,
bullet_diameter: diameter,
bullet_length,
muzzle_angle: angle.to_radians(),
target_distance: max_range,
azimuth_angle: 0.0,
shot_azimuth: shot_direction.map(|d| d.to_radians()).unwrap_or(0.0),
shooting_angle: shooting_angle.to_radians(),
cant_angle: cant.to_radians(),
sight_height,
sight_offset_lateral_m,
muzzle_height: bore_height, target_height: 0.0,
zero_poi_vertical_m,
zero_poi_horizontal_m,
ground_threshold: if ignore_ground_impact {
f64::NEG_INFINITY
} else {
0.0
},
altitude,
temperature,
pressure,
humidity,
latitude,
wind_speed,
wind_angle: wind_direction_rad,
twist_rate: twist_rate.unwrap_or_else(|| {
ballistics_engine::stability::default_twist_inches(diameter, mass, velocity)
}),
is_twist_right: twist_right,
caliber_inches: diameter / 0.0254,
weight_grains: mass / GRAINS_TO_KG,
manufacturer: None,
bullet_model: None,
bullet_id: None,
bullet_cluster: None,
use_rk4,
use_adaptive_rk45: use_rk45,
enable_advanced_effects: enable_magnus || enable_coriolis, enable_magnus,
enable_coriolis,
use_powder_sensitivity,
powder_temp_sensitivity: if use_powder_sensitivity {
UnitConverter::velocity_to_metric(powder_temp_sensitivity, units)
/ UnitConverter::temperature_delta_to_metric(1.0, units)
} else {
0.0
},
powder_temp: UnitConverter::temperature_to_metric(powder_temp, units),
powder_temp_curve: powder_temp_curve.clone(),
powder_curve_temp_c,
tipoff_yaw: 0.0,
cd_delta2: 7.5,
tipoff_decay_distance: 50.0,
use_bc_segments,
bc_segments: None,
bc_segments_data: bc_segments_data.clone(),
use_enhanced_spin_drift: enable_spin_drift,
enable_aerodynamic_jump,
use_form_factor: false,
enable_wind_shear,
wind_shear_model: if enable_wind_shear {
wind_shear_model.as_engine_str().to_string()
} else {
"none".to_string()
},
enable_trajectory_sampling: sample_trajectory,
sample_interval,
drops_reference: drops_reference.to_engine(),
enable_pitch_damping,
enable_precession_nutation: enable_precession,
use_cluster_bc,
bc_type_str: None,
custom_drag_table: custom_drag_table.clone(),
cd_scale,
};
if let Some(zero_distance_m) = zero_solve_distance_m {
inputs.azimuth_angle += inputs.windage_zero_bias_rad(zero_distance_m);
}
let wind = WindConditions {
speed: wind_speed,
direction: wind_direction_rad,
vertical_speed: wind_vertical,
};
let (resolved_temperature, resolved_pressure) = if density_altitude_active {
(temperature, pressure)
} else {
resolve_station_conditions_with_pressure_mode(
temperature,
pressure,
altitude,
pressure_type,
)
};
let atmosphere = AtmosphericConditions {
temperature: resolved_temperature,
pressure: resolved_pressure,
humidity,
altitude,
};
let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
inputs.clone(),
wind,
atmosphere.clone(),
);
solver.set_max_range(max_range);
solver.set_time_step(time_step);
if !wind_segments.is_empty() {
if enable_wind_shear {
return Err(
"--wind-segment cannot be combined with --enable-wind-shear \
(downrange segments + altitude shear is not yet a defined model)"
.into(),
);
}
if wind_speed != 0.0 || wind_vertical != 0.0 {
eprintln!(
"note: --wind-segment overrides --wind-speed/--wind-direction/--wind-vertical \
(scalar wind ignored)"
);
}
let coverage_m = wind_segments
.iter()
.map(|seg| seg.until_m)
.fold(0.0_f64, f64::max);
if coverage_m < max_range - 1.0 {
let (cov_disp, max_disp, unit) = match units {
UnitSystem::Imperial => (coverage_m / 0.9144, max_range / 0.9144, "yd"),
UnitSystem::Metric => (coverage_m, max_range, "m"),
};
eprintln!(
"warning: wind segments cover only {cov_disp:.0} {unit} of the {max_disp:.0} {unit} \
trajectory; wind is treated as zero beyond {cov_disp:.0} {unit}"
);
}
solver.set_wind_segments(wind_segments.clone());
}
let mut result = solver.solve()?;
if let Some(table) = dsf_table.as_ref() {
apply_dsf(&mut result, table);
}
if let Some(table) = custom_drag_table.as_ref() {
let (_, sos) = ballistics_engine::atmosphere::calculate_atmosphere(
altitude,
Some(resolved_temperature),
Some(resolved_pressure),
humidity,
);
if sos.is_finite() && sos > 0.0 {
let muzzle_mach = velocity / sos;
let impact_mach = result.impact_velocity / sos;
if let (Some(&lo), Some(&hi)) = (table.mach_values.first(), table.mach_values.last()) {
if muzzle_mach > hi || impact_mach < lo {
eprintln!(
"Warning: shot Mach range [{impact_mach:.2}, {muzzle_mach:.2}] extends beyond \
the drag table domain [{lo:.2}, {hi:.2}]; the nearest tabulated Cd is held \
outside that range (approximate)."
);
}
}
}
}
let effective_twist_rate = inputs.twist_rate;
let stability = if effective_twist_rate > 0.0 {
ballistics_engine::stability::compute_stability_coefficient(
&inputs,
(altitude, resolved_temperature, resolved_pressure, 1.0),
)
} else {
0.0
};
let spin_drift = if enable_spin_drift && effective_twist_rate > 0.0 && stability > 0.0 {
ballistics_engine::spin_drift::litz_drift_meters(
stability,
result.time_of_flight,
twist_right,
)
} else {
0.0
};
const INTEGRATION_TIME_CAP_S: f64 = 100.0;
let reached_max_range = result.max_range >= max_range - (max_range.abs() * 1e-3).max(1e-6);
let reached_time_cap = result
.points
.last()
.map(|p| p.time >= INTEGRATION_TIME_CAP_S - 0.05)
.unwrap_or(false)
&& !reached_max_range;
match output {
OutputFormat::Json => {
let units_label = match units {
UnitSystem::Metric => "metric",
UnitSystem::Imperial => "imperial",
};
let trajectory_result = TrajectoryResult {
units: units_label.to_string(),
max_range: UnitConverter::distance_from_metric(result.max_range, units),
max_height: UnitConverter::distance_from_metric(result.max_height, units),
time_of_flight: result.time_of_flight,
impact_velocity: UnitConverter::velocity_from_metric(result.impact_velocity, units),
impact_energy: UnitConverter::energy_from_metric(result.impact_energy, units),
stability_coefficient: if stability > 0.0 {
Some(stability)
} else {
None
},
spin_drift: if spin_drift.abs() > 0.0001 {
Some(UnitConverter::distance_from_metric(spin_drift, units))
} else {
None
},
zero_angle_degrees: solved_zero_angle_deg,
min_pitch_damping: result.min_pitch_damping,
transonic_mach: result.transonic_mach,
max_yaw_angle: result.max_yaw_angle,
max_precession_angle: result.max_precession_angle,
final_pitch_angle: result.angular_state.as_ref().map(|s| s.pitch_angle),
final_yaw_angle: result.angular_state.as_ref().map(|s| s.yaw_angle),
trajectory: if full {
result
.points
.into_iter()
.map(|p| {
let (mover_ring_m, mover_ring_mil) = if ring_enabled {
let (ring_m, ring_mil) = ballistics_engine::mover_ring(
target_speed_mps,
p.time,
p.position.x,
);
(Some(ring_m), ring_mil.map(|mil| mil / windage_cf))
} else {
(None, None)
};
TrajectoryPoint {
time: p.time,
x: UnitConverter::distance_from_metric(p.position.z, units),
y: UnitConverter::distance_from_metric(p.position.y, units),
z: UnitConverter::distance_from_metric(p.position.x, units),
velocity: UnitConverter::velocity_from_metric(
p.velocity_magnitude,
units,
),
energy: UnitConverter::energy_from_metric(p.kinetic_energy, units),
mover_ring_m,
mover_ring_mil,
drag_coefficient: if with_drag_coefficient {
p.drag_coefficient
} else {
None
},
}
})
.collect()
} else {
vec![]
},
legend: TrajectoryLegend::for_units(units),
};
println!("{}", serde_json::to_string_pretty(&trajectory_result)?);
}
OutputFormat::Csv => {
let (dist_unit, vel_unit, energy_unit) = match units {
UnitSystem::Metric => ("m", "m/s", "J"),
UnitSystem::Imperial => ("yd", "fps", "ft-lb"),
};
if full {
if let Some(ref sampled) = result.sampled_points {
let defl_unit = match units {
UnitSystem::Metric => "m",
UnitSystem::Imperial => "in",
};
let drop_col = match drops_reference {
DropsReferenceArg::Los => format!("drop_{}", defl_unit),
DropsReferenceArg::Target => format!("drop_target_{}", defl_unit),
};
let header = format!(
"distance_{},{},drift_{},velocity_{},energy_{},time_s",
dist_unit, drop_col, defl_unit, vel_unit, energy_unit
);
if ring_enabled {
println!("{},ring_mil", header);
} else {
println!("{}", header);
}
for s in sampled {
let distance = UnitConverter::distance_from_metric(s.distance_m, units);
let (drop, drift) = match units {
UnitSystem::Imperial => (s.drop_m * 39.3701, s.wind_drift_m * 39.3701),
UnitSystem::Metric => (s.drop_m, s.wind_drift_m),
};
let vel = UnitConverter::velocity_from_metric(s.velocity_mps, units);
let energy = UnitConverter::energy_from_metric(s.energy_j, units);
let row = format!(
"{:.2},{:.2},{:.2},{:.2},{:.2},{:.4}",
distance, drop, drift, vel, energy, s.time_s
);
if ring_enabled {
let (_, ring_mil) = ballistics_engine::mover_ring(
target_speed_mps,
s.time_s,
s.distance_m,
);
match ring_mil {
Some(mil) => println!("{},{:.3}", row, mil / windage_cf),
None => println!("{},", row),
}
} else {
println!("{}", row);
}
}
} else {
let header = format!(
"time_s,x_{},y_{},z_{},velocity_{},energy_{}",
dist_unit, dist_unit, dist_unit, vel_unit, energy_unit
);
if ring_enabled {
println!("{},ring_mil", header);
} else {
println!("{}", header);
}
for p in result.points {
let x = UnitConverter::distance_from_metric(p.position.z, units);
let y = UnitConverter::distance_from_metric(p.position.y, units);
let z = UnitConverter::distance_from_metric(p.position.x, units);
let vel = UnitConverter::velocity_from_metric(p.velocity_magnitude, units);
let energy = UnitConverter::energy_from_metric(p.kinetic_energy, units);
let row = format!(
"{:.4},{:.2},{:.2},{:.2},{:.2},{:.2}",
p.time, x, y, z, vel, energy
);
if ring_enabled {
let (_, ring_mil) = ballistics_engine::mover_ring(
target_speed_mps,
p.time,
p.position.x,
);
match ring_mil {
Some(mil) => println!("{},{:.3}", row, mil / windage_cf),
None => println!("{},", row),
}
} else {
println!("{}", row);
}
}
}
} else {
println!("metric,value,unit");
println!(
"max_range,{:.2},{}",
UnitConverter::distance_from_metric(result.max_range, units),
dist_unit
);
println!(
"max_height,{:.2},{}",
UnitConverter::distance_from_metric(result.max_height, units),
dist_unit
);
println!("time_of_flight,{:.4},s", result.time_of_flight);
println!(
"impact_velocity,{:.2},{}",
UnitConverter::velocity_from_metric(result.impact_velocity, units),
vel_unit
);
println!(
"impact_energy,{:.2},{}",
UnitConverter::energy_from_metric(result.impact_energy, units),
energy_unit
);
if stability > 0.0 {
println!("stability_coefficient,{:.2},", stability);
}
if spin_drift.abs() > 0.0001 {
println!(
"spin_drift,{:.4},{}",
UnitConverter::distance_from_metric(spin_drift, units),
dist_unit
);
}
if let Some(zero_deg) = solved_zero_angle_deg {
println!("zero_angle_degrees,{:.4},degrees", zero_deg);
}
}
}
OutputFormat::Table => {
let range_display = UnitConverter::distance_from_metric(result.max_range, units);
let height_display = UnitConverter::distance_from_metric(result.max_height, units);
let velocity_display =
UnitConverter::velocity_from_metric(result.impact_velocity, units);
let energy_display = UnitConverter::energy_from_metric(result.impact_energy, units);
let (range_unit, velocity_unit, energy_unit) = match units {
UnitSystem::Metric => ("m", "m/s", "J"),
UnitSystem::Imperial => ("yd", "fps", "ft-lb"),
};
println!("╔════════════════════════════════════════╗");
println!("║ TRAJECTORY RESULTS ║");
println!("╠════════════════════════════════════════╣");
println!(
"║ Max Range: {:>8.2} {:3} ║",
range_display, range_unit
);
println!(
"║ Max Height: {:>8.2} {:3} ║",
height_display, range_unit
);
if let Some(zero_deg) = solved_zero_angle_deg {
println!("║ Zero Angle: {:>8.4}° ║", zero_deg);
}
if reached_time_cap {
println!(
"║ Final Velocity: {:>8.2} {:3} ║",
velocity_display, velocity_unit
);
println!(
"║ Time at Cap: {:>8.3} s ║",
result.time_of_flight
);
println!("║ Impact: No impact (cap) ║");
} else {
println!(
"║ Time of Flight: {:>8.3} s ║",
result.time_of_flight
);
println!(
"║ Impact Velocity: {:>8.2} {:3} ║",
velocity_display, velocity_unit
);
println!(
"║ Impact Energy: {:>8.2} {:5} ║",
energy_display, energy_unit
);
}
if stability > 0.0 {
println!("╠════════════════════════════════════════╣");
println!("║ Stability (SG): {:>8.2} ║", stability);
if twist_assumed {
println!("║ Status: {:>8} ║", "assumed");
} else {
let stability_status = if stability < 1.0 {
"UNSTABLE"
} else if stability < 1.5 {
"MARGINAL"
} else {
"STABLE "
};
println!("║ Status: {:>8} ║", stability_status);
}
}
if spin_drift.abs() > 0.0001 {
let drift_display = UnitConverter::distance_from_metric(spin_drift.abs(), units);
let direction = if spin_drift > 0.0 { "Right" } else { "Left" };
println!("╠════════════════════════════════════════╣");
println!(
"║ Spin Drift: {:>8.2} {:3} ║",
drift_display, range_unit
);
println!("║ Direction: {:>8} ║", direction);
}
if let Some(min_damping) = result.min_pitch_damping {
println!("╠════════════════════════════════════════╣");
println!("║ Min Pitch Damping: {:>8.3} ║", min_damping);
let stability_warning = if min_damping > 0.0 {
"UNSTABLE" } else if min_damping > -2.0 {
"MARGINAL"
} else {
"STABLE "
};
println!("║ Transonic Status: {:>8} ║", stability_warning);
if let Some(mach) = result.transonic_mach {
println!("║ Entered at Mach: {:>8.2} ║", mach);
}
}
if let Some(ref angular_state) = result.angular_state {
println!("╠════════════════════════════════════════╣");
println!("║ Angular Motion Analysis ║");
println!("╠════════════════════════════════════════╣");
println!(
"║ Final Pitch Angle: {:>8.4} rad ║",
angular_state.pitch_angle
);
println!(
"║ Final Yaw Angle: {:>8.4} rad ║",
angular_state.yaw_angle
);
if let Some(max_yaw) = result.max_yaw_angle {
println!("║ Max Yaw Angle: {:>8.4} rad ║", max_yaw);
println!(
"║ ({:>8.2}°) ║",
max_yaw.to_degrees()
);
}
if let Some(max_prec) = result.max_precession_angle {
println!("║ Max Precession: {:>8.4} rad ║", max_prec);
}
println!(
"║ Nutation Phase: {:>8.2} rad ║",
angular_state.nutation_phase
);
}
println!("╚════════════════════════════════════════╝");
if twist_assumed && stability > 0.0 {
let twist_note = match units {
UnitSystem::Imperial => format!("1:{:.1}\"", effective_twist_rate),
UnitSystem::Metric => format!("1:{:.1}mm", effective_twist_rate * 25.4),
};
println!(
"note: twist rate not supplied; assumed {} for the stability/spin-drift \
estimate — Sg shown is not an evaluated stability verdict.",
twist_note
);
}
if let Some(table) = dsf_table.as_ref() {
println!("DSF table active ({})", dsf_table_summary(table.points()));
}
if reached_time_cap {
println!();
println!(
"No impact: integration reached the {:.0} s time cap (use a shorter --max-range \
or remove --ignore-ground-impact).",
INTEGRATION_TIME_CAP_S
);
} else if let Some(last_point) = result.points.last() {
let last_height = last_point.position.y;
let last_range = last_point.position.x;
if last_height <= 0.001 {
println!();
let range_display = UnitConverter::distance_from_metric(last_range, units);
println!(
"Bullet struck ground at {:.0} {}",
range_display, range_unit
);
}
}
if shooting_angle != 0.0 {
if let Some(zero_m) = zero_solve_distance_m {
if let Some(ehr_m) =
solver.equivalent_horizontal_range(result.max_range, zero_m)
{
let ehr_display = UnitConverter::distance_from_metric(ehr_m, units);
println!();
println!(
"Equivalent horizontal range: {:.0} {} (shoot-to for BDC)",
ehr_display, range_unit
);
}
}
}
if full && !result.points.is_empty() {
println!();
println!("Trajectory Points:");
let (dist_hdr, vel_hdr, energy_hdr) = match units {
UnitSystem::Metric => ("(m)", "(m/s)", "(J)"),
UnitSystem::Imperial => ("(yd)", "(fps)", "(ft-lb)"),
};
let step = if result.points.len() > 20 {
result.points.len() / 20
} else {
1
};
if ring_enabled {
println!("┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐");
println!(
"│ Time (s) │ X {:5} │ Y {:5} │ Vel{:5} │Energy{:5}│ {}│",
dist_hdr, dist_hdr, vel_hdr, energy_hdr, ring_hdr
);
println!("├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤");
for (i, p) in result.points.iter().enumerate() {
if i % step == 0 || i == result.points.len() - 1 {
let x_display =
UnitConverter::distance_from_metric(p.position.x, units); let y_display =
UnitConverter::distance_from_metric(p.position.y, units);
let vel_display =
UnitConverter::velocity_from_metric(p.velocity_magnitude, units);
let energy_display =
UnitConverter::energy_from_metric(p.kinetic_energy, units);
let (_, ring_mil) = ballistics_engine::mover_ring(
target_speed_mps,
p.time,
p.position.x,
);
let ring_cell = match ring_mil.map(|mil| mil / windage_cf) {
Some(mil) => match ring_unit {
RingUnit::Factor(f) => format!("{:>8.2}", mil * f),
RingUnit::Clicks(click) => {
format!("{:>8}", clicks_for(mil, 1000.0, &click))
}
},
None => format!("{:>8}", "-"),
};
println!(
"│ {:>8.3} │ {:>8.2} │ {:>8.2} │ {:>8.2} │ {:>8.2} │ {} │",
p.time,
x_display,
y_display,
vel_display,
energy_display,
ring_cell
);
}
}
println!("└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘");
} else {
println!("┌──────────┬──────────┬──────────┬──────────┬──────────┐");
println!(
"│ Time (s) │ X {:5} │ Y {:5} │ Vel{:5} │Energy{:5}│",
dist_hdr, dist_hdr, vel_hdr, energy_hdr
);
println!("├──────────┼──────────┼──────────┼──────────┼──────────┤");
for (i, p) in result.points.iter().enumerate() {
if i % step == 0 || i == result.points.len() - 1 {
let x_display =
UnitConverter::distance_from_metric(p.position.x, units); let y_display =
UnitConverter::distance_from_metric(p.position.y, units);
let vel_display =
UnitConverter::velocity_from_metric(p.velocity_magnitude, units);
let energy_display =
UnitConverter::energy_from_metric(p.kinetic_energy, units);
println!(
"│ {:>8.3} │ {:>8.2} │ {:>8.2} │ {:>8.2} │ {:>8.2} │",
p.time, x_display, y_display, vel_display, energy_display
);
}
}
println!("└──────────┴──────────┴──────────┴──────────┴──────────┘");
}
}
if let Some(ref samples) = result.sampled_points {
if !samples.is_empty() {
println!();
println!(
"Sampled Trajectory (every {:.0} {})",
UnitConverter::distance_from_metric(sample_interval, units),
match units {
UnitSystem::Metric => "m",
UnitSystem::Imperial => "yd",
}
);
let (dist_hdr, drop_hdr, drift_hdr, vel_hdr) = match units {
UnitSystem::Metric => ("(m)", "(m)", "(m)", "(m/s)"),
UnitSystem::Imperial => ("(yd)", "(in)", "(in)", "(fps)"),
};
let target_drops = drops_reference == DropsReferenceArg::Target;
if target_drops {
println!(
"┌──────────┬───────────────────┬──────────┬──────────┬──────────┐"
);
println!(
"│ Dist{:4} │ {:>17} │Drift{:4} │ Vel{:5} │ Flags │",
dist_hdr,
format!("Drop (target){}", drop_hdr),
drift_hdr,
vel_hdr
);
println!(
"├──────────┼───────────────────┼──────────┼──────────┼──────────┤"
);
} else {
println!("┌──────────┬──────────┬──────────┬──────────┬──────────┐");
println!(
"│ Dist{:4} │ Drop{:4} │Drift{:4} │ Vel{:5} │ Flags │",
dist_hdr, drop_hdr, drift_hdr, vel_hdr
);
println!("├──────────┼──────────┼──────────┼──────────┼──────────┤");
}
for sample in samples.iter() {
let dist_display =
UnitConverter::distance_from_metric(sample.distance_m, units);
let drop_display = if units == UnitSystem::Imperial {
sample.drop_m * 39.3701 } else {
sample.drop_m
};
let drift_display = if units == UnitSystem::Imperial {
sample.wind_drift_m * 39.3701 } else {
sample.wind_drift_m
};
let vel_display =
UnitConverter::velocity_from_metric(sample.velocity_mps, units);
let flags_str = sample
.flags
.iter()
.map(|f| match f {
trajectory_sampling::TrajectoryFlag::ZeroCrossing => "Zero",
trajectory_sampling::TrajectoryFlag::MachTransition => "Mach",
trajectory_sampling::TrajectoryFlag::Apex => "Apex",
})
.collect::<Vec<_>>()
.join(",");
if target_drops {
println!(
"│ {:>8.1} │ {:>17.2} │ {:>8.2} │ {:>8.1} │ {:8} │",
dist_display,
drop_display,
drift_display,
vel_display,
if flags_str.is_empty() {
"-"
} else {
&flags_str
}
);
} else {
println!(
"│ {:>8.1} │ {:>8.2} │ {:>8.2} │ {:>8.1} │ {:8} │",
dist_display,
drop_display,
drift_display,
vel_display,
if flags_str.is_empty() {
"-"
} else {
&flags_str
}
);
}
}
if target_drops {
println!(
"└──────────┴───────────────────┴──────────┴──────────┴──────────┘"
);
} else {
println!("└──────────┴──────────┴──────────┴──────────┴──────────┘");
}
}
}
if let Some(plot_style) = plot {
if !result.points.is_empty() {
let drop_label = format!("drop ({})", range_unit);
let drop_points: Vec<(f64, f64)> = result
.points
.iter()
.map(|p| {
(
UnitConverter::distance_from_metric(p.position.x, units),
UnitConverter::distance_from_metric(p.position.y, units),
)
})
.collect();
println!();
println!("Drop vs Range:");
println!(
"{}",
terminal_plot::render_chart(
&[(drop_label.as_str(), drop_points.as_slice())],
72,
12,
plot_style.as_canvas_style(),
)
);
let drift_label = format!("drift ({})", range_unit);
let drift_points: Vec<(f64, f64)> = result
.points
.iter()
.map(|p| {
(
UnitConverter::distance_from_metric(p.position.x, units),
UnitConverter::distance_from_metric(p.position.z, units),
)
})
.collect();
println!();
println!("Lateral Drift vs Range:");
println!(
"{}",
terminal_plot::render_chart(
&[(drift_label.as_str(), drift_points.as_slice())],
72,
12,
plot_style.as_canvas_style(),
)
);
let velocity_label = format!("velocity ({})", velocity_unit);
let velocity_points: Vec<(f64, f64)> = result
.points
.iter()
.map(|p| {
(
UnitConverter::distance_from_metric(p.position.x, units),
UnitConverter::velocity_from_metric(p.velocity_magnitude, units),
)
})
.collect();
println!();
println!("Velocity vs Range:");
println!(
"{}",
terminal_plot::render_chart(
&[(velocity_label.as_str(), velocity_points.as_slice())],
72,
12,
plot_style.as_canvas_style(),
)
);
let energy_label = format!("energy ({})", energy_unit);
let energy_points: Vec<(f64, f64)> = result
.points
.iter()
.map(|p| {
(
UnitConverter::distance_from_metric(p.position.x, units),
UnitConverter::energy_from_metric(p.kinetic_energy, units),
)
})
.collect();
println!();
println!("Energy vs Range:");
println!(
"{}",
terminal_plot::render_chart(
&[(energy_label.as_str(), energy_points.as_slice())],
72,
12,
plot_style.as_canvas_style(),
)
);
}
}
}
#[cfg(feature = "pdf")]
OutputFormat::Pdf => {
let pdf_meta = pdf_metadata.as_ref().ok_or(
"PDF output requires metadata (use --target-speed, --powder, --bullet-name, etc.)",
)?;
let output_path = pdf_meta
.output_file
.as_ref()
.ok_or("PDF output requires --output-file")?;
let sampled = result
.sampled_points
.as_ref()
.ok_or("PDF output requires --sample-trajectory flag for trajectory data")?;
let da_ft = calculate_density_altitude(
pdf_meta.altitude_ft,
pdf_meta.pressure_inhg,
pdf_meta.temperature_f,
);
let pdf_config = DopeCardConfig {
rifle_name: pdf_meta.rifle_name.clone(),
location: pdf_meta.location_name.clone(),
density_altitude_ft: da_ft,
pressure_inhg: pdf_meta.pressure_inhg,
pressure_hpa: pdf_meta.pressure_inhg * 33.8639,
temperature_f: pdf_meta.temperature_f,
altitude_ft: pdf_meta.altitude_ft,
wind_speed_mph: pdf_meta.wind_speed_mph,
target_speed_mph: pdf_meta.target_speed_mph,
solver_mode: if cfg!(feature = "online") {
"online".to_string()
} else {
"offline".to_string()
},
powder: pdf_meta.powder.clone(),
bullet: pdf_meta.bullet_name.clone(),
weight_gr: pdf_meta.weight_gr,
bc,
drag_model: drag_model.to_string(),
velocity_fps: pdf_meta.velocity_fps,
font_scale: pdf_meta.font_scale,
bold_data: pdf_meta.bold_data,
elevation_unit_label: adjustment_unit_label(pdf_meta.adjustment_unit),
windage_unit_label: adjustment_unit_label(pdf_meta.windage_unit),
engine_version: env!("CARGO_PKG_VERSION").to_string(),
table_version: String::new(),
truncation_note: String::new(),
};
let rows: Vec<CardRow> = sampled
.iter()
.filter(|s| {
s.distance_m >= UnitConverter::distance_to_metric(100.0, UnitSystem::Imperial)
})
.map(|s| {
dope_card_row_from_sample(
s,
pdf_meta,
elevation_click,
windage_click,
zero_set_elevation_bias_mil,
zero_set_windage_bias_mil,
elevation_cf,
windage_cf,
)
})
.collect();
if rows.is_empty() {
return Err("No trajectory data available for PDF output. Ensure --sample-trajectory is enabled and range > 100 yards.".into());
}
let pdf_bytes = ballistics_engine::pdf_dope_card::generate_dope_card_pdf(
&pdf_config,
&rows,
RangeUnit::Yards,
)?;
std::fs::write(output_path, &pdf_bytes)?;
eprintln!("PDF dope card written to: {}", output_path.display());
eprintln!(
" {} ranges from {} to {} yards",
rows.len(),
rows.first().map(|r| r.range.round() as i64).unwrap_or(0),
rows.last().map(|r| r.range.round() as i64).unwrap_or(0)
);
}
#[cfg(not(feature = "pdf"))]
OutputFormat::Pdf => {
return Err("PDF output requires building with the 'pdf' feature (e.g. cargo build --features pdf)".into());
}
}
Ok(())
}
#[cfg(feature = "online")]
fn display_api_trajectory_result(
response: &ballistics_engine::api_client::TrajectoryResponse,
output: OutputFormat,
units: UnitSystem,
full: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match output {
OutputFormat::Json => {
let result = serde_json::json!({
"source": "api",
"zero_angle_degrees": response.zero_angle.to_degrees(),
"time_of_flight": response.time_of_flight,
"bc_confidence": response.bc_confidence,
"ml_corrections_applied": response.ml_corrections_applied,
"max_ordinate": response.max_ordinate,
"impact_velocity": response.impact_velocity,
"impact_energy": response.impact_energy,
"trajectory": if full {
response.trajectory.iter().map(|p| {
serde_json::json!({
"range": p.range,
"drop": p.drop,
"drift": p.drift,
"velocity": p.velocity,
"energy": p.energy,
"time": p.time
})
}).collect::<Vec<_>>()
} else {
vec![]
}
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
OutputFormat::Csv => {
let (dist_unit, vel_unit, energy_unit) = match units {
UnitSystem::Metric => ("m", "m/s", "J"),
UnitSystem::Imperial => ("yd", "fps", "ft-lb"),
};
if full {
println!(
"range_{},drop_{},drift_{},velocity_{},energy_{},time_s",
dist_unit, dist_unit, dist_unit, vel_unit, energy_unit
);
for p in &response.trajectory {
let range = UnitConverter::distance_from_metric(p.range, units);
let drop = if units == UnitSystem::Imperial {
p.drop * 39.3701 } else {
p.drop
};
let drift = if units == UnitSystem::Imperial {
p.drift * 39.3701
} else {
p.drift
};
let vel = UnitConverter::velocity_from_metric(p.velocity, units);
let energy = UnitConverter::energy_from_metric(p.energy, units);
println!(
"{:.2},{:.2},{:.2},{:.2},{:.2},{:.4}",
range, drop, drift, vel, energy, p.time
);
}
} else {
println!("metric,value,unit");
println!("source,api,");
println!("time_of_flight,{:.4},s", response.time_of_flight);
println!("zero_angle,{:.4},degrees", response.zero_angle.to_degrees());
if let Some(confidence) = response.bc_confidence {
println!("bc_confidence,{:.2},%", confidence * 100.0);
}
if let Some(ref corrections) = response.ml_corrections_applied {
println!("ml_corrections,{},", corrections.join(";"));
}
}
}
OutputFormat::Table => {
let (range_unit, vel_unit, energy_unit) = match units {
UnitSystem::Metric => ("m", "m/s", "J"),
UnitSystem::Imperial => ("yd", "fps", "ft-lb"),
};
println!("╔════════════════════════════════════════╗");
println!("║ TRAJECTORY RESULTS (API/ML) ║");
println!("╠════════════════════════════════════════╣");
if let Some(ref corrections) = response.ml_corrections_applied {
if !corrections.is_empty() {
println!("║ ML Corrections Applied: ║");
for correction in corrections.iter().take(3) {
let truncated: String = correction.chars().take(32).collect();
println!("║ • {:32} ║", truncated);
}
println!("╠════════════════════════════════════════╣");
}
}
if let Some(confidence) = response.bc_confidence {
println!(
"║ BC Confidence: {:>8.1}% ║",
confidence * 100.0
);
println!("╠════════════════════════════════════════╣");
}
println!(
"║ Time of Flight: {:>8.3} s ║",
response.time_of_flight
);
println!(
"║ Zero Angle: {:>8.4}° ║",
response.zero_angle.to_degrees()
);
if let Some(max_ord) = response.max_ordinate {
let max_ord_display = UnitConverter::distance_from_metric(max_ord, units);
println!(
"║ Max Ordinate: {:>8.2} {:3} ║",
max_ord_display, range_unit
);
}
if let Some(impact_vel) = response.impact_velocity {
let vel_display = UnitConverter::velocity_from_metric(impact_vel, units);
println!(
"║ Impact Velocity: {:>8.2} {:3} ║",
vel_display, vel_unit
);
}
if let Some(impact_e) = response.impact_energy {
let energy_display = UnitConverter::energy_from_metric(impact_e, units);
println!(
"║ Impact Energy: {:>8.2} {:5} ║",
energy_display, energy_unit
);
}
println!("╚════════════════════════════════════════╝");
if full && !response.trajectory.is_empty() {
println!();
println!("Trajectory Points (from API):");
let (dist_hdr, drop_hdr, drift_hdr, vel_hdr) = match units {
UnitSystem::Metric => ("(m)", "(m)", "(m)", "(m/s)"),
UnitSystem::Imperial => ("(yd)", "(in)", "(in)", "(fps)"),
};
println!("┌──────────┬──────────┬──────────┬──────────┬──────────┐");
println!(
"│Range{:4} │ Drop{:4} │Drift{:4} │ Vel{:5} │ Time (s) │",
dist_hdr, drop_hdr, drift_hdr, vel_hdr
);
println!("├──────────┼──────────┼──────────┼──────────┼──────────┤");
let step = if response.trajectory.len() > 20 {
response.trajectory.len() / 20
} else {
1
};
for (i, p) in response.trajectory.iter().enumerate() {
if i % step == 0 || i == response.trajectory.len() - 1 {
let range_display = UnitConverter::distance_from_metric(p.range, units);
let drop_display = if units == UnitSystem::Imperial {
p.drop * 39.3701
} else {
p.drop
};
let drift_display = if units == UnitSystem::Imperial {
p.drift * 39.3701
} else {
p.drift
};
let vel_display = UnitConverter::velocity_from_metric(p.velocity, units);
println!(
"│ {:>8.1} │ {:>8.2} │ {:>8.2} │ {:>8.1} │ {:>8.4} │",
range_display, drop_display, drift_display, vel_display, p.time
);
}
}
println!("└──────────┴──────────┴──────────┴──────────┴──────────┘");
}
}
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for API trajectory results.");
eprintln!(
"Hint: Use local calculation (without --online) for PDF dope card generation."
);
}
}
Ok(())
}
fn wez_dominant_label(row: &WezRow) -> String {
if row.attribution_unavailable {
"n/a".to_string()
} else {
row.dominant_error_source
.map(|b| b.to_string())
.unwrap_or_else(|| "none".to_string())
}
}
fn print_wez_full(result: &WezResult) -> Result<(), Box<dyn Error>> {
println!("{}", serde_json::to_string_pretty(result)?);
Ok(())
}
fn print_wez_statistics(result: &WezResult, units: UnitSystem) {
println!("range,p_hit,dominant_error_source,wind_call_share,mv_sd_share,other_share");
for row in &result.rows {
println!(
"{:.2},{:.4},{},{:.4},{:.4},{:.4}",
UnitConverter::distance_from_metric(row.range_m, units),
row.p_hit,
wez_dominant_label(row),
row.wind_call_share,
row.mv_sd_share,
row.other_share
);
}
}
fn print_wez_summary(result: &WezResult, units: UnitSystem) {
let dist_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
let wind_unit = match units {
UnitSystem::Imperial => "mph",
UnitSystem::Metric => "m/s",
};
println!(
"WEZ sweep: {} sims/step, wind call {:.2} {wind_unit} + wind std {:.2} {wind_unit} (quadrature) = {:.2} {wind_unit} effective",
result.num_sims_per_step,
UnitConverter::wind_from_metric(result.wind_call_error_mps, units),
UnitConverter::wind_from_metric(result.wind_speed_std_mps, units),
UnitConverter::wind_from_metric(result.combined_wind_speed_std_mps, units),
);
println!("┌────────────┬──────────┬───────────────┬───────────┬───────────┬───────────┐");
println!(
"│ Range ({dist_unit:>3}) │ P(hit) │ Dominant │ Wind call │ MV SD │ Other/grp │"
);
println!("├────────────┼──────────┼───────────────┼───────────┼───────────┼───────────┤");
for row in &result.rows {
println!(
"│ {:>10.1} │ {:>7.1}% │ {:<13} │ {:>8.1}% │ {:>8.1}% │ {:>8.1}% │",
UnitConverter::distance_from_metric(row.range_m, units),
row.p_hit * 100.0,
wez_dominant_label(row),
row.wind_call_share * 100.0,
row.mv_sd_share * 100.0,
row.other_share * 100.0,
);
}
println!("└────────────┴──────────┴───────────────┴───────────┴───────────┴───────────┘");
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
)]
fn run_monte_carlo_wez(
velocity: f64,
angle: f64,
bc: f64,
mass: f64,
diameter: f64,
num_sims: usize,
velocity_std: f64,
angle_std: f64,
bc_std: f64,
wind_std: f64,
wind_direction_std: f64,
wind_speed: f64,
wind_direction: f64,
wind_vertical: f64,
wind_call_error: f64,
target_size: TargetSizeMetric,
wez_start: f64,
wez_end: f64,
wez_step: f64,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
cd_scale: f64,
cant: f64,
sight_offset_lateral_m: f64,
output: MonteCarloOutput,
units: UnitSystem,
) -> Result<(), Box<dyn Error>> {
let result = compute_wez(
velocity,
angle,
bc,
mass,
diameter,
num_sims,
velocity_std,
angle_std,
bc_std,
wind_std,
wind_direction_std,
wind_speed,
wind_direction,
wind_vertical,
wind_call_error,
target_size,
wez_start,
wez_end,
wez_step,
DragModel::G1,
custom_drag_table,
cd_scale,
cant,
sight_offset_lateral_m,
)?;
match output {
MonteCarloOutput::Full => print_wez_full(&result)?,
MonteCarloOutput::Statistics => print_wez_statistics(&result, units),
MonteCarloOutput::Summary => print_wez_summary(&result, units),
}
Ok(())
}
#[cfg(test)]
mod wez_tests {
use super::*;
#[test]
fn zero_muzzle_velocity_is_a_clean_error_not_a_panic() {
let result = run_monte_carlo_wez(
0.0,
0.0,
0.475,
0.010_886,
0.007_82,
10,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
TargetSizeMetric::Rect {
width_m: 0.4572,
height_m: 0.762,
},
200.0,
300.0,
100.0,
None,
1.0,
0.0,
0.0,
MonteCarloOutput::Summary,
UnitSystem::Metric,
);
let err = result.expect_err("a zero muzzle velocity must not solve successfully");
assert!(
err.to_string().contains("muzzle_velocity"),
"expected a muzzle_velocity validation error, got: {err}"
);
}
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable Monte Carlo CLI command shape"
)]
fn run_monte_carlo(
velocity: f64,
angle: f64,
bc: f64,
bc_reference_standard: BcReferenceStandard,
mass: f64,
diameter: f64,
num_sims: usize,
velocity_std: f64,
angle_std: f64,
bc_std: f64,
wind_std: f64,
wind_direction_std: f64,
wind_speed: f64,
wind_direction: f64,
wind_vertical: f64,
target_distance: Option<f64>,
target_radius: f64,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
cd_scale: f64,
cant: f64,
sight_offset_lateral_m: f64,
seed: Option<u64>,
confidence: ConfidenceLevel,
output: MonteCarloOutput,
) -> Result<(), Box<dyn Error>> {
let bore_height_metric = 1.5_f64;
let base_inputs = BallisticInputs {
muzzle_velocity: velocity,
muzzle_angle: angle.to_radians(),
bc_value: bc,
bc_reference_standard,
bullet_mass: mass,
bullet_diameter: diameter,
muzzle_height: bore_height_metric,
ground_threshold: 0.0,
custom_drag_table,
cd_scale,
cant_angle: cant.to_radians(),
sight_offset_lateral_m,
..Default::default()
};
let base_wind = WindConditions {
speed: wind_speed,
direction: wind_direction.to_radians(),
vertical_speed: wind_vertical,
};
let mc_params = MonteCarloParams {
num_simulations: num_sims,
velocity_std_dev: velocity_std,
angle_std_dev: angle_std.to_radians(),
bc_std_dev: bc_std,
wind_speed_std_dev: wind_std,
target_distance,
base_wind_speed: wind_speed,
base_wind_direction: wind_direction.to_radians(),
azimuth_std_dev: angle_std.to_radians() * 0.5, };
let results = match seed {
Some(seed) => ballistics_engine::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
base_inputs,
base_wind,
mc_params,
wind_direction_std.to_radians(),
seed,
)?,
None => ballistics_engine::run_monte_carlo_with_wind_and_direction_std_dev(
base_inputs,
base_wind,
mc_params,
wind_direction_std.to_radians(),
)?,
};
let mean_range = results.ranges.iter().sum::<f64>() / results.ranges.len() as f64;
let variance_range: f64 = results
.ranges
.iter()
.map(|r| (r - mean_range).powi(2))
.sum::<f64>()
/ results.ranges.len() as f64;
let std_range = variance_range.sqrt();
let mean_velocity =
results.impact_velocities.iter().sum::<f64>() / results.impact_velocities.len() as f64;
let variance_velocity: f64 = results
.impact_velocities
.iter()
.map(|v| (v - mean_velocity).powi(2))
.sum::<f64>()
/ results.impact_velocities.len() as f64;
let std_velocity = variance_velocity.sqrt();
let cep = results.target_plane_cep();
let target_shortfall_fraction = results.target_shortfall_fraction();
let hit_probability = target_distance.map(|_target| results.hit_probability(target_radius));
let hit_probability_ci =
target_distance.map(|_target| results.hit_probability_wilson(target_radius, confidence));
match output {
MonteCarloOutput::Summary => {
println!("╔════════════════════════════════════════╗");
println!("║ MONTE CARLO RESULTS ║");
println!("║ {} simulations ║", num_sims);
println!("╠════════════════════════════════════════╣");
println!("║ Mean Range: {:>8.2} m ║", mean_range);
println!("║ Std Dev Range: {:>8.2} m ║", std_range);
println!("║ Mean Impact Vel: {:>8.2} m/s ║", mean_velocity);
println!("║ Std Dev Velocity: {:>8.2} m/s ║", std_velocity);
if let Some(cep) = cep {
println!("║ CEP (arrivals): {:>8.2} m ║", cep);
} else {
println!("║ CEP (arrivals): N/A ║");
}
println!(
"║ Target Shortfall: {:>8.1} % ║",
target_shortfall_fraction * 100.0
);
if let Some(prob) = hit_probability {
println!("║ Hit Probability: {:>8.1} % ║", prob * 100.0);
}
println!("╚════════════════════════════════════════╝");
if let Some((_, (lo, hi), n)) = hit_probability_ci {
println!(
"Hit probability {}% CI: [{lo:.4}, {hi:.4}] (Wilson, n={n})",
confidence.as_percent()
);
}
}
MonteCarloOutput::Full => {
let mc_result = MonteCarloResult {
mean_range,
std_range,
mean_impact_velocity: mean_velocity,
std_impact_velocity: std_velocity,
cep,
target_shortfall_fraction,
hit_probability,
hit_probability_ci: hit_probability_ci.map(|(_, (lo, hi), n)| HitProbabilityCi {
method: MC_FIXED_COUNT_CI_METHOD.to_string(),
confidence_percent: confidence.as_percent(),
low: lo,
high: hi,
samples: n,
}),
};
println!("{}", serde_json::to_string_pretty(&mc_result)?);
}
MonteCarloOutput::Statistics => {
println!("range_min,range_max,range_mean,range_std,vel_min,vel_max,vel_mean,vel_std");
let range_min = results.ranges.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let range_max = results
.ranges
.iter()
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
let vel_min = results
.impact_velocities
.iter()
.fold(f64::INFINITY, |a, &b| a.min(b));
let vel_max = results
.impact_velocities
.iter()
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
println!(
"{:.2},{:.2},{:.2},{:.2},{:.2},{:.2},{:.2},{:.2}",
range_min,
range_max,
mean_range,
std_range,
vel_min,
vel_max,
mean_velocity,
std_velocity
);
}
}
Ok(())
}
struct AdaptiveMcCliControls {
confidence: Option<ConfidenceLevel>,
target_half_width: f64,
min_samples: Option<u64>,
max_samples: Option<u64>,
batch_size: u64,
seed: Option<u64>,
}
const DEFAULT_MC_MIN_SAMPLES: u64 = 1_000;
const DEFAULT_MC_MAX_SAMPLES: u64 = 100_000;
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror run_monte_carlo's stable CLI command shape; the six \
--adaptive-only knobs are bundled into AdaptiveMcCliControls rather than adding \
six more trailing scalars"
)]
fn run_monte_carlo_adaptive(
velocity: f64,
angle: f64,
bc: f64,
bc_reference_standard: BcReferenceStandard,
mass: f64,
diameter: f64,
velocity_std: f64,
angle_std: f64,
bc_std: f64,
wind_std: f64,
wind_speed: f64,
wind_direction: f64,
wind_vertical: f64,
target_distance: Option<f64>,
target_radius: f64,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
cd_scale: f64,
cant: f64,
sight_offset_lateral_m: f64,
controls: AdaptiveMcCliControls,
output: MonteCarloOutput,
) -> Result<(), Box<dyn Error>> {
let bore_height_metric = 1.5_f64;
let base_inputs = BallisticInputs {
muzzle_velocity: velocity,
muzzle_angle: angle.to_radians(),
bc_value: bc,
bc_reference_standard,
bullet_mass: mass,
bullet_diameter: diameter,
muzzle_height: bore_height_metric,
ground_threshold: 0.0,
custom_drag_table,
cd_scale,
cant_angle: cant.to_radians(),
sight_offset_lateral_m,
..Default::default()
};
let base_wind = WindConditions {
speed: wind_speed,
direction: wind_direction.to_radians(),
vertical_speed: wind_vertical,
};
let mc_params = MonteCarloParams {
num_simulations: 0,
velocity_std_dev: velocity_std,
angle_std_dev: angle_std.to_radians(),
bc_std_dev: bc_std,
wind_speed_std_dev: wind_std,
target_distance,
base_wind_speed: wind_speed,
base_wind_direction: wind_direction.to_radians(),
azimuth_std_dev: angle_std.to_radians() * 0.5,
};
let min_samples = controls.min_samples.unwrap_or(DEFAULT_MC_MIN_SAMPLES);
let max_samples = controls.max_samples.unwrap_or(DEFAULT_MC_MAX_SAMPLES);
if max_samples < min_samples {
let describe = |flag: &str, value: Option<u64>, default: u64| match value {
Some(v) => format!("{flag} ({v})"),
None => format!("{flag} (default {default})"),
};
return Err(format!(
"{} must be at least {}",
describe(
"--max-samples",
controls.max_samples,
DEFAULT_MC_MAX_SAMPLES
),
describe(
"--min-samples",
controls.min_samples,
DEFAULT_MC_MIN_SAMPLES
),
)
.into());
}
let convergence = McConvergence {
level: controls.confidence.unwrap_or(ConfidenceLevel::P95),
target_half_width: controls.target_half_width,
min_samples,
max_samples,
batch_size: controls.batch_size,
};
use rand::RngExt as _;
let seed = controls.seed.unwrap_or_else(|| rand::rng().random::<u64>());
let report = run_monte_carlo_adaptive_seeded(
&base_inputs,
&base_wind,
&mc_params,
&convergence,
target_radius,
seed,
)
.map_err(|e| format!("--adaptive: {e}"))?;
match output {
MonteCarloOutput::Summary => print_adaptive_mc_summary(&report),
MonteCarloOutput::Full => println!("{}", serde_json::to_string_pretty(&report)?),
MonteCarloOutput::Statistics => {
return Err(
"--output statistics is not supported with --adaptive (the adaptive \
driver retains no per-trial data to tabulate); use --output summary or \
--output full/json instead"
.into(),
);
}
}
Ok(())
}
fn print_adaptive_mc_summary(report: &AdaptiveMcReportV1) {
let stop_reason = match report.stop_reason {
McStopReason::TargetHalfWidthMet => "target_half_width_met",
McStopReason::MaxSamplesReached => "max_samples_reached",
};
println!("Adaptive Monte Carlo (confidence-controlled sampling, MBA-1352)");
println!(" method: {}", report.method);
println!(" confidence level: {}%", report.confidence_percent);
println!(
" hit probability: {:.4} ({}% CI: [{:.4}, {:.4}])",
report.hit_probability, report.confidence_percent, report.ci_low, report.ci_high
);
println!(
" samples: {} (attempts: {}, arrivals: {})",
report.samples, report.attempts, report.arrivals
);
println!(" stop reason: {stop_reason}");
println!(" hit radius: {:.4} m", report.hit_radius_m);
println!(" target distance: {:.4} m", report.target_distance_m);
println!(
" impact velocity: mean {:.3} m/s, std {:.3} m/s",
report.mean_impact_velocity_mps, report.std_impact_velocity_mps
);
println!(
" drop at target: mean {:.4} m, std {:.4} m",
report.mean_drop_at_target_m, report.std_drop_at_target_m
);
println!(
" drift at target: mean {:.4} m, std {:.4} m",
report.mean_wind_drift_at_target_m, report.std_wind_drift_at_target_m
);
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable zero-calculation CLI command shape"
)]
fn run_zero_calculation(
velocity: f64,
bc: f64,
bc_reference_standard: BcReferenceStandard,
mass: f64,
diameter: f64,
drag_model: DragModel,
target_distance: f64,
target_height: f64,
sight_height: f64,
temperature: f64,
pressure: f64,
pressure_type: PressureReferenceMode,
humidity: f64,
altitude: f64,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
cd_scale: f64,
elevation_cf: f64,
output: OutputFormat,
units: UnitSystem,
) -> Result<(), Box<dyn Error>> {
let inputs = BallisticInputs {
muzzle_velocity: velocity,
bc_value: bc,
bc_type: drag_model,
bc_reference_standard,
bullet_mass: mass,
bullet_diameter: diameter,
sight_height,
temperature,
pressure,
humidity,
altitude,
custom_drag_table,
cd_scale,
..Default::default()
};
let (resolved_temperature, resolved_pressure) = resolve_station_conditions_with_pressure_mode(
temperature,
pressure,
altitude,
pressure_type,
);
let atmosphere = AtmosphericConditions {
temperature: resolved_temperature,
pressure: resolved_pressure,
humidity,
altitude,
};
let los_target_height = sight_height + target_height;
let zero_angle = ballistics_engine::calculate_zero_angle_with_resolved_conditions(
inputs.clone(),
target_distance,
los_target_height,
WindConditions::default(),
atmosphere.clone(),
)?;
let mut zeroed_inputs = inputs;
zeroed_inputs.muzzle_angle = zero_angle;
let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
zeroed_inputs,
Default::default(),
atmosphere,
);
solver.set_max_range(target_distance * 3.0);
let trajectory = solver.solve()?;
let sight_adjustment_moa = (zero_angle.to_degrees() * 60.0
- (target_height / target_distance * 3437.75))
/ elevation_cf;
let mut entered_point_blank_band = false;
let point_blank_range = trajectory
.points
.iter()
.find_map(|p| {
let los_y = sight_height + target_height * (p.position.x / target_distance);
let height_from_los = p.position.y - los_y;
if height_from_los >= -0.05 {
entered_point_blank_band = true;
None
} else if entered_point_blank_band {
Some(p.position.x)
} else {
None
}
})
.unwrap_or(trajectory.max_range);
match output {
OutputFormat::Json => {
let units_label = match units {
UnitSystem::Metric => "metric",
UnitSystem::Imperial => "imperial",
};
let result = serde_json::json!({
"units": units_label,
"zero_angle_degrees": zero_angle.to_degrees(),
"zero_angle_moa": zero_angle.to_degrees() * 60.0 / elevation_cf,
"zero_angle_mrad": zero_angle * 1000.0 / elevation_cf,
"sight_adjustment_moa": sight_adjustment_moa,
"max_ordinate": UnitConverter::distance_from_metric(trajectory.max_height, units),
"point_blank_range": UnitConverter::distance_from_metric(point_blank_range, units),
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
OutputFormat::Csv => {
println!("metric,value,unit");
println!("zero_angle,{:.4},degrees", zero_angle.to_degrees());
println!(
"zero_angle_moa,{:.2},MOA",
zero_angle.to_degrees() * 60.0 / elevation_cf
);
println!(
"zero_angle_mrad,{:.2},mrad",
zero_angle * 1000.0 / elevation_cf
);
println!("max_ordinate,{:.3},meters", trajectory.max_height);
}
OutputFormat::Table => {
let target_dist_display = UnitConverter::distance_from_metric(target_distance, units);
let target_height_display = UnitConverter::distance_from_metric(target_height, units);
let sight_height_display = UnitConverter::distance_from_metric(sight_height, units);
let max_ordinate_display =
UnitConverter::distance_from_metric(trajectory.max_height, units);
let dist_unit = match units {
UnitSystem::Metric => "m",
UnitSystem::Imperial => "yd",
};
println!("╔════════════════════════════════════════╗");
println!("║ ZERO CALCULATION ║");
println!("╠════════════════════════════════════════╣");
println!(
"║ Target Distance: {:>8.1} {:3} ║",
target_dist_display, dist_unit
);
println!(
"║ Target Height: {:>8.2} {:3} ║",
target_height_display, dist_unit
);
println!(
"║ Sight Height: {:>8.3} {:3} ║",
sight_height_display, dist_unit
);
println!("╠════════════════════════════════════════╣");
println!(
"║ Zero Angle: {:>8.4}° ║",
zero_angle.to_degrees()
);
println!(
"║ Zero Angle (MOA): {:>8.2} MOA ║",
zero_angle.to_degrees() * 60.0 / elevation_cf
);
println!(
"║ Zero Angle (mrad): {:>8.2} mrad ║",
zero_angle * 1000.0 / elevation_cf
);
println!(
"║ Max Ordinate: {:>8.3} {:3} ║",
max_ordinate_display, dist_unit
);
println!("╚════════════════════════════════════════╝");
}
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for zero calculation.");
eprintln!("Hint: Use --output json, --output csv, or --output table instead.");
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)] fn run_zero_range_calculation(
velocity: f64,
bc: f64,
bc_reference_standard: BcReferenceStandard,
mass: f64,
diameter: f64,
drag_model: DragModel,
zero_angle_rad: f64,
target_height: f64,
sight_height: f64,
temperature: f64,
pressure: f64,
pressure_type: PressureReferenceMode,
humidity: f64,
altitude: f64,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
cd_scale: f64,
elevation_cf: f64,
output: OutputFormat,
units: UnitSystem,
) -> Result<(), Box<dyn Error>> {
let inputs = BallisticInputs {
muzzle_velocity: velocity,
bc_value: bc,
bc_type: drag_model,
bc_reference_standard,
bullet_mass: mass,
bullet_diameter: diameter,
sight_height,
temperature,
pressure,
humidity,
altitude,
custom_drag_table,
cd_scale,
..Default::default()
};
let (resolved_temperature, resolved_pressure) = resolve_station_conditions_with_pressure_mode(
temperature,
pressure,
altitude,
pressure_type,
);
let atmosphere = AtmosphericConditions {
temperature: resolved_temperature,
pressure: resolved_pressure,
humidity,
altitude,
};
let los_target_height = sight_height + target_height;
let crossings = ballistics_engine::calculate_zero_range_from_angle_with_resolved_conditions(
inputs.clone(),
zero_angle_rad,
los_target_height,
WindConditions::default(),
atmosphere.clone(),
)?;
let primary_range = crossings.far_m.or(crossings.near_m).expect(
"calculate_zero_range_from_angle_with_resolved_conditions guarantees at least one \
crossing on Ok",
);
let primary_crossing = if crossings.far_m.is_some() {
"far"
} else {
"near"
};
let mut zeroed_inputs = inputs;
zeroed_inputs.muzzle_angle = zero_angle_rad;
let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
zeroed_inputs,
Default::default(),
atmosphere,
);
let diagnostics_envelope = if crossings.far_m.is_some() {
primary_range * 3.0
} else {
(primary_range * 3.0).max(ballistics_engine::ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M)
};
solver.set_max_range(diagnostics_envelope);
let trajectory = solver.solve()?;
let sight_adjustment_moa = (zero_angle_rad.to_degrees() * 60.0
- (target_height / primary_range * 3437.75))
/ elevation_cf;
let mut entered_point_blank_band = false;
let point_blank_range = trajectory
.points
.iter()
.find_map(|p| {
let los_y = sight_height + target_height * (p.position.x / primary_range);
let height_from_los = p.position.y - los_y;
if height_from_los >= -0.05 {
entered_point_blank_band = true;
None
} else if entered_point_blank_band {
Some(p.position.x)
} else {
None
}
})
.unwrap_or(trajectory.max_range);
let apex_is_contained = {
let ys: Vec<f64> = trajectory.points.iter().map(|p| p.position.y).collect();
match (
ys.iter()
.cloned()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)),
ys.last(),
) {
(Some((peak_index, peak_y)), Some(&last_y)) => {
peak_index + 1 < ys.len() && peak_y > last_y + 1e-9
}
_ => false,
}
};
let dist_unit = match units {
UnitSystem::Metric => "m",
UnitSystem::Imperial => "yd",
};
let near_range_display = crossings
.near_m
.map(|m| UnitConverter::distance_from_metric(m, units));
let far_range_display = crossings
.far_m
.map(|m| UnitConverter::distance_from_metric(m, units));
let primary_range_display = UnitConverter::distance_from_metric(primary_range, units);
match output {
OutputFormat::Json => {
let units_label = match units {
UnitSystem::Metric => "metric",
UnitSystem::Imperial => "imperial",
};
let mut result = serde_json::json!({
"units": units_label,
"zero_angle_degrees": zero_angle_rad.to_degrees(),
"zero_angle_moa": zero_angle_rad.to_degrees() * 60.0 / elevation_cf,
"zero_angle_mrad": zero_angle_rad * 1000.0 / elevation_cf,
"zero_range": primary_range_display,
"primary_crossing": primary_crossing,
"sight_adjustment_moa": sight_adjustment_moa,
"point_blank_range": UnitConverter::distance_from_metric(point_blank_range, units),
});
if apex_is_contained {
result["max_ordinate"] = serde_json::json!(UnitConverter::distance_from_metric(
trajectory.max_height,
units
));
}
if let Some(near) = near_range_display {
result["near_zero_range"] = serde_json::json!(near);
}
if let Some(far) = far_range_display {
result["far_zero_range"] = serde_json::json!(far);
}
println!("{}", serde_json::to_string_pretty(&result)?);
}
OutputFormat::Csv => {
println!("metric,value,unit");
println!("zero_angle,{:.4},degrees", zero_angle_rad.to_degrees());
println!(
"zero_angle_moa,{:.2},MOA",
zero_angle_rad.to_degrees() * 60.0 / elevation_cf
);
println!(
"zero_angle_mrad,{:.2},mrad",
zero_angle_rad * 1000.0 / elevation_cf
);
println!("zero_range,{:.2},{}", primary_range_display, dist_unit);
println!("primary_crossing,{},crossing", primary_crossing);
if let Some(near) = near_range_display {
println!("near_zero_range,{:.2},{}", near, dist_unit);
}
if let Some(far) = far_range_display {
println!("far_zero_range,{:.2},{}", far, dist_unit);
}
if apex_is_contained {
println!("max_ordinate,{:.3},meters", trajectory.max_height);
}
}
OutputFormat::Table => {
let target_height_display = UnitConverter::distance_from_metric(target_height, units);
let sight_height_display = UnitConverter::distance_from_metric(sight_height, units);
let max_ordinate_display =
UnitConverter::distance_from_metric(trajectory.max_height, units);
println!("╔════════════════════════════════════════╗");
println!("║ ZERO RANGE FROM STORED ANGLE ║");
println!("╠════════════════════════════════════════╣");
println!(
"║ Input Zero Angle: {:>8.4}° ║",
zero_angle_rad.to_degrees()
);
println!(
"║ Input Zero Angle: {:>8.2} MOA ║",
zero_angle_rad.to_degrees() * 60.0 / elevation_cf
);
println!(
"║ Input Zero Angle: {:>8.2} mrad ║",
zero_angle_rad * 1000.0 / elevation_cf
);
println!("╠════════════════════════════════════════╣");
match near_range_display {
Some(near) => {
println!("║ Near Zero (ascending): {:>8.1} {:3} ║", near, dist_unit)
}
None => println!("║ Near Zero: not in solved range ║"),
}
match far_range_display {
Some(far) => println!("║ Far Zero (descending): {:>8.1} {:3} ║", far, dist_unit),
None => println!("║ Far Zero: not in solved range ║"),
}
println!(
"║ Target Height: {:>8.2} {:3} ║",
target_height_display, dist_unit
);
println!(
"║ Sight Height: {:>8.3} {:3} ║",
sight_height_display, dist_unit
);
if apex_is_contained {
println!(
"║ Max Ordinate: {:>8.3} {:3} ║",
max_ordinate_display, dist_unit
);
} else {
println!("║ Max Ordinate: still climbing at limit ║");
}
println!("╚════════════════════════════════════════╝");
}
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for zero calculation.");
eprintln!("Hint: Use --output json, --output csv, or --output table instead.");
}
}
Ok(())
}
fn run_bc_segment_generation(
bc: f64,
mass: f64,
diameter: f64,
model: &str,
drag_model: &str,
output: OutputFormat,
units: UnitSystem,
) -> Result<(), Box<dyn Error>> {
use ballistics_engine::bc_estimation::BCSegmentEstimator;
let weight_grains = mass / GRAINS_TO_KG;
let caliber_inches = diameter / 0.0254;
if let Some(parsed) = DragModel::from_str(drag_model) {
warn_aux_g1_coercion("generate-bc-segments", parsed);
}
let segments = BCSegmentEstimator::estimate_bc_segments(
bc,
caliber_inches,
weight_grains,
model,
drag_model,
);
match output {
OutputFormat::Json => {
let segments_json: Vec<_> = segments
.iter()
.map(|s| {
serde_json::json!({
"velocity_min_fps": s.velocity_min,
"velocity_max_fps": s.velocity_max,
"bc": s.bc_value
})
})
.collect();
let result = serde_json::json!({
"base_bc": bc,
"mass_grains": weight_grains,
"caliber_inches": caliber_inches,
"model": model,
"drag_model": drag_model,
"segments": segments_json
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
OutputFormat::Csv => {
println!("velocity_min_fps,velocity_max_fps,bc");
for segment in &segments {
println!(
"{},{},{:.4}",
segment.velocity_min, segment.velocity_max, segment.bc_value
);
}
}
OutputFormat::Table => {
println!("╔════════════════════════════════════════╗");
println!("║ BC SEGMENT GENERATION ║");
println!("╠════════════════════════════════════════╣");
println!("║ Base BC: {:.3} ║", bc);
println!("║ Caliber: {:.3} inches ║", caliber_inches);
println!("║ Weight: {:.1} grains ║", weight_grains);
if !model.is_empty() {
println!("║ Model: {:20} ║", model);
}
println!("║ Drag Model: {:20} ║", drag_model);
println!("╠════════════════════════════════════════╣");
println!("║ VELOCITY SEGMENTS ║");
println!("╠════════════════════════════════════════╣");
for segment in &segments {
let vel_display = if units == UnitSystem::Imperial {
format!(
"{:.0}-{:.0} fps",
segment.velocity_min, segment.velocity_max
)
} else {
format!(
"{:.0}-{:.0} m/s",
segment.velocity_min / 3.28084,
segment.velocity_max / 3.28084
)
};
println!("║ {:18} → BC: {:.4} ║", vel_display, segment.bc_value);
}
println!("╚════════════════════════════════════════╝");
}
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for BC segment generation.");
eprintln!("Hint: Use --output json, --output csv, or --output table instead.");
}
}
Ok(())
}
fn parse_data_pairs(s: &str) -> Result<Vec<(f64, f64)>, Box<dyn Error>> {
let cleaned = s.trim().trim_matches('\'').trim_matches('"');
let mut out = Vec::new();
for pair in cleaned.split(';') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let parts: Vec<&str> = pair.split(',').collect();
if parts.len() != 2 {
return Err(format!(
"Malformed data pair '{}': expected \"distance,value\".",
pair
)
.into());
}
let d: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("Invalid distance '{}'.", parts[0].trim()))?;
let v: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("Invalid value '{}'.", parts[1].trim()))?;
out.push((d, v));
}
if out.is_empty() {
return Err("No valid data pairs found.".into());
}
Ok(out)
}
fn parse_drag_models(s: &str) -> Result<Vec<DragModel>, Box<dyn Error>> {
let trimmed = s.trim();
match trimmed.to_lowercase().as_str() {
"both" | "all" | "g1,g7" | "g1g7" => Ok(vec![DragModel::G1, DragModel::G7]),
other => DragModel::from_str(other).map(|m| vec![m]).ok_or_else(|| {
format!(
"Unknown --drag-model '{}'; use g1, g7, both, or a specific drag family (g2, g5, g6, g8, gi, gs, ra4).",
other
)
.into()
}),
}
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the existing BC-estimation compatibility helper"
)]
fn run_bc_estimation_multi(
velocity: f64,
mass: f64,
diameter: f64,
drop_points: &[(f64, f64)],
vel_points: &[(f64, f64)],
models: &[DragModel],
atmosphere: AtmosphericConditions,
zero_range: Option<f64>,
sight_height: f64,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
use ballistics_engine::{estimate_bc_fit, BcFitMode};
struct Basis<'a> {
mode: BcFitMode,
points: &'a [(f64, f64)],
}
let mut bases: Vec<Basis> = Vec::new();
if !drop_points.is_empty() {
bases.push(Basis {
mode: BcFitMode::Drop,
points: drop_points,
});
}
if !vel_points.is_empty() {
bases.push(Basis {
mode: BcFitMode::Velocity,
points: vel_points,
});
}
struct Variant {
model: DragModel,
mode: BcFitMode,
bc: f64,
rms_user: f64,
rms_unit: &'static str,
n: usize,
at_bound: bool,
}
let mut variants: Vec<Variant> = Vec::new();
for &model in models {
for basis in &bases {
let zr = match basis.mode {
BcFitMode::Drop => zero_range,
BcFitMode::Velocity => None,
};
let est = estimate_bc_fit(
velocity,
mass,
diameter,
basis.points,
model,
basis.mode,
atmosphere.clone(),
zr,
sight_height,
)?;
let (rms_user, rms_unit) = match basis.mode {
BcFitMode::Drop => match units {
UnitSystem::Imperial => (est.rms_error / 0.0254, "in"),
UnitSystem::Metric => (est.rms_error, "m"),
},
BcFitMode::Velocity => match units {
UnitSystem::Imperial => (est.rms_error / 0.3048, "fps"),
UnitSystem::Metric => (est.rms_error, "m/s"),
},
};
variants.push(Variant {
model,
mode: basis.mode,
bc: est.bc,
rms_user,
rms_unit,
n: basis.points.len(),
at_bound: est.at_bound,
});
}
}
let model_name = |m: DragModel| m.to_string();
let basis_name = |mode: BcFitMode| match mode {
BcFitMode::Drop => "drop",
BcFitMode::Velocity => "velocity",
};
match output {
OutputFormat::Json => {
let vs: Vec<_> = variants
.iter()
.map(|v| {
serde_json::json!({
"drag_model": model_name(v.model),
"fit_basis": basis_name(v.mode),
"estimated_bc": (v.bc * 1000.0).round() / 1000.0,
"fit_rms": (v.rms_user * 1000.0).round() / 1000.0,
"fit_rms_unit": v.rms_unit,
"n_points": v.n,
"reliable": !v.at_bound,
})
})
.collect();
let result = serde_json::json!({
"velocity": velocity,
"mass": mass,
"diameter": diameter,
"variants": vs,
});
println!("{}", serde_json::to_string_pretty(&result)?);
}
OutputFormat::Csv => {
println!("drag_model,fit_basis,estimated_bc,fit_rms,fit_rms_unit,n_points,reliable");
for v in &variants {
println!(
"{},{},{:.4},{:.3},{},{},{}",
model_name(v.model),
basis_name(v.mode),
v.bc,
v.rms_user,
v.rms_unit,
v.n,
!v.at_bound
);
}
}
OutputFormat::Table => {
println!("BC Estimation");
println!(
" {:<6} {:<20} {:>12} {:<10} ",
"Model", "Fit basis", "Estimated BC", "Fit RMS"
);
println!(" {:-<6} {:-<20} {:->12} {:-<10}", "", "", "", "");
for v in &variants {
println!(
" {:<6} {:<20} {:>12.3} {:>6.2} {:<4}{}",
model_name(v.model),
format!("{} ({} pts)", basis_name(v.mode), v.n),
v.bc,
v.rms_user,
v.rms_unit,
if v.at_bound {
" ⚠ UNRELIABLE (hit BC limit)"
} else {
""
}
);
}
if variants.iter().any(|v| v.at_bound) {
println!();
println!(" ⚠ One or more fits ran to the BC search limit — the data did not");
println!(" determine a real value. Add more (and longer-range) points, and");
println!(" check --zero-range / --temperature / --pressure match the data.");
}
}
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for BC estimation.");
eprintln!("Hint: Use --output json, --output csv, or --output table instead.");
}
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the existing velocity-truing display helper"
)]
fn display_true_velocity_result(
effective_vel: f64,
vel_unit: &str,
velocity_adjustment: Option<f64>,
adjustment_percent: Option<f64>,
confidence: &str,
iterations: i32,
final_error_mil: f64,
calculated_drop_mil: f64,
measured_drop: f64,
units: UnitSystem,
output: OutputFormat,
used_bc_table: bool,
) -> Result<(), Box<dyn Error>> {
match output {
OutputFormat::Json => {
let mut json_output = serde_json::json!({
"effective_velocity": effective_vel,
"velocity_unit": vel_unit,
"confidence": confidence,
"iterations": iterations,
"final_error_mil": final_error_mil,
"calculated_drop_mil": calculated_drop_mil,
"measured_drop_mil": measured_drop,
"used_bc_table": used_bc_table,
});
if let Some(adj) = velocity_adjustment {
let adj_display = match units {
UnitSystem::Imperial => adj,
UnitSystem::Metric => adj * 0.3048,
};
json_output["velocity_adjustment"] = serde_json::json!(adj_display);
}
if let Some(pct) = adjustment_percent {
json_output["adjustment_percent"] = serde_json::json!(pct);
}
println!("{}", serde_json::to_string_pretty(&json_output)?);
}
OutputFormat::Csv => {
println!("effective_velocity,unit,adjustment,adjustment_pct,confidence,iterations,final_error_mil,calculated_drop_mil,used_bc_table");
println!(
"{:.1},{},{},{},{},{},{:.4},{:.2},{}",
effective_vel,
vel_unit,
velocity_adjustment
.map(|v| {
let adj = match units {
UnitSystem::Imperial => v,
UnitSystem::Metric => v * 0.3048,
};
format!("{:.1}", adj)
})
.unwrap_or_default(),
adjustment_percent
.map(|v| format!("{:.2}", v))
.unwrap_or_default(),
confidence,
iterations,
final_error_mil,
calculated_drop_mil,
used_bc_table,
);
}
OutputFormat::Table => {
println!();
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ VELOCITY TRUING RESULTS ║");
println!("╠════════════════════════════════════════════════════════════╣");
println!(
"║ Effective Muzzle Velocity: {:>8.1} {:>4} ║",
effective_vel, vel_unit
);
if let Some(adj) = velocity_adjustment {
let adj_display = match units {
UnitSystem::Imperial => adj,
UnitSystem::Metric => adj * 0.3048,
};
println!(
"║ Adjustment from Chrono: {:>+8.1} {:>4} ║",
adj_display, vel_unit
);
if let Some(pct) = adjustment_percent {
println!(
"║ Adjustment Percentage: {:>+8.2}% ║",
pct
);
}
}
println!("╠════════════════════════════════════════════════════════════╣");
println!(
"║ Confidence: {:>8} ║",
confidence
);
println!(
"║ Iterations: {:>8} ║",
iterations
);
println!(
"║ Final Error: {:>8.4} MIL ║",
final_error_mil
);
println!(
"║ Calculated Drop: {:>8.2} MIL ║",
calculated_drop_mil
);
println!(
"║ Measured Drop: {:>8.2} MIL ║",
measured_drop
);
if used_bc_table {
println!("╠════════════════════════════════════════════════════════════╣");
println!("║ BC5D Table: Yes ║");
}
println!("╚════════════════════════════════════════════════════════════╝");
println!();
}
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for velocity truing results.");
eprintln!("Hint: Use --output json, --output csv, or --output table instead.");
}
}
Ok(())
}
fn parse_uncertain_truing_observation(
token: &str,
units: UnitSystem,
default_sigma: f64,
) -> Result<(TruingObservation, f64), String> {
let parts: Vec<&str> = token.split(':').collect();
if !(2..=3).contains(&parts.len()) {
return Err(format!(
"invalid --observed '{token}': expected RANGE:DROP or RANGE:DROP:SIGMA"
));
}
let base = format!("{}:{}", parts[0], parts[1]);
let observation = parse_truing_observation(&base, units)?;
let sigma = if parts.len() == 3 {
parts[2]
.trim()
.parse::<f64>()
.map_err(|_| format!("invalid --observed sigma '{}' in '{token}'", parts[2]))?
} else {
default_sigma
};
if !sigma.is_finite() || sigma <= 0.0 {
return Err(format!(
"invalid --observed sigma in '{token}': sigma must be positive and finite"
));
}
Ok((observation, sigma))
}
fn parse_normal_prior(token: &str, flag: &str) -> Result<(f64, f64), String> {
let (mean, sigma) = token
.split_once(':')
.ok_or_else(|| format!("{flag} expects MEAN:SIGMA"))?;
let mean = mean
.trim()
.parse::<f64>()
.map_err(|_| format!("{flag} has an invalid mean in '{token}'"))?;
let sigma = sigma
.trim()
.parse::<f64>()
.map_err(|_| format!("{flag} has an invalid sigma in '{token}'"))?;
if !mean.is_finite() {
return Err(format!("{flag} mean must be finite"));
}
if !sigma.is_finite() || sigma <= 0.0 {
return Err(format!("{flag} sigma must be positive and finite"));
}
Ok((mean, sigma))
}
fn parse_truing_range_grid(token: &str) -> Result<Vec<f64>, String> {
let parts: Vec<&str> = token.split(':').collect();
if parts.len() != 3 {
return Err(format!(
"--range-grid expects START:END:STEP (got '{token}')"
));
}
let mut values = [0.0_f64; 3];
for (i, part) in parts.iter().enumerate() {
values[i] = part
.trim()
.parse::<f64>()
.map_err(|_| format!("--range-grid contains an invalid number in '{token}'"))?;
}
let [start, end, step] = values;
if !start.is_finite() || !end.is_finite() || !step.is_finite() {
return Err("--range-grid values must be finite".to_string());
}
if start <= 0.0 || end < start || step <= 0.0 {
return Err("--range-grid requires 0 < START <= END and STEP > 0".to_string());
}
let count = ((end - start) / step + 1e-12).floor() as usize + 1;
if count > 1_000 {
return Err(format!(
"--range-grid expands to {count} candidates; maximum is 1000"
));
}
Ok((0..count).map(|i| start + i as f64 * step).collect())
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable true-velocity CLI command shape"
)]
fn run_multi_observation_truing(
measured_drop: f64,
range_yd: f64,
observed: &[String],
drop_unit: DropUnit,
observation_cf: f64,
bc_input: f64,
drag_model: DragModelArg,
mass_gr: f64,
diameter_in: f64,
zero_yd: f64,
sight_in: f64,
temp_f: f64,
press_inhg: f64,
humidity: f64,
alt_ft: f64,
bc_segments: &Option<Vec<BCSegmentData>>,
chrono_fps: Option<f64>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let mut observations: Vec<TruingObservation> = Vec::with_capacity(observed.len() + 1);
observations.push(TruingObservation {
range_yd,
drop: measured_drop,
});
for token in observed {
let mut observation = parse_truing_observation(token, units)?;
observation.drop *= observation_cf;
observations.push(observation);
}
validate_truing_observations(&observations)?;
eprintln!(
"Fitting {} observations (joint MV+BC calibration)...",
observations.len()
);
let report = run_multi_observation_truing_core(
&observations,
drop_unit,
bc_input,
drag_model,
mass_gr,
diameter_in,
zero_yd,
sight_in,
temp_f,
press_inhg,
humidity,
alt_ft,
bc_segments,
)?;
let display_report =
ballistics_engine::truing::scale_report_dial_values(&report, observation_cf);
display_multi_truing_result(&display_report, drop_unit, units, chrono_fps, output);
Ok(())
}
fn display_multi_truing_result(
report: &MultiTruingReport,
drop_unit: DropUnit,
units: UnitSystem,
chrono_fps: Option<f64>,
output: OutputFormat,
) {
let vel_unit = match units {
UnitSystem::Imperial => "fps",
UnitSystem::Metric => "m/s",
};
let range_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
let drop_label = drop_unit.label();
let mv_display = match units {
UnitSystem::Imperial => report.fitted_mv_fps,
UnitSystem::Metric => report.fitted_mv_fps * 0.3048,
};
let range_display = |range_yd: f64| match units {
UnitSystem::Imperial => range_yd,
UnitSystem::Metric => range_yd * 0.9144,
};
let (adj_display, adj_pct) = match chrono_fps {
Some(c) => {
let adj_fps = report.fitted_mv_fps - c;
let adj_disp = match units {
UnitSystem::Imperial => adj_fps,
UnitSystem::Metric => adj_fps * 0.3048,
};
let pct = if c != 0.0 { adj_fps / c * 100.0 } else { 0.0 };
(Some(adj_disp), Some(pct))
}
None => (None, None),
};
let mv_window = mv_calibration_window(report.mach_1_2_distance_m);
if let Some((lo_m, hi_m)) = mv_window {
let lo = UnitConverter::distance_from_metric(lo_m, units);
let hi = UnitConverter::distance_from_metric(hi_m, units);
for o in &report.observations {
let range_m = o.range_yd * 0.9144;
if range_m < lo_m || range_m > hi_m {
let r = range_display(o.range_yd);
eprintln!(
"warning: observation at {r:.1} is outside the MV-calibration window \
({lo:.1}-{hi:.1}); MV fits from this range are weakly identified"
);
}
}
}
match output {
OutputFormat::Json => {
let obs_json: Vec<serde_json::Value> = report
.observations
.iter()
.enumerate()
.map(|(i, o)| {
serde_json::json!({
format!("range_{range_unit}"): range_display(o.range_yd),
format!("observed_drop_{drop_label}"): o.drop,
format!("predicted_drop_{drop_label}"): report.predicted[i],
format!("residual_{drop_label}"): report.residuals[i],
})
})
.collect();
let mut json_output = serde_json::json!({
"mode": if report.bc_fitted { "joint_mv_bc" } else { "mv_only" },
"fitted_muzzle_velocity": mv_display,
"velocity_unit": vel_unit,
"bc_fitted": report.bc_fitted,
"fitted_bc": report.fitted_bc,
"input_bc": report.bc_input,
"observations": obs_json,
format!("rms_residual_{drop_label}"): report.rms,
"iterations": report.iterations,
"converged": report.converged,
"bc_sensitivity_ratio": report.sensitivity_ratio,
"condition_number": if report.condition_number.is_finite() {
serde_json::json!(report.condition_number)
} else {
serde_json::Value::Null
},
"quality": report.quality,
"legend": {
"units": {
"range": range_unit,
"drop": drop_label,
"velocity": vel_unit,
},
},
});
if !report.reason.is_empty() {
json_output["bc_hold_reason"] = serde_json::json!(report.reason);
}
if let Some(adj) = adj_display {
json_output["velocity_adjustment"] = serde_json::json!(adj);
}
if let Some(pct) = adj_pct {
json_output["adjustment_percent"] = serde_json::json!(pct);
}
json_output["mv_window_start_m"] = match mv_window {
Some((lo_m, _)) => serde_json::json!(lo_m),
None => serde_json::Value::Null,
};
json_output["mv_window_end_m"] = match mv_window {
Some((_, hi_m)) => serde_json::json!(hi_m),
None => serde_json::Value::Null,
};
match serde_json::to_string_pretty(&json_output) {
Ok(s) => println!("{s}"),
Err(e) => eprintln!("Error serializing JSON: {e}"),
}
}
OutputFormat::Csv => {
println!("range_{range_unit},observed_drop_{drop_label},predicted_drop_{drop_label},residual_{drop_label}");
for (i, o) in report.observations.iter().enumerate() {
println!(
"{:.1},{:.4},{:.4},{:+.4}",
range_display(o.range_yd),
o.drop,
report.predicted[i],
report.residuals[i]
);
}
println!();
println!("fitted_muzzle_velocity_{vel_unit},bc_fitted,fitted_bc,input_bc,rms_residual_{drop_label},iterations,converged,bc_sensitivity_ratio,condition_number");
println!(
"{:.1},{},{:.4},{:.4},{:.4},{},{},{:.4},{}",
mv_display,
report.bc_fitted,
report.fitted_bc,
report.bc_input,
report.rms,
report.iterations,
report.converged,
report.sensitivity_ratio,
if report.condition_number.is_finite() {
format!("{:.1}", report.condition_number)
} else {
"inf".to_string()
},
);
}
OutputFormat::Table => {
println!();
println!("=== VELOCITY + BC TRUING (multi-observation) ===");
println!();
println!(" Fitted muzzle velocity: {:>9.1} {}", mv_display, vel_unit);
if report.bc_fitted {
println!(
" Fitted BC: {:>9.4} (input {:.4})",
report.fitted_bc, report.bc_input
);
} else {
println!(
" BC: {:>9.4} (held; not fitted)",
report.fitted_bc
);
}
if let Some(adj) = adj_display {
println!(" Adjustment from chrono: {:>+9.1} {}", adj, vel_unit);
if let Some(pct) = adj_pct {
println!(" Adjustment percentage: {:>+9.2}%", pct);
}
}
println!();
println!(
" {:>10} {:>14} {:>14} {:>12}",
format!("Range ({range_unit})"),
format!("Observed ({drop_label})"),
format!("Predicted ({drop_label})"),
format!("Resid ({drop_label})"),
);
println!(" {}", "-".repeat(56));
for (i, o) in report.observations.iter().enumerate() {
println!(
" {:>10.1} {:>14.3} {:>14.3} {:>+12.3}",
range_display(o.range_yd),
o.drop,
report.predicted[i],
report.residuals[i]
);
}
println!(" {}", "-".repeat(56));
println!(
" RMS residual: {:.3} {} | iterations: {}{}",
report.rms,
drop_label,
report.iterations,
if report.converged {
""
} else {
" (not fully converged)"
}
);
println!();
println!(" {}", report.quality);
if !report.reason.is_empty() {
println!(" Note: {}", report.reason);
}
println!(
" Diagnostics: BC sensitivity ratio {:.4}, conditioning {}",
report.sensitivity_ratio,
if report.condition_number.is_finite() {
format!("{:.0}", report.condition_number)
} else {
"inf".to_string()
}
);
match mv_window {
Some((lo_m, hi_m)) => {
let lo = UnitConverter::distance_from_metric(lo_m, units);
let hi = UnitConverter::distance_from_metric(hi_m, units);
println!(
" MV-calibration window: {lo:.1}-{hi:.1} {range_unit} (90-100% of the Mach 1.2 distance)"
);
}
None if report.muzzle_mach >= 1.2 => {
let max_display =
UnitConverter::distance_from_metric(report.window_solved_range_m, units);
println!(
" note: no MV window: trajectory is supersonic through {max_display:.1} \
{range_unit}; MV is identifiable at any range"
);
}
None => {
println!(
" note: no MV window: trajectory never reaches Mach 1.2; calibrate \
muzzle velocity with a chronograph, then collect DSF points"
);
}
}
println!(" for optimal observation ranges run: ballistics plan-truing");
println!();
}
OutputFormat::Pdf => {
eprintln!("Error: PDF output is not supported for velocity truing results.");
eprintln!("Hint: Use --output json, --output csv, or --output table instead.");
}
}
}
fn enum_variant_code(value: &impl std::fmt::Debug) -> String {
let name = format!("{value:?}");
let mut code = String::with_capacity(name.len() + 4);
for (index, ch) in name.chars().enumerate() {
if ch.is_ascii_uppercase() && index > 0 {
code.push('_');
}
code.push(ch.to_ascii_lowercase());
}
code
}
fn display_truing_experiment_plan(
plan: &TruingExperimentPlanV1,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let range_scale = match units {
UnitSystem::Imperial => 1.0,
UnitSystem::Metric => 0.9144,
};
let range_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
let range = |yards: f64| yards * range_scale;
let mode = match plan.mode {
TruingPlanModeV1::JointMvBc => "joint_mv_bc",
TruingPlanModeV1::MvOnly => "mv_only",
};
match output {
OutputFormat::Json => {
let selected: Vec<serde_json::Value> = plan
.selected_stations
.iter()
.map(|station| {
serde_json::json!({
"input_index": station.input_index,
format!("range_{range_unit}"): range(station.range_yd),
format!("predicted_drop_{}", plan.measurement_drop_unit): station.predicted_drop,
"scaled_mv_sensitivity": station.scaled_mv_sensitivity,
"scaled_bc_sensitivity": station.scaled_bc_sensitivity,
"leave_one_out_information_gain_nats": station.leave_one_out_information_gain_nats,
})
})
.collect();
let rejected: Vec<serde_json::Value> = plan
.rejected_candidates
.iter()
.map(|candidate| {
serde_json::json!({
"input_index": candidate.input_index,
format!("range_{range_unit}"): if candidate.range_yd.is_finite() {
serde_json::json!(range(candidate.range_yd))
} else {
serde_json::Value::Null
},
"reason": enum_variant_code(&candidate.reason),
"detail": candidate.detail,
})
})
.collect();
let info = &plan.information;
let value = serde_json::json!({
"schema_version": "truing-experiment-plan-v1",
"mode": mode,
"requested_observation_count": plan.requested_observation_count,
format!("minimum_separation_{range_unit}"): range(plan.minimum_separation_yd),
"measurement_model": {
"sigma_1sd": plan.measurement_sigma_1sd,
"drop_unit": plan.measurement_drop_unit,
"assumption": "independent Gaussian impact-reading error at every station",
},
"selected_stations": selected,
format!("unselected_candidate_ranges_{range_unit}"): plan
.unselected_candidate_ranges_yd
.iter()
.map(|value| range(*value))
.collect::<Vec<_>>(),
"rejected_candidates": rejected,
"information": {
"bc_sensitivity_ratio": info.sensitivity_ratio,
"condition_number": info.condition_number,
"minimum_singular_value": info.minimum_singular_value,
"maximum_singular_value": info.maximum_singular_value,
"weak_axis_fractional_sigma_1sd": info.weak_axis_fractional_sigma_1sd,
"log_determinant": info.log_determinant,
"expected_information_gain_nats": info.expected_information_gain_nats,
},
"optimizer": {
"search_strategy": enum_variant_code(&plan.search_strategy),
"eligible_candidate_count": plan.eligible_candidate_count,
"raw_combination_count": plan.raw_combination_count,
"evaluated_design_count": plan.evaluated_design_count,
},
"warnings": plan.warnings,
"legend": {
"range_unit": range_unit,
"drop_unit": plan.measurement_drop_unit,
"scaled_sensitivity": "predicted drop change in declared observation sigmas for a unit fractional parameter change",
"information_gain": "0.5 log det(I + F), where I is the disclosed identity reference information in fractional MV/BC coordinates; design score only",
},
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
OutputFormat::Csv => {
println!("plan_mode,measurement_sigma_1sd,measurement_drop_unit,requested_observation_count,minimum_separation_{range_unit},bc_sensitivity_ratio,condition_number,minimum_singular_value,maximum_singular_value,weak_axis_fractional_sigma_1sd,log_determinant,expected_information_gain_nats");
println!(
"{mode},{:.8},{},{},{:.8},{:.10},{},{:.10},{:.10},{},{},{:.10}",
plan.measurement_sigma_1sd,
plan.measurement_drop_unit,
plan.requested_observation_count,
range(plan.minimum_separation_yd),
plan.information.sensitivity_ratio,
plan.information
.condition_number
.map(|value| format!("{value:.10}"))
.unwrap_or_default(),
plan.information.minimum_singular_value,
plan.information.maximum_singular_value,
plan.information
.weak_axis_fractional_sigma_1sd
.map(|value| format!("{value:.10}"))
.unwrap_or_default(),
plan.information
.log_determinant
.map(|value| format!("{value:.10}"))
.unwrap_or_default(),
plan.information.expected_information_gain_nats,
);
println!();
println!("range_{range_unit},status,predicted_drop_{},scaled_mv_sensitivity,scaled_bc_sensitivity,information_gain_nats,detail", plan.measurement_drop_unit);
for station in &plan.selected_stations {
println!(
"{:.6},selected,{:.8},{:.8},{:.8},{:.8},",
range(station.range_yd),
station.predicted_drop,
station.scaled_mv_sensitivity,
station.scaled_bc_sensitivity,
station.leave_one_out_information_gain_nats,
);
}
for candidate in &plan.unselected_candidate_ranges_yd {
println!("{:.6},eligible_not_selected,,,,,", range(*candidate));
}
for candidate in &plan.rejected_candidates {
let display_range = if candidate.range_yd.is_finite() {
range(candidate.range_yd).to_string()
} else {
String::new()
};
println!(
"{},rejected:{},,,,,{}",
display_range,
enum_variant_code(&candidate.reason),
candidate.detail.replace(',', ";")
);
}
if !plan.warnings.is_empty() {
println!();
println!("warning");
for warning in &plan.warnings {
println!("{}", warning.replace(',', ";").replace('\n', " "));
}
}
}
OutputFormat::Table => {
println!();
println!("=== TRUING EXPERIMENT PLAN ===");
println!(
" Recommendation: {}",
if plan.mode == TruingPlanModeV1::JointMvBc {
"joint MV + BC"
} else {
"MV only (available ranges do not identify BC)"
}
);
println!(
" Measurement assumption: independent 1-sigma reading error = {:.4} {}",
plan.measurement_sigma_1sd, plan.measurement_drop_unit
);
println!(
" Selected ranges: {} {range_unit}",
plan.selected_stations
.iter()
.map(|station| format!("{:.1}", range(station.range_yd)))
.collect::<Vec<_>>()
.join(", ")
);
println!(
" Constraints: {} stations, minimum separation {:.1} {range_unit}",
plan.requested_observation_count,
range(plan.minimum_separation_yd)
);
println!();
println!(
" BC sensitivity ratio: {:.5}",
plan.information.sensitivity_ratio
);
println!(
" Condition number: {}",
plan.information
.condition_number
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "unbounded".to_string())
);
println!(
" Singular values: min {:.5}, max {:.5}; weak-axis fractional 1-sigma: {}",
plan.information.minimum_singular_value,
plan.information.maximum_singular_value,
plan.information
.weak_axis_fractional_sigma_1sd
.map(|value| format!("{value:.4}"))
.unwrap_or_else(|| "unbounded".to_string())
);
println!();
println!(
" {:>10} {:>12} {:>13} {:>13} {:>12}",
format!("Range ({range_unit})"),
format!("Drop ({})", plan.measurement_drop_unit),
"MV sensitivity",
"BC sensitivity",
"Info (nats)",
);
for station in &plan.selected_stations {
println!(
" {:>10.1} {:>12.4} {:>13.5} {:>13.5} {:>12.5}",
range(station.range_yd),
station.predicted_drop,
station.scaled_mv_sensitivity,
station.scaled_bc_sensitivity,
station.leave_one_out_information_gain_nats,
);
}
println!();
println!(
" Search: {} ({} of {} candidate sets evaluated)",
enum_variant_code(&plan.search_strategy),
plan.evaluated_design_count,
plan.raw_combination_count,
);
if !plan.rejected_candidates.is_empty() {
println!(" Rejected candidates:");
for candidate in &plan.rejected_candidates {
let display_range = if candidate.range_yd.is_finite() {
format!("{:.1} {range_unit}", range(candidate.range_yd))
} else {
"non-finite range".to_string()
};
println!(
" - {display_range}: {} ({})",
enum_variant_code(&candidate.reason),
candidate.detail
);
}
}
if !plan.warnings.is_empty() {
println!(" Notes:");
for warning in &plan.warnings {
println!(" - {warning}");
}
}
println!();
}
OutputFormat::Pdf => {
return Err("PDF output is not supported for truing experiment plans".into());
}
}
Ok(())
}
fn display_uncertainty_truing_result(
report: &UncertaintyTruingReportV1,
units: UnitSystem,
chrono_fps: Option<f64>,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let velocity_scale = match units {
UnitSystem::Imperial => 1.0,
UnitSystem::Metric => 0.3048,
};
let velocity_unit = match units {
UnitSystem::Imperial => "fps",
UnitSystem::Metric => "m/s",
};
let range_scale = match units {
UnitSystem::Imperial => 1.0,
UnitSystem::Metric => 0.9144,
};
let range_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
let drop_unit = report.drop_unit.label();
let velocity = |fps: f64| fps * velocity_scale;
let range = |yards: f64| yards * range_scale;
match output {
OutputFormat::Json => {
let observations: Vec<serde_json::Value> = report
.observations
.iter()
.map(|observation| {
serde_json::json!({
format!("range_{range_unit}"): range(observation.range_yd),
format!("observed_drop_{drop_unit}"): observation.observed_drop,
format!("sigma_1sd_{drop_unit}"): observation.sigma,
format!("predicted_drop_{drop_unit}"): observation.predicted_drop,
format!("residual_{drop_unit}"): observation.residual,
"standardized_residual": observation.standardized_residual,
})
})
.collect();
let priors = serde_json::json!({
"muzzle_velocity": report.priors.muzzle_velocity_fps.map(|prior| serde_json::json!({
"mean": velocity(prior.mean),
"sigma_1sd": velocity(prior.sigma),
"unit": velocity_unit,
})),
"ballistic_coefficient": report.priors.ballistic_coefficient.map(|prior| serde_json::json!({
"mean": prior.mean,
"sigma_1sd": prior.sigma,
})),
});
let approximation = match &report.approximation {
TruingApproximationV1::Available(approximation) => {
let mv_interval = approximation.muzzle_velocity_interval_95;
let bc_interval = approximation.ballistic_coefficient_interval_95;
serde_json::json!({
"available": true,
"method": "laplace_gauss_newton_known_sigma",
"interval_probability": mv_interval.probability,
"muzzle_velocity": {
"estimate": velocity(mv_interval.estimate),
"standard_deviation": velocity(mv_interval.standard_deviation),
"lower": velocity(mv_interval.lower),
"upper": velocity(mv_interval.upper),
"unit": velocity_unit,
},
"ballistic_coefficient": {
"estimate": bc_interval.estimate,
"standard_deviation": bc_interval.standard_deviation,
"lower": bc_interval.lower,
"upper": bc_interval.upper,
},
"covariance": {
format!("mv_variance_{velocity_unit}2"): approximation.covariance.mv_variance_fps2 * velocity_scale * velocity_scale,
format!("mv_bc_covariance_{velocity_unit}"): approximation.covariance.mv_bc_covariance_fps * velocity_scale,
"bc_variance": approximation.covariance.bc_variance,
},
"mv_bc_correlation": approximation.mv_bc_correlation,
"scaled_information_condition_number": approximation.scaled_information_condition_number,
})
}
TruingApproximationV1::Unavailable(failure) => serde_json::json!({
"available": false,
"method": "laplace_gauss_newton_known_sigma",
"failure": {
"code": enum_variant_code(&failure.code),
"message": failure.message,
},
}),
};
let predictive_bands: Vec<serde_json::Value> = report
.predictive_bands
.iter()
.map(|band| {
let interval = |value: ballistics_engine::truing_uncertainty::GaussianIntervalV1| {
serde_json::json!({
"standard_deviation": value.standard_deviation,
"lower": value.lower,
"upper": value.upper,
"probability": value.probability,
})
};
serde_json::json!({
format!("range_{range_unit}"): range(band.range_yd),
format!("predicted_drop_{drop_unit}"): band.predicted_drop,
"latent_model_drop_interval": band.latent_interval_95.map(interval),
"future_observation_interval": band.future_observation_interval_95.map(interval),
})
})
.collect();
let warnings: Vec<serde_json::Value> = report
.warnings
.iter()
.map(|warning| {
serde_json::json!({
"code": enum_variant_code(&warning.code),
"message": warning.message,
})
})
.collect();
let diagnostics = &report.diagnostics;
let mut value = serde_json::json!({
"schema_version": "truing-uncertainty-v1",
"mode": "joint_mv_bc_map",
"fitted_muzzle_velocity": velocity(report.map_muzzle_velocity_fps),
"velocity_unit": velocity_unit,
"fitted_bc": report.map_ballistic_coefficient,
"iterations": report.iterations,
"converged": report.converged,
"observations": observations,
"priors": priors,
"diagnostics": {
"chi_square": diagnostics.chi_square,
"prior_penalty": diagnostics.prior_penalty,
"penalized_chi_square": diagnostics.penalized_chi_square,
"effective_parameter_count": diagnostics.effective_parameter_count,
"effective_degrees_of_freedom": diagnostics.effective_degrees_of_freedom,
"reduced_chi_square": diagnostics.reduced_chi_square,
"bc_sensitivity_ratio": diagnostics.bc_sensitivity_ratio,
"data_condition_number": if diagnostics.data_condition_number.is_finite() {
serde_json::json!(diagnostics.data_condition_number)
} else {
serde_json::Value::Null
},
"map_scaled_gradient_inf_norm": diagnostics.map_scaled_gradient_inf_norm,
"map_convergence_criterion": diagnostics
.map_convergence_criterion
.map(|criterion| enum_variant_code(&criterion)),
"map_objective_poll_radius": diagnostics.map_objective_poll_radius,
"map_max_objective_poll_improvement": diagnostics
.map_max_objective_poll_improvement,
"map_objective_poll_evaluations": diagnostics
.map_objective_poll_evaluations,
},
"approximation": approximation,
"predictive_bands": predictive_bands,
"warnings": warnings,
"legend": {
"units": {
"range": range_unit,
"drop": drop_unit,
"velocity": velocity_unit,
},
"observation_sigma": "absolute known one-standard-deviation reading error",
"latent_model_drop_interval": "parameter uncertainty only",
"future_observation_interval": "parameter uncertainty plus explicitly declared future reading error",
},
});
if let Some(chrono) = chrono_fps {
value["velocity_adjustment"] =
serde_json::json!(velocity(report.map_muzzle_velocity_fps - chrono));
value["adjustment_percent"] = if chrono != 0.0 {
serde_json::json!((report.map_muzzle_velocity_fps - chrono) / chrono * 100.0)
} else {
serde_json::Value::Null
};
}
println!("{}", serde_json::to_string_pretty(&value)?);
}
OutputFormat::Csv => {
let convergence_criterion = report
.diagnostics
.map_convergence_criterion
.map(|criterion| enum_variant_code(&criterion))
.unwrap_or_default();
println!("range_{range_unit},observed_drop_{drop_unit},sigma_1sd_{drop_unit},predicted_drop_{drop_unit},residual_{drop_unit},standardized_residual");
for observation in &report.observations {
println!(
"{:.6},{:.8},{:.8},{:.8},{:.8},{:.8}",
range(observation.range_yd),
observation.observed_drop,
observation.sigma,
observation.predicted_drop,
observation.residual,
observation.standardized_residual,
);
}
println!();
println!("fitted_muzzle_velocity_{velocity_unit},fitted_bc,chi_square,prior_penalty,penalized_chi_square,effective_parameter_count,effective_degrees_of_freedom,reduced_chi_square,bc_sensitivity_ratio,data_condition_number,converged,map_convergence_criterion,map_scaled_gradient_inf_norm,map_objective_poll_radius,map_max_objective_poll_improvement,map_objective_poll_evaluations");
println!(
"{:.8},{:.10},{:.8},{:.8},{:.8},{},{},{},{:.8},{},{},{},{:.10},{},{},{}",
velocity(report.map_muzzle_velocity_fps),
report.map_ballistic_coefficient,
report.diagnostics.chi_square,
report.diagnostics.prior_penalty,
report.diagnostics.penalized_chi_square,
report
.diagnostics
.effective_parameter_count
.map(|value| format!("{value:.8}"))
.unwrap_or_default(),
report
.diagnostics
.effective_degrees_of_freedom
.map(|value| format!("{value:.8}"))
.unwrap_or_default(),
report
.diagnostics
.reduced_chi_square
.map(|value| format!("{value:.8}"))
.unwrap_or_default(),
report.diagnostics.bc_sensitivity_ratio,
if report.diagnostics.data_condition_number.is_finite() {
format!("{:.8}", report.diagnostics.data_condition_number)
} else {
"inf".to_string()
},
report.converged,
convergence_criterion,
report.diagnostics.map_scaled_gradient_inf_norm,
report
.diagnostics
.map_objective_poll_radius
.map(|value| format!("{value:.10}"))
.unwrap_or_default(),
report
.diagnostics
.map_max_objective_poll_improvement
.map(|value| format!("{value:.10}"))
.unwrap_or_default(),
report.diagnostics.map_objective_poll_evaluations,
);
println!();
println!("prior_parameter,mean,sigma_1sd,unit");
if let Some(prior) = report.priors.muzzle_velocity_fps {
println!(
"muzzle_velocity,{:.8},{:.8},{velocity_unit}",
velocity(prior.mean),
velocity(prior.sigma)
);
}
if let Some(prior) = report.priors.ballistic_coefficient {
println!(
"ballistic_coefficient,{:.10},{:.10},dimensionless",
prior.mean, prior.sigma
);
}
println!();
match &report.approximation {
TruingApproximationV1::Available(approximation) => {
println!("parameter,estimate,standard_deviation,interval_95_lower,interval_95_upper,interval_probability,unit");
let mv = approximation.muzzle_velocity_interval_95;
let bc = approximation.ballistic_coefficient_interval_95;
println!(
"muzzle_velocity,{:.8},{:.8},{:.8},{:.8},{:.6},{velocity_unit}",
velocity(mv.estimate),
velocity(mv.standard_deviation),
velocity(mv.lower),
velocity(mv.upper),
mv.probability,
);
println!(
"ballistic_coefficient,{:.10},{:.10},{:.10},{:.10},{:.6},dimensionless",
bc.estimate, bc.standard_deviation, bc.lower, bc.upper, bc.probability,
);
println!();
println!("covariance_component,value");
println!(
"mv_variance_{velocity_unit}2,{:.12}",
approximation.covariance.mv_variance_fps2 * velocity_scale * velocity_scale
);
println!(
"mv_bc_covariance_{velocity_unit},{:.12}",
approximation.covariance.mv_bc_covariance_fps * velocity_scale
);
println!("bc_variance,{:.12}", approximation.covariance.bc_variance);
println!("mv_bc_correlation,{:.12}", approximation.mv_bc_correlation);
println!(
"scaled_information_condition_number,{:.12}",
approximation.scaled_information_condition_number
);
}
TruingApproximationV1::Unavailable(failure) => {
println!("approximation_failure_code,message");
println!(
"{},{}",
enum_variant_code(&failure.code),
failure.message.replace(',', ";").replace('\n', " ")
);
}
}
if !report.predictive_bands.is_empty() {
println!();
println!("prediction_range_{range_unit},predicted_drop_{drop_unit},latent_lower_{drop_unit},latent_upper_{drop_unit},future_lower_{drop_unit},future_upper_{drop_unit}");
for band in &report.predictive_bands {
println!(
"{:.6},{:.8},{},{},{},{}",
range(band.range_yd),
band.predicted_drop,
band.latent_interval_95
.map(|interval| format!("{:.8}", interval.lower))
.unwrap_or_default(),
band.latent_interval_95
.map(|interval| format!("{:.8}", interval.upper))
.unwrap_or_default(),
band.future_observation_interval_95
.map(|interval| format!("{:.8}", interval.lower))
.unwrap_or_default(),
band.future_observation_interval_95
.map(|interval| format!("{:.8}", interval.upper))
.unwrap_or_default(),
);
}
}
if !report.warnings.is_empty() {
println!();
println!("warning_code,message");
for warning in &report.warnings {
println!(
"{},{}",
enum_variant_code(&warning.code),
warning.message.replace(',', ";").replace('\n', " ")
);
}
}
}
OutputFormat::Table => {
println!();
println!("=== UNCERTAINTY-AWARE MV + BC TRUING ===");
println!(" Method: weighted joint MAP + local Gaussian approximation");
println!(" Observation errors: absolute known 1-sigma ({drop_unit})");
println!();
println!(
" MAP muzzle velocity: {:>10.2} {velocity_unit}",
velocity(report.map_muzzle_velocity_fps)
);
println!(
" MAP ballistic coefficient: {:>8.5}",
report.map_ballistic_coefficient
);
match &report.approximation {
TruingApproximationV1::Available(approximation) => {
let mv = approximation.muzzle_velocity_interval_95;
let bc = approximation.ballistic_coefficient_interval_95;
println!(
" MV 95% interval: [{:.2}, {:.2}] {velocity_unit} (SD {:.2})",
velocity(mv.lower),
velocity(mv.upper),
velocity(mv.standard_deviation)
);
println!(
" BC 95% interval: [{:.5}, {:.5}] (SD {:.5})",
bc.lower, bc.upper, bc.standard_deviation
);
println!(
" MV/BC correlation: {:+.5}",
approximation.mv_bc_correlation
);
}
TruingApproximationV1::Unavailable(failure) => {
println!(
" Gaussian approximation: unavailable ({})",
enum_variant_code(&failure.code)
);
println!(" {}", failure.message);
}
}
println!();
println!(
" {:>10} {:>12} {:>9} {:>12} {:>10}",
format!("Range ({range_unit})"),
format!("Observed ({drop_unit})"),
"Sigma",
format!("Predicted ({drop_unit})"),
"Std resid",
);
for observation in &report.observations {
println!(
" {:>10.1} {:>12.4} {:>9.4} {:>12.4} {:>+10.3}",
range(observation.range_yd),
observation.observed_drop,
observation.sigma,
observation.predicted_drop,
observation.standardized_residual,
);
}
println!();
println!(
" Chi-square: {:.3}; effective DOF: {}; reduced chi-square: {}",
report.diagnostics.chi_square,
report
.diagnostics
.effective_degrees_of_freedom
.map(|value| format!("{value:.3}"))
.unwrap_or_else(|| "unavailable".to_string()),
report
.diagnostics
.reduced_chi_square
.map(|value| format!("{value:.3}"))
.unwrap_or_else(|| "unavailable".to_string()),
);
if !report.predictive_bands.is_empty() {
println!();
println!(" Predictive drop bands (95%):");
for band in &report.predictive_bands {
let latent = band
.latent_interval_95
.map(|interval| format!("[{:.4}, {:.4}]", interval.lower, interval.upper))
.unwrap_or_else(|| "unavailable".to_string());
let future = band
.future_observation_interval_95
.map(|interval| {
format!("; future [{:.4}, {:.4}]", interval.lower, interval.upper)
})
.unwrap_or_default();
println!(
" {:>8.1} {range_unit}: mean {:.4} {drop_unit}; model {latent}{future}",
range(band.range_yd),
band.predicted_drop,
);
}
}
if !report.warnings.is_empty() {
println!();
println!(" Warnings:");
for warning in &report.warnings {
println!(
" - {}: {}",
enum_variant_code(&warning.code),
warning.message
);
}
}
println!();
}
OutputFormat::Pdf => {
return Err("PDF output is not supported for uncertainty-aware truing".into());
}
}
Ok(())
}
fn zero_poi_display_to_metric(value: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Imperial => value * 0.0254, UnitSystem::Metric => value * 0.01, }
}
fn sight_offset_display_to_metric(value: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Imperial => value * 0.0254, UnitSystem::Metric => value * 0.001, }
}
fn resolve_param(
cli_val: Option<f64>,
profile: &Option<ProfileData>,
getter: fn(&ProfileData) -> f64,
) -> Option<f64> {
cli_val.or_else(|| profile.as_ref().map(getter))
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable MPBR CLI command shape"
)]
fn handle_mpbr(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
vital_zone: f64, sight_height: f64, zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let velocity_m = UnitConverter::velocity_to_metric(velocity, units);
let mass_kg = UnitConverter::mass_to_metric(mass, units);
let diameter_m = UnitConverter::diameter_to_metric(diameter, units);
let sight_height_m = UnitConverter::sight_height_to_metric(sight_height, units);
let temperature_c = UnitConverter::temperature_to_metric(temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(altitude, units);
let vital_zone_m = match units {
UnitSystem::Imperial => vital_zone * 0.0254, UnitSystem::Metric => vital_zone * 0.01, };
let half_vital_m = vital_zone_m / 2.0;
let mut zero_low_m = UnitConverter::distance_to_metric(50.0, units);
let mut zero_high_m = UnitConverter::distance_to_metric(2000.0, units);
let tolerance_m = 0.001; let max_iter = 60;
let mut best_zero_m = 0.0;
let mut best_max_ord_m = 0.0;
let mut best_max_ord_dist_m = 0.0;
for _ in 0..max_iter {
let test_zero_m = (zero_low_m + zero_high_m) / 2.0;
let drag_model_enum = drag_model;
let zero_inputs = BallisticInputs {
bc_value: bc,
bc_type: drag_model_enum,
bullet_mass: mass_kg,
muzzle_velocity: velocity_m,
bullet_diameter: diameter_m,
bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
sight_height: sight_height_m,
use_rk4: true,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity,
altitude: altitude_m,
};
let zero_angle = match ballistics_engine::calculate_zero_angle_with_conditions(
zero_inputs,
test_zero_m,
sight_height_m,
WindConditions::default(),
atmosphere,
) {
Ok(a) => a,
Err(_) => {
zero_high_m = test_zero_m;
continue;
}
};
let samples = match run_sampled_trajectory(
velocity_m,
bc,
mass_kg,
diameter_m,
drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
0.0,
0.0,
test_zero_m * 1.5, UnitConverter::distance_to_metric(1.0, UnitSystem::Imperial), zero_angle,
None,
None,
None,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(test_zero_m),
) {
Ok(s) => s,
Err(_) => {
zero_high_m = test_zero_m;
continue;
}
};
let mut max_ord_m: f64 = 0.0;
let mut max_ord_dist_m: f64 = 0.0;
for s in &samples {
let height_above_los = -s.drop_m; if height_above_los > max_ord_m {
max_ord_m = height_above_los;
max_ord_dist_m = s.distance_m;
}
}
if max_ord_m < 0.001 {
for s in &samples {
if s.drop_m > max_ord_m {
max_ord_m = s.drop_m;
max_ord_dist_m = s.distance_m;
}
}
}
best_zero_m = test_zero_m;
best_max_ord_m = max_ord_m;
best_max_ord_dist_m = max_ord_dist_m;
let diff = max_ord_m - half_vital_m;
if diff.abs() < tolerance_m {
break;
}
if max_ord_m > half_vital_m {
zero_high_m = test_zero_m;
} else {
zero_low_m = test_zero_m;
}
}
let drag_model_enum = drag_model;
let final_inputs = BallisticInputs {
bc_value: bc,
bc_type: drag_model_enum,
bullet_mass: mass_kg,
muzzle_velocity: velocity_m,
bullet_diameter: diameter_m,
bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
sight_height: sight_height_m,
use_rk4: true,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity,
altitude: altitude_m,
};
let final_zero_angle = ballistics_engine::calculate_zero_angle_with_conditions(
final_inputs,
best_zero_m,
sight_height_m,
WindConditions::default(),
atmosphere,
)?;
let final_samples = run_sampled_trajectory(
velocity_m,
bc,
mass_kg,
diameter_m,
drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
0.0,
0.0,
best_zero_m * 2.0,
UnitConverter::distance_to_metric(1.0, UnitSystem::Imperial),
final_zero_angle,
None,
None,
None,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(best_zero_m),
)?;
let mut near_zero_m: f64 = 0.0;
let mut far_zero_m: f64 = 0.0;
let mut mpbr_m: f64 = 0.0;
let mut impact_vel_mps: f64 = 0.0;
let mut impact_energy_j: f64 = 0.0;
let mut found_near = false;
let mut found_far = false;
let sign_flip = if final_samples.len() > 10 {
let mid = &final_samples[final_samples.len() / 3];
mid.drop_m < 0.0 } else {
false
};
for i in 1..final_samples.len() {
let prev_drop = if sign_flip {
-final_samples[i - 1].drop_m
} else {
final_samples[i - 1].drop_m
};
let curr_drop = if sign_flip {
-final_samples[i].drop_m
} else {
final_samples[i].drop_m
};
if !found_near && prev_drop <= 0.0 && curr_drop > 0.0 && final_samples[i].distance_m > 5.0 {
let frac = (-prev_drop) / (curr_drop - prev_drop);
near_zero_m = final_samples[i - 1].distance_m
+ frac * (final_samples[i].distance_m - final_samples[i - 1].distance_m);
found_near = true;
}
if found_near && !found_far && prev_drop > 0.0 && curr_drop <= 0.0 {
let frac = prev_drop / (prev_drop - curr_drop);
far_zero_m = final_samples[i - 1].distance_m
+ frac * (final_samples[i].distance_m - final_samples[i - 1].distance_m);
found_far = true;
}
if found_far && curr_drop < -half_vital_m && mpbr_m == 0.0 {
let frac = (-half_vital_m - prev_drop) / (curr_drop - prev_drop);
mpbr_m = final_samples[i - 1].distance_m
+ frac * (final_samples[i].distance_m - final_samples[i - 1].distance_m);
impact_vel_mps = final_samples[i].velocity_mps;
impact_energy_j = final_samples[i].energy_j;
}
}
let mpbr_display = UnitConverter::distance_from_metric(mpbr_m, units);
let optimal_zero_display = UnitConverter::distance_from_metric(best_zero_m, units);
let near_zero_display = UnitConverter::distance_from_metric(near_zero_m, units);
let far_zero_display = UnitConverter::distance_from_metric(far_zero_m, units);
let max_ord_display = match units {
UnitSystem::Imperial => best_max_ord_m / 0.0254, UnitSystem::Metric => best_max_ord_m * 100.0, };
let max_ord_dist_display = UnitConverter::distance_from_metric(best_max_ord_dist_m, units);
let impact_vel_display = UnitConverter::velocity_from_metric(impact_vel_mps, units);
let impact_energy_display = UnitConverter::energy_from_metric(impact_energy_j, units);
let (dist_unit, vel_unit, energy_unit, size_unit) = match units {
UnitSystem::Imperial => ("yd", "fps", "ft-lb", "in"),
UnitSystem::Metric => ("m", "m/s", "J", "cm"),
};
let vital_zone_display = match units {
UnitSystem::Imperial => vital_zone, UnitSystem::Metric => vital_zone, };
match output {
OutputFormat::Json => {
let json = serde_json::json!({
"mpbr": mpbr_display,
"mpbr_unit": dist_unit,
"optimal_zero": optimal_zero_display,
"near_zero": near_zero_display,
"far_zero": far_zero_display,
"max_ordinate": max_ord_display,
"max_ordinate_unit": size_unit,
"max_ordinate_distance": max_ord_dist_display,
"impact_velocity": impact_vel_display,
"impact_velocity_unit": vel_unit,
"impact_energy": impact_energy_display,
"impact_energy_unit": energy_unit,
"vital_zone": vital_zone_display,
"vital_zone_unit": size_unit,
});
println!("{}", serde_json::to_string_pretty(&json)?);
}
OutputFormat::Csv => {
println!("metric,value,unit");
println!("mpbr,{:.1},{}", mpbr_display, dist_unit);
println!("optimal_zero,{:.1},{}", optimal_zero_display, dist_unit);
println!("near_zero,{:.1},{}", near_zero_display, dist_unit);
println!("far_zero,{:.1},{}", far_zero_display, dist_unit);
println!("max_ordinate,{:.1},{}", max_ord_display, size_unit);
println!(
"max_ordinate_distance,{:.1},{}",
max_ord_dist_display, dist_unit
);
println!("impact_velocity,{:.0},{}", impact_vel_display, vel_unit);
println!("impact_energy,{:.0},{}", impact_energy_display, energy_unit);
}
OutputFormat::Table | OutputFormat::Pdf => {
println!();
println!(
"MPBR Analysis (vital zone: {:.1} {})",
vital_zone_display, size_unit
);
println!("╔════════════════════════════════════════╗");
println!(
"║ MPBR: {:>6.0} {:<14}║",
mpbr_display, dist_unit
);
println!(
"║ Optimal zero: {:>6.0} {:<14}║",
optimal_zero_display, dist_unit
);
println!(
"║ Near zero: {:>6.0} {:<14}║",
near_zero_display, dist_unit
);
println!(
"║ Far zero: {:>6.0} {:<14}║",
far_zero_display, dist_unit
);
println!(
"║ Max ordinate: {:>6.1} {} at {:.0} {} ║",
max_ord_display, size_unit, max_ord_dist_display, dist_unit
);
println!(
"║ Impact velocity: {:>6.0} {:<14}║",
impact_vel_display, vel_unit
);
println!(
"║ Impact energy: {:>6.0} {:<14}║",
impact_energy_display, energy_unit
);
println!("╚════════════════════════════════════════╝");
}
}
Ok(())
}
fn card_row_sample(
samples: &[trajectory_sampling::TrajectorySample],
range_display: f64,
units: UnitSystem,
) -> Option<trajectory_sampling::TrajectorySample> {
sample_at_range(
samples,
UnitConverter::distance_to_metric(range_display, units),
)
}
fn card_distance_unit(units: UnitSystem) -> &'static str {
match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
}
}
fn card_truncation(
requested_end: f64,
last_row: f64,
reach_m: f64,
units: UnitSystem,
) -> CardTruncation {
CardTruncation {
requested_end,
last_row,
reach: UnitConverter::distance_from_metric(reach_m, units),
}
}
fn card_no_rows_error(first_row: f64, reach_m: f64, units: UnitSystem) -> Box<dyn Error> {
Box::<dyn Error>::from(range_not_sampled_message(
first_row,
UnitConverter::distance_from_metric(reach_m, units),
card_distance_unit(units),
))
}
fn warn_card_truncated(truncation: &CardTruncation, units: UnitSystem) {
eprintln!("warning: {}", truncation.message(card_distance_unit(units)));
}
fn card_truncation_json(truncation: &CardTruncation, units: UnitSystem) -> serde_json::Value {
serde_json::json!({
"requested_end": truncation.requested_end,
"last_row": truncation.last_row,
"reach": truncation.reach,
"message": truncation.message(card_distance_unit(units)),
})
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable come-ups CLI command shape"
)]
fn handle_come_ups(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
zero_distance: f64,
start: f64,
end: f64,
step: f64,
adjustment_unit: AdjustmentUnit,
elevation_click: Option<ClickValue>,
elevation_cf: f64,
zero_set_elevation_bias_mil: f64,
sight_height: f64,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
wind_speed: f64,
wind_direction: f64,
units: UnitSystem,
output: OutputFormat,
bc_segments_data: Option<Vec<BCSegmentData>>,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
dsf_table: Option<DsfTable>,
) -> Result<(), Box<dyn Error>> {
let velocity_m = UnitConverter::velocity_to_metric(velocity, units);
let mass_kg = UnitConverter::mass_to_metric(mass, units);
let diameter_m = UnitConverter::diameter_to_metric(diameter, units);
let sight_height_m = UnitConverter::sight_height_to_metric(sight_height, units);
let zero_distance_m = UnitConverter::distance_to_metric(zero_distance, units);
let temperature_c = UnitConverter::temperature_to_metric(temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(altitude, units);
let wind_speed_m = UnitConverter::wind_to_metric(wind_speed, units);
let end_m = UnitConverter::distance_to_metric(end, units);
let drag_model_enum = drag_model;
let zero_inputs = BallisticInputs {
bc_value: bc,
bc_type: drag_model_enum,
bullet_mass: mass_kg,
muzzle_velocity: velocity_m,
bullet_diameter: diameter_m,
bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
sight_height: sight_height_m,
use_rk4: true,
use_bc_segments: bc_segments_data.is_some(),
bc_segments_data: bc_segments_data.clone(),
custom_drag_table: custom_drag_table.clone(),
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity,
altitude: altitude_m,
};
let zero_angle = ballistics_engine::calculate_zero_angle_with_conditions(
zero_inputs,
zero_distance_m,
sight_height_m,
WindConditions::default(),
atmosphere,
)?;
let flight = run_sampled_flight(
velocity_m,
bc,
mass_kg,
diameter_m,
drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
wind_speed_m,
wind_direction, card_sample_max_range_m(end_m),
CARD_SAMPLE_INTERVAL_M,
zero_angle,
bc_segments_data,
custom_drag_table,
dsf_table.as_ref(),
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(zero_distance_m),
)?;
let adj_label = match adjustment_unit {
AdjustmentUnit::Mil => "MIL",
AdjustmentUnit::Moa => "MOA",
AdjustmentUnit::Smoa => "SMOA",
AdjustmentUnit::Iphy => "IPHY",
AdjustmentUnit::Clicks => "CLICKS",
};
let (dist_unit, vel_unit, energy_unit) = match units {
UnitSystem::Imperial => ("yd", "fps", "ft-lb"),
UnitSystem::Metric => ("m", "m/s", "J"),
};
let mut rows: Vec<CardRow> = Vec::new();
let mut truncation: Option<CardTruncation> = None;
let mut current_range = start;
let mut prev_drop_adj: f64 = 0.0;
while current_range <= end + 0.1 {
let Some(sample) = card_row_sample(&flight.samples, current_range, units) else {
let Some(last) = rows.last() else {
return Err(card_no_rows_error(current_range, flight.reach_m, units));
};
truncation = Some(card_truncation(end, last.range, flight.reach_m, units));
break;
};
let drop_yd = UnitConverter::distance_from_metric(sample.drop_m, units);
let drop_adj = adjustment_display(
drop_yd,
current_range,
adjustment_unit,
elevation_click,
zero_set_elevation_bias_mil,
elevation_cf,
)
.value;
let come_up = drop_adj - prev_drop_adj;
rows.push(CardRow {
range: current_range,
drop_linear: None,
drop_adj: Some(drop_adj),
come_up: Some(come_up),
wind_linear: None,
wind_adj: None,
velocity: Some(UnitConverter::velocity_from_metric(
sample.velocity_mps,
units,
)),
energy: Some(UnitConverter::energy_from_metric(sample.energy_j, units)),
time: Some(sample.time_s),
lead_adj: None,
wind_columns: Vec::new(),
});
prev_drop_adj = drop_adj;
current_range += step;
}
match output {
OutputFormat::Json => {
let json_rows: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"range": r.range,
"drop": r.drop_adj,
"come_up": r.come_up,
"velocity": r.velocity,
"energy": r.energy,
"time": r.time,
})
})
.collect();
let mut json = serde_json::json!({
"zero_distance": zero_distance,
"adjustment_unit": adj_label,
"distance_unit": dist_unit,
"velocity_unit": vel_unit,
"energy_unit": energy_unit,
"data": json_rows,
});
if let Some(truncation) = &truncation {
json["truncated"] = card_truncation_json(truncation, units);
}
println!("{}", serde_json::to_string_pretty(&json)?);
}
OutputFormat::Csv => {
println!(
"range_{},drop_{},come_up_{},velocity_{},energy_{},time_s",
dist_unit,
adj_label.to_lowercase(),
adj_label.to_lowercase(),
vel_unit,
energy_unit
);
let is_clicks = matches!(adjustment_unit, AdjustmentUnit::Clicks);
for r in &rows {
if is_clicks {
println!(
"{:.0},{:.0},{:.0},{:.0},{:.0},{:.3}",
r.range,
r.drop_adj.unwrap(),
r.come_up.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap()
);
} else {
println!(
"{:.0},{:.3},{:.3},{:.0},{:.0},{:.3}",
r.range,
r.drop_adj.unwrap(),
r.come_up.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap()
);
}
}
}
OutputFormat::Table | OutputFormat::Pdf => {
println!();
println!(
"Come-Up Table (zero: {:.0} {}, {})",
zero_distance, dist_unit, adj_label
);
let drop_label_w = come_up_drop_label_width(adj_label);
let drop_dashes = "─".repeat(drop_label_w + 7);
let ten = "─".repeat(10);
println!("┌{ten}┬{drop_dashes}┬{ten}┬{ten}┬{ten}┬{ten}┐");
println!("{}", come_up_header_line(dist_unit, adj_label, vel_unit));
println!("├{ten}┼{drop_dashes}┼{ten}┼{ten}┼{ten}┼{ten}┤");
let is_clicks = matches!(adjustment_unit, AdjustmentUnit::Clicks);
let drop_field_w = drop_label_w + 6;
for (i, r) in rows.iter().enumerate() {
println!("{}", come_up_row_line(r, i, is_clicks, drop_field_w));
}
println!("└{ten}┴{drop_dashes}┴{ten}┴{ten}┴{ten}┴{ten}┘");
if let Some(table) = dsf_table.as_ref() {
println!("DSF table active ({})", dsf_table_summary(table.points()));
}
}
}
if let Some(truncation) = &truncation {
warn_card_truncated(truncation, units);
}
Ok(())
}
fn come_up_drop_label_width(adj_label: &str) -> usize {
adj_label.len().max(3)
}
fn come_up_header_line(dist_unit: &str, adj_label: &str, vel_unit: &str) -> String {
let w = come_up_drop_label_width(adj_label);
format!(
"│Range ({:>2})|Drop ({:>w$})|Come-Up │ Vel ({:>3})│Energy │ Time (s) │",
dist_unit,
adj_label,
vel_unit,
w = w
)
}
fn come_up_row_line(r: &CardRow, i: usize, is_clicks: bool, drop_field_w: usize) -> String {
let drop_adj = r.drop_adj.unwrap();
let drop_str = if is_clicks {
format!("{:>drop_field_w$.0}", drop_adj)
} else {
format!("{:>drop_field_w$.3}", drop_adj)
};
let come_up_str = if i == 0 {
" — ".to_string()
} else if is_clicks {
format!("{:>9.0} ", r.come_up.unwrap())
} else {
format!("{:>9.3} ", r.come_up.unwrap())
};
format!(
"│{:>9.0} │{} │{}│{:>9.0} │{:>9.0} │{:>9.3} │",
r.range,
drop_str,
come_up_str,
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap()
)
}
#[allow(clippy::too_many_arguments)]
fn handle_powder(
velocity: Option<f64>,
temperature: Option<f64>,
powder_temp_sensitivity: Option<f64>,
powder_temp: Option<f64>,
powder_temp_curve_si: Option<Vec<(f64, f64)>>,
sweep: Option<String>,
mass: Option<f64>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), String> {
use ballistics_engine::cli_api::parse_powder_sweep;
use ballistics_engine::resolve_powder_adjusted_velocity;
let has_curve = powder_temp_curve_si.as_ref().is_some_and(|c| !c.is_empty());
let curve_ref = powder_temp_curve_si.as_deref();
let sens_display = powder_temp_sensitivity.unwrap_or(match units {
UnitSystem::Imperial => 1.0,
UnitSystem::Metric => 0.3048 / (5.0 / 9.0),
});
let sens_si = UnitConverter::velocity_to_metric(sens_display, units)
/ UnitConverter::temperature_delta_to_metric(1.0, units);
let ref_temp_c = powder_temp
.map(|t| UnitConverter::temperature_to_metric(t, units))
.unwrap_or(DEFAULT_POWDER_REFERENCE_TEMP_C);
let powder_curve_temp_c: Option<f64> =
powder_temp.map(|t| UnitConverter::temperature_to_metric(t, units));
let ambient_c: Option<f64> =
temperature.map(|t| UnitConverter::temperature_to_metric(t, units));
let velocity_si: Option<f64> = velocity.map(|v| UnitConverter::velocity_to_metric(v, units));
let mass_kg: Option<f64> = mass.map(|m| UnitConverter::mass_to_metric(m, units));
if !has_curve && velocity.is_none() {
return Err(
"--velocity is required for the linear model (or supply --powder-temp-curve)"
.to_string(),
);
}
let sweep_temps: Option<Vec<f64>> = match sweep.as_deref() {
Some(s) => Some(parse_powder_sweep(s)?),
None => None,
};
if sweep_temps.is_none() {
if !has_curve && temperature.is_none() {
return Err(
"--temperature is required (the shot-day air temperature), or use --sweep"
.to_string(),
);
}
if has_curve && powder_temp.is_none() && temperature.is_none() {
return Err(
"--powder-temp or --temperature is required with --powder-temp-curve, or use --sweep"
.to_string(),
);
}
}
let resolve_at_c = |temp_c: f64| -> f64 {
resolve_powder_adjusted_velocity(
velocity_si.unwrap_or(0.0),
temp_c,
!has_curve, sens_si,
ref_temp_c,
curve_ref,
None, )
};
let energy_at = |v_mps: f64| -> Option<f64> {
mass_kg.map(|m| UnitConverter::energy_from_metric(0.5 * m * v_mps * v_mps, units))
};
let (vel_unit, temp_unit, energy_unit) = match units {
UnitSystem::Imperial => ("fps", "°F", "ft·lb"),
UnitSystem::Metric => ("m/s", "°C", "J"),
};
struct PowderRow {
temp_display: f64,
velocity_display: f64,
shift_display: Option<f64>,
energy_display: Option<f64>,
}
let row_at = |temp_display: f64, temp_c: f64| -> PowderRow {
let v_mps = resolve_at_c(temp_c);
let v_display = UnitConverter::velocity_from_metric(v_mps, units);
PowderRow {
temp_display,
velocity_display: v_display,
shift_display: velocity.map(|nominal| v_display - nominal),
energy_display: energy_at(v_mps),
}
};
let rows: Vec<PowderRow> = match &sweep_temps {
Some(temps) => temps
.iter()
.map(|&t| row_at(t, UnitConverter::temperature_to_metric(t, units)))
.collect(),
None => {
let (t_display, t_c) = if has_curve {
match (powder_temp, powder_curve_temp_c, temperature, ambient_c) {
(Some(td), Some(tc), _, _) => (td, tc),
(_, _, Some(td), Some(tc)) => (td, tc),
_ => unreachable!("validated above"),
}
} else {
(
temperature.expect("validated above"),
ambient_c.expect("validated above"),
)
};
vec![row_at(t_display, t_c)]
}
};
let model_desc = if has_curve {
let curve = curve_ref.expect("has_curve");
let lo = UnitConverter::temperature_from_metric(curve[0].0, units);
let hi = UnitConverter::temperature_from_metric(curve[curve.len() - 1].0, units);
format!(
"measured curve, {} points ({:.1}\u{2013}{:.1} {})",
curve.len(),
lo,
hi,
temp_unit
)
} else {
format!("linear, {:.2} {}/{}", sens_display, vel_unit, temp_unit)
};
match output {
OutputFormat::Table => {
println!("Powder Temperature Velocity");
println!("===========================");
println!(" Model: {}", model_desc);
if !has_curve {
println!(
" Reference temp: {:.1} {}",
UnitConverter::temperature_from_metric(ref_temp_c, units),
temp_unit
);
}
if let Some(v) = velocity {
println!(" Nominal velocity: {:.1} {}", v, vel_unit);
}
println!();
if rows.len() == 1 {
let r = &rows[0];
let temp_label = if has_curve {
"Powder temp:"
} else {
"Shot temp:"
};
println!(" {:<20}{:.1} {}", temp_label, r.temp_display, temp_unit);
match r.shift_display {
Some(s) => println!(
" Resolved velocity: {:.1} {} ({:+.1})",
r.velocity_display, vel_unit, s
),
None => println!(
" Resolved velocity: {:.1} {}",
r.velocity_display, vel_unit
),
}
if let Some(e) = r.energy_display {
println!(" Muzzle energy: {:.0} {}", e, energy_unit);
}
} else {
let has_shift = rows.first().is_some_and(|r| r.shift_display.is_some());
let has_energy = rows.first().is_some_and(|r| r.energy_display.is_some());
print!(
" {:>10} {:>14}",
format!("Temp ({})", temp_unit),
format!("Velocity ({})", vel_unit)
);
if has_shift {
print!(" {:>12}", format!("Shift ({})", vel_unit));
}
if has_energy {
print!(" {:>15}", format!("Energy ({})", energy_unit));
}
println!();
for r in &rows {
print!(" {:>10.1} {:>14.1}", r.temp_display, r.velocity_display);
if let Some(s) = r.shift_display {
print!(" {:>12.1}", s);
}
if let Some(e) = r.energy_display {
print!(" {:>15.0}", e);
}
println!();
}
}
}
OutputFormat::Json => {
let round1 = |x: f64| (x * 10.0).round() / 10.0;
let rows_json: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
let mut o = serde_json::json!({
"temperature": round1(r.temp_display),
"velocity": round1(r.velocity_display),
});
if let Some(s) = r.shift_display {
o["shift"] = serde_json::json!(round1(s));
}
if let Some(e) = r.energy_display {
o["energy"] = serde_json::json!(e.round());
}
o
})
.collect();
let mut result = serde_json::json!({
"command": "powder",
"units": match units {
UnitSystem::Imperial => "imperial",
UnitSystem::Metric => "metric",
},
"model": if has_curve { "curve" } else { "linear" },
});
if !has_curve {
result["sensitivity"] = serde_json::json!(sens_display);
result["reference_temp"] = serde_json::json!(round1(
UnitConverter::temperature_from_metric(ref_temp_c, units)
));
} else {
result["curve_points"] = serde_json::json!(curve_ref.expect("has_curve").len());
}
if let Some(v) = velocity {
result["nominal_velocity"] = serde_json::json!(v);
}
if let Some(m) = mass {
result["mass"] = serde_json::json!(m);
}
if sweep_temps.is_some() {
result["sweep"] = serde_json::json!(rows_json);
} else {
result["resolved"] = rows_json
.into_iter()
.next()
.unwrap_or(serde_json::json!(null));
}
println!(
"{}",
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
);
}
OutputFormat::Csv => {
let has_shift = rows.first().is_some_and(|r| r.shift_display.is_some());
let has_energy = rows.first().is_some_and(|r| r.energy_display.is_some());
let mut header = format!(
"temperature_{},velocity_{}",
temp_unit_ascii(units),
vel_unit_ascii(units)
);
if has_shift {
header.push_str(&format!(",shift_{}", vel_unit_ascii(units)));
}
if has_energy {
header.push_str(&format!(",energy_{}", energy_unit_ascii(units)));
}
println!("{}", header);
for r in &rows {
let mut line = format!("{:.1},{:.1}", r.temp_display, r.velocity_display);
if let Some(s) = r.shift_display {
line.push_str(&format!(",{:.1}", s));
}
if let Some(e) = r.energy_display {
line.push_str(&format!(",{:.0}", e));
}
println!("{}", line);
}
}
OutputFormat::Pdf => {
return Err("PDF output is not supported for the powder command".to_string());
}
}
Ok(())
}
fn temp_unit_ascii(units: UnitSystem) -> &'static str {
match units {
UnitSystem::Imperial => "f",
UnitSystem::Metric => "c",
}
}
fn vel_unit_ascii(units: UnitSystem) -> &'static str {
match units {
UnitSystem::Imperial => "fps",
UnitSystem::Metric => "mps",
}
}
fn energy_unit_ascii(units: UnitSystem) -> &'static str {
match units {
UnitSystem::Imperial => "ftlb",
UnitSystem::Metric => "j",
}
}
const LBF_TO_N: f64 = 4.4482216152605;
#[allow(clippy::too_many_arguments)]
fn handle_recoil(
bullet_weight: f64,
charge_weight: f64,
velocity: f64,
firearm_weight: f64,
firearm_type: FirearmType,
gas_velocity_factor: Option<f64>,
gas_velocity: Option<f64>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), String> {
let bullet_mass_kg = match units {
UnitSystem::Imperial => bullet_weight * GRAINS_TO_KG,
UnitSystem::Metric => bullet_weight * 0.001, };
let charge_mass_kg = match units {
UnitSystem::Imperial => charge_weight * GRAINS_TO_KG,
UnitSystem::Metric => charge_weight * 0.001, };
let firearm_mass_kg = match units {
UnitSystem::Imperial => firearm_weight * POUNDS_TO_KG,
UnitSystem::Metric => firearm_weight, };
let muzzle_velocity_mps = match units {
UnitSystem::Imperial => velocity * FPS_TO_MPS,
UnitSystem::Metric => velocity,
};
let (gas_model, gas_model_desc): (GasVelocityModel, String) = if let Some(v) = gas_velocity {
let v_mps = match units {
UnitSystem::Imperial => v * FPS_TO_MPS,
UnitSystem::Metric => v,
};
(GasVelocityModel::Fixed(v_mps), "fixed".to_string())
} else if let Some(f) = gas_velocity_factor {
(GasVelocityModel::Factor(f), format!("factor({f:.3})"))
} else {
let name = match firearm_type {
FirearmType::Rifle => "saami-rifle",
FirearmType::Pistol => "saami-pistol",
FirearmType::ShotgunAverage => "saami-shotgun-average",
FirearmType::ShotgunLong => "saami-shotgun-long",
};
(GasVelocityModel::Saami(firearm_type), name.to_string())
};
let gas_velocity_mps = gas_model.resolve_mps(muzzle_velocity_mps);
let result = free_recoil(FreeRecoilInputs {
bullet_mass_kg,
charge_mass_kg,
muzzle_velocity_mps,
firearm_mass_kg,
gas_velocity_mps,
})?;
let (weight_unit, firearm_weight_unit, vel_unit, energy_unit, impulse_unit) = match units {
UnitSystem::Imperial => ("gr", "lb", "fps", "ft-lb", "lb-s"),
UnitSystem::Metric => ("g", "kg", "m/s", "J", "N-s"),
};
let gas_velocity_display = match units {
UnitSystem::Imperial => gas_velocity_mps / FPS_TO_MPS,
UnitSystem::Metric => gas_velocity_mps,
};
let recoil_velocity_display = match units {
UnitSystem::Imperial => result.recoil_velocity_mps / FPS_TO_MPS,
UnitSystem::Metric => result.recoil_velocity_mps,
};
let recoil_energy_display = match units {
UnitSystem::Imperial => result.recoil_energy_j * 0.737562, UnitSystem::Metric => result.recoil_energy_j,
};
let impulse_display = match units {
UnitSystem::Imperial => result.impulse_ns / LBF_TO_N, UnitSystem::Metric => result.impulse_ns,
};
match output {
OutputFormat::Table => {
println!("Free Recoil (SAAMI Momentum Balance)");
println!("=====================================");
println!(
" Firearm weight: {:.2} {}",
firearm_weight, firearm_weight_unit
);
println!(
" Bullet weight: {:.1} {}",
bullet_weight, weight_unit
);
println!(
" Charge weight: {:.1} {}",
charge_weight, weight_unit
);
println!(" Muzzle velocity: {:.1} {}", velocity, vel_unit);
println!(
" Gas velocity model: {} ({:.1} {})",
gas_model_desc, gas_velocity_display, vel_unit
);
println!();
println!(
" Recoil velocity: {:.2} {}",
recoil_velocity_display, vel_unit
);
println!(
" Recoil energy: {:.2} {}",
recoil_energy_display, energy_unit
);
println!(
" Recoil impulse: {:.3} {}",
impulse_display, impulse_unit
);
}
OutputFormat::Json => {
let result_json = serde_json::json!({
"command": "recoil",
"units": match units {
UnitSystem::Imperial => "imperial",
UnitSystem::Metric => "metric",
},
"inputs": {
"bullet_weight": bullet_weight,
"charge_weight": charge_weight,
"velocity": velocity,
"firearm_weight": firearm_weight,
},
"gas_velocity_model": gas_model_desc,
"gas_velocity": gas_velocity_display,
"recoil_velocity": recoil_velocity_display,
"recoil_energy": recoil_energy_display,
"impulse": impulse_display,
});
println!(
"{}",
serde_json::to_string_pretty(&result_json).map_err(|e| e.to_string())?
);
}
OutputFormat::Csv => {
println!(
"bullet_weight_{w},charge_weight_{w},velocity_{v},firearm_weight_{fw},gas_velocity_{v},recoil_velocity_{v},recoil_energy_{e},impulse_{i}",
w = weight_unit,
v = vel_unit_ascii(units),
fw = match units { UnitSystem::Imperial => "lb", UnitSystem::Metric => "kg" },
e = energy_unit_ascii(units),
i = match units { UnitSystem::Imperial => "lbs", UnitSystem::Metric => "ns" },
);
println!(
"{:.2},{:.2},{:.2},{:.2},{:.2},{:.3},{:.3},{:.4}",
bullet_weight,
charge_weight,
velocity,
firearm_weight,
gas_velocity_display,
recoil_velocity_display,
recoil_energy_display,
impulse_display,
);
}
OutputFormat::Pdf => {
return Err("PDF output is not supported for the recoil command".to_string());
}
}
Ok(())
}
fn handle_power_factor(
weight: f64,
velocity: f64,
organization: Option<String>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), String> {
let weight_gr = match units {
UnitSystem::Imperial => weight,
UnitSystem::Metric => weight * GRAINS_PER_GRAM,
};
let velocity_fps = match units {
UnitSystem::Imperial => velocity,
UnitSystem::Metric => velocity / FPS_TO_MPS,
};
let raw_pf = weight_gr * velocity_fps / 1000.0;
let scored_pf = scored_power_factor(weight_gr, velocity_fps);
let mut rows = pf_evaluate_all(weight_gr, velocity_fps);
if let Some(org) = organization.as_deref() {
let org_lower = org.to_ascii_lowercase();
rows.retain(|r| r.organization.eq_ignore_ascii_case(&org_lower));
if rows.is_empty() {
return Err(format!(
"--organization '{org}' matched nothing; expected uspsa, idpa, or sass"
));
}
}
let (weight_unit, vel_unit) = match units {
UnitSystem::Imperial => ("gr", "fps"),
UnitSystem::Metric => ("g", "m/s"),
};
match output {
OutputFormat::Table => {
println!("Power Factor");
println!("============");
println!(" Weight: {:.1} {}", weight, weight_unit);
println!(" Velocity: {:.1} {}", velocity, vel_unit);
println!(" Power factor (raw): {:.2}", raw_pf);
println!(" Power factor (scored): {:.0}", scored_pf);
println!();
println!(
" {:<8} {:<26} {:>8} {:>6} {:>18}",
"Org", "Class", "Min PF", "Pass", "Velocity limit"
);
for r in &rows {
let vel_limit = match (r.min_velocity_fps, r.max_velocity_fps) {
(Some(min), Some(max)) => format!("{:.0}-{:.0} fps", min, max),
(Some(min), None) => format!(">= {:.0} fps", min),
(None, Some(max)) => format!("<= {:.0} fps", max),
(None, None) => "-".to_string(),
};
println!(
" {:<8} {:<26} {:>8.0} {:>6} {:>18}",
r.organization,
r.class,
r.min_pf,
if r.pass { "PASS" } else { "FAIL" },
vel_limit,
);
}
}
OutputFormat::Json => {
let rows_json: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"organization": r.organization,
"class": r.class,
"min_pf": r.min_pf,
"pf_pass": r.pf_pass,
"min_velocity_fps": r.min_velocity_fps,
"max_velocity_fps": r.max_velocity_fps,
"velocity_pass": r.velocity_pass,
"pass": r.pass,
})
})
.collect();
let result_json = serde_json::json!({
"command": "power-factor",
"units": match units {
UnitSystem::Imperial => "imperial",
UnitSystem::Metric => "metric",
},
"weight": weight,
"velocity": velocity,
"power_factor_raw": raw_pf,
"power_factor_scored": scored_pf,
"thresholds": rows_json,
});
println!(
"{}",
serde_json::to_string_pretty(&result_json).map_err(|e| e.to_string())?
);
}
OutputFormat::Csv => {
println!("organization,class,min_pf,pf_pass,min_velocity_fps,max_velocity_fps,velocity_pass,pass");
for r in &rows {
println!(
"{},{},{:.0},{},{},{},{},{}",
r.organization,
r.class,
r.min_pf,
r.pf_pass,
r.min_velocity_fps
.map(|v| format!("{v:.0}"))
.unwrap_or_default(),
r.max_velocity_fps
.map(|v| format!("{v:.0}"))
.unwrap_or_default(),
r.velocity_pass.map(|b| b.to_string()).unwrap_or_default(),
r.pass,
);
}
}
OutputFormat::Pdf => {
return Err("PDF output is not supported for the power-factor command".to_string());
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn handle_lead(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
sight_height: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
wind_speed: f64,
wind_direction: f64,
use_powder_sensitivity: bool,
powder_temp_sensitivity: f64,
powder_temp: f64,
powder_temp_curve: Option<Vec<(f64, f64)>>,
powder_curve_temp_c: Option<f64>,
target_speed: f64,
target_angle: f64,
target_length: Option<f64>,
start: f64,
end: f64,
step: f64,
adjustment_unit: AdjustmentUnit,
windage_click: Option<ClickValue>,
windage_cf: f64,
units: UnitSystem,
output: OutputFormat,
bc_segments_data: Option<Vec<BCSegmentData>>,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
) -> Result<(), Box<dyn Error>> {
let velocity_m = UnitConverter::velocity_to_metric(velocity, units);
let mass_kg = UnitConverter::mass_to_metric(mass, units);
let diameter_m = UnitConverter::diameter_to_metric(diameter, units);
let sight_height_m = UnitConverter::sight_height_to_metric(sight_height, units);
let temperature_c = UnitConverter::temperature_to_metric(temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(altitude, units);
let wind_speed_m = UnitConverter::wind_to_metric(wind_speed, units);
let target_speed_mps = UnitConverter::wind_to_metric(target_speed, units);
let target_length_m = target_length.map(|l| match units {
UnitSystem::Imperial => l * 0.0254, UnitSystem::Metric => l / 1000.0, });
let end_m = UnitConverter::distance_to_metric(end, units);
let step_m = UnitConverter::distance_to_metric(step, units);
let (mut inputs, wind, atmosphere) = build_trajectory_components(
velocity_m,
bc,
mass_kg,
diameter_m,
drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
wind_speed_m,
wind_direction,
end_m,
step_m,
bc_segments_data,
custom_drag_table,
0.0,
0.0,
0.0,
);
inputs.use_powder_sensitivity = use_powder_sensitivity;
inputs.powder_temp_sensitivity = if use_powder_sensitivity {
UnitConverter::velocity_to_metric(powder_temp_sensitivity, units)
/ UnitConverter::temperature_delta_to_metric(1.0, units)
} else {
0.0
};
inputs.powder_temp = UnitConverter::temperature_to_metric(powder_temp, units);
inputs.powder_temp_curve = powder_temp_curve;
inputs.powder_curve_temp_c = powder_curve_temp_c;
let adj_label = adjustment_unit_label(adjustment_unit);
let (dist_unit, speed_unit) = match units {
UnitSystem::Imperial => ("yd", "mph"),
UnitSystem::Metric => ("m", "m/s"),
};
let has_bodies = target_length_m.is_some();
struct LeadRow {
range: f64,
result: Result<ballistics_engine::LeadSolution, ballistics_engine::LeadError>,
bodies: Option<f64>,
}
let mut rows: Vec<LeadRow> = Vec::new();
let mut current_range = start;
while current_range <= end + 0.1 {
let range_m = UnitConverter::distance_to_metric(current_range, units);
let result = ballistics_engine::calculate_lead(
inputs.clone(),
wind.clone(),
atmosphere.clone(),
target_speed_mps,
target_angle,
range_m,
);
let bodies = match (&result, target_length_m) {
(Ok(sol), Some(len_m)) if len_m > 0.0 => Some(sol.lead_m / len_m),
_ => None,
};
rows.push(LeadRow {
range: current_range,
result,
bodies,
});
current_range += step;
}
match output {
OutputFormat::Json => {
let json_rows: Vec<serde_json::Value> = rows
.iter()
.map(|r| match &r.result {
Ok(sol) => {
let mut row = serde_json::json!({
"range": r.range,
"tof_s": sol.time_of_flight_s,
"lead": UnitConverter::distance_from_metric(sol.lead_m, units),
"lead_mil": sol.lead_mil / windage_cf,
"lead_moa": sol.lead_moa / windage_cf,
"intercept_range": UnitConverter::distance_from_metric(
sol.corrected_range_m,
units
),
"iterations": sol.iterations,
});
if let Some(bodies) = r.bodies {
row["bodies"] = serde_json::json!(bodies);
}
row
}
Err(e) => serde_json::json!({
"range": r.range,
"error": e.to_string(),
}),
})
.collect();
let json = serde_json::json!({
"target_speed": target_speed,
"target_speed_unit": speed_unit,
"target_angle": target_angle,
"units": match units {
UnitSystem::Imperial => "imperial",
UnitSystem::Metric => "metric",
},
"distance_unit": dist_unit,
"adjustment_unit": adj_label,
"rows": json_rows,
});
println!("{}", serde_json::to_string_pretty(&json)?);
}
OutputFormat::Csv => {
if has_bodies {
println!(
"range_{dist_unit},tof_s,lead_{dist_unit},lead_{unit},intercept_{dist_unit},iterations,bodies,error",
dist_unit = dist_unit,
unit = adj_label.to_lowercase()
);
} else {
println!(
"range_{dist_unit},tof_s,lead_{dist_unit},lead_{unit},intercept_{dist_unit},iterations,error",
dist_unit = dist_unit,
unit = adj_label.to_lowercase()
);
}
for r in &rows {
match &r.result {
Ok(sol) => {
let lead_disp = UnitConverter::distance_from_metric(sol.lead_m, units);
let lead_adj = match adjustment_unit {
AdjustmentUnit::Mil => sol.lead_mil / windage_cf,
AdjustmentUnit::Moa => sol.lead_moa / windage_cf,
AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => {
sol.lead_mil * smoa_per_mil() / windage_cf
}
AdjustmentUnit::Clicks => windage_click
.map(|c| clicks_for(lead_disp / windage_cf, r.range, &c) as f64)
.unwrap_or(sol.lead_mil / windage_cf),
};
let intercept_disp =
UnitConverter::distance_from_metric(sol.corrected_range_m, units);
if has_bodies {
println!(
"{:.0},{:.3},{:.2},{:.3},{:.1},{},{},",
r.range,
sol.time_of_flight_s,
lead_disp,
lead_adj,
intercept_disp,
sol.iterations,
r.bodies.map(|b| format!("{:.2}", b)).unwrap_or_default()
);
} else {
println!(
"{:.0},{:.3},{:.2},{:.3},{:.1},{},",
r.range,
sol.time_of_flight_s,
lead_disp,
lead_adj,
intercept_disp,
sol.iterations
);
}
}
Err(e) => {
if has_bodies {
println!("{:.0},,,,,,,{}", r.range, e);
} else {
println!("{:.0},,,,,,{}", r.range, e);
}
}
}
}
}
OutputFormat::Table | OutputFormat::Pdf => {
println!();
println!(
"Moving-Target Lead Table (target speed: {:.1} {}, angle: {:.0}\u{b0}, {})",
target_speed, speed_unit, target_angle, adj_label
);
println!("Positive lead = hold in the direction of target travel.");
println!();
if has_bodies {
println!("┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐");
println!(
"│Range ({:>2})│TOF (s) │Lead ({:>3})│Lead ({:>3})│Intercept │Bodies │",
dist_unit, dist_unit, adj_label
);
println!("├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤");
for r in &rows {
match &r.result {
Ok(sol) => {
let lead_disp = UnitConverter::distance_from_metric(sol.lead_m, units);
let lead_adj = match adjustment_unit {
AdjustmentUnit::Mil => sol.lead_mil / windage_cf,
AdjustmentUnit::Moa => sol.lead_moa / windage_cf,
AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => {
sol.lead_mil * smoa_per_mil() / windage_cf
}
AdjustmentUnit::Clicks => windage_click
.map(|c| clicks_for(lead_disp / windage_cf, r.range, &c) as f64)
.unwrap_or(sol.lead_mil / windage_cf),
};
let intercept_disp =
UnitConverter::distance_from_metric(sol.corrected_range_m, units);
println!(
"│{:>9.0} │{:>9.3} │{:>9.2} │{:>9.3} │{:>9.1} │{:>9.2} │",
r.range,
sol.time_of_flight_s,
lead_disp,
lead_adj,
intercept_disp,
r.bodies.unwrap_or(0.0)
);
}
Err(e) => {
println!(
"│{:>9.0} │{:>9} │{:>9} │{:>9} │{:>9} │{:>9} │ {}",
r.range, "--", "--", "--", "--", "--", e
);
}
}
}
println!("└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘");
} else {
println!("┌──────────┬──────────┬──────────┬──────────┬──────────┐");
println!(
"│Range ({:>2})│TOF (s) │Lead ({:>3})│Lead ({:>3})│Intercept │",
dist_unit, dist_unit, adj_label
);
println!("├──────────┼──────────┼──────────┼──────────┼──────────┤");
for r in &rows {
match &r.result {
Ok(sol) => {
let lead_disp = UnitConverter::distance_from_metric(sol.lead_m, units);
let lead_adj = match adjustment_unit {
AdjustmentUnit::Mil => sol.lead_mil / windage_cf,
AdjustmentUnit::Moa => sol.lead_moa / windage_cf,
AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => {
sol.lead_mil * smoa_per_mil() / windage_cf
}
AdjustmentUnit::Clicks => windage_click
.map(|c| clicks_for(lead_disp / windage_cf, r.range, &c) as f64)
.unwrap_or(sol.lead_mil / windage_cf),
};
let intercept_disp =
UnitConverter::distance_from_metric(sol.corrected_range_m, units);
println!(
"│{:>9.0} │{:>9.3} │{:>9.2} │{:>9.3} │{:>9.1} │",
r.range, sol.time_of_flight_s, lead_disp, lead_adj, intercept_disp
);
}
Err(e) => {
println!(
"│{:>9.0} │{:>9} │{:>9} │{:>9} │{:>9} │ {}",
r.range, "--", "--", "--", "--", e
);
}
}
}
println!("└──────────┴──────────┴──────────┴──────────┴──────────┘");
}
}
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable wind-card CLI command shape"
)]
fn handle_wind_card(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
zero_distance: f64,
wind_speeds: &[f64],
wind_angles: &[f64],
legacy_labels: bool,
start: f64,
end: f64,
step: f64,
adjustment_unit: AdjustmentUnit,
windage_click: Option<ClickValue>,
windage_cf: f64,
zero_set_windage_bias_mil: f64,
sight_height: f64,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let velocity_m = UnitConverter::velocity_to_metric(velocity, units);
let mass_kg = UnitConverter::mass_to_metric(mass, units);
let diameter_m = UnitConverter::diameter_to_metric(diameter, units);
let sight_height_m = UnitConverter::sight_height_to_metric(sight_height, units);
let zero_distance_m = UnitConverter::distance_to_metric(zero_distance, units);
let temperature_c = UnitConverter::temperature_to_metric(temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(altitude, units);
let end_m = UnitConverter::distance_to_metric(end, units);
let drag_model_enum = drag_model;
let zero_inputs = BallisticInputs {
bc_value: bc,
bc_type: drag_model_enum,
bullet_mass: mass_kg,
muzzle_velocity: velocity_m,
bullet_diameter: diameter_m,
bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
sight_height: sight_height_m,
use_rk4: true,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity,
altitude: altitude_m,
};
let zero_angle = ballistics_engine::calculate_zero_angle_with_conditions(
zero_inputs,
zero_distance_m,
sight_height_m,
WindConditions::default(),
atmosphere,
)?;
let adj_label = adjustment_unit_label(adjustment_unit);
let (dist_unit, wind_unit) = match units {
UnitSystem::Imperial => ("yd", "mph"),
UnitSystem::Metric => ("m", "m/s"),
};
let mut ranges: Vec<f64> = Vec::new();
let mut current = start;
while current <= end + 0.1 {
ranges.push(current);
current += step;
}
let mut json_cards: Vec<serde_json::Value> = Vec::new();
let mut reachable = ranges.len();
let mut reach_m = f64::INFINITY;
let mut per_angle_drifts: Vec<Vec<Vec<f64>>> = Vec::new();
for &angle_deg in wind_angles.iter() {
let mut all_drifts: Vec<Vec<f64>> = vec![Vec::new(); ranges.len()];
for &ws in wind_speeds {
let ws_m = UnitConverter::wind_to_metric(ws, units);
let flight = run_sampled_flight(
velocity_m,
bc,
mass_kg,
diameter_m,
drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
ws_m,
angle_deg,
card_sample_max_range_m(end_m),
CARD_SAMPLE_INTERVAL_M,
zero_angle,
None,
None,
None,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(zero_distance_m),
)?;
reach_m = reach_m.min(flight.reach_m);
for (ri, &range_display) in ranges.iter().enumerate().take(reachable) {
let Some(sample) = card_row_sample(&flight.samples, range_display, units) else {
reachable = ri;
break;
};
let drift_yd = UnitConverter::distance_from_metric(sample.wind_drift_m, units);
let drift_adj = adjustment_display(
drift_yd,
range_display,
adjustment_unit,
windage_click,
zero_set_windage_bias_mil,
windage_cf,
)
.value;
all_drifts[ri].push(drift_adj);
}
}
per_angle_drifts.push(all_drifts);
}
let truncation = if reachable < ranges.len() {
if reachable == 0 {
return Err(card_no_rows_error(ranges[0], reach_m, units));
}
let last_row = ranges[reachable - 1];
ranges.truncate(reachable);
Some(card_truncation(end, last_row, reach_m, units))
} else {
None
};
for (angle_idx, &angle_deg) in wind_angles.iter().enumerate() {
let all_drifts = &per_angle_drifts[angle_idx];
let wind_rows: Vec<CardRow> = ranges
.iter()
.enumerate()
.map(|(i, &range)| CardRow {
range,
drop_linear: None,
drop_adj: None,
come_up: None,
wind_linear: None,
wind_adj: None,
velocity: None,
energy: None,
time: None,
lead_adj: None,
wind_columns: all_drifts[i].clone(),
})
.collect();
match output {
OutputFormat::Json => {
let json_rows: Vec<serde_json::Value> = wind_rows
.iter()
.map(|r| {
let mut row = serde_json::json!({ "range": r.range });
for (j, &ws) in wind_speeds.iter().enumerate() {
row[format!("wind_{}", ws)] =
serde_json::json!(r.wind_columns.get(j).unwrap_or(&0.0));
}
row
})
.collect();
let mut card = serde_json::json!({
"zero_distance": zero_distance,
"adjustment_unit": adj_label,
"distance_unit": dist_unit,
"wind_unit": wind_unit,
"wind_speeds": wind_speeds,
"data": json_rows,
});
if legacy_labels {
card["crosswind"] = serde_json::json!("full-value (90°)");
} else {
card["wind_angle"] = serde_json::json!(angle_deg);
}
if let Some(truncation) = &truncation {
card["truncated"] = card_truncation_json(truncation, units);
}
json_cards.push(card);
}
OutputFormat::Csv => {
if !legacy_labels {
if angle_idx > 0 {
println!();
}
println!("# wind_angle={}", angle_deg);
}
let ws_headers: Vec<String> = wind_speeds
.iter()
.map(|ws| format!("wind_{}_{}", ws, wind_unit))
.collect();
println!("range_{},{}", dist_unit, ws_headers.join(","));
for r in &wind_rows {
let drift_strs: Vec<String> = r
.wind_columns
.iter()
.map(|d| {
let d = if d.abs() < 1e-9 { 0.0 } else { *d };
format!("{:.1}", d)
})
.collect();
println!("{:.0},{}", r.range, drift_strs.join(","));
}
}
OutputFormat::Table | OutputFormat::Pdf => {
println!();
if legacy_labels {
println!(
"Wind Card (zero: {:.0} {}, {}, full-value crosswind)",
zero_distance, dist_unit, adj_label
);
} else {
println!(
"Wind Card (zero: {:.0} {}, {}) — wind angle {}° (wind-FROM: 0=head, 90=right, 180=tail, 270=left)",
zero_distance, dist_unit, adj_label, angle_deg
);
}
let col_width = 10;
let range_header = format!("Range ({:>2})", dist_unit);
let mut header = format!("┌{:─>w$}", "", w = col_width);
for _ in wind_speeds {
header += &format!("┬{:─>w$}", "", w = col_width);
}
header += "┐";
println!("{}", header);
let mut label_row = format!("│{:<w$}", range_header, w = col_width);
for ws in wind_speeds {
label_row += &format!("│{:>8} {} ", ws, wind_unit);
}
label_row += "│";
println!("{}", label_row);
let mut sep = format!("├{:─>w$}", "", w = col_width);
for _ in wind_speeds {
sep += &format!("┼{:─>w$}", "", w = col_width);
}
sep += "┤";
println!("{}", sep);
for r in &wind_rows {
let mut row_str = format!("│{:>9.0} ", r.range);
for d in &r.wind_columns {
let d = if d.abs() < 1e-9 { 0.0 } else { *d };
row_str += &format!("│{:>9.1} ", d);
}
row_str += "│";
println!("{}", row_str);
}
let mut footer = format!("└{:─>w$}", "", w = col_width);
for _ in wind_speeds {
footer += &format!("┴{:─>w$}", "", w = col_width);
}
footer += "┘";
println!("{}", footer);
}
}
}
if let OutputFormat::Json = output {
if json_cards.len() == 1 {
println!("{}", serde_json::to_string_pretty(&json_cards[0])?);
} else {
let array = serde_json::Value::Array(json_cards);
println!("{}", serde_json::to_string_pretty(&array)?);
}
}
if let Some(truncation) = &truncation {
warn_card_truncated(truncation, units);
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable stability CLI command shape"
)]
fn handle_stability(
mass: f64,
diameter: f64,
length: f64,
twist_rate: f64,
velocity: f64,
temperature: f64,
pressure: f64,
altitude: f64,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let mass_kg = UnitConverter::mass_to_metric(mass, units);
let diameter_m = UnitConverter::diameter_to_metric(diameter, units);
let length_m = UnitConverter::sight_height_to_metric(length, units); let velocity_m = UnitConverter::velocity_to_metric(velocity, units);
let temperature_c = UnitConverter::temperature_to_metric(temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(altitude, units);
let twist_rate_inches = match units {
UnitSystem::Imperial => twist_rate,
UnitSystem::Metric => twist_rate / 25.4, };
let inputs = BallisticInputs {
bullet_mass: mass_kg,
bullet_diameter: diameter_m,
bullet_length: length_m,
muzzle_velocity: velocity_m,
twist_rate: twist_rate_inches,
..Default::default()
};
let atmo_params = (altitude_m, temperature_c, pressure_hpa, 1.0);
let sg = ballistics_engine::stability::compute_stability_coefficient(&inputs, atmo_params);
let status = if sg >= 1.5 {
"Fully Stable"
} else if sg >= 1.0 {
"Marginally Stable"
} else {
"UNSTABLE"
};
let find_twist_for_sg = |target_sg: f64| -> f64 {
let mut low = 1.0_f64; let mut high = 40.0_f64;
for _ in 0..100 {
let mid = (low + high) / 2.0;
let test_inputs = BallisticInputs {
bullet_mass: mass_kg,
bullet_diameter: diameter_m,
bullet_length: length_m,
muzzle_velocity: velocity_m,
twist_rate: mid,
..Default::default()
};
let test_sg = ballistics_engine::stability::compute_stability_coefficient(
&test_inputs,
atmo_params,
);
if test_sg > target_sg {
low = mid;
} else {
high = mid;
}
if (test_sg - target_sg).abs() < 0.001 {
break;
}
}
(low + high) / 2.0
};
let min_twist_1_5 = find_twist_for_sg(1.5);
let min_twist_1_0 = find_twist_for_sg(1.0);
let (min15_out, min10_out) = match units {
UnitSystem::Imperial => (min_twist_1_5, min_twist_1_0),
UnitSystem::Metric => (min_twist_1_5 * 25.4, min_twist_1_0 * 25.4),
};
let (len_unit, vel_unit, twist_display, min15_display, min10_display) = match units {
UnitSystem::Imperial => (
"\"",
"fps",
format!("1:{:.0}\" RH", twist_rate),
format!("1:{:.1}\"", min_twist_1_5),
format!("1:{:.1}\"", min_twist_1_0),
),
UnitSystem::Metric => (
"mm",
"m/s",
format!("1:{:.0}mm RH", twist_rate),
format!("1:{:.1}mm", min_twist_1_5 * 25.4),
format!("1:{:.1}mm", min_twist_1_0 * 25.4),
),
};
match output {
OutputFormat::Json => {
let json = serde_json::json!({
"stability_factor": (sg * 100.0).round() / 100.0,
"status": status,
"twist_rate": twist_rate,
"twist_rate_unit": if units == UnitSystem::Imperial { "in/turn" } else { "mm/turn" },
"min_twist_sg_1_5": (min15_out * 100.0).round() / 100.0,
"min_twist_sg_1_0": (min10_out * 100.0).round() / 100.0,
"bullet_length": length,
"bullet_diameter": diameter,
"bullet_mass": mass,
"muzzle_velocity": velocity,
"units": if units == UnitSystem::Imperial { "imperial" } else { "metric" },
});
println!("{}", serde_json::to_string_pretty(&json)?);
}
OutputFormat::Csv => {
println!("sg,status,twist_rate,min_twist_sg1.5,min_twist_sg1.0,length,velocity");
println!(
"{:.2},{},{:.1},{:.1},{:.1},{:.3},{:.0}",
sg, status, twist_rate, min15_out, min10_out, length, velocity
);
}
OutputFormat::Table | OutputFormat::Pdf => {
println!();
println!("╔════════════════════════════════════════╗");
println!("║ Stability Analysis ║");
println!("╠════════════════════════════════════════╣");
println!("║ Stability Factor (Sg): {:>12.2} ║", sg);
println!("║ Status: {:>14} ║", status);
println!("║ Twist Rate: {:>14} ║", twist_display);
println!("║ Min Twist (Sg=1.5): {:>14} ║", min15_display);
println!("║ Min Twist (Sg=1.0): {:>14} ║", min10_display);
println!("║ Bullet Length: {:>12.3}{:>4} ║", length, len_unit);
println!(
"║ Muzzle Velocity: {:>10.0} {:<3} ║",
velocity, vel_unit
);
println!("╚════════════════════════════════════════╝");
}
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable range-table CLI command shape"
)]
fn handle_range_table(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
zero_distance: f64,
start: f64,
end: f64,
step: f64,
wind_speed: f64,
wind_direction: f64,
adjustment_unit: AdjustmentUnit,
windage_unit: AdjustmentUnit,
elevation_click: Option<ClickValue>,
windage_click: Option<ClickValue>,
elevation_cf: f64,
windage_cf: f64,
zero_set_elevation_bias_mil: f64,
zero_set_windage_bias_mil: f64,
sight_height: f64,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let velocity_m = UnitConverter::velocity_to_metric(velocity, units);
let mass_kg = UnitConverter::mass_to_metric(mass, units);
let diameter_m = UnitConverter::diameter_to_metric(diameter, units);
let sight_height_m = UnitConverter::sight_height_to_metric(sight_height, units);
let zero_distance_m = UnitConverter::distance_to_metric(zero_distance, units);
let temperature_c = UnitConverter::temperature_to_metric(temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(altitude, units);
let wind_speed_m = UnitConverter::wind_to_metric(wind_speed, units);
let end_m = UnitConverter::distance_to_metric(end, units);
let drag_model_enum = drag_model;
let zero_inputs = BallisticInputs {
bc_value: bc,
bc_type: drag_model_enum,
bullet_mass: mass_kg,
muzzle_velocity: velocity_m,
bullet_diameter: diameter_m,
bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
sight_height: sight_height_m,
use_rk4: true,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity,
altitude: altitude_m,
};
let zero_angle = ballistics_engine::calculate_zero_angle_with_conditions(
zero_inputs,
zero_distance_m,
sight_height_m,
WindConditions::default(),
atmosphere,
)?;
let wind_flight = run_sampled_flight(
velocity_m,
bc,
mass_kg,
diameter_m,
drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
wind_speed_m,
wind_direction,
card_sample_max_range_m(end_m),
CARD_SAMPLE_INTERVAL_M,
zero_angle,
None,
None,
None,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(zero_distance_m),
)?;
let no_wind_flight = run_sampled_flight(
velocity_m,
bc,
mass_kg,
diameter_m,
drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
0.0,
0.0,
card_sample_max_range_m(end_m),
CARD_SAMPLE_INTERVAL_M,
zero_angle,
None,
None,
None,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(zero_distance_m),
)?;
let elev_label = adjustment_unit_label(adjustment_unit);
let wind_label = adjustment_unit_label(windage_unit);
let (dist_unit, vel_unit, energy_unit, drop_unit, wind_unit_label) = match units {
UnitSystem::Imperial => ("yd", "fps", "ft-lb", "in", "mph"),
UnitSystem::Metric => ("m", "m/s", "J", "mm", "m/s"),
};
let reach_m = wind_flight.reach_m.min(no_wind_flight.reach_m);
let mut rows: Vec<CardRow> = Vec::new();
let mut truncation: Option<CardTruncation> = None;
let mut current_range = start;
while current_range <= end + 0.1 {
let (Some(nw), Some(w)) = (
card_row_sample(&no_wind_flight.samples, current_range, units),
card_row_sample(&wind_flight.samples, current_range, units),
) else {
let Some(last) = rows.last() else {
return Err(card_no_rows_error(current_range, reach_m, units));
};
truncation = Some(card_truncation(end, last.range, reach_m, units));
break;
};
let range_display = current_range;
let drop_linear = match units {
UnitSystem::Imperial => nw.drop_m / 0.0254,
UnitSystem::Metric => nw.drop_m * 1000.0,
};
let drop_yd = UnitConverter::distance_from_metric(nw.drop_m, units);
let drop_adj = adjustment_display(
drop_yd,
range_display,
adjustment_unit,
elevation_click,
zero_set_elevation_bias_mil,
elevation_cf,
)
.value;
let wind_linear = match units {
UnitSystem::Imperial => w.wind_drift_m / 0.0254,
UnitSystem::Metric => w.wind_drift_m * 1000.0,
};
let drift_yd = UnitConverter::distance_from_metric(w.wind_drift_m, units);
let wind_adj = windage_adjustment_display(
drift_yd,
range_display,
windage_unit,
windage_click,
zero_set_windage_bias_mil,
windage_cf,
)
.value;
rows.push(CardRow {
range: current_range,
drop_linear: Some(drop_linear),
drop_adj: Some(drop_adj),
come_up: None,
wind_linear: Some(wind_linear),
wind_adj: Some(wind_adj),
velocity: Some(UnitConverter::velocity_from_metric(nw.velocity_mps, units)),
energy: Some(UnitConverter::energy_from_metric(nw.energy_j, units)),
time: Some(nw.time_s),
lead_adj: None,
wind_columns: Vec::new(),
});
current_range += step;
}
match output {
OutputFormat::Json => {
let json_rows: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"range": r.range,
"drop": r.drop_linear,
"drop_adj": r.drop_adj,
"wind_drift": r.wind_linear,
"wind_adj": r.wind_adj,
"velocity": r.velocity,
"energy": r.energy,
"time": r.time,
})
})
.collect();
let mut json = serde_json::json!({
"zero_distance": zero_distance,
"wind_speed": wind_speed,
"wind_direction": wind_direction,
"adjustment_unit": elev_label,
"distance_unit": dist_unit,
"velocity_unit": vel_unit,
"energy_unit": energy_unit,
"drop_unit": drop_unit,
"data": json_rows,
});
if windage_unit != adjustment_unit {
json["windage_unit"] = serde_json::json!(wind_label);
}
if let Some(truncation) = &truncation {
json["truncated"] = card_truncation_json(truncation, units);
}
println!("{}", serde_json::to_string_pretty(&json)?);
}
OutputFormat::Csv => {
println!(
"range_{},drop_{},drop_{},wind_{},wind_{},velocity_{},energy_{},tof_s",
dist_unit,
drop_unit,
elev_label.to_lowercase(),
drop_unit,
wind_label.to_lowercase(),
vel_unit,
energy_unit
);
for r in &rows {
println!(
"{:.0},{:.1},{:.3},{:.1},{:.2},{:.0},{:.0},{:.2}",
r.range,
r.drop_linear.unwrap(),
r.drop_adj.unwrap(),
r.wind_linear.unwrap(),
r.wind_adj.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap()
);
}
}
OutputFormat::Table | OutputFormat::Pdf => {
println!();
println!(
"Range Table (zero: {:.0} {}, {:.0} {} crosswind)",
zero_distance, dist_unit, wind_speed, wind_unit_label
);
println!(
"┌───────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬───────┐"
);
println!(
"│Range │Drop │Drop │Wind │Wind │Vel │Energy │ToF │"
);
println!("│({:>2}) │({:>2}) │({:>3}) │({:>2}) │({:>3}) │({:>3}) │({:>4}) │(s) │",
dist_unit, drop_unit, elev_label, drop_unit, wind_label, vel_unit, energy_unit);
println!(
"├───────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼───────┤"
);
for r in &rows {
println!("{}", range_table_row_line(r));
}
println!(
"└───────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴───────┘"
);
}
}
if let Some(truncation) = &truncation {
warn_card_truncated(truncation, units);
}
Ok(())
}
fn range_table_row_line(r: &CardRow) -> String {
format!(
"│{:>6.0} │{:>8.1} │{:>8.3} │{:>8.1} │{:>8.2} │{:>8.0} │{:>8.0} │{:>6.2} │",
r.range,
r.drop_linear.unwrap(),
r.drop_adj.unwrap(),
r.wind_linear.unwrap(),
r.wind_adj.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap()
)
}
struct CompareLoadSpec {
name: String,
drag_model: DragModel,
bc: f64,
mass: f64,
velocity: f64,
diameter: f64,
bc_segments_data: Option<Vec<BCSegmentData>>,
custom_drag_table: Option<ballistics_engine::drag::DragTable>,
}
fn parse_compare_load_spec(
spec: &str,
units: UnitSystem,
) -> Result<CompareLoadSpec, Box<dyn Error>> {
let parts: Vec<&str> = spec.split(':').collect();
if parts.len() != 5 && parts.len() != 6 {
return Err(format!(
"invalid --load '{spec}': expected NAME:DRAG:BC:MASS:VELOCITY[:DIAMETER] \
(5 or 6 colon-separated fields; NAME must not contain ':')"
)
.into());
}
let name = parts[0].trim();
if name.is_empty() {
return Err(format!("invalid --load '{spec}': NAME is empty").into());
}
let drag_model = DragModel::from_str(parts[1].trim()).ok_or_else(|| {
format!(
"invalid --load '{spec}': DRAG '{}' is not a recognized drag model \
(g1, g2, g5, g6, g7, g8, gi, gs, ra4)",
parts[1].trim()
)
})?;
let num = |i: usize, field: &str| -> Result<f64, Box<dyn Error>> {
let v: f64 = parts[i].trim().parse().map_err(|_| {
format!(
"invalid --load '{spec}': {field} '{}' is not a number",
parts[i]
)
})?;
if !v.is_finite() || v <= 0.0 {
return Err(format!("invalid --load '{spec}': {field} must be finite and > 0").into());
}
Ok(v)
};
let bc = num(2, "BC")?;
let mass = num(3, "MASS")?;
let velocity = num(4, "VELOCITY")?;
let diameter = if parts.len() == 6 {
num(5, "DIAMETER")?
} else {
match units {
UnitSystem::Imperial => 0.308,
UnitSystem::Metric => 7.82,
}
};
Ok(CompareLoadSpec {
name: name.to_string(),
drag_model,
bc,
mass,
velocity,
diameter,
bc_segments_data: None,
custom_drag_table: None,
})
}
#[allow(clippy::too_many_arguments)] fn handle_compare(
loads: Vec<CompareLoadSpec>,
zero_distance: f64,
start: f64,
end: f64,
step: f64,
wind_speed: f64,
wind_direction: f64,
adjustment_unit: AdjustmentUnit,
windage_unit: AdjustmentUnit,
elevation_click: Option<ClickValue>,
windage_click: Option<ClickValue>,
elevation_cf: f64,
windage_cf: f64,
sight_height: f64,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let sight_height_m = UnitConverter::sight_height_to_metric(sight_height, units);
let zero_distance_m = UnitConverter::distance_to_metric(zero_distance, units);
let temperature_c = UnitConverter::temperature_to_metric(temperature, units);
let pressure_hpa = UnitConverter::pressure_to_metric(pressure, units);
let altitude_m = UnitConverter::altitude_to_metric(altitude, units);
let wind_speed_m = UnitConverter::wind_to_metric(wind_speed, units);
let end_m = UnitConverter::distance_to_metric(end, units);
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity,
altitude: altitude_m,
};
struct CompareLoad {
name: String,
rows: Vec<CardRow>,
}
let mut ranges: Vec<f64> = Vec::new();
let mut r = start;
while r <= end + 0.1 {
ranges.push(r);
r += step;
}
let mut results: Vec<CompareLoad> = Vec::new();
let mut zero_angles_deg: Vec<f64> = Vec::new();
let mut reaches_m: Vec<f64> = Vec::new();
for load in &loads {
let velocity_m = UnitConverter::velocity_to_metric(load.velocity, units);
let mass_kg = UnitConverter::mass_to_metric(load.mass, units);
let diameter_m = UnitConverter::diameter_to_metric(load.diameter, units);
let drag_model_enum = load.drag_model;
let zero_inputs = BallisticInputs {
bc_value: load.bc,
bc_type: drag_model_enum,
bullet_mass: mass_kg,
muzzle_velocity: velocity_m,
bullet_diameter: diameter_m,
bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
sight_height: sight_height_m,
use_rk4: true,
use_bc_segments: load.bc_segments_data.is_some(),
bc_segments_data: load.bc_segments_data.clone(),
custom_drag_table: load.custom_drag_table.clone(),
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
..Default::default()
};
let zero_angle = ballistics_engine::calculate_zero_angle_with_conditions(
zero_inputs,
zero_distance_m,
sight_height_m,
WindConditions::default(),
atmosphere.clone(),
)
.map_err(|e| format!("load '{}': zeroing failed: {e}", load.name))?;
let wind_flight = run_sampled_flight(
velocity_m,
load.bc,
mass_kg,
diameter_m,
load.drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
wind_speed_m,
wind_direction,
card_sample_max_range_m(end_m),
CARD_SAMPLE_INTERVAL_M,
zero_angle,
load.bc_segments_data.clone(),
load.custom_drag_table.clone(),
None,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(zero_distance_m),
)
.map_err(|e| format!("load '{}': {e}", load.name))?;
let no_wind_flight = run_sampled_flight(
velocity_m,
load.bc,
mass_kg,
diameter_m,
load.drag_model,
sight_height_m,
temperature_c,
pressure_hpa,
humidity,
altitude_m,
0.0,
0.0,
card_sample_max_range_m(end_m),
CARD_SAMPLE_INTERVAL_M,
zero_angle,
load.bc_segments_data.clone(),
load.custom_drag_table.clone(),
None,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
Some(zero_distance_m),
)
.map_err(|e| format!("load '{}': {e}", load.name))?;
let reach_m = wind_flight.reach_m.min(no_wind_flight.reach_m);
let mut rows: Vec<CardRow> = Vec::new();
for &range_display in &ranges {
let (Some(nw), Some(w)) = (
card_row_sample(&no_wind_flight.samples, range_display, units),
card_row_sample(&wind_flight.samples, range_display, units),
) else {
if rows.is_empty() {
return Err(format!(
"load '{}': {}",
load.name,
card_no_rows_error(range_display, reach_m, units)
)
.into());
}
break;
};
let drop_linear = match units {
UnitSystem::Imperial => nw.drop_m / 0.0254,
UnitSystem::Metric => nw.drop_m * 1000.0,
};
let drop_yd = UnitConverter::distance_from_metric(nw.drop_m, units);
let drop_adj = adjustment_display(
drop_yd,
range_display,
adjustment_unit,
elevation_click,
0.0,
elevation_cf,
)
.value;
let wind_linear = match units {
UnitSystem::Imperial => w.wind_drift_m / 0.0254,
UnitSystem::Metric => w.wind_drift_m * 1000.0,
};
let drift_yd = UnitConverter::distance_from_metric(w.wind_drift_m, units);
let wind_adj = windage_adjustment_display(
drift_yd,
range_display,
windage_unit,
windage_click,
0.0,
windage_cf,
)
.value;
rows.push(CardRow {
range: range_display,
drop_linear: Some(drop_linear),
drop_adj: Some(drop_adj),
come_up: None,
wind_linear: Some(wind_linear),
wind_adj: Some(wind_adj),
velocity: Some(UnitConverter::velocity_from_metric(nw.velocity_mps, units)),
energy: Some(UnitConverter::energy_from_metric(nw.energy_j, units)),
time: Some(nw.time_s),
lead_adj: None,
wind_columns: Vec::new(),
});
}
zero_angles_deg.push(zero_angle.to_degrees());
reaches_m.push(reach_m);
results.push(CompareLoad {
name: load.name.clone(),
rows,
});
}
let shortest = results
.iter()
.enumerate()
.min_by_key(|(_, res)| res.rows.len())
.map(|(li, res)| (li, res.name.clone(), res.rows.len()));
let truncation = match shortest {
Some((li, name, common)) if common < ranges.len() => {
let last_row = ranges[common - 1];
ranges.truncate(common);
for res in &mut results {
res.rows.truncate(common);
}
Some((name, card_truncation(end, last_row, reaches_m[li], units)))
}
_ => None,
};
let elev_label = adjustment_unit_label(adjustment_unit);
let wind_label = adjustment_unit_label(windage_unit);
let (dist_unit, vel_unit, energy_unit, drop_unit, wind_unit_label) = match units {
UnitSystem::Imperial => ("yd", "fps", "ft-lb", "in", "mph"),
UnitSystem::Metric => ("m", "m/s", "J", "mm", "m/s"),
};
match output {
OutputFormat::Json => {
let mut json = serde_json::json!({
"compare": {
"loads": loads.iter().zip(zero_angles_deg.iter()).map(|(l, &zero_angle_deg)| {
serde_json::json!({
"name": l.name,
"drag_model": l.drag_model.to_string(),
"bc": l.bc,
"mass": l.mass,
"velocity": l.velocity,
"diameter": l.diameter,
"zero_angle_deg": zero_angle_deg,
"bc_segments": l.bc_segments_data.is_some(),
"custom_drag_curve": l.custom_drag_table.is_some(),
})
}).collect::<Vec<_>>(),
"conditions": {
"zero_distance": zero_distance,
"wind_speed": wind_speed,
"wind_direction": wind_direction,
"temperature": temperature,
"pressure": pressure,
"humidity": humidity,
"altitude": altitude,
"sight_height": sight_height,
},
"units": {
"distance": dist_unit,
"drop": drop_unit,
"adjustment": elev_label,
"velocity": vel_unit,
"energy": energy_unit,
"wind_speed": wind_unit_label,
},
"rows": ranges.iter().enumerate().map(|(ri, &range)| {
serde_json::json!({
"range": range,
"loads": results.iter().map(|res| {
let row = &res.rows[ri];
let baseline = &results[0].rows[ri];
serde_json::json!({
"name": res.name,
"drop": row.drop_linear,
"drop_adj": row.drop_adj,
"drift": row.wind_linear,
"drift_adj": row.wind_adj,
"velocity": row.velocity,
"energy": row.energy,
"time": row.time,
"delta_drop": row.drop_linear.unwrap() - baseline.drop_linear.unwrap(),
"delta_drift": row.wind_linear.unwrap() - baseline.wind_linear.unwrap(),
"delta_velocity": row.velocity.unwrap() - baseline.velocity.unwrap(),
"delta_energy": row.energy.unwrap() - baseline.energy.unwrap(),
})
}).collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
}
});
if windage_unit != adjustment_unit {
json["compare"]["units"]["windage_adjustment"] = serde_json::json!(wind_label);
}
if let Some((name, truncation)) = &truncation {
let mut block = card_truncation_json(truncation, units);
block["load"] = serde_json::json!(name);
json["compare"]["truncated"] = block;
}
println!("{}", serde_json::to_string_pretty(&json)?);
}
OutputFormat::Csv => {
let safe: Vec<String> = loads
.iter()
.map(|l| {
l.name
.chars()
.map(|c| {
if c == ',' || c == '"' || c == '\n' {
' '
} else {
c
}
})
.collect()
})
.collect();
let mut header = format!("range_{dist_unit}");
for name in &safe {
header.push_str(&format!(
",{name}_drop_{drop_unit},{name}_drop_{elev_label},{name}_drift_{drop_unit},\
{name}_drift_{wind_label},{name}_velocity_{vel_unit},{name}_energy_{energy_unit},\
{name}_tof_s"
));
}
println!("{header}");
for (ri, &range) in ranges.iter().enumerate() {
let mut line = format!("{range:.0}");
for res in &results {
let row = &res.rows[ri];
line.push_str(&format!(
",{:.2},{:.2},{:.2},{:.2},{:.0},{:.0},{:.3}",
row.drop_linear.unwrap(),
row.drop_adj.unwrap(),
row.wind_linear.unwrap(),
row.wind_adj.unwrap(),
row.velocity.unwrap(),
row.energy.unwrap(),
row.time.unwrap()
));
}
println!("{line}");
}
}
OutputFormat::Pdf => {
return Err("PDF output is not supported for compare (use the card commands)".into());
}
OutputFormat::Table => {
println!("Load Comparison");
println!(
"Zero: {zero_distance:.0} {dist_unit} | Wind: {wind_speed:.0} {wind_unit_label} @ {wind_direction:.0}\u{b0} | Sight: {sight_height} {sh_unit} | Alt: {altitude:.0} {alt_unit}",
sh_unit = match units { UnitSystem::Imperial => "in", UnitSystem::Metric => "mm" },
alt_unit = match units { UnitSystem::Imperial => "ft", UnitSystem::Metric => "m" }
);
println!();
for (i, l) in loads.iter().enumerate() {
let physics_tag = match (&l.bc_segments_data, &l.custom_drag_table) {
(_, Some(_)) => " [custom drag curve]",
(Some(_), None) => " [BC segments]",
(None, None) => "",
};
println!(
" #{num} {name}: {dm} BC {bc}, {mass} @ {vel} {vel_unit} (dia {dia}){physics_tag}",
num = i + 1,
name = l.name,
dm = l.drag_model,
bc = l.bc,
mass = l.mass,
vel = l.velocity,
dia = l.diameter,
physics_tag = physics_tag,
);
}
println!();
let trunc = |s: &str| -> String {
if s.chars().count() > 26 {
let t: String = s.chars().take(25).collect();
format!("{t}\u{2026}")
} else {
s.to_string()
}
};
let mut h1 = format!("{:>8} ", "Range");
let mut h2 = format!("{:>8} ", format!("({dist_unit})"));
for l in &loads {
h1.push_str(&format!("| {:^26} ", trunc(&l.name)));
h2.push_str(&format!("| {:>8} {:>8} {:>8} ", "Drop", "Drift", "Vel"));
}
println!("{h1}");
println!("{h2}");
let width = 9 + loads.len() * 29;
println!("{}", "-".repeat(width));
for (ri, &range) in ranges.iter().enumerate() {
let mut line = format!("{range:>8.0} ");
for res in &results {
let row = &res.rows[ri];
line.push_str(&format!(
"| {:>8.2} {:>8.2} {:>8.0} ",
row.drop_adj.unwrap(),
row.wind_adj.unwrap(),
row.velocity.unwrap()
));
}
println!("{line}");
}
println!("{}", "-".repeat(width));
if elev_label == wind_label {
println!(
"Drop/Drift in {elev_label} (- is down/left), Vel in {vel_unit}. \
Use -o json or -o csv for linear drop/drift ({drop_unit}), energy \
({energy_unit}), time of flight, and deltas vs load #1."
);
} else {
println!(
"Drop in {elev_label}, Drift in {wind_label} (- is down/left), Vel in {vel_unit}. \
Use -o json or -o csv for linear drop/drift ({drop_unit}), energy \
({energy_unit}), time of flight, and deltas vs load #1."
);
}
}
}
if let Some((name, truncation)) = &truncation {
eprintln!(
"warning: load '{name}': {}",
truncation.message(card_distance_unit(units))
);
}
Ok(())
}
#[cfg(test)]
mod card_row_sentinel_tests {
use super::{come_up_drop_label_width, come_up_row_line, range_table_row_line, CardRow};
fn sentinel_row() -> CardRow {
CardRow {
range: 111.0,
drop_linear: Some(222.1),
drop_adj: Some(333.2),
come_up: Some(444.3),
wind_linear: Some(555.4),
wind_adj: Some(666.5),
velocity: Some(777.6),
energy: Some(888.7),
time: Some(9.123),
lead_adj: Some(101.0),
wind_columns: Vec::new(),
}
}
#[test]
fn come_up_row_line_places_each_sentinel_in_its_own_column() {
let r = sentinel_row();
let drop_field_w = come_up_drop_label_width("MIL") + 6;
let line = come_up_row_line(&r, 1, false, drop_field_w);
let expected = format!(
"│{:>9.0} │{:>drop_field_w$.3} │{:>9.3} │{:>9.0} │{:>9.0} │{:>9.3} │",
r.range,
r.drop_adj.unwrap(),
r.come_up.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap(),
);
assert_eq!(line, expected);
assert!(
line.contains("333.200"),
"drop_adj sentinel missing/misplaced: {line}"
);
assert!(
line.contains("444.300"),
"come_up sentinel missing/misplaced: {line}"
);
assert!(
!line.contains("666.5"),
"wind_adj has no come-ups column but its sentinel leaked in: {line}"
);
}
#[test]
fn come_up_row_line_places_each_sentinel_in_its_own_column_when_clicks() {
let r = sentinel_row();
let drop_field_w = come_up_drop_label_width("CLICKS") + 6;
let line = come_up_row_line(&r, 1, true, drop_field_w);
let expected = format!(
"│{:>9.0} │{:>drop_field_w$.0} │{:>9.0} │{:>9.0} │{:>9.0} │{:>9.3} │",
r.range,
r.drop_adj.unwrap(),
r.come_up.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap(),
);
assert_eq!(line, expected);
assert!(
line.contains("333"),
"drop_adj sentinel missing/misplaced: {line}"
);
assert!(
line.contains("444"),
"come_up sentinel missing/misplaced: {line}"
);
}
#[test]
fn range_table_row_line_places_each_sentinel_in_its_own_column() {
let r = sentinel_row();
let line = range_table_row_line(&r);
let expected = format!(
"│{:>6.0} │{:>8.1} │{:>8.3} │{:>8.1} │{:>8.2} │{:>8.0} │{:>8.0} │{:>6.2} │",
r.range,
r.drop_linear.unwrap(),
r.drop_adj.unwrap(),
r.wind_linear.unwrap(),
r.wind_adj.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap(),
);
assert_eq!(line, expected);
assert!(
line.contains("222.1"),
"drop_linear sentinel missing/misplaced: {line}"
);
assert!(
line.contains("333.200"),
"drop_adj sentinel missing/misplaced: {line}"
);
assert!(
line.contains("555.4"),
"wind_linear sentinel missing/misplaced: {line}"
);
assert!(
line.contains("666.50"),
"wind_adj sentinel missing/misplaced: {line}"
);
}
}
#[cfg(all(test, feature = "pdf"))]
mod dope_card_row_tests {
use super::{dope_card_row_from_sample, trajectory_sampling, AdjustmentUnit, PdfMetadata};
fn sample_at(distance_m: f64) -> trajectory_sampling::TrajectorySample {
trajectory_sampling::TrajectorySample {
distance_m,
drop_m: 0.5,
wind_drift_m: 0.1,
velocity_mps: 0.0,
energy_j: 0.0,
time_s: 0.3,
flags: Vec::new(),
}
}
fn pdf_meta_fixture() -> PdfMetadata {
PdfMetadata {
rifle_name: "Test".to_string(),
location_name: "Test".to_string(),
powder: "Test".to_string(),
bullet_name: "Test".to_string(),
target_speed_mph: 0.0,
output_file: None,
velocity_fps: 2700.0,
temperature_f: 59.0,
pressure_inhg: 29.92,
altitude_ft: 0.0,
wind_speed_mph: 0.0,
weight_gr: 175.0,
font_scale: 1.0,
bold_data: false,
adjustment_unit: AdjustmentUnit::Mil,
windage_unit: AdjustmentUnit::Mil,
}
}
#[test]
fn trajectory_built_rows_carry_integer_valued_ranges() {
let pdf_meta = pdf_meta_fixture();
let expected = [
(100.0, 109.0),
(110.0, 120.0),
(120.0, 131.0),
(130.0, 142.0),
(730.0, 798.0),
];
for (distance_m, expected_range_yd) in expected {
let row = dope_card_row_from_sample(
&sample_at(distance_m),
&pdf_meta,
None,
None,
0.0,
0.0,
1.0,
1.0,
);
assert_eq!(
row.range, expected_range_yd,
"distance_m={distance_m}: expected legacy-rounded range {expected_range_yd}, got {}",
row.range
);
assert_eq!(
(row.range - row.range.round()).abs(),
0.0,
"distance_m={distance_m}: range must be integer-valued, got {}",
row.range
);
}
}
}
#[cfg(all(test, feature = "pdf"))]
mod density_altitude_round_trip_tests {
use ballistics_engine::atmosphere::resolve_atmosphere_for_density_altitude;
use ballistics_engine::pdf_dope_card::calculate_density_altitude;
const FT_TO_M: f64 = 0.3048;
#[test]
fn density_altitude_round_trips_through_the_dope_card_formula_default_temperature() {
for da_ft in [0.0_f64, 500.0, 1500.0, 3000.0, 6000.0, 9000.0] {
let da_m = da_ft * FT_TO_M;
let (_altitude_m, temp_c, pressure_hpa) =
resolve_atmosphere_for_density_altitude(da_m, None);
let temp_f = temp_c * 9.0 / 5.0 + 32.0;
let pressure_inhg = pressure_hpa / 33.863_886_666_667; let round_tripped_ft = calculate_density_altitude(-999_999.0, pressure_inhg, temp_f);
assert!(
(round_tripped_ft - da_ft).abs() < 1e-6,
"da_ft={da_ft}: round-tripped {round_tripped_ft} (temp_c={temp_c}, \
pressure_hpa={pressure_hpa})"
);
}
}
#[test]
fn density_altitude_round_trips_through_the_dope_card_formula_explicit_temperature() {
for da_ft in [0.0_f64, 1000.0, 4000.0, 8000.0] {
for explicit_temp_c in [-10.0_f64, 0.0, 25.0, 40.0] {
let da_m = da_ft * FT_TO_M;
let (_altitude_m, temp_c, pressure_hpa) =
resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
assert_eq!(
temp_c, explicit_temp_c,
"explicit temperature must be honored exactly"
);
let temp_f = temp_c * 9.0 / 5.0 + 32.0;
let pressure_inhg = pressure_hpa / 33.863_886_666_667; let round_tripped_ft = calculate_density_altitude(0.0, pressure_inhg, temp_f);
assert!(
(round_tripped_ft - da_ft).abs() < 1e-6,
"da_ft={da_ft} explicit_temp_c={explicit_temp_c}: round-tripped \
{round_tripped_ft} (pressure_hpa={pressure_hpa})"
);
}
}
}
}
#[cfg(test)]
mod profile_unit_tests {
use super::*;
use ballistics_engine::optic::HoldBounds;
fn metric_profile() -> ProfileData {
ProfileData {
name: "metric-exact".to_string(),
velocity: 762.0,
bc: 0.243,
mass: 11.339_809_25,
diameter: 7.8232,
drag_model: "G7".to_string(),
twist_rate: Some(254.0),
sight_height: Some(50.8),
zero_distance: Some(91.44),
units: "metric".to_string(),
temperature: 15.0,
pressure: 1_013.207_888,
humidity: 55.0,
altitude: 304.8,
bullet_name: Some("175gr SMK".to_string()),
created: Some("test".to_string()),
wind_speed: Some(4.4704),
wind_direction: Some(90.0),
shooting_angle: Some(-5.0),
auto_zero: Some(91.44),
twist_right: Some(false),
use_bc_segments: Some(true),
bullet_length: Some(30.48),
elevation_click: None,
windage_click: None,
bc_segments: None,
drag_curve: None,
dsf_points: None,
bc_reference: None,
pressure_reference: None,
density_altitude: None,
zero_poi_up_m: None,
zero_poi_right_m: None,
sight_offset_lateral_m: None,
elevation_cf: None,
windage_cf: None,
zero_sets: None,
reticle: None,
clicks_per_revolution: None,
zero_stop: None,
elevation_travel_up_mil: None,
elevation_travel_down_mil: None,
windage_travel_left_mil: None,
windage_travel_right_mil: None,
turret_elevation_dialed_mil: None,
turret_windage_dialed_mil: None,
hold_bound_up_mil: None,
hold_bound_down_mil: None,
hold_bound_left_mil: None,
hold_bound_right_mil: None,
}
}
fn assert_close(actual: f64, expected: f64) {
let tolerance = expected.abs().max(1.0) * 1e-12;
assert!(
(actual - expected).abs() <= tolerance,
"actual={actual} expected={expected}"
);
}
#[test]
fn profile_conversion_preserves_physics_and_unitless_fields() {
let metric = metric_profile();
let imperial = metric.clone().converted_to(UnitSystem::Imperial).unwrap();
assert_eq!(imperial.units, "imperial");
assert_close(imperial.velocity, 2500.0);
assert_close(imperial.mass, 175.0);
assert_close(imperial.diameter, 0.308);
assert_close(imperial.twist_rate.unwrap(), 10.0);
assert_close(imperial.sight_height.unwrap(), 2.0);
assert_close(imperial.zero_distance.unwrap(), 100.0);
assert_close(imperial.temperature, 59.0);
assert_close(imperial.pressure, 29.92);
assert_close(imperial.altitude, 1000.0);
assert_close(imperial.wind_speed.unwrap(), 10.0);
assert_close(imperial.auto_zero.unwrap(), 100.0);
assert_close(imperial.bullet_length.unwrap(), 1.2);
assert_eq!(imperial.bc.to_bits(), metric.bc.to_bits());
assert_eq!(imperial.humidity.to_bits(), metric.humidity.to_bits());
assert_eq!(imperial.wind_direction, metric.wind_direction);
assert_eq!(imperial.shooting_angle, metric.shooting_angle);
assert_eq!(imperial.twist_right, metric.twist_right);
assert_eq!(imperial.use_bc_segments, metric.use_bc_segments);
assert_eq!(imperial.bullet_name, metric.bullet_name);
let round_trip = imperial.converted_to(UnitSystem::Metric).unwrap();
assert_close(round_trip.velocity, metric.velocity);
assert_close(round_trip.mass, metric.mass);
assert_close(round_trip.diameter, metric.diameter);
assert_close(round_trip.twist_rate.unwrap(), metric.twist_rate.unwrap());
assert_close(
round_trip.sight_height.unwrap(),
metric.sight_height.unwrap(),
);
assert_close(round_trip.temperature, metric.temperature);
assert_close(round_trip.pressure, metric.pressure);
assert_close(round_trip.altitude, metric.altitude);
}
#[test]
fn bc_segments_and_drag_curve_survive_unit_conversion_untouched() {
let base = metric_profile();
let with_v2_fields = ProfileData {
bc_segments: Some(vec![
ProfileBcSegment {
bc: 0.243,
velocity_mps: 792.0,
},
ProfileBcSegment {
bc: 0.230,
velocity_mps: 400.0,
},
]),
drag_curve: Some(vec![
ProfileDragPoint {
mach: 0.5,
cd: 0.23,
},
ProfileDragPoint {
mach: 1.2,
cd: 0.45,
},
ProfileDragPoint {
mach: 3.0,
cd: 0.28,
},
]),
..base
};
let imperial = with_v2_fields
.clone()
.converted_to(UnitSystem::Imperial)
.unwrap();
assert_eq!(imperial.bc_segments, with_v2_fields.bc_segments);
assert_eq!(imperial.drag_curve, with_v2_fields.drag_curve);
let round_trip = imperial.converted_to(UnitSystem::Metric).unwrap();
assert_eq!(round_trip.bc_segments, with_v2_fields.bc_segments);
assert_eq!(round_trip.drag_curve, with_v2_fields.drag_curve);
let same_unit = with_v2_fields
.clone()
.converted_to(UnitSystem::Metric)
.unwrap();
assert_eq!(same_unit.bc_segments, with_v2_fields.bc_segments);
assert_eq!(same_unit.drag_curve, with_v2_fields.drag_curve);
}
#[test]
fn phase1_profile_json_without_v2_fields_deserializes_with_none() {
let phase1_json = r#"{
"name": "phase1-profile",
"velocity": 2700.0,
"bc": 0.475,
"mass": 175.0,
"diameter": 0.308,
"drag_model": "G7",
"units": "imperial",
"temperature": 59.0,
"pressure": 29.92,
"humidity": 50.0,
"altitude": 0.0
}"#;
let profile: ProfileData = serde_json::from_str(phase1_json).unwrap();
assert_eq!(profile.name, "phase1-profile");
assert!(profile.bc_segments.is_none());
assert!(profile.drag_curve.is_none());
let reserialized = serde_json::to_string(&profile).unwrap();
assert!(!reserialized.contains("bc_segments"));
assert!(!reserialized.contains("drag_curve"));
}
#[test]
fn profile_without_bc_reference_key_round_trips_as_icao() {
let phase1_json = r#"{
"name": "pre-mba-1365",
"velocity": 2700.0,
"bc": 0.475,
"mass": 175.0,
"diameter": 0.308,
"drag_model": "G7",
"units": "imperial",
"temperature": 59.0,
"pressure": 29.92,
"humidity": 50.0,
"altitude": 0.0
}"#;
let profile: ProfileData = serde_json::from_str(phase1_json).unwrap();
assert_eq!(profile.bc_reference, None);
assert_eq!(
parse_bc_reference_profile_field(profile.bc_reference.as_deref()).unwrap(),
BcReferenceStandard::Icao
);
let reserialized = serde_json::to_string(&profile).unwrap();
assert!(!reserialized.contains("bc_reference"));
}
#[test]
fn profile_with_army_standard_metro_bc_reference_round_trips() {
let profile = ProfileData {
bc_reference: Some("army-standard-metro".to_string()),
..metric_profile()
};
let json = serde_json::to_string(&profile).unwrap();
assert!(json.contains("army-standard-metro"));
let reloaded: ProfileData = serde_json::from_str(&json).unwrap();
assert_eq!(
reloaded.bc_reference.as_deref(),
Some("army-standard-metro")
);
assert_eq!(
parse_bc_reference_profile_field(reloaded.bc_reference.as_deref()).unwrap(),
BcReferenceStandard::ArmyStandardMetro
);
}
#[test]
fn parse_bc_reference_profile_field_rejects_garbage() {
assert!(parse_bc_reference_profile_field(Some("metro")).is_err());
assert!(parse_bc_reference_profile_field(Some("ICAO")).is_ok()); }
#[test]
fn profile_without_pressure_reference_key_round_trips_as_absolute() {
let phase1_json = r#"{
"name": "pre-mba-1397",
"velocity": 2700.0,
"bc": 0.475,
"mass": 175.0,
"diameter": 0.308,
"drag_model": "G7",
"units": "imperial",
"temperature": 59.0,
"pressure": 29.92,
"humidity": 50.0,
"altitude": 0.0
}"#;
let profile: ProfileData = serde_json::from_str(phase1_json).unwrap();
assert_eq!(profile.pressure_reference, None);
assert_eq!(
parse_pressure_reference_profile_field(profile.pressure_reference.as_deref()).unwrap(),
PressureReferenceMode::Absolute
);
let reserialized = serde_json::to_string(&profile).unwrap();
assert!(!reserialized.contains("pressure_reference"));
}
#[test]
fn profile_with_qnh_pressure_reference_round_trips() {
let profile = ProfileData {
pressure_reference: Some("qnh".to_string()),
..metric_profile()
};
let json = serde_json::to_string(&profile).unwrap();
assert!(json.contains("qnh"));
let reloaded: ProfileData = serde_json::from_str(&json).unwrap();
assert_eq!(reloaded.pressure_reference.as_deref(), Some("qnh"));
assert_eq!(
parse_pressure_reference_profile_field(reloaded.pressure_reference.as_deref()).unwrap(),
PressureReferenceMode::Qnh
);
}
#[test]
fn parse_pressure_reference_profile_field_rejects_garbage() {
assert!(parse_pressure_reference_profile_field(Some("barometric")).is_err());
assert!(parse_pressure_reference_profile_field(Some("QNH")).is_ok()); }
#[test]
fn profile_without_density_altitude_key_round_trips_as_none() {
let phase1_json = r#"{
"name": "pre-mba-1366",
"velocity": 2700.0,
"bc": 0.475,
"mass": 175.0,
"diameter": 0.308,
"drag_model": "G7",
"units": "imperial",
"temperature": 59.0,
"pressure": 29.92,
"humidity": 50.0,
"altitude": 0.0
}"#;
let profile: ProfileData = serde_json::from_str(phase1_json).unwrap();
assert_eq!(profile.density_altitude, None);
let reserialized = serde_json::to_string(&profile).unwrap();
assert!(!reserialized.contains("density_altitude"));
}
#[test]
fn profile_with_density_altitude_round_trips_and_converts_like_altitude() {
let profile = ProfileData {
density_altitude: Some(3000.0), units: "imperial".to_string(),
altitude: 1000.0,
..metric_profile()
};
let json = serde_json::to_string(&profile).unwrap();
assert!(json.contains("density_altitude"));
let reloaded: ProfileData = serde_json::from_str(&json).unwrap();
assert_eq!(reloaded.density_altitude, Some(3000.0));
let metric = reloaded.converted_to(UnitSystem::Metric).unwrap();
let expected_altitude_m = 1000.0 * 0.3048;
let expected_da_m = 3000.0 * 0.3048;
assert!((metric.altitude - expected_altitude_m).abs() < 1e-9);
assert!(
(metric.density_altitude.unwrap() - expected_da_m).abs() < 1e-9,
"density_altitude must convert like altitude: got {:?}",
metric.density_altitude
);
}
#[test]
fn same_unit_profile_conversion_is_bit_preserving() {
let profile = metric_profile();
let before = serde_json::to_string(&profile).unwrap();
let after = profile.converted_to(UnitSystem::Metric).unwrap();
assert_eq!(serde_json::to_string(&after).unwrap(), before);
}
#[test]
fn zero_poi_fields_round_trip_and_survive_unit_conversion_untouched() {
let profile = metric_profile();
assert_eq!(profile.zero_poi_up_m, None);
let json = serde_json::to_string(&profile).unwrap();
assert!(!json.contains("zero_poi_up_m") && !json.contains("zero_poi_right_m"));
let profile = ProfileData {
zero_poi_up_m: Some(0.00254),
zero_poi_right_m: Some(-0.00508),
sight_offset_lateral_m: Some(0.0127), ..metric_profile()
};
let reloaded: ProfileData =
serde_json::from_str(&serde_json::to_string(&profile).unwrap()).unwrap();
assert_eq!(reloaded.zero_poi_up_m, Some(0.00254));
assert_eq!(reloaded.zero_poi_right_m, Some(-0.00508));
assert_eq!(reloaded.sight_offset_lateral_m, Some(0.0127));
let imperial = reloaded.converted_to(UnitSystem::Imperial).unwrap();
assert_eq!(imperial.zero_poi_up_m, Some(0.00254));
assert_eq!(imperial.zero_poi_right_m, Some(-0.00508));
assert_eq!(imperial.sight_offset_lateral_m, Some(0.0127));
}
#[test]
fn converted_profile_keeps_explicit_cli_precedence() {
let profile = Some(metric_profile().converted_to(UnitSystem::Imperial).unwrap());
assert_eq!(
resolve_param(Some(2600.0), &profile, |p| p.velocity),
Some(2600.0)
);
assert_eq!(resolve_param(None, &profile, |p| p.velocity), Some(2500.0));
assert_eq!(
Some(2.5).or_else(|| profile.as_ref().and_then(|p| p.sight_height)),
Some(2.5)
);
assert_eq!(
None.or_else(|| profile.as_ref().and_then(|p| p.sight_height)),
Some(2.0)
);
}
#[test]
fn legacy_profiles_default_to_imperial_and_unknown_units_are_rejected() {
let legacy: ProfileData = serde_json::from_str(
r#"{"name":"legacy","velocity":2500.0,"bc":0.243,"mass":175.0,"diameter":0.308,"drag_model":"G7"}"#,
)
.unwrap();
assert_eq!(legacy.units, "imperial");
let metric = legacy.converted_to(UnitSystem::Metric).unwrap();
assert_close(metric.velocity, 762.0);
assert_close(metric.mass, 11.339_809_25);
assert_close(metric.diameter, 7.8232);
let mut invalid = metric_profile();
invalid.units = "si".to_string();
let error = invalid.converted_to(UnitSystem::Imperial).unwrap_err();
assert!(error.to_string().contains("unsupported units 'si'"));
}
#[test]
fn profile_click_fields_roundtrip_and_flags_override() {
let mut profile = metric_profile();
profile.elevation_click = Some("0.1mil".to_string());
profile.windage_click = Some("0.2mil".to_string());
let (el, wi) = resolve_click_values(None, None, Some(&profile))
.unwrap()
.unwrap();
assert_eq!(el, parse_click_value("0.1mil").unwrap());
assert_eq!(wi, parse_click_value("0.2mil").unwrap());
let (el2, wi2) = resolve_click_values(Some("0.25moa"), Some("0.5moa"), Some(&profile))
.unwrap()
.unwrap();
assert_eq!(el2, parse_click_value("0.25moa").unwrap());
assert_eq!(wi2, parse_click_value("0.5moa").unwrap());
let mut elev_only = metric_profile();
elev_only.elevation_click = Some("0.25moa".to_string());
let (el3, wi3) = resolve_click_values(None, None, Some(&elev_only))
.unwrap()
.unwrap();
assert_eq!(el3, wi3);
let imperial = profile.clone().converted_to(UnitSystem::Imperial).unwrap();
assert_eq!(imperial.elevation_click, profile.elevation_click);
assert_eq!(imperial.windage_click, profile.windage_click);
}
#[test]
fn parse_angular_mil_converts_suffixed_values_exactly() {
assert_eq!(parse_angular_mil("28mil").unwrap(), 28.0);
assert_eq!(
parse_angular_mil("34.38moa").unwrap(),
34.38 * 1000.0 / 3438.0
);
assert_eq!(parse_angular_mil("3.5smoa").unwrap(), 3.5 * 1000.0 / 3600.0);
assert_eq!(
parse_angular_mil("3.5iphy").unwrap(),
parse_angular_mil("3.5smoa").unwrap()
);
assert_eq!(parse_angular_mil("-5.2mil").unwrap(), -5.2);
assert_eq!(parse_angular_mil("0mil").unwrap(), 0.0);
}
#[test]
fn parse_angular_mil_requires_a_suffix() {
let e = parse_angular_mil("5").unwrap_err();
assert!(
e.contains("mil") && e.contains("moa") && e.contains("smoa"),
"{e}"
);
assert!(parse_angular_mil("").is_err());
assert!(parse_angular_mil("mil").is_err()); assert!(parse_angular_mil("nanmil").is_err()); assert!(parse_angular_mil("infmil").is_err()); }
#[test]
fn optic_fields_round_trip_and_assemble_into_a_valid_optic_profile() {
let profile = ProfileData {
elevation_click: Some("0.1mil".to_string()),
windage_click: Some("0.2mil".to_string()),
clicks_per_revolution: Some(12),
zero_stop: Some(true),
elevation_travel_up_mil: Some(28.0),
elevation_travel_down_mil: Some(5.0),
windage_travel_left_mil: Some(10.0),
windage_travel_right_mil: Some(11.0),
turret_elevation_dialed_mil: Some(3.5),
turret_windage_dialed_mil: Some(-1.2),
hold_bound_up_mil: Some(4.0),
hold_bound_down_mil: Some(12.0),
hold_bound_left_mil: Some(6.0),
hold_bound_right_mil: Some(6.5),
..metric_profile()
};
let json = serde_json::to_string_pretty(&profile).unwrap();
let reloaded: ProfileData = serde_json::from_str(&json).unwrap();
assert_eq!(reloaded.clicks_per_revolution, Some(12));
assert_eq!(reloaded.zero_stop, Some(true));
assert_eq!(reloaded.elevation_travel_up_mil, Some(28.0));
assert_eq!(reloaded.elevation_travel_down_mil, Some(5.0));
assert_eq!(reloaded.windage_travel_left_mil, Some(10.0));
assert_eq!(reloaded.windage_travel_right_mil, Some(11.0));
assert_eq!(reloaded.turret_elevation_dialed_mil, Some(3.5));
assert_eq!(reloaded.turret_windage_dialed_mil, Some(-1.2));
assert_eq!(reloaded.hold_bound_up_mil, Some(4.0));
assert_eq!(reloaded.hold_bound_down_mil, Some(12.0));
assert_eq!(reloaded.hold_bound_left_mil, Some(6.0));
assert_eq!(reloaded.hold_bound_right_mil, Some(6.5));
let optic = reloaded
.optic_profile()
.unwrap()
.expect("elevation_click set");
optic.validate().expect("well-formed optic profile");
assert_eq!(optic.elevation_click, parse_click_value("0.1mil").unwrap());
assert_eq!(optic.windage_click, parse_click_value("0.2mil").unwrap());
assert_eq!(optic.clicks_per_revolution, Some(12));
assert!(optic.zero_stop);
assert_eq!(
optic.elevation_travel,
Some(TravelLimits {
up_mil: 28.0,
down_mil: 5.0
})
);
assert_eq!(
optic.windage_travel,
Some(TravelLimits {
up_mil: 11.0,
down_mil: 10.0
})
);
assert_eq!(
optic.turret_state,
Some(TurretState {
elevation_mil: 3.5,
windage_mil: -1.2
})
);
assert_eq!(
optic.reticle_hold_bounds,
Some(HoldBounds {
up_mil: 4.0,
down_mil: 12.0,
left_mil: 6.0,
right_mil: 6.5
})
);
let imperial = reloaded.clone().converted_to(UnitSystem::Imperial).unwrap();
assert_eq!(
imperial.elevation_travel_up_mil,
reloaded.elevation_travel_up_mil
);
assert_eq!(imperial.hold_bound_right_mil, reloaded.hold_bound_right_mil);
assert_eq!(
imperial.turret_windage_dialed_mil,
reloaded.turret_windage_dialed_mil
);
}
#[test]
fn optic_profile_with_only_elevation_click_set_has_none_for_the_rest() {
let profile = ProfileData {
elevation_click: Some("0.25moa".to_string()),
..metric_profile()
};
let optic = profile
.optic_profile()
.unwrap()
.expect("elevation_click set");
assert_eq!(optic.windage_click, optic.elevation_click); assert_eq!(optic.clicks_per_revolution, None);
assert!(!optic.zero_stop); assert_eq!(optic.elevation_travel, None);
assert_eq!(optic.windage_travel, None);
assert_eq!(optic.turret_state, None);
assert_eq!(optic.reticle_hold_bounds, None);
optic
.validate()
.expect("a minimal optic profile is still valid");
}
#[test]
fn optic_profile_is_none_for_a_completely_untouched_profile() {
assert_eq!(metric_profile().optic_profile().unwrap(), None);
}
#[test]
fn optic_profile_errors_when_other_fields_are_set_without_an_elevation_click() {
let profile = ProfileData {
elevation_travel_up_mil: Some(10.0),
elevation_travel_down_mil: Some(10.0),
..metric_profile()
};
let err = profile.optic_profile().unwrap_err();
assert!(err.contains("--elevation-click"), "{err}");
}
#[test]
fn optic_profile_errors_on_out_of_range_state_even_without_an_elevation_click() {
let profile = ProfileData {
elevation_travel_up_mil: Some(10.0),
elevation_travel_down_mil: Some(10.0),
turret_elevation_dialed_mil: Some(15.0), turret_windage_dialed_mil: Some(0.0),
..metric_profile()
};
let err = profile.optic_profile().unwrap_err();
assert!(err.contains("--elevation-click"), "{err}");
}
#[test]
fn optic_profile_validation_fails_naming_the_axis_when_dialed_state_exceeds_travel() {
let profile = ProfileData {
elevation_click: Some("0.1mil".to_string()),
elevation_travel_up_mil: Some(10.0),
elevation_travel_down_mil: Some(10.0),
turret_elevation_dialed_mil: Some(15.0), turret_windage_dialed_mil: Some(0.0),
..metric_profile()
};
let optic = profile
.optic_profile()
.unwrap()
.expect("elevation_click set");
let err = optic.validate().unwrap_err().to_string();
assert!(err.contains("elevation"), "{err}");
assert!(err.contains("outside its travel"), "{err}");
}
#[test]
fn optic_profile_errors_naming_the_flag_when_a_travel_pair_is_incomplete() {
let profile = ProfileData {
elevation_click: Some("0.1mil".to_string()),
elevation_travel_up_mil: Some(10.0),
..metric_profile()
};
let err = profile.optic_profile().unwrap_err();
assert!(err.contains("--travel-up"), "{err}");
assert!(err.contains("--travel-down"), "{err}");
assert!(!err.contains("elevation_travel_up_mil"), "{err}");
}
#[test]
fn optic_profile_errors_naming_missing_flags_when_hold_bounds_are_incomplete() {
let profile = ProfileData {
elevation_click: Some("0.1mil".to_string()),
hold_bound_up_mil: Some(4.0),
hold_bound_left_mil: Some(6.0),
..metric_profile()
};
let err = profile.optic_profile().unwrap_err();
assert!(err.contains("--hold-down"), "{err}");
assert!(err.contains("--hold-right"), "{err}");
assert!(!err.contains("hold_bound_down_mil"), "{err}");
}
#[test]
fn optic_profile_validation_fails_for_zero_clicks_per_revolution() {
let profile = ProfileData {
elevation_click: Some("0.1mil".to_string()),
clicks_per_revolution: Some(0),
..metric_profile()
};
let optic = profile
.optic_profile()
.unwrap()
.expect("elevation_click set");
let err = optic.validate().unwrap_err().to_string();
assert!(err.contains("clicks_per_revolution"), "{err}");
}
#[test]
fn zero_sets_roundtrip_and_absent_key_stays_absent() {
let mut profile = metric_profile();
assert!(!serde_json::to_string(&profile)
.unwrap()
.contains("zero_sets"));
profile.zero_sets = Some(vec![
ProfileZeroSet {
name: "suppressed".to_string(),
zero_distance: Some(200.0),
poi_up_mil: Some(-0.3),
poi_right_mil: Some(0.1),
notes: Some("suppressed load".to_string()),
},
ProfileZeroSet {
name: "match".to_string(),
zero_distance: None,
poi_up_mil: Some(0.25),
poi_right_mil: None,
notes: None,
},
]);
let json = serde_json::to_string_pretty(&profile).unwrap();
let back: ProfileData = serde_json::from_str(&json).unwrap();
assert_eq!(back.zero_sets, profile.zero_sets);
}
#[test]
fn old_reader_drops_zero_sets_on_resave_and_keeps_master_zero() {
#[derive(serde::Serialize, serde::Deserialize)]
struct OldProfileData {
name: String,
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: String,
#[serde(default)]
zero_distance: Option<f64>,
#[serde(default)]
auto_zero: Option<f64>,
}
let mut profile = metric_profile();
profile.zero_sets = Some(vec![ProfileZeroSet {
name: "alt".to_string(),
zero_distance: Some(300.0),
poi_up_mil: Some(-0.5),
poi_right_mil: None,
notes: None,
}]);
let json = serde_json::to_string_pretty(&profile).unwrap();
let old: OldProfileData = serde_json::from_str(&json).expect("old reader loads");
assert_eq!(old.zero_distance, Some(91.44));
assert_eq!(old.auto_zero, Some(91.44));
let resaved = serde_json::to_string(&old).unwrap();
assert!(!resaved.contains("zero_sets"));
let reread: ProfileData = serde_json::from_str(&json).unwrap();
let err = resolve_zero_selection(Some("gone"), Some(&reread), None, None, None)
.map(|_| ())
.unwrap_err();
assert!(err.contains("available zero sets: alt"), "{err}");
}
#[test]
fn converted_to_rescales_zero_set_distances_only() {
let mut profile = metric_profile();
profile.zero_sets = Some(vec![ProfileZeroSet {
name: "far".to_string(),
zero_distance: Some(91.44),
poi_up_mil: Some(-0.3),
poi_right_mil: Some(0.15),
notes: None,
}]);
let imperial = profile.converted_to(UnitSystem::Imperial).unwrap();
let set = &imperial.zero_sets.as_deref().unwrap()[0];
assert_close(set.zero_distance.unwrap(), 100.0);
assert_eq!(set.poi_up_mil, Some(-0.3));
assert_eq!(set.poi_right_mil, Some(0.15));
}
#[test]
fn zero_selection_resolver_precedence_and_errors() {
let mut profile = metric_profile();
profile.zero_sets = Some(vec![ProfileZeroSet {
name: "far".to_string(),
zero_distance: Some(300.0),
poi_up_mil: Some(0.25),
poi_right_mil: Some(-0.1),
notes: None,
}]);
let sel = resolve_zero_selection(None, Some(&profile), None, None, Some(91.44)).unwrap();
assert_eq!(sel.zero_distance, Some(91.44));
assert_eq!(sel.elevation_bias_mil, 0.0);
assert_eq!(sel.windage_bias_mil, 0.0);
let sel =
resolve_zero_selection(Some("far"), Some(&profile), None, None, Some(91.44)).unwrap();
assert_eq!(sel.zero_distance, Some(300.0));
assert_eq!(sel.elevation_bias_mil, 0.25);
assert_eq!(sel.windage_bias_mil, -0.1);
let sel =
resolve_zero_selection(Some("far"), Some(&profile), None, Some(150.0), None).unwrap();
assert_eq!(sel.zero_distance, Some(150.0));
assert_eq!(sel.elevation_bias_mil, 0.25);
let mut csv = HashMap::new();
csv.insert("V_OFFSET_MIL".to_string(), "0.3".to_string());
csv.insert("H_OFFSET_MIL".to_string(), "-0.1".to_string());
let csv_set = zero_set_from_profile_csv(&csv, Some("R1")).expect("csv set");
assert_eq!(csv_set.poi_up_mil, Some(0.3));
assert_eq!(csv_set.poi_right_mil, Some(-0.1));
let err = resolve_zero_selection(Some("nope"), Some(&profile), Some(&csv_set), None, None)
.map(|_| ())
.unwrap_err();
assert!(err.contains("available zero sets: far, R1"), "{err}");
let sel = resolve_zero_selection(Some("R1"), None, Some(&csv_set), None, None).unwrap();
assert_eq!(sel.elevation_bias_mil, 0.3);
assert_eq!(sel.windage_bias_mil, -0.1);
let err = resolve_zero_selection(Some("x"), None, None, None, None)
.map(|_| ())
.unwrap_err();
assert!(err.contains("requires a profile"), "{err}");
assert!(zero_set_from_profile_csv(&HashMap::new(), Some("R1")).is_none());
}
#[test]
fn zero_set_bias_applies_before_the_cf_division() {
let drop_yd = 0.2; let range_yd = 200.0;
let base = drop_to_adjustment(drop_yd, range_yd, AdjustmentUnit::Mil);
assert!((base - 1.0).abs() < 1e-12);
let cf = 0.95;
let bias = 0.25;
let got = adjustment_display(drop_yd, range_yd, AdjustmentUnit::Mil, None, bias, cf).value;
assert!(
((got - (base + bias) / cf).abs()) < 1e-12,
"expected (true + bias)/cf, got {got}"
);
assert!((got - (base / cf + bias)).abs() > 1e-3);
let moa = adjustment_display(drop_yd, range_yd, AdjustmentUnit::Moa, None, bias, cf).value;
let base_moa = drop_to_adjustment(drop_yd, range_yd, AdjustmentUnit::Moa);
let expected_moa = (base_moa + bias * 3.438) / cf;
assert!((moa - expected_moa).abs() < 1e-12, "moa = {moa}");
let click = parse_click_value("0.1mil").unwrap();
let clicks = adjustment_display(
drop_yd,
range_yd,
AdjustmentUnit::Clicks,
Some(click),
bias,
cf,
)
.value;
let expected_clicks = (((base + bias) / cf) / 0.1).round();
assert_eq!(clicks, expected_clicks);
let none = adjustment_display(drop_yd, range_yd, AdjustmentUnit::Mil, None, 0.0, cf).value;
assert_eq!(none.to_bits(), (base / cf).to_bits());
}
#[test]
fn adjustment_display_clicks_arm_delegates_to_clicks_for() {
let mil = parse_click_value("0.1mil").unwrap();
let quarter_moa = parse_click_value("0.25moa").unwrap();
let cases = [
(0.2, 200.0, AdjustmentUnit::Clicks, mil, 0.0, 1.0),
(0.2, 200.0, AdjustmentUnit::Clicks, mil, 0.25, 0.95),
(-0.37, 300.0, AdjustmentUnit::Clicks, quarter_moa, 0.0, 1.0),
(1.6, 400.0, AdjustmentUnit::Clicks, quarter_moa, -0.5, 1.05),
(10.0, 0.5, AdjustmentUnit::Clicks, mil, 1.0, 0.5),
];
for (drop_yd, range_yd, unit, click, bias_mil, cf) in cases {
let got = adjustment_display(drop_yd, range_yd, unit, Some(click), bias_mil, cf).value;
let biased_drop_yd = if bias_mil != 0.0 {
drop_yd + bias_mil / 1000.0 * range_yd
} else {
drop_yd
};
let want = clicks_for(biased_drop_yd / cf, range_yd, &click) as f64;
assert_eq!(
got, want,
"drop_yd={drop_yd} range_yd={range_yd} bias_mil={bias_mil} cf={cf}"
);
}
}
#[test]
fn clicks_arm_order_of_operations_is_load_bearing() {
let click = parse_click_value("0.1mil").unwrap();
let (drop_yd, range_yd, bias_mil, cf) = (0.6, 100.0, 1.0, 0.5);
let result = adjustment_display(
drop_yd,
range_yd,
AdjustmentUnit::Clicks,
Some(click),
bias_mil,
cf,
);
assert_eq!(
result.value, 140.0,
"expected the bias-then-CF order's 140 clicks"
);
let wrong_order_clicks = {
let base = (drop_yd / range_yd) * adjustment_factor(ClickBase::Mil);
let angle_wrong = base / cf + bias_mil;
(angle_wrong / click.size).round()
};
assert_eq!(
wrong_order_clicks, 130.0,
"sanity: the wrong order really does diverge"
);
assert_ne!(result.value, wrong_order_clicks);
let q = result
.quantized
.expect("clicks arm always returns Some(Quantized)");
assert_eq!(q.clicks, 140);
let biased_drop_yd = drop_yd + bias_mil / 1000.0 * range_yd;
let angle = (biased_drop_yd / cf / range_yd) * adjustment_factor(ClickBase::Mil);
assert_eq!(
(q.clicks as f64 * click.size + q.residual).to_bits(),
angle.to_bits(),
"clicks*size + residual must reconstruct the quantized angle bit-exactly"
);
}
}
#[cfg(test)]
#[cfg(feature = "profile-import")]
mod a7p_import_mapping_tests {
use super::*;
use ballistics_engine::profile_import::{parse_a7p, wrap_payload};
fn enc_varint(mut v: u64, out: &mut Vec<u8>) {
loop {
let byte = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
out.push(byte);
return;
}
out.push(byte | 0x80);
}
}
fn enc_i32(number: u32, value: i64, out: &mut Vec<u8>) {
enc_varint(u64::from(number) << 3, out);
enc_varint(value as u64, out);
}
fn enc_str(number: u32, s: &str, out: &mut Vec<u8>) {
enc_varint((u64::from(number) << 3) | 2, out);
enc_varint(s.len() as u64, out);
out.extend_from_slice(s.as_bytes());
}
fn enc_bytes(number: u32, payload: &[u8], out: &mut Vec<u8>) {
enc_varint((u64::from(number) << 3) | 2, out);
enc_varint(payload.len() as u64, out);
out.extend_from_slice(payload);
}
fn barnes_338_file(bc_type: i64) -> Vec<u8> {
let mut p = Vec::new();
enc_str(1, "338LM/BARNES 300", &mut p); enc_str(3, "BARNES 300GR OTM", &mut p);
enc_i32(9, 90, &mut p);
enc_i32(10, 1000, &mut p);
enc_i32(11, 7920, &mut p);
enc_i32(12, 15, &mut p);
enc_i32(13, 1000, &mut p);
enc_i32(15, 15, &mut p);
enc_i32(16, 10000, &mut p);
enc_i32(17, 50, &mut p);
enc_i32(20, 338, &mut p);
enc_i32(21, 3000, &mut p);
enc_i32(22, 1800, &mut p);
enc_i32(24, bc_type, &mut p);
let mut packed = Vec::new();
enc_varint(10_000, &mut packed);
enc_bytes(26, &packed, &mut p);
for (bc, mv) in [(7500i64, 5000i64), (7160, 7920), (6900, 3000)] {
let mut row = Vec::new();
enc_i32(1, bc, &mut row);
enc_i32(2, mv, &mut row);
enc_bytes(27, &row, &mut p);
}
let mut payload = Vec::new();
enc_bytes(1, &p, &mut payload);
wrap_payload(&payload)
}
#[test]
fn zero_click_converts_click_counts_with_correct_signs() {
let mut p = Vec::new();
enc_str(1, "ZEROED", &mut p);
enc_i32(7, -20_000, &mut p);
enc_i32(8, 10_000, &mut p);
enc_i32(11, 7920, &mut p);
enc_i32(20, 338, &mut p);
enc_i32(21, 3000, &mut p);
enc_i32(24, 0, &mut p);
let mut row = Vec::new();
enc_i32(1, 7160, &mut row);
enc_i32(2, 7920, &mut row);
enc_bytes(27, &row, &mut p);
let mut packed = Vec::new();
enc_varint(10_000, &mut packed); enc_bytes(26, &packed, &mut p);
let mut payload = Vec::new();
enc_bytes(1, &p, &mut payload);
let doc = parse_a7p(&wrap_payload(&payload)).unwrap();
let outcome = map_a7p_to_profile(&doc, None, None).unwrap();
assert_eq!(outcome.profile.zero_poi_up_m, None);
assert_eq!(outcome.profile.zero_poi_right_m, None);
assert_eq!(outcome.profile.zero_sets, None);
assert_eq!(outcome.profile.elevation_click, None);
assert_eq!(outcome.profile.windage_click, None);
assert!(outcome
.report
.unmapped
.iter()
.any(|(f, msg)| f == "zero_x / zero_y"
&& msg
== "scope zeroing click offsets (-20000, 10000) — device click size not \
supplied; pass --zero-click to record the profile's turret \
graduation and convert this offset"));
let click = ballistics_engine::adjustment::parse_click_value("0.1mil").unwrap();
let outcome = map_a7p_to_profile(&doc, None, Some(click)).unwrap();
let up = outcome.profile.zero_poi_up_m.expect("up mapped");
let right = outcome.profile.zero_poi_right_m.expect("right mapped");
assert!((up - 0.1).abs() < 1e-12, "up = {up}");
assert!((right - 0.2).abs() < 1e-12, "right = {right}");
assert!(!outcome
.report
.unmapped
.iter()
.any(|(f, _)| f == "zero_x / zero_y"));
assert!(outcome
.report
.mapped
.iter()
.any(|row| row[0] == "zero_x / zero_y"
&& row[3] == "zero_poi_up_m + zero_poi_right_m + zero_sets[a7p-zero]"));
assert_eq!(
outcome.profile.elevation_click,
outcome.profile.windage_click
);
assert_eq!(outcome.profile.elevation_click.as_deref(), Some("0.1mil"));
assert_eq!(outcome.profile.windage_click.as_deref(), Some("0.1mil"));
assert!(outcome
.report
.mapped
.iter()
.any(|row| row[0] == "--zero-click" && row[3] == "elevation_click + windage_click"));
let sets = outcome
.profile
.zero_sets
.as_deref()
.expect("zero set stored");
assert_eq!(sets.len(), 1);
assert_eq!(sets[0].name, "a7p-zero");
assert_eq!(sets[0].zero_distance, None);
let up_mil = sets[0].poi_up_mil.expect("up mil");
let right_mil = sets[0].poi_right_mil.expect("right mil");
assert!((up_mil - (-1.0)).abs() < 1e-12, "up_mil = {up_mil}");
assert!(
(right_mil - (-2.0)).abs() < 1e-12,
"right_mil = {right_mil}"
);
}
#[test]
fn w_pitch_is_reported_unmapped_with_the_reason_it_is_out_of_scope() {
let mut p = Vec::new();
enc_i32(24, 0, &mut p); let mut row = Vec::new();
enc_i32(1, 5000, &mut row);
enc_i32(2, 5000, &mut row);
enc_bytes(27, &row, &mut p);
enc_i32(18, 250, &mut p); let mut payload = Vec::new();
enc_bytes(1, &p, &mut payload);
let doc = parse_a7p(&wrap_payload(&payload)).unwrap();
let outcome = map_a7p_to_profile(&doc, None, None).unwrap();
let (_, msg) = outcome
.report
.unmapped
.iter()
.find(|(f, _)| f == "c_zero_w_pitch")
.expect("w_pitch reported unmapped");
assert!(msg.contains("250"), "{msg}");
assert!(msg.contains("rifle-mount geometry"), "{msg}");
assert!(msg.contains("outside the turret model"), "{msg}");
assert!(msg.contains("absorbed by zeroing"), "{msg}");
}
#[test]
fn maps_g1_profile_to_metric_profiledata() {
let doc = parse_a7p(&barnes_338_file(0)).unwrap();
let outcome = map_a7p_to_profile(&doc, None, None).unwrap();
let p = &outcome.profile;
assert_eq!(p.name, "338LM_BARNES 300"); assert_eq!(p.units, "metric");
assert_eq!(p.drag_model, "G1");
assert!((p.velocity - 792.0).abs() < 1e-9);
assert!((p.bc - 0.716).abs() < 1e-9); assert!((p.mass - 300.0 * 0.06479891).abs() < 1e-9); assert!((p.diameter - 0.338 * 25.4).abs() < 1e-9); assert!((p.bullet_length.unwrap() - 1.8 * 25.4).abs() < 1e-9); assert!((p.twist_rate.unwrap() - 254.0).abs() < 1e-9); assert_eq!(p.twist_right, Some(true));
assert!((p.sight_height.unwrap() - 90.0).abs() < 1e-9);
assert!((p.zero_distance.unwrap() - 100.0).abs() < 1e-9);
assert!((p.temperature - 15.0).abs() < 1e-9);
assert!((p.pressure - 1000.0).abs() < 1e-9);
assert!((p.humidity - 50.0).abs() < 1e-9);
assert_eq!(p.bullet_name.as_deref(), Some("BARNES 300GR OTM"));
assert!(p.created.is_some());
let segs = p.bc_segments.as_ref().expect("bc_segments populated");
assert_eq!(segs.len(), 3);
let velocities: Vec<f64> = segs.iter().map(|s| s.velocity_mps).collect();
assert_eq!(velocities, vec![792.0, 500.0, 300.0]); assert!((segs[0].bc - 0.716).abs() < 1e-9); assert!((segs[1].bc - 0.750).abs() < 1e-9);
assert!((segs[2].bc - 0.690).abs() < 1e-9);
assert!(outcome
.report
.mapped
.iter()
.any(|[field, ..]| field == "coef_rows[all]"));
let tcoeff = outcome
.report
.unmapped
.iter()
.find(|(field, _)| field == "c_t_coeff")
.expect("c_t_coeff reported");
assert!(tcoeff.1.contains("0.528"));
let zero_temp = outcome
.report
.unmapped
.iter()
.find(|(field, _)| field == "c_zero_temperature")
.expect("c_zero_temperature reported");
assert!(zero_temp.1.contains("15 C"));
}
#[test]
fn name_override_is_applied() {
let doc = parse_a7p(&barnes_338_file(0)).unwrap();
let outcome = map_a7p_to_profile(&doc, Some("my-338"), None).unwrap();
assert_eq!(outcome.profile.name, "my-338");
}
#[test]
fn custom_bc_type_imports_a_drag_curve() {
let doc = parse_a7p(&barnes_338_file(2)).unwrap();
let outcome = map_a7p_to_profile(&doc, None, None).unwrap();
let p = &outcome.profile;
assert_eq!(p.drag_model, "CUSTOM");
assert_eq!(p.bc, 0.0); assert!(p.bc_segments.is_none());
let curve = p.drag_curve.as_ref().expect("drag_curve populated");
assert_eq!(curve.len(), 3);
let machs: Vec<f64> = curve.iter().map(|pt| pt.mach).collect();
assert_eq!(machs, vec![0.3, 0.5, 0.792]);
assert!((curve[0].cd - 0.69).abs() < 1e-9);
assert!((curve[1].cd - 0.75).abs() < 1e-9);
assert!((curve[2].cd - 0.716).abs() < 1e-9);
let mach_values: Vec<f64> = curve.iter().map(|pt| pt.mach).collect();
let cd_values: Vec<f64> = curve.iter().map(|pt| pt.cd).collect();
assert!(ballistics_engine::drag::DragTable::try_new(mach_values, cd_values).is_ok());
assert!(outcome
.report
.warnings
.iter()
.any(|w| w.contains("inert sentinel")));
assert!(outcome
.report
.mapped
.iter()
.any(|[field, ..]| field == "coef_rows[custom]"));
}
#[test]
fn custom_bc_type_rejects_a_degenerate_curve() {
let mut p = Vec::new();
enc_i32(24, 2, &mut p); let mut row = Vec::new();
enc_i32(1, 5000, &mut row);
enc_i32(2, 5000, &mut row);
enc_bytes(27, &row, &mut p);
let mut payload = Vec::new();
enc_bytes(1, &p, &mut payload);
let doc = parse_a7p(&wrap_payload(&payload)).unwrap();
let err = map_a7p_to_profile(&doc, None, None).unwrap_err();
assert!(err.contains("CUSTOM drag curve is invalid"), "{err}");
}
#[test]
fn g7_maps_and_report_renders() {
let doc = parse_a7p(&barnes_338_file(1)).unwrap();
let outcome = map_a7p_to_profile(&doc, None, None).unwrap();
assert_eq!(outcome.profile.drag_model, "G7");
let rendered = render_import_report(&outcome.report);
assert!(rendered.contains("muzzle velocity"));
assert!(rendered.contains("NOT imported"));
}
#[test]
fn missing_bc_rows_is_an_error() {
let mut p = Vec::new();
enc_i32(11, 7920, &mut p);
let mut payload = Vec::new();
enc_bytes(1, &p, &mut payload);
let doc = parse_a7p(&wrap_payload(&payload)).unwrap();
let err = map_a7p_to_profile(&doc, None, None).unwrap_err();
assert!(err.contains("no BC rows"), "{err}");
}
}
#[cfg(test)]
mod adjustment_unit_tests {
use super::*;
#[test]
fn mil_conversion_matches_geometry() {
assert!((drop_to_adjustment(0.1, 100.0, AdjustmentUnit::Mil) - 1.0).abs() < 1e-9);
assert!((drop_to_adjustment(1.78, 500.0, AdjustmentUnit::Mil) - 3.56).abs() < 0.01);
}
#[test]
fn moa_is_3438_thousandths_per_radian_ratio() {
let mil = drop_to_adjustment(1.0, 100.0, AdjustmentUnit::Mil);
let moa = drop_to_adjustment(1.0, 100.0, AdjustmentUnit::Moa);
assert!((moa / mil - 3.438).abs() < 1e-9, "moa={moa} mil={mil}");
assert!((drop_to_adjustment(0.1, 100.0, AdjustmentUnit::Moa) - 3.438).abs() < 1e-9);
}
#[test]
fn short_range_returns_zero() {
assert_eq!(drop_to_adjustment(1.0, 0.5, AdjustmentUnit::Moa), 0.0);
assert_eq!(drop_to_adjustment(1.0, 0.5, AdjustmentUnit::Mil), 0.0);
}
#[test]
fn smoa_iphy_conversion_is_inches_per_hundred_yards() {
let v = drop_to_adjustment(0.1, 100.0, AdjustmentUnit::Smoa);
assert!(
(v - 3.6).abs() < 1e-12,
"0.1 yd @ 100 yd = 3.6 IPHY, got {v}"
);
let i = drop_to_adjustment(0.1, 100.0, AdjustmentUnit::Iphy);
assert_eq!(v, i, "smoa and iphy are the same unit");
let t = drop_to_adjustment(0.1, 100.0, AdjustmentUnit::Moa);
assert!(
t < v && (v / t - 1.047).abs() < 0.005,
"tmoa {t} vs smoa {v}"
);
}
#[test]
fn clicks_unit_requires_elevation_graduation() {
let e = resolve_click_values(None, None, None).unwrap_err();
assert!(e.contains("--elevation-click-value"), "{e}");
}
#[test]
fn windage_click_defaults_to_elevation() {
let (el, wi) = resolve_click_values(Some("0.25moa"), None, None)
.unwrap()
.unwrap();
assert_eq!(el, wi);
}
#[test]
fn explicit_windage_flag_overrides_elevation_fallback() {
let (el, wi) = resolve_click_values(Some("0.25moa"), Some("0.1mil"), None)
.unwrap()
.unwrap();
assert_ne!(el, wi);
assert_eq!(wi, parse_click_value("0.1mil").unwrap());
}
#[test]
fn windage_unit_defaults_to_elevation_and_can_diverge() {
assert_eq!(
resolve_windage_unit(AdjustmentUnit::Mil, None).unwrap(),
AdjustmentUnit::Mil
);
assert_eq!(
resolve_windage_unit(AdjustmentUnit::Mil, Some(AdjustmentUnit::Moa)).unwrap(),
AdjustmentUnit::Moa,
"an explicit --windage-unit must genuinely diverge from the elevation unit"
);
assert_eq!(
resolve_windage_unit(AdjustmentUnit::Moa, None).unwrap(),
AdjustmentUnit::Moa,
"omitted --windage-unit falls back to whatever --adjustment-unit resolved to"
);
}
#[test]
fn windage_unit_clicks_requires_elevation_clicks() {
assert_eq!(
resolve_windage_unit(AdjustmentUnit::Clicks, None).unwrap(),
AdjustmentUnit::Clicks
);
assert_eq!(
resolve_windage_unit(AdjustmentUnit::Clicks, Some(AdjustmentUnit::Clicks)).unwrap(),
AdjustmentUnit::Clicks
);
assert_eq!(
resolve_windage_unit(AdjustmentUnit::Clicks, Some(AdjustmentUnit::Moa)).unwrap(),
AdjustmentUnit::Moa
);
let e =
resolve_windage_unit(AdjustmentUnit::Mil, Some(AdjustmentUnit::Clicks)).unwrap_err();
assert!(e.contains("--windage-unit"), "{e}");
assert!(e.contains("--adjustment-unit"), "{e}");
}
#[test]
fn single_axis_click_value_falls_back_windage_then_elevation() {
let e = resolve_single_axis_click_value(None, None).unwrap_err();
assert!(e.contains("--windage-click-value"), "{e}");
let explicit = resolve_single_axis_click_value(Some("0.25moa"), None).unwrap();
assert_eq!(explicit, parse_click_value("0.25moa").unwrap());
fn profile_stub() -> ProfileData {
ProfileData {
name: "stub".to_string(),
velocity: 762.0,
bc: 0.243,
mass: 11.339_809_25,
diameter: 7.8232,
drag_model: "G7".to_string(),
twist_rate: None,
sight_height: None,
zero_distance: None,
units: "metric".to_string(),
temperature: 15.0,
pressure: 1_013.207_888,
humidity: 55.0,
altitude: 0.0,
bullet_name: None,
created: None,
wind_speed: None,
wind_direction: None,
shooting_angle: None,
auto_zero: None,
twist_right: None,
use_bc_segments: None,
bullet_length: None,
elevation_click: None,
windage_click: None,
bc_segments: None,
drag_curve: None,
dsf_points: None,
bc_reference: None,
pressure_reference: None,
density_altitude: None,
zero_poi_up_m: None,
zero_poi_right_m: None,
sight_offset_lateral_m: None,
elevation_cf: None,
windage_cf: None,
zero_sets: None,
reticle: None,
clicks_per_revolution: None,
zero_stop: None,
elevation_travel_up_mil: None,
elevation_travel_down_mil: None,
windage_travel_left_mil: None,
windage_travel_right_mil: None,
turret_elevation_dialed_mil: None,
turret_windage_dialed_mil: None,
hold_bound_up_mil: None,
hold_bound_down_mil: None,
hold_bound_left_mil: None,
hold_bound_right_mil: None,
}
}
let mut profile = profile_stub();
profile.elevation_click = Some("0.1mil".to_string());
let from_elevation = resolve_single_axis_click_value(None, Some(&profile)).unwrap();
assert_eq!(from_elevation, parse_click_value("0.1mil").unwrap());
profile.windage_click = Some("0.2mil".to_string());
let from_windage = resolve_single_axis_click_value(None, Some(&profile)).unwrap();
assert_eq!(
from_windage,
parse_click_value("0.2mil").unwrap(),
"profile windage_click must beat profile elevation_click"
);
let flag_beats_profile =
resolve_single_axis_click_value(Some("0.3moa"), Some(&profile)).unwrap();
assert_eq!(flag_beats_profile, parse_click_value("0.3moa").unwrap());
}
#[test]
fn default_trajectory_header_is_stable() {
assert_eq!(
come_up_header_line("yd", "MIL", "fps"),
"│Range (yd)|Drop (MIL)|Come-Up │ Vel (fps)│Energy │ Time (s) │"
);
}
}
#[cfg(test)]
mod drag_model_arg_warning_tests {
use super::*;
#[test]
fn drag_model_arg_warning_only_flags_truly_unknown_strings() {
for s in ["g1", "G2", "g5", "G6", "g7", "G8", "gi", "GS", "ra4"] {
assert!(
drag_model_arg_warning(s).is_none(),
"{s} should be recognized"
);
}
let w = drag_model_arg_warning("banana").expect("must warn");
assert!(w.contains("using G1"), "{w}");
}
#[test]
fn parse_drag_model_arg_resolves_the_full_family() {
assert_eq!(parse_drag_model_arg("G7"), DragModel::G7);
assert_eq!(parse_drag_model_arg("g5"), DragModel::G5);
assert_eq!(parse_drag_model_arg("ra4"), DragModel::RA4);
assert_eq!(parse_drag_model_arg("gi"), DragModel::GI);
assert_eq!(parse_drag_model_arg("banana"), DragModel::G1);
}
#[test]
fn parse_drag_model_arg_for_truing_still_constrains_to_g1_or_g7() {
assert_eq!(parse_drag_model_arg_for_truing("G7"), DragModelArg::G7);
assert_eq!(parse_drag_model_arg_for_truing("g7"), DragModelArg::G7);
assert_eq!(parse_drag_model_arg_for_truing("G1"), DragModelArg::G1);
assert_eq!(parse_drag_model_arg_for_truing("G5"), DragModelArg::G1);
assert_eq!(parse_drag_model_arg_for_truing("banana"), DragModelArg::G1);
}
#[test]
fn aux_g1_coercion_warning_only_flags_families_other_than_g1_g7() {
assert!(aux_g1_coercion_warning("--online mode", DragModel::G1).is_none());
assert!(aux_g1_coercion_warning("--online mode", DragModel::G7).is_none());
for (model, tag) in [
(DragModel::G2, "G2"),
(DragModel::G5, "G5"),
(DragModel::G6, "G6"),
(DragModel::G8, "G8"),
(DragModel::GI, "GI"),
(DragModel::GS, "GS"),
(DragModel::RA4, "RA4"),
] {
let w = aux_g1_coercion_warning("--bc-table correction", model)
.unwrap_or_else(|| panic!("{tag} must warn"));
assert!(w.contains(tag), "{w}");
assert!(w.contains("--bc-table correction"), "{w}");
assert!(w.contains("G1/G7 only"), "{w}");
}
}
#[test]
fn warn_aux_g1_coercion_returns_the_bc_type_string_the_estimator_accepts() {
assert_eq!(warn_aux_g1_coercion("test", DragModel::G1), "G1");
assert_eq!(warn_aux_g1_coercion("test", DragModel::G7), "G7");
assert_eq!(warn_aux_g1_coercion("test", DragModel::G5), "G1");
assert_eq!(warn_aux_g1_coercion("test", DragModel::RA4), "G1");
}
}
#[cfg(test)]
mod cd_scale_tests {
use super::*;
#[test]
fn range_warning_is_none_inside_the_typical_truing_band() {
for v in [0.90, 1.0, 1.10, 0.5, 2.0] {
assert!(
cd_scale_range_warning(v).is_none(),
"cd_scale={v} is within [0.5, 2.0] and must not warn"
);
}
}
#[test]
fn range_warning_fires_outside_the_typical_truing_band() {
for v in [0.49, 2.01, 0.1, 5.0, 3.0] {
let w = cd_scale_range_warning(v).unwrap_or_else(|| panic!("cd_scale={v} must warn"));
assert!(w.contains("--cd-scale"), "{w}");
assert!(w.contains("typical truing range (0.90-1.10)"), "{w}");
assert!(w.contains(&format!("{v}")), "{w}");
}
}
#[test]
fn resolve_defaults_to_the_neutral_scale_when_omitted() {
assert_eq!(resolve_cd_scale(None, false, true), Ok(1.0));
assert_eq!(resolve_cd_scale(None, true, true), Ok(1.0));
}
#[test]
fn resolve_passes_through_the_scale_when_a_table_is_present() {
assert_eq!(resolve_cd_scale(Some(1.10), true, true), Ok(1.10));
assert_eq!(resolve_cd_scale(Some(3.0), true, true), Ok(3.0));
}
#[test]
fn resolve_errors_naming_the_pairing_requirement_without_a_table() {
let err = resolve_cd_scale(Some(1.10), false, true)
.expect_err("--cd-scale without a table must be rejected");
assert!(err.contains("--cd-scale requires --drag-table"), "{err}");
assert!(err.contains("--bc-adjustment"), "{err}");
}
#[test]
fn resolve_omits_the_bc_adjustment_suggestion_on_a_surface_without_it() {
let err = resolve_cd_scale(Some(1.10), false, false)
.expect_err("--cd-scale without a table must be rejected");
assert!(err.contains("--cd-scale requires --drag-table"), "{err}");
assert!(
!err.contains("--bc-adjustment"),
"surface has no --bc-adjustment flag to suggest: {err}"
);
}
}
#[cfg(test)]
mod adjustment_unit_noop_tests {
use super::*;
#[test]
fn warns_for_a_non_default_unit_with_no_ring_and_no_pdf() {
for unit in [
AdjustmentUnit::Moa,
AdjustmentUnit::Smoa,
AdjustmentUnit::Iphy,
AdjustmentUnit::Clicks,
] {
let warning = adjustment_unit_noop_warning(unit, None, 0.0, false)
.unwrap_or_else(|| panic!("{unit:?} without --target-speed or -o pdf must warn"));
assert!(warning.contains("--adjustment-unit"), "{warning}");
assert!(warning.contains("--target-speed"), "{warning}");
assert!(warning.contains("-o pdf"), "{warning}");
}
}
#[test]
fn silent_when_the_ring_column_or_the_dope_card_consumes_the_unit() {
assert!(adjustment_unit_noop_warning(AdjustmentUnit::Moa, None, 10.0, false).is_none());
assert!(adjustment_unit_noop_warning(AdjustmentUnit::Moa, None, 0.0, true).is_none());
assert!(adjustment_unit_noop_warning(AdjustmentUnit::Clicks, None, 0.0, true).is_none());
}
#[test]
fn silent_for_the_default_unit() {
assert!(adjustment_unit_noop_warning(AdjustmentUnit::Mil, None, 0.0, false).is_none());
}
#[test]
fn explicit_windage_unit_alone_warns_without_pdf_and_is_silent_with_it() {
let warning = adjustment_unit_noop_warning(
AdjustmentUnit::Mil,
Some(AdjustmentUnit::Moa),
0.0,
false,
)
.unwrap();
assert!(warning.contains("--windage-unit"), "{warning}");
assert!(!warning.contains("--adjustment-unit"), "{warning}");
assert!(adjustment_unit_noop_warning(
AdjustmentUnit::Mil,
Some(AdjustmentUnit::Moa),
10.0,
false
)
.is_some());
assert!(adjustment_unit_noop_warning(
AdjustmentUnit::Mil,
Some(AdjustmentUnit::Moa),
0.0,
true
)
.is_none());
assert!(adjustment_unit_noop_warning(
AdjustmentUnit::Mil,
Some(AdjustmentUnit::Mil),
0.0,
false
)
.is_none());
}
#[test]
fn both_axes_inert_names_both_flags() {
let warning = adjustment_unit_noop_warning(
AdjustmentUnit::Moa,
Some(AdjustmentUnit::Smoa),
0.0,
false,
)
.unwrap();
assert!(warning.contains("--adjustment-unit"), "{warning}");
assert!(warning.contains("--windage-unit"), "{warning}");
}
}
#[cfg(test)]
mod windage_click_value_noop_tests {
use super::*;
#[test]
fn warns_when_supplied() {
let w = windage_click_value_noop_warning(Some("0.1mil"))
.expect("a supplied --windage-click-value on come-ups must warn");
assert!(w.contains("--windage-click-value"), "{w}");
assert!(w.contains("come-ups"), "{w}");
assert!(w.contains("--elevation-click-value"), "{w}");
}
#[test]
fn silent_when_omitted() {
assert!(windage_click_value_noop_warning(None).is_none());
}
}
#[cfg(test)]
mod bc_segment_parse_tests {
use super::*;
#[test]
fn resolved_bc_schedule_preserves_explicit_precedence_and_estimator_fallback() {
let explicit = vec![BCSegmentData {
velocity_min: 0.0,
velocity_max: 5_000.0,
bc_value: 0.42,
}];
let provided = resolve_velocity_bc_segments(
Some(&explicit),
true,
0.19,
0.224 * 0.0254,
77.0 * GRAINS_TO_KG,
DragModel::G7,
)
.unwrap();
assert_eq!(provided.len(), 1);
assert_eq!(provided[0].bc_value.to_bits(), 0.42_f64.to_bits());
let estimated = resolve_velocity_bc_segments(
None,
true,
0.19,
0.224 * 0.0254,
77.0 * GRAINS_TO_KG,
DragModel::G7,
)
.unwrap();
let expected = ballistics_engine::bc_estimation::BCSegmentEstimator::estimate_bc_segments(
0.19, 0.224, 77.0, "", "G7",
);
assert_eq!(estimated.len(), expected.len());
for (actual, expected) in estimated.iter().zip(&expected) {
assert_eq!(
actual.velocity_min.to_bits(),
expected.velocity_min.to_bits()
);
assert_eq!(
actual.velocity_max.to_bits(),
expected.velocity_max.to_bits()
);
assert_eq!(actual.bc_value.to_bits(), expected.bc_value.to_bits());
}
assert!(resolve_velocity_bc_segments(
None,
false,
0.19,
0.224 * 0.0254,
77.0 * GRAINS_TO_KG,
DragModel::G7,
)
.is_none());
}
#[test]
fn parses_valid_imperial_pair_as_fps() {
let seg = parse_bc_segment("1500:1800:0.243", UnitSystem::Imperial).unwrap();
assert_eq!(seg.velocity_min, 1500.0);
assert_eq!(seg.velocity_max, 1800.0);
assert!((seg.bc_value - 0.243).abs() < 1e-12);
}
#[test]
fn converts_metric_velocity_to_fps() {
let seg = parse_bc_segment("305:427:0.15", UnitSystem::Metric).unwrap();
assert!((seg.velocity_min - 305.0 * 3.280_839_895).abs() < 1e-6);
assert!((seg.velocity_max - 427.0 * 3.280_839_895).abs() < 1e-6);
assert!((seg.bc_value - 0.15).abs() < 1e-12);
}
#[test]
fn rejects_wrong_field_count() {
assert!(parse_bc_segment("1000:1400", UnitSystem::Imperial).is_err());
assert!(parse_bc_segment("1000:1400:0.2:extra", UnitSystem::Imperial).is_err());
}
#[test]
fn rejects_vmin_not_less_than_vmax() {
assert!(parse_bc_segment("1400:1000:0.2", UnitSystem::Imperial).is_err());
assert!(parse_bc_segment("1400:1400:0.2", UnitSystem::Imperial).is_err());
}
#[test]
fn rejects_nonpositive_bc_and_nonnumeric() {
assert!(parse_bc_segment("1000:1400:0", UnitSystem::Imperial).is_err());
assert!(parse_bc_segment("1000:1400:-0.1", UnitSystem::Imperial).is_err());
assert!(parse_bc_segment("abc:1400:0.2", UnitSystem::Imperial).is_err());
assert!(parse_bc_segment("1000:xyz:0.2", UnitSystem::Imperial).is_err());
assert!(parse_bc_segment("1000:1400:bc", UnitSystem::Imperial).is_err());
}
}
#[cfg(test)]
mod profile_bc_segments_and_drag_curve_consumption_tests {
use super::*;
#[test]
fn bands_are_gapless_and_non_overlapping_for_two_rows() {
let rows = vec![
ProfileBcSegment {
bc: 0.5,
velocity_mps: 900.0,
},
ProfileBcSegment {
bc: 0.2,
velocity_mps: 300.0,
},
];
let segs = bc_segments_from_profile(&rows);
assert_eq!(segs.len(), 2);
let fast = &segs[0];
let slow = &segs[1];
assert!((fast.bc_value - 0.5).abs() < 1e-9);
assert!((slow.bc_value - 0.2).abs() < 1e-9);
assert_eq!(slow.velocity_min, 0.0);
assert_eq!(slow.velocity_max, fast.velocity_min);
let expected_floor_fps = 900.0 * 3.280_839_895;
assert!((fast.velocity_min - expected_floor_fps).abs() < 1e-6);
assert!(fast.velocity_max > expected_floor_fps);
for probe in [fast.velocity_min + 10.0, slow.velocity_max - 10.0] {
let hits = segs
.iter()
.filter(|s| probe >= s.velocity_min && probe < s.velocity_max)
.count();
assert_eq!(
hits, 1,
"velocity {probe} fps must be claimed by exactly one band"
);
}
}
#[test]
fn single_row_covers_the_entire_velocity_axis() {
let rows = vec![ProfileBcSegment {
bc: 0.3,
velocity_mps: 800.0,
}];
let segs = bc_segments_from_profile(&rows);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].velocity_min, 0.0);
assert!(segs[0].velocity_max > 800.0 * 3.280_839_895);
}
#[test]
fn unsorted_input_rows_are_sorted_by_velocity() {
let rows = vec![
ProfileBcSegment {
bc: 0.2,
velocity_mps: 300.0,
},
ProfileBcSegment {
bc: 0.5,
velocity_mps: 900.0,
},
];
let segs = bc_segments_from_profile(&rows);
assert!((segs[0].bc_value - 0.5).abs() < 1e-9);
assert!((segs[1].bc_value - 0.2).abs() < 1e-9);
}
#[test]
fn drag_table_from_profile_builds_a_valid_table() {
let points = vec![
ProfileDragPoint {
mach: 0.5,
cd: 0.23,
},
ProfileDragPoint {
mach: 1.2,
cd: 0.45,
},
ProfileDragPoint {
mach: 3.0,
cd: 0.28,
},
];
let table = drag_table_from_profile(&points).expect("valid table");
assert_eq!(table.mach_values, vec![0.5, 1.2, 3.0]);
assert_eq!(table.cd_values, vec![0.23, 0.45, 0.28]);
}
#[test]
fn drag_table_from_profile_rejects_degenerate_input() {
let points = vec![ProfileDragPoint {
mach: 0.5,
cd: 0.23,
}];
assert!(drag_table_from_profile(&points).is_err());
}
}
#[cfg(test)]
mod drg_extension_dispatch_tests {
use super::*;
const SYNTH_DRG: &str = "SYN-MBA1409, synthetic test deck (invented values)\n\
0.50\t0.230\n\
0.80\t0.210\n\
1.00\t0.520\n\
1.50\t0.400\n\
2.50\t0.300\n";
const EXISTING_CSV_FIXTURE: &str = "mach,cd\n0.5,0.23\n1.0,0.40\n2.0,0.30\n3.0,0.26\n";
fn write_temp(suffix: &str, contents: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"mba1409_drg_dispatch_{}_{nonce}{suffix}",
std::process::id()
));
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn drg_extension_dispatches_to_the_drg_parser() {
let path = write_temp(".drg", SYNTH_DRG);
let table = load_drag_table(&path).expect("valid .drg file should load");
std::fs::remove_file(&path).ok();
assert_eq!(table.mach_values, vec![0.50, 0.80, 1.00, 1.50, 2.50]);
assert_eq!(table.cd_values, vec![0.230, 0.210, 0.520, 0.400, 0.300]);
}
#[test]
fn drg_extension_is_case_insensitive() {
let path = write_temp(".DRG", SYNTH_DRG);
let table = load_drag_table(&path).expect("uppercase .DRG extension should load");
std::fs::remove_file(&path).ok();
assert_eq!(table.mach_values.len(), 5);
}
#[test]
fn drg_file_with_csv_ish_garbage_errors_naming_the_line_number() {
let path = write_temp(".drg", "title\n0.5 0.3\nnot two numbers\n1.0 0.31\n");
let err = load_drag_table(&path).expect_err("garbage row should be rejected");
std::fs::remove_file(&path).ok();
assert!(
err.contains("line 3"),
"expected the parser's line number in the error, got: {err}"
);
}
#[test]
fn csv_extension_behavior_is_unchanged() {
let path = write_temp(".csv", EXISTING_CSV_FIXTURE);
let table = load_drag_table(&path).expect("existing csv fixture should still load");
std::fs::remove_file(&path).ok();
assert_eq!(table.mach_values, vec![0.5, 1.0, 2.0, 3.0]);
assert_eq!(table.cd_values, vec![0.23, 0.40, 0.30, 0.26]);
}
#[test]
fn no_extension_still_uses_the_csv_path() {
let path = write_temp("", EXISTING_CSV_FIXTURE);
let table = load_drag_table(&path).expect("extensionless file should still parse as csv");
std::fs::remove_file(&path).ok();
assert_eq!(table.mach_values, vec![0.5, 1.0, 2.0, 3.0]);
}
}
#[cfg(test)]
mod wind_angle_unit_tests {
use super::*;
#[test]
fn trajectory_components_store_wind_direction_in_radians() {
let (inputs, wind, _) = build_trajectory_components(
800.0,
0.4,
0.01,
0.00762,
DragModel::G1,
0.05,
15.0,
1013.25,
50.0,
0.0,
10.0,
90.0,
1000.0,
100.0,
None,
None,
0.0,
0.0,
0.0,
);
assert!((inputs.wind_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
assert_eq!(inputs.wind_angle.to_bits(), wind.direction.to_bits());
}
}
#[cfg(test)]
mod dsf_cli_tests {
use super::*;
#[test]
fn parse_observed_drop_accepts_all_three_suffixes() {
let (v, u) = parse_observed_drop("5.1mil").unwrap();
assert!((v - 5.1).abs() < 1e-12);
assert_eq!(u, DropUnit::Mil);
let (v, u) = parse_observed_drop("17.4moa").unwrap();
assert!((v - 17.4).abs() < 1e-12);
assert_eq!(u, DropUnit::Moa);
let (v, u) = parse_observed_drop("42.0in").unwrap();
assert!((v - 42.0).abs() < 1e-12);
assert_eq!(u, DropUnit::In);
}
#[test]
fn parse_observed_drop_is_case_insensitive_and_trims_whitespace() {
let (v, u) = parse_observed_drop(" 5.1MIL ").unwrap();
assert!((v - 5.1).abs() < 1e-12);
assert_eq!(u, DropUnit::Mil);
}
#[test]
fn parse_observed_drop_rejects_missing_suffix() {
let e = parse_observed_drop("5.1").unwrap_err();
assert!(
e.contains("mil") && e.contains("moa") && e.contains("in"),
"{e}"
);
}
#[test]
fn parse_observed_drop_rejects_garbage_number() {
for bad in ["mil", "abcmil", "", "5.1.2moa"] {
assert!(
parse_observed_drop(bad).is_err(),
"{bad:?} must be rejected"
);
}
}
#[test]
fn parse_observed_drop_rejects_non_finite() {
assert!(parse_observed_drop("nanmil").is_err());
assert!(parse_observed_drop("infmoa").is_err());
}
#[test]
fn supersonic_error_text_is_exact() {
let msg = dsf_supersonic_error(1.35);
assert_eq!(
msg,
"error: observation is supersonic (Mach 1.35); calibrate muzzle velocity first \
(true-velocity), then collect DSF points at Mach <= 1.2"
);
}
#[test]
fn beyond_90pct_warning_text_is_exact() {
let msg = dsf_beyond_90pct_warning(950.0, "yd");
assert_eq!(
msg,
"warning: observation at 950 yd is beyond 90% of the solved range; solution \
reliability degrades past this point"
);
}
#[test]
fn beyond_90pct_warning_text_uses_metric_unit_label() {
let msg = dsf_beyond_90pct_warning(500.0, "m");
assert_eq!(
msg,
"warning: observation at 500 m is beyond 90% of the solved range; solution \
reliability degrades past this point"
);
}
#[test]
fn transonic_gap_warning_text_is_exact() {
assert_eq!(
dsf_transonic_gap_warning(),
"warning: no DSF point in the transonic band (Mach 1.2-0.9); transonic drops \
remain uncorrected"
);
}
#[test]
fn observation_beyond_90pct_boundary() {
assert!(!ballistics_engine::truing_dsf::dsf_observation_beyond_90pct(899.9, 1000.0));
assert!(!ballistics_engine::truing_dsf::dsf_observation_beyond_90pct(900.0, 1000.0)); assert!(ballistics_engine::truing_dsf::dsf_observation_beyond_90pct(
900.1, 1000.0
));
assert!(ballistics_engine::truing_dsf::dsf_observation_beyond_90pct(
1000.0, 1000.0
));
assert!(!ballistics_engine::truing_dsf::dsf_observation_beyond_90pct(500.0, 0.0));
}
fn traj_point(x: f64, velocity_magnitude: f64) -> ballistics_engine::cli_api::TrajectoryPoint {
ballistics_engine::cli_api::TrajectoryPoint {
time: 0.0,
position: nalgebra::Vector3::new(x, 0.0, 0.0),
velocity_magnitude,
kinetic_energy: 0.0,
drag_coefficient: None,
}
}
fn traj_result_fixture(
points: Vec<ballistics_engine::cli_api::TrajectoryPoint>,
station_speed_of_sound_mps: f64,
max_range: f64,
) -> ballistics_engine::cli_api::TrajectoryResult {
ballistics_engine::cli_api::TrajectoryResult {
max_range,
max_height: 0.0,
time_of_flight: 0.0,
impact_velocity: 0.0,
impact_energy: 0.0,
projectile_mass_kg: 0.01,
line_of_sight_height_m: 0.05,
station_speed_of_sound_mps,
termination: ballistics_engine::trajectory_observation::TrajectoryTermination::MaxRange,
points,
sampled_points: None,
min_pitch_damping: None,
transonic_mach: None,
angular_state: None,
max_yaw_angle: None,
max_precession_angle: None,
aerodynamic_jump: None,
mach_1_2_distance_m: None,
mach_1_0_distance_m: None,
mach_0_9_distance_m: None,
}
}
#[test]
fn mach_1_crossing_range_m_interpolates_the_downward_crossing() {
let sos = 340.0;
let result = traj_result_fixture(
vec![
traj_point(0.0, 1.3 * sos),
traj_point(100.0, 1.1 * sos),
traj_point(200.0, 0.8 * sos),
],
sos,
500.0,
);
let crossing = ballistics_engine::truing_dsf::mach_1_crossing_range_m(&result)
.expect("must find a crossing");
assert!(
(crossing - (100.0 + 100.0 / 3.0)).abs() < 1e-6,
"{crossing}"
);
}
#[test]
fn mach_1_crossing_range_m_none_when_trajectory_never_goes_subsonic() {
let sos = 340.0;
let result = traj_result_fixture(
vec![traj_point(0.0, 1.3 * 340.0), traj_point(100.0, 1.05 * sos)],
sos,
500.0,
);
assert!(ballistics_engine::truing_dsf::mach_1_crossing_range_m(&result).is_none());
}
#[test]
fn mach_1_crossing_range_m_none_for_degenerate_station_speed_of_sound() {
let result = traj_result_fixture(vec![traj_point(0.0, 300.0)], 0.0, 500.0);
assert!(ballistics_engine::truing_dsf::mach_1_crossing_range_m(&result).is_none());
}
#[test]
fn warrants_90pct_warning_requires_both_gates() {
assert!(
!ballistics_engine::truing_dsf::dsf_observation_warrants_90pct_warning(
950.0, None, 1000.0
)
);
assert!(
!ballistics_engine::truing_dsf::dsf_observation_warrants_90pct_warning(
950.0,
Some(960.0),
1000.0
)
);
assert!(
!ballistics_engine::truing_dsf::dsf_observation_warrants_90pct_warning(
500.0,
Some(400.0),
1000.0
)
);
assert!(
ballistics_engine::truing_dsf::dsf_observation_warrants_90pct_warning(
950.0,
Some(400.0),
1000.0
)
);
}
#[test]
fn transonic_coverage_gate_checks_highest_point_only() {
assert!(dsf_table_missing_transonic_coverage(&[]));
assert!(dsf_table_missing_transonic_coverage(&[DsfPoint {
mach: 0.5,
dsf: 1.1
}]));
assert!(!dsf_table_missing_transonic_coverage(&[
DsfPoint {
mach: 0.5,
dsf: 1.1
},
DsfPoint {
mach: 0.95,
dsf: 1.05
},
]));
assert!(!dsf_table_missing_transonic_coverage(&[DsfPoint {
mach: 0.9,
dsf: 1.0
}]));
}
#[test]
fn table_summary_formats_count_and_mach_span() {
let points = vec![
DsfPoint {
mach: 0.6,
dsf: 1.1,
},
DsfPoint {
mach: 0.85,
dsf: 1.02,
},
];
assert_eq!(dsf_table_summary(&points), "2 points, Mach 0.60-0.85");
}
fn pt(x: f64, y: f64, v: f64) -> ballistics_engine::cli_api::TrajectoryPoint {
ballistics_engine::cli_api::TrajectoryPoint {
time: 0.0,
position: nalgebra::Vector3::new(x, y, 0.0),
velocity_magnitude: v,
kinetic_energy: 0.0,
drag_coefficient: None,
}
}
#[test]
fn interpolates_linearly_between_bracketing_points() {
let points = vec![pt(0.0, 1.0, 800.0), pt(100.0, 0.5, 700.0)];
let (y, v) =
ballistics_engine::truing_dsf::interpolate_position_and_velocity(&points, 50.0)
.unwrap();
assert!((y - 0.75).abs() < 1e-9);
assert!((v - 750.0).abs() < 1e-9);
}
#[test]
fn interpolation_returns_none_past_the_last_point() {
let points = vec![pt(0.0, 1.0, 800.0), pt(100.0, 0.5, 700.0)];
assert!(
ballistics_engine::truing_dsf::interpolate_position_and_velocity(&points, 200.0)
.is_none()
);
}
#[test]
fn interpolation_at_or_before_the_first_point_returns_its_own_values() {
let points = vec![pt(0.0, 1.0, 800.0), pt(100.0, 0.5, 700.0)];
let (y, v) =
ballistics_engine::truing_dsf::interpolate_position_and_velocity(&points, 0.0).unwrap();
assert_eq!(y, 1.0);
assert_eq!(v, 800.0);
}
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable Clap command shape"
)]
fn handle_hold_corridor(
scenarios_path: PathBuf,
ranges: String,
target: Option<String>,
profile: Option<String>,
velocity: Option<f64>,
bc: Option<f64>,
mass: Option<f64>,
diameter: Option<f64>,
drag_model: DragModel,
zero_distance: Option<f64>,
sight_height: Option<f64>,
temperature: Option<f64>,
pressure: Option<f64>,
pressure_type: Option<PressureReferenceMode>,
humidity: f64,
altitude: f64,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let format = match output {
OutputFormat::Table => RobustHoldFormat::Table,
OutputFormat::Json => RobustHoldFormat::Json,
OutputFormat::Csv => {
return Err(
"hold-corridor has no CSV form (a corridor row is a block, not a line); \
use -o table or -o json"
.into(),
)
}
OutputFormat::Pdf => {
return Err("hold-corridor has no PDF form; use -o table or -o json".into())
}
};
let text = fs::read_to_string(&scenarios_path).map_err(|e| {
format!(
"could not read scenario file {}: {e}",
scenarios_path.display()
)
})?;
let scenario_set = parse_wind_scenario_set(&text, units).map_err(|e| e.to_string())?;
let ranges_m: Vec<f64> = ranges
.split(',')
.map(|token| {
let value: f64 = token.trim().parse().map_err(|_| {
format!(
"invalid --ranges entry '{}': expected a number",
token.trim()
)
})?;
Ok::<f64, String>(UnitConverter::distance_to_metric(value, units))
})
.collect::<Result<_, _>>()?;
let target = match target.as_deref() {
Some(spec) => Some(parse_target_spec(spec, units).map_err(|e| e.to_string())?),
None => None,
};
let profile_data = match profile.as_deref() {
Some(name) => Some(load_profile(name)?),
None => None,
};
let require = |value: Option<f64>, flag: &str| -> Result<f64, Box<dyn Error>> {
value.ok_or_else(|| format!("{flag} is required (directly or from --profile)").into())
};
let velocity = require(
resolve_param(velocity, &profile_data, |p| p.velocity),
"--velocity",
)?;
let bc = require(resolve_param(bc, &profile_data, |p| p.bc), "--bc")?;
let mass = require(resolve_param(mass, &profile_data, |p| p.mass), "--mass")?;
let diameter = require(
resolve_param(diameter, &profile_data, |p| p.diameter),
"--diameter",
)?;
let zero_distance = require(
zero_distance.or_else(|| profile_data.as_ref().and_then(|p| p.zero_distance)),
"--zero-distance",
)?;
let sight_height = sight_height
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let temperature = UnitConverter::resolve_temperature(temperature, units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, units)?,
altitude,
pressure_type,
units,
);
let mass_kg = UnitConverter::mass_to_metric(mass, units);
let diameter_m = UnitConverter::diameter_to_metric(diameter, units);
let request = RobustHoldRequest {
scenarios: scenario_set,
ranges_m,
target,
load: CorridorLoad {
muzzle_velocity_mps: UnitConverter::velocity_to_metric(velocity, units),
bc,
drag_model,
mass_kg,
diameter_m,
bullet_length_m: fallback_bullet_length_m(diameter_m, mass_kg),
zero_distance_m: UnitConverter::distance_to_metric(zero_distance, units),
sight_height_m: UnitConverter::sight_height_to_metric(sight_height, units),
temperature_c: UnitConverter::temperature_to_metric(temperature, units),
pressure_hpa: UnitConverter::pressure_to_metric(pressure, units),
humidity_pct: humidity,
altitude_m: UnitConverter::altitude_to_metric(altitude, units),
},
};
let report = solve_robust_hold(&request).map_err(|e| e.to_string())?;
print!("{}", format_robust_hold_report(&report, format, units));
Ok(())
}
const GOLDEN_SECTION_RATIO: f64 = 0.618_033_988_749_894_9;
const OPTIMAL_ZERO_MAX_ITERATIONS: u32 = 60;
const OPTIMAL_ZERO_TOLERANCE_M: f64 = 0.01;
const OPTIMAL_ZERO_MIN_M: f64 = 10.0;
const OPTIMAL_ZERO_MAX_M: f64 = 2000.0;
fn true_subtension_mil(nominal_mil: f64, focal_plane: FocalPlane, scale: f64) -> f64 {
match focal_plane {
FocalPlane::First => nominal_mil,
FocalPlane::Second => nominal_mil * scale,
}
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable Clap command shape"
)]
fn handle_mark_to_range(
marks: Vec<f64>,
reticle_json: Option<PathBuf>,
mag: f64,
focal_plane: FocalPlaneArg,
reference_mag: f64,
zero_distance: Option<f64>,
max_range: f64,
load_args: InverseSolverLoadArgs,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let format = reticle_format(output)?;
let (nominal_marks, plane, reference_mag, excluded, source_name) = match &reticle_json {
Some(path) => {
if !marks.is_empty() {
return Err("--mark and --reticle-json are mutually exclusive".into());
}
let description = load_reticle_description(path)?;
let holdovers: Vec<f64> = description
.marks
.iter()
.filter(|m| m.right_mil == 0.0 && m.down_mil != 0.0)
.map(|m| m.down_mil)
.collect();
let excluded = description.marks.len() - holdovers.len();
if holdovers.is_empty() {
return Err(format!(
"reticle '{}' has no marks on the vertical stadium to map to a range \
(only the optical center and off-axis windage marks)",
description.name
)
.into());
}
(
holdovers,
description.focal_plane,
description.reference_magnification,
excluded,
description.name,
)
}
None => {
if marks.is_empty() {
return Err(
"at least one --mark <MIL> is required (or --reticle-json <FILE>)".into(),
);
}
for mark in &marks {
if !mark.is_finite() {
return Err(format!("--mark {mark} must be a finite subtension").into());
}
}
(
marks,
focal_plane.into(),
reference_mag,
0,
"supplied marks".to_string(),
)
}
};
if plane == FocalPlane::Second && (!reference_mag.is_finite() || reference_mag <= 0.0) {
return Err("an SFP reticle needs a positive reference magnification".into());
}
let scale = reference_mag / mag;
let (load, _) = load_args.resolve(zero_distance, units)?;
let max_range_m = UnitConverter::distance_to_metric(max_range, units);
let curve = HoldCurve::solve(&load, max_range_m * 1.02)?;
struct Row {
nominal_mil: f64,
true_mil: f64,
outcome: MarkToRangeOutcome,
}
let rows: Vec<Row> = nominal_marks
.iter()
.map(|&nominal_mil| {
let true_mil = true_subtension_mil(nominal_mil, plane, scale);
Row {
nominal_mil,
true_mil,
outcome: curve.range_for_angular_drop_mil(true_mil),
}
})
.collect();
let (dist_unit, vel_unit, energy_unit) = match units {
UnitSystem::Imperial => ("yd", "fps", "ft-lb"),
UnitSystem::Metric => ("m", "m/s", "J"),
};
match format {
ReticleFormat::Json => {
let entries: Vec<serde_json::Value> = rows
.iter()
.map(|row| match &row.outcome {
MarkToRangeOutcome::Reached(point) => serde_json::json!({
"nominal_mil": row.nominal_mil,
"true_mil": row.true_mil,
"status": "reached",
"range": UnitConverter::distance_from_metric(point.range_m, units),
"time": point.time_s,
"velocity": UnitConverter::velocity_from_metric(point.velocity_mps, units),
"energy": UnitConverter::energy_from_metric(point.energy_j, units),
}),
MarkToRangeOutcome::InsideZero { far_zero_range_m } => serde_json::json!({
"nominal_mil": row.nominal_mil,
"true_mil": row.true_mil,
"status": "not_a_holdover",
"far_zero_range": UnitConverter::distance_from_metric(*far_zero_range_m, units),
}),
MarkToRangeOutcome::BeyondSearch { max_range_m, max_drop_mil } => {
serde_json::json!({
"nominal_mil": row.nominal_mil,
"true_mil": row.true_mil,
"status": "beyond_max_range",
"max_range": UnitConverter::distance_from_metric(*max_range_m, units),
"max_drop_mil": max_drop_mil,
})
}
})
.collect();
let value = serde_json::json!({
"reticle": source_name,
"focal_plane": plane.label(),
"reference_magnification": reference_mag,
"magnification": mag,
"mark_scale": if plane == FocalPlane::Second { scale } else { 1.0 },
"zero_distance": UnitConverter::distance_from_metric(load.zero_distance_m, units),
"distance_unit": dist_unit,
"velocity_unit": vel_unit,
"energy_unit": energy_unit,
"excluded_marks": excluded,
"marks": entries,
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
ReticleFormat::Table => {
println!("Mark-to-Range");
println!("=============\n");
println!("Reticle: {source_name}");
println!("Focal plane: {}", plane.label());
if plane == FocalPlane::Second {
println!("Reference: {reference_mag:.2}x In use: {mag:.2}x Subtension scale: {scale:.4}x");
}
println!(
"Zero: {:.0} {}\n",
UnitConverter::distance_from_metric(load.zero_distance_m, units),
dist_unit
);
if excluded > 0 {
println!(
"Note: {excluded} mark(s) excluded — the optical center and off-axis \
windage marks do not map to a range.\n"
);
}
println!(
"Mark(mil) True(mil) Range({dist_unit}) Time(s) Vel({vel_unit}) Energy({energy_unit})"
);
println!("--------- --------- ---------- ------- ---------- ------------");
for row in &rows {
match &row.outcome {
MarkToRangeOutcome::Reached(point) => println!(
"{:>9.2} {:>9.3} {:>10.0} {:>7.3} {:>10.0} {:>12.0}",
row.nominal_mil,
row.true_mil,
UnitConverter::distance_from_metric(point.range_m, units),
point.time_s,
UnitConverter::velocity_from_metric(point.velocity_mps, units),
UnitConverter::energy_from_metric(point.energy_j, units)
),
MarkToRangeOutcome::InsideZero { far_zero_range_m } => println!(
"{:>9.2} {:>9.3} {:>10} (not a holdover: drop is 0 at the far zero, {:.0} {}, and only grows past it)",
row.nominal_mil,
row.true_mil,
"-",
UnitConverter::distance_from_metric(*far_zero_range_m, units),
dist_unit
),
MarkToRangeOutcome::BeyondSearch {
max_range_m,
max_drop_mil,
} => println!(
"{:>9.2} {:>9.3} {:>10} (never reached: {:.2} mil at {:.0} {} is the most this load drops)",
row.nominal_mil,
row.true_mil,
"-",
max_drop_mil,
UnitConverter::distance_from_metric(*max_range_m, units),
dist_unit
),
}
}
}
}
Ok(())
}
struct BdcMatchPair {
nominal_mil: f64,
range_display: f64,
range_m: f64,
}
fn parse_bdc_match_pair(token: &str, units: UnitSystem) -> Result<BdcMatchPair, String> {
let parts: Vec<&str> = token.split(':').collect();
if parts.len() != 2 {
return Err(format!(
"invalid --mark-range '{token}': expected MIL:RANGE (e.g. 2.0:300)"
));
}
let nominal_mil: f64 = parts[0].trim().parse().map_err(|_| {
format!(
"invalid --mark-range subtension '{}' in '{token}'",
parts[0]
)
})?;
let range: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("invalid --mark-range range '{}' in '{token}'", parts[1]))?;
if !nominal_mil.is_finite() || nominal_mil <= 0.0 {
return Err(format!(
"invalid --mark-range '{token}': the subtension must be a positive holdover in \
milliradians"
));
}
if !range.is_finite() || range <= 0.0 {
return Err(format!(
"invalid --mark-range '{token}': the range must be finite and greater than zero"
));
}
Ok(BdcMatchPair {
nominal_mil,
range_display: range,
range_m: UnitConverter::distance_to_metric(range, units),
})
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable Clap command shape"
)]
fn handle_bdc_match(
mark_ranges: Vec<String>,
reference_mag: f64,
focal_plane: FocalPlaneArg,
residual_warn: f64,
zero_distance: Option<f64>,
load_args: InverseSolverLoadArgs,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let format = reticle_format(output)?;
if FocalPlane::from(focal_plane) == FocalPlane::First {
return Err(
"bdc-match applies to SECOND focal plane reticles only: an FFP reticle's \
subtensions are the same at every magnification, so no magnification makes it \
match a load better than any other. Use `mark-to-range` to see where its marks \
land instead."
.into(),
);
}
let pairs: Vec<BdcMatchPair> = mark_ranges
.iter()
.map(|token| parse_bdc_match_pair(token, units))
.collect::<Result<_, _>>()?;
if pairs.len() < 2 {
return Err(
"bdc-match needs at least two --mark-range MIL:RANGE pairs to fit a magnification \
(one pair is satisfied exactly by some magnification and says nothing about fit)"
.into(),
);
}
let (load, _) = load_args.resolve(zero_distance, units)?;
let furthest_m = pairs.iter().fold(0.0_f64, |acc, p| acc.max(p.range_m));
let curve = HoldCurve::solve(&load, furthest_m * 1.05)?;
let mut solved_mil = Vec::with_capacity(pairs.len());
for pair in &pairs {
let point = curve.at_range(pair.range_m).ok_or_else(|| {
format!(
"the trajectory does not reach {:.0} — it was solved only to {:.0}",
pair.range_display,
UnitConverter::distance_from_metric(curve.max_sampled_range_m(), units)
)
})?;
solved_mil.push(point.drop_mil);
}
let sum_st: f64 = pairs
.iter()
.zip(&solved_mil)
.map(|(pair, t)| pair.nominal_mil * t)
.sum();
let sum_ss: f64 = pairs.iter().map(|pair| pair.nominal_mil.powi(2)).sum();
if sum_ss <= 0.0 {
return Err("the supplied subtensions carry no information to fit".into());
}
let u = sum_st / sum_ss;
if !u.is_finite() || u <= 0.0 {
return Err(
"no positive magnification fits these marks: the solved drops and the supplied \
subtensions disagree in sign (check that the ranges pair with the right marks)"
.into(),
);
}
let fitted_mag = reference_mag / u;
struct Residual {
nominal_mil: f64,
range_display: f64,
apparent_mil: f64,
solved_mil: f64,
residual_mil: f64,
}
let residuals: Vec<Residual> = pairs
.iter()
.zip(&solved_mil)
.map(|(pair, &t)| {
let apparent = pair.nominal_mil * u;
Residual {
nominal_mil: pair.nominal_mil,
range_display: pair.range_display,
apparent_mil: apparent,
solved_mil: t,
residual_mil: apparent - t,
}
})
.collect();
let worst = residuals
.iter()
.fold(0.0_f64, |acc, r| acc.max(r.residual_mil.abs()));
let rms = (residuals
.iter()
.map(|r| r.residual_mil.powi(2))
.sum::<f64>()
/ residuals.len() as f64)
.sqrt();
let dist_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
match format {
ReticleFormat::Json => {
let value = serde_json::json!({
"fitted_magnification": fitted_mag,
"reference_magnification": reference_mag,
"subtension_scale": u,
"zero_distance": UnitConverter::distance_from_metric(load.zero_distance_m, units),
"distance_unit": dist_unit,
"worst_residual_mil": worst,
"rms_residual_mil": rms,
"residual_warn_mil": residual_warn,
"fit_warning": worst > residual_warn,
"marks": residuals.iter().map(|r| serde_json::json!({
"nominal_mil": r.nominal_mil,
"range": r.range_display,
"apparent_mil": r.apparent_mil,
"solved_drop_mil": r.solved_mil,
"residual_mil": r.residual_mil,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
ReticleFormat::Table => {
println!("BDC Magnification Match");
println!("=======================\n");
println!("Reference magnification: {reference_mag:.2}x");
println!("Fitted magnification: {fitted_mag:.3}x");
println!("Subtension scale at fit: {u:.4}x");
println!(
"Zero: {:.0} {}\n",
UnitConverter::distance_from_metric(load.zero_distance_m, units),
dist_unit
);
println!("Mark(mil) Range({dist_unit}) Apparent(mil) Drop(mil) Residual(mil)");
println!("--------- ---------- ------------- --------- -------------");
for r in &residuals {
println!(
"{:>9.2} {:>10.0} {:>13.3} {:>9.3} {:>13.3}",
r.nominal_mil, r.range_display, r.apparent_mil, r.solved_mil, r.residual_mil
);
}
println!("\nWorst residual: {worst:.3} mil RMS: {rms:.3} mil");
if worst > residual_warn {
println!(
"\nWARNING: the worst residual exceeds {residual_warn:.3} mil — this reticle \
does not fit this load well at ANY magnification. Treat the fitted value as \
the least-bad compromise, not a BDC calibration."
);
}
}
}
Ok(())
}
struct OptimalZeroTarget {
range_display: f64,
range_m: f64,
height_m: Option<f64>,
}
fn parse_optimal_zero_target(token: &str, units: UnitSystem) -> Result<OptimalZeroTarget, String> {
let parts: Vec<&str> = token.split(':').collect();
if parts.len() != 1 && parts.len() != 2 {
return Err(format!(
"invalid --target '{token}': expected RANGE or RANGE:HEIGHT (e.g. 400 or 400:12)"
));
}
let range: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("invalid --target range '{}' in '{token}'", parts[0]))?;
if !range.is_finite() || range <= 0.0 {
return Err(format!(
"invalid --target '{token}': the range must be finite and greater than zero"
));
}
let height = match parts.get(1) {
Some(token_height) => {
let value: f64 = token_height
.trim()
.parse()
.map_err(|_| format!("invalid --target height '{token_height}' in '{token}'"))?;
if !value.is_finite() || value <= 0.0 {
return Err(format!(
"invalid --target '{token}': the height must be finite and greater than zero"
));
}
Some(value)
}
None => None,
};
Ok(OptimalZeroTarget {
range_display: range,
range_m: UnitConverter::distance_to_metric(range, units),
height_m: height.map(|value| match units {
UnitSystem::Imperial => value * 0.0254,
UnitSystem::Metric => value * 0.01,
}),
})
}
fn handle_optimal_zero(
targets: Vec<String>,
vital: Option<f64>,
load_args: InverseSolverLoadArgs,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
const MIN_TARGETS: usize = 2;
const MAX_TARGETS: usize = 16;
let format = reticle_format(output)?;
let mut parsed: Vec<OptimalZeroTarget> = targets
.iter()
.map(|token| parse_optimal_zero_target(token, units))
.collect::<Result<_, _>>()?;
if parsed.len() < MIN_TARGETS || parsed.len() > MAX_TARGETS {
return Err(format!(
"optimal-zero needs {MIN_TARGETS} to {MAX_TARGETS} --target entries (got {}): one \
target is just a zero distance, and beyond {MAX_TARGETS} the min-max answer is \
dominated by the extremes anyway",
parsed.len()
)
.into());
}
let vital_m = vital.map(|value| match units {
UnitSystem::Imperial => value * 0.0254,
UnitSystem::Metric => value * 0.01,
});
for target in parsed.iter_mut() {
if target.height_m.is_none() {
target.height_m = vital_m;
}
if target.height_m.is_none() {
return Err(format!(
"--target {} carries no height and no --vital was given; one of the two must \
say how big the target is",
target.range_display
)
.into());
}
}
parsed.sort_by(|a, b| a.range_m.total_cmp(&b.range_m));
let furthest_m = parsed.iter().fold(0.0_f64, |acc, t| acc.max(t.range_m));
let nearest_m = parsed
.iter()
.fold(f64::INFINITY, |acc, t| acc.min(t.range_m));
let curve_max_m = furthest_m * 1.05;
let (base_load, _) = load_args.resolve(Some(1.0), units)?;
let holds_at = |zero_m: f64| -> Option<Vec<f64>> {
let mut load = base_load.clone();
load.zero_distance_m = zero_m;
let curve = HoldCurve::solve(&load, curve_max_m).ok()?;
parsed
.iter()
.map(|target| curve.at_range(target.range_m).map(|p| p.drop_mil))
.collect()
};
let objective = |zero_m: f64| -> f64 {
match holds_at(zero_m) {
Some(holds) => holds.iter().fold(0.0_f64, |acc, h| acc.max(h.abs())),
None => f64::INFINITY,
}
};
let mut lo = (nearest_m * 0.15).max(OPTIMAL_ZERO_MIN_M);
let mut hi = (furthest_m * 1.05).min(OPTIMAL_ZERO_MAX_M).max(lo + 1.0);
let mut x1 = hi - GOLDEN_SECTION_RATIO * (hi - lo);
let mut x2 = lo + GOLDEN_SECTION_RATIO * (hi - lo);
let mut f1 = objective(x1);
let mut f2 = objective(x2);
for _ in 0..OPTIMAL_ZERO_MAX_ITERATIONS {
if hi - lo <= OPTIMAL_ZERO_TOLERANCE_M {
break;
}
if f1 < f2 {
hi = x2;
x2 = x1;
f2 = f1;
x1 = hi - GOLDEN_SECTION_RATIO * (hi - lo);
f1 = objective(x1);
} else {
lo = x1;
x1 = x2;
f1 = f2;
x2 = lo + GOLDEN_SECTION_RATIO * (hi - lo);
f2 = objective(x2);
}
}
let best_zero_m = 0.5 * (lo + hi);
let holds = holds_at(best_zero_m).ok_or_else(|| {
format!(
"no trajectory could be solved at the fitted zero of {:.1} {}",
UnitConverter::distance_from_metric(best_zero_m, units),
match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
}
)
})?;
struct TargetRow {
range_display: f64,
height_m: f64,
hold_mil: f64,
offset_m: f64,
fits: bool,
}
let rows: Vec<TargetRow> = parsed
.iter()
.zip(&holds)
.map(|(target, &hold_mil)| {
let height_m = target.height_m.expect("filled in above");
let offset_m = hold_mil / 1000.0 * target.range_m;
TargetRow {
range_display: target.range_display,
height_m,
hold_mil,
offset_m,
fits: offset_m.abs() <= height_m / 2.0,
}
})
.collect();
let max_hold_mil = rows
.iter()
.fold(0.0_f64, |acc, r| acc.max(r.hold_mil.abs()));
let all_fit = rows.iter().all(|r| r.fits);
let (dist_unit, size_unit) = match units {
UnitSystem::Imperial => ("yd", "in"),
UnitSystem::Metric => ("m", "cm"),
};
let size_from_m = |value: f64| match units {
UnitSystem::Imperial => value / 0.0254,
UnitSystem::Metric => value * 100.0,
};
match format {
ReticleFormat::Json => {
let value = serde_json::json!({
"optimal_zero": UnitConverter::distance_from_metric(best_zero_m, units),
"distance_unit": dist_unit,
"size_unit": size_unit,
"max_hold_mil": max_hold_mil,
"all_targets_fit": all_fit,
"targets": rows.iter().map(|r| serde_json::json!({
"range": r.range_display,
"height": size_from_m(r.height_m),
"hold_mil": r.hold_mil,
"offset": size_from_m(r.offset_m),
"fits": r.fits,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
ReticleFormat::Table => {
println!("Optimal Zero (min-max hold)");
println!("===========================\n");
println!(
"Optimal zero: {:.1} {}",
UnitConverter::distance_from_metric(best_zero_m, units),
dist_unit
);
println!("Largest hold: {max_hold_mil:.3} mil\n");
println!(
"Range({dist_unit}) Target({size_unit}) Hold(mil) Center-hold miss({size_unit}) Fits"
);
println!("---------- ---------- --------- --------------------- ----");
for r in &rows {
println!(
"{:>10.0} {:>10.1} {:>9.3} {:>21.2} {}",
r.range_display,
size_from_m(r.height_m),
r.hold_mil,
size_from_m(r.offset_m),
if r.fits { "yes" } else { "NO" }
);
}
println!(
"\n{}",
if all_fit {
"Every target is inside its vital zone with a dead-center hold."
} else {
"At least one target needs a hold: no single zero keeps them all centered."
}
);
println!(
"Center-hold miss is signed: positive = the bullet lands LOW of the aim point."
);
}
}
Ok(())
}
fn reticle_format(output: OutputFormat) -> Result<ReticleFormat, Box<dyn Error>> {
match output {
OutputFormat::Table => Ok(ReticleFormat::Table),
OutputFormat::Json => Ok(ReticleFormat::Json),
OutputFormat::Csv => Err("reticle output has no CSV form; use -o table or -o json".into()),
OutputFormat::Pdf => Err("reticle output has no PDF form; use -o table or -o json".into()),
}
}
const MAX_REPORTED_ARCS: usize = 8;
fn print_reticle_import_report(
file: &std::path::Path,
report: &ballistics_engine::reticle_import::VentumImportReport,
) {
if report.is_empty() {
return;
}
eprintln!(
"note: {}: {} element(s) carry no hold point and were not imported ({})",
file.display(),
report.dropped_elements,
report.tally()
);
if report.circle_repeats_unreadable > 0 {
eprintln!(
"note: {} circle element(s) declared a `repeat` this importer could not read, so \
each was counted once; the document may draw more.",
report.circle_repeats_unreadable
);
}
if report.arcs.is_empty() && report.arcs_unresolved == 0 {
return;
}
eprintln!(
"note: an arc's aiming points cannot be declared in this format, so none were \
imported as marks. Add them yourself if your reticle holds on them:"
);
for arc in report.arcs.iter().take(MAX_REPORTED_ARCS) {
eprintln!(
" arc {:.0}-{:.0} deg: apex {}, tips {} and {}",
arc.start_degrees,
arc.end_degrees,
format_reticle_arc_point(arc.apex),
format_reticle_arc_point(arc.start_tip),
format_reticle_arc_point(arc.end_tip),
);
}
if report.arcs.len() > MAX_REPORTED_ARCS {
eprintln!(
" ... and {} more arc(s)",
report.arcs.len() - MAX_REPORTED_ARCS
);
}
if report.arcs_unresolved > 0 {
eprintln!(
" {} further arc(s) declared geometry this importer could not read \
(radius, sweep or center), so their points could not be computed at all",
report.arcs_unresolved
);
}
}
fn format_reticle_arc_point(point: ballistics_engine::reticle_import::VentumArcPoint) -> String {
format!(
"{} / {} mil",
format_reticle_arc_axis(point.right_mil, "left", "right"),
format_reticle_arc_axis(point.down_mil, "up", "down")
)
}
fn format_reticle_arc_axis(value: f64, negative: &str, positive: &str) -> String {
let rounded = (value * 100.0).round() / 100.0;
if rounded == 0.0 {
return "0.00".to_string();
}
let direction = if rounded < 0.0 { negative } else { positive };
format!("{:.2} {}", rounded.abs(), direction)
}
fn parse_reticle_bdc_drop(token: &str, units: UnitSystem) -> Result<(f64, f64, f64), String> {
let parts: Vec<&str> = token.split(':').collect();
if parts.len() != 2 {
return Err(format!(
"invalid --drop '{token}': expected RANGE:DROP_MIL (e.g. 400:2.7)"
));
}
let range: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("invalid --drop range '{}' in '{token}'", parts[0]))?;
let drop_mil: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("invalid --drop value '{}' in '{token}'", parts[1]))?;
if !range.is_finite() || range <= 0.0 {
return Err(format!(
"invalid --drop '{token}': range must be finite and greater than zero"
));
}
if !drop_mil.is_finite() {
return Err(format!("invalid --drop '{token}': drop must be finite"));
}
Ok((
range,
UnitConverter::distance_to_metric(range, units),
drop_mil,
))
}
fn handle_reticle(action: ReticleAction, units: UnitSystem) -> Result<(), Box<dyn Error>> {
match action {
ReticleAction::Generate { layout } => {
let (mut description, common) = match layout {
ReticleLayout::MilGrid {
spacing,
extent,
common,
} => (
ReticleDescription::mil_grid(spacing, extent).map_err(|e| e.to_string())?,
common,
),
ReticleLayout::Tree {
rows,
row_spacing,
spread_step,
common,
} => (
ReticleDescription::tree(rows, row_spacing, spread_step)
.map_err(|e| e.to_string())?,
common,
),
ReticleLayout::Bdc { drops, common } => {
let parsed = drops
.iter()
.map(|token| parse_reticle_bdc_drop(token, units))
.collect::<Result<Vec<_>, _>>()?;
let metric: Vec<(f64, f64)> = parsed
.iter()
.map(|&(_, range_m, drop_mil)| (range_m, drop_mil))
.collect();
let mut description =
ReticleDescription::bdc_from_drops(&metric).map_err(|e| e.to_string())?;
let dist_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
for (mark, &(range_display, _, _)) in
description.marks.iter_mut().skip(1).zip(parsed.iter())
{
mark.label = Some(format!("{range_display:.0} {dist_unit}"));
}
(description, common)
}
};
description.focal_plane = common.focal_plane.into();
description.reference_magnification = common.reference_mag;
if let Some(name) = common.name {
description.name = name;
}
description.validate().map_err(|e| e.to_string())?;
print!(
"{}",
format_reticle_description(&description, reticle_format(common.output)?)
);
Ok(())
}
ReticleAction::Import { file, output } => {
let json = std::fs::read_to_string(&file)
.map_err(|e| format!("reading reticle file '{}': {e}", file.display()))?;
let (description, report) =
ballistics_engine::reticle_import::import_ventum_reticle_with_report(&json)
.map_err(|e| e.to_string())?;
description.validate().map_err(|e| e.to_string())?;
print_reticle_import_report(&file, &report);
print!(
"{}",
format_reticle_description(&description, reticle_format(output)?)
);
Ok(())
}
ReticleAction::Hold {
reticle_json,
profile,
drop_mil,
wind_mil,
range,
mag,
velocity,
bc,
mass,
diameter,
drag_model,
zero_distance,
sight_height,
temperature,
pressure,
pressure_type,
humidity,
altitude,
wind_speed,
wind_direction,
output,
} => {
let format = reticle_format(output)?;
let profile_data = match profile.as_deref() {
Some(name) => Some(load_profile(name)?),
None => None,
};
let description = match (&reticle_json, profile_data.as_ref()) {
(Some(path), _) => load_reticle_description(path)?,
(None, Some(data)) => data.reticle.clone().ok_or_else(|| {
format!(
"profile '{}' has no attached reticle; attach one with \
`profile save {} --reticle-json <FILE>`, or pass --reticle-json here",
data.name, data.name
)
})?,
(None, None) => {
return Err(
"a reticle is required: pass --reticle-json <FILE> or --profile <NAME>"
.into(),
)
}
};
let (hold_drop_mil, hold_wind_mil, solved) = match (drop_mil, range) {
(Some(_), Some(_)) => {
return Err(
"--drop-mil and --range are mutually exclusive: give the angular \
hold directly, or let --range solve for it"
.into(),
)
}
(Some(drop), None) => (drop, wind_mil.unwrap_or(0.0), None),
(None, Some(range)) => {
if wind_mil.is_some() {
return Err(
"--wind-mil applies to --drop-mil only; with --range the wind \
deflection comes from --wind-speed/--wind-direction"
.into(),
);
}
let require = |value: Option<f64>, flag: &str| -> Result<f64, Box<dyn Error>> {
value.ok_or_else(|| {
format!("--range requires {flag} (directly or from --profile)").into()
})
};
let velocity = require(
resolve_param(velocity, &profile_data, |p| p.velocity),
"--velocity",
)?;
let bc = require(resolve_param(bc, &profile_data, |p| p.bc), "--bc")?;
let mass = require(resolve_param(mass, &profile_data, |p| p.mass), "--mass")?;
let diameter = require(
resolve_param(diameter, &profile_data, |p| p.diameter),
"--diameter",
)?;
let zero_distance = require(
zero_distance
.or_else(|| profile_data.as_ref().and_then(|p| p.zero_distance)),
"--zero-distance",
)?;
let sight_height = sight_height
.or_else(|| profile_data.as_ref().and_then(|p| p.sight_height))
.unwrap_or(match units {
UnitSystem::Imperial => 2.0,
UnitSystem::Metric => 50.0,
});
let temperature = UnitConverter::resolve_temperature(temperature, units)?;
let pressure = apply_pressure_mode(
UnitConverter::resolve_pressure(pressure, units)?,
altitude,
pressure_type,
units,
);
let load = HoldCurveLoad {
velocity_mps: UnitConverter::velocity_to_metric(velocity, units),
bc,
mass_kg: UnitConverter::mass_to_metric(mass, units),
diameter_m: UnitConverter::diameter_to_metric(diameter, units),
drag_model,
sight_height_m: UnitConverter::sight_height_to_metric(sight_height, units),
zero_distance_m: UnitConverter::distance_to_metric(zero_distance, units),
temperature_c: UnitConverter::temperature_to_metric(temperature, units),
pressure_hpa: UnitConverter::pressure_to_metric(pressure, units),
humidity,
altitude_m: UnitConverter::altitude_to_metric(altitude, units),
wind_speed_mps: UnitConverter::wind_to_metric(wind_speed, units),
wind_direction_deg: wind_direction.degrees,
};
let range_m = UnitConverter::distance_to_metric(range, units);
let curve = HoldCurve::solve(&load, range_m * 1.05)?;
let point = curve.at_range(range_m).ok_or_else(|| {
format!(
"the trajectory does not reach {range} — it was sampled only to {:.1}",
UnitConverter::distance_from_metric(curve.max_sampled_range_m(), units)
)
})?;
(point.drop_mil, point.wind_mil, Some(point))
}
(None, None) => {
return Err(
"a firing solution is required: pass --drop-mil (with optional \
--wind-mil) or --range"
.into(),
)
}
};
let hold = hold_point_in_reticle(hold_drop_mil, hold_wind_mil, mag, &description)
.map_err(|e| e.to_string())?;
if let (Some(point), OutputFormat::Table) = (solved, output) {
let (dist_unit, vel_unit) = match units {
UnitSystem::Imperial => ("yd", "fps"),
UnitSystem::Metric => ("m", "m/s"),
};
println!(
"Solved at {:.0} {} — {:.1} {} remaining, {:.3} s time of flight\n",
UnitConverter::distance_from_metric(point.range_m, units),
dist_unit,
UnitConverter::velocity_from_metric(point.velocity_mps, units),
vel_unit,
point.time_s
);
}
print!("{}", format_reticle_hold(&hold, &description, mag, format));
Ok(())
}
}
}
fn run_drag_curve(
drag_model: DragModel,
output: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let format = match output {
OutputFormat::Table => ballistics_engine::drag::ReferenceDragCurveFormat::Table,
OutputFormat::Csv => ballistics_engine::drag::ReferenceDragCurveFormat::Csv,
OutputFormat::Json => ballistics_engine::drag::ReferenceDragCurveFormat::Json,
OutputFormat::Pdf => {
return Err("drag-curve has no PDF form; use -o table, -o csv, or -o json".into());
}
};
print!(
"{}",
ballistics_engine::drag::format_reference_drag_curve(&drag_model, format)
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_bc_convert(
source_model: BcConvertModelArg,
target_model: BcConvertModelArg,
bc: Option<f64>,
mach: Option<f64>,
velocity: Option<f64>,
bc_segment: &[String],
speed_of_sound: Option<f64>,
output: BcConversionOutputFormat,
units: UnitSystem,
) -> Result<(), Box<dyn std::error::Error>> {
use ballistics_engine::bc_conversion::{
analyze_bc_segments, convert_bc_at_mach, convert_bc_at_velocity,
format_bc_conversion_report, BcConversionFormat, BcConversionReportV1,
};
let format = match output {
BcConversionOutputFormat::Table => BcConversionFormat::Table,
BcConversionOutputFormat::Csv => BcConversionFormat::Csv,
BcConversionOutputFormat::Json => BcConversionFormat::Json,
};
let source: DragModel = source_model.into();
let target: DragModel = target_model.into();
let report = if let Some(source_bc) = bc {
let result = match (mach, velocity) {
(Some(mach), None) => convert_bc_at_mach(source_bc, source, target, mach)
.map_err(|error| error.to_string())?,
(None, Some(velocity)) => convert_bc_at_velocity(
source_bc,
source,
target,
bc_convert_velocity_to_fps(velocity, units),
bc_convert_speed_of_sound_fps(speed_of_sound, units),
)
.map_err(|error| error.to_string())?,
_ => {
return Err(
"scalar conversion requires exactly one of --mach or --velocity".into(),
);
}
};
BcConversionReportV1::Scalar { result }
} else {
let segments: Vec<BCSegmentData> = bc_segment
.iter()
.map(|segment| parse_bc_segment(segment, units))
.collect::<Result<_, _>>()?;
let result = analyze_bc_segments(
&segments,
source,
target,
&[DragModel::G1, DragModel::G7],
bc_convert_speed_of_sound_fps(speed_of_sound, units),
)
.map_err(|error| error.to_string())?;
BcConversionReportV1::Banded { result }
};
print!(
"{}",
format_bc_conversion_report(&report, format).map_err(|error| error.to_string())?
);
Ok(())
}
fn bc_convert_velocity_to_fps(value: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Imperial => value,
UnitSystem::Metric => value * 3.280_839_895,
}
}
fn bc_convert_speed_of_sound_fps(value: Option<f64>, units: UnitSystem) -> f64 {
value.map_or_else(
|| {
ballistics_engine::constants::SPEED_OF_SOUND_MPS
/ ballistics_engine::constants::FPS_TO_MPS
},
|value| bc_convert_velocity_to_fps(value, units),
)
}
fn apply_pressure_mode(
pressure_hpa: f64,
altitude_display: f64,
mode: Option<PressureReferenceMode>,
units: UnitSystem,
) -> f64 {
match mode.unwrap_or(PressureReferenceMode::Absolute) {
PressureReferenceMode::Absolute => pressure_hpa,
PressureReferenceMode::Qnh => {
let altitude_m = UnitConverter::altitude_to_metric(altitude_display, units);
ballistics_engine::atmosphere::reduce_qnh_to_station_pressure(pressure_hpa, altitude_m)
}
}
}
fn describe_solve_error(e: &SolveErrorEnvelopeV1) -> String {
match e.error.path() {
Some(path) => format!("{:?} at {path}: {}", e.error.code, e.error.message),
None => format!("{:?}: {}", e.error.code, e.error.message),
}
}
fn load_resolved_request(path: &Path) -> Result<ResolvedSolveRequestV1, Box<dyn Error>> {
let text = fs::read_to_string(path).map_err(|e| {
format!(
"could not read solve-json request file {}: {e}",
path.display()
)
})?;
let request = decode_solve_request_v1(&text)
.map_err(|e| format!("{}: {}", path.display(), describe_solve_error(&e)))?;
let success = ballistics_engine::solve_v1(request)
.map_err(|e| format!("{}: {}", path.display(), describe_solve_error(&e)))?;
Ok(success.resolved_request)
}
fn axis_kebab_name(axis: InputAxis) -> &'static str {
use InputAxis::*;
match axis {
Mass => "mass",
Diameter => "diameter",
Length => "length",
BallisticCoefficient => "ballistic-coefficient",
TwistRate => "twist-rate",
TwistDirection => "twist-direction",
DragModel => "drag-model",
MuzzleVelocityMps => "muzzle-velocity-mps",
SightHeight => "sight-height",
ZeroDistance => "zero-distance",
ZeroPoiUp => "zero-poi-up",
ZeroPoiRight => "zero-poi-right",
SightOffsetLateral => "sight-offset-lateral",
MuzzleHeight => "muzzle-height",
MuzzleAngle => "muzzle-angle",
Altitude => "altitude",
Temperature => "temperature",
Pressure => "pressure",
RelativeHumidity => "relative-humidity",
Latitude => "latitude",
WindSpeed => "wind-speed",
WindDirection => "wind-direction",
WindVertical => "wind-vertical",
TargetDistance => "target-distance",
ShootingAngle => "shooting-angle",
Cant => "cant",
ShotAzimuth => "shot-azimuth",
AimAzimuth => "aim-azimuth",
TargetHeight => "target-height",
MagnusEnabled => "magnus-enabled",
CoriolisEnabled => "coriolis-enabled",
EnhancedSpinDriftEnabled => "enhanced-spin-drift-enabled",
}
}
fn parse_input_axis(s: &str) -> Result<InputAxis, String> {
InputAxis::ALL
.iter()
.copied()
.find(|&axis| axis_kebab_name(axis) == s)
.ok_or_else(|| {
let mut names: Vec<&str> = InputAxis::ALL
.iter()
.copied()
.map(axis_kebab_name)
.collect();
names.sort_unstable();
format!(
"unknown axis '{s}'; accepted axis names are: {}",
names.join(", ")
)
})
}
fn axis_unit_suffix(axis: InputAxis) -> &'static str {
match axis_meta(axis).kind {
AxisKind::Continuous { unit, .. } => unit,
AxisKind::Categorical => "",
}
}
fn parse_domain_arg(s: &str) -> Result<(InputAxis, (f64, f64)), String> {
let malformed =
|| format!("invalid --domain '{s}': expected AXIS=LO:HI (e.g. wind-speed=0:20)");
let (axis_part, range_part) = s.split_once('=').ok_or_else(malformed)?;
let axis = parse_input_axis(axis_part.trim())?;
let (lo_str, hi_str) = range_part.split_once(':').ok_or_else(malformed)?;
let (lo_str, hi_str) = (lo_str.trim(), hi_str.trim());
let lo: f64 = lo_str
.parse()
.map_err(|_| format!("invalid --domain '{s}': lower bound '{lo_str}' is not a number"))?;
let hi: f64 = hi_str
.parse()
.map_err(|_| format!("invalid --domain '{s}': upper bound '{hi_str}' is not a number"))?;
Ok((axis, (lo, hi)))
}
fn parse_sigma_arg(s: &str) -> Result<(InputAxis, f64), String> {
let (axis_part, value_part) = s.split_once('=').ok_or_else(|| {
format!("invalid --sigma '{s}': expected AXIS=VALUE (e.g. wind-speed=1.5)")
})?;
let axis = parse_input_axis(axis_part.trim())?;
let value_str = value_part.trim();
let value: f64 = value_str
.parse()
.map_err(|_| format!("invalid --sigma '{s}': value '{value_str}' is not a number"))?;
if !(value.is_finite() && value >= 0.0) {
return Err(format!(
"invalid --sigma for axis '{}': {value_str} must be finite and non-negative",
axis_kebab_name(axis)
));
}
Ok((axis, value))
}
fn format_delta(d: &DeltaV1) -> String {
format!(
"drop {:+9.6} m windage {:+9.6} m time {:+9.6} s velocity {:+9.4} m/s",
d.drop_m, d.windage_m, d.time_s, d.velocity_mps
)
}
fn render_explain_table(report: &SolutionDiffReportV1) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "explain -- method: {}", report.method);
for row in &report.rows {
let _ = writeln!(out);
let _ = writeln!(out, "range {:.3} m", row.range_m);
let _ = writeln!(out, " {:<22} {}", "total", format_delta(&row.total));
for c in &row.contributions {
let group_name = format!("{:?}", c.group);
let _ = writeln!(out, " {group_name:<22} {}", format_delta(&c.delta));
}
let _ = writeln!(
out,
" {:<22} {} (unexplained by any single group)",
"interaction remainder",
format_delta(&row.interaction_remainder)
);
}
if !report.skipped_axes.is_empty() {
let _ = writeln!(out);
let _ = writeln!(
out,
"skipped axes (excluded from that group's swap on both directions):"
);
for s in &report.skipped_axes {
let direction = format!("{:?}", s.direction);
let group = format!("{:?}", s.group);
let axis = axis_kebab_name(s.axis);
let _ = writeln!(out, " [{direction}] {group} / {axis} -- {}", s.reason);
}
}
let _ = writeln!(out);
let _ = writeln!(out, "assumptions:");
for a in &report.assumptions {
let _ = writeln!(out, " - {a}");
}
out
}
fn handle_explain(
a_path: PathBuf,
b_path: PathBuf,
ranges_m: Vec<f64>,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
match output {
OutputFormat::Csv => return Err("explain has no CSV form; use -o table or -o json".into()),
OutputFormat::Pdf => return Err("explain has no PDF form; use -o table or -o json".into()),
OutputFormat::Table | OutputFormat::Json => {}
}
let a = load_resolved_request(&a_path)?;
let b = load_resolved_request(&b_path)?;
let report = explain_difference(&a, &b, &ranges_m).map_err(|e| e.to_string())?;
if matches!(output, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_explain_table(&report));
}
Ok(())
}
fn render_tolerance_direction(
bound: Option<f64>,
has_no_effect: bool,
boundary: Option<LimitingBoundaryV1>,
unit: &str,
) -> String {
let unit_sp = if unit.is_empty() { "" } else { " " };
if let Some(v) = bound {
let edge = boundary.expect("a found bound always carries a limiting boundary");
format!("bound at {v:.6}{unit_sp}{unit} -- crosses the {edge:?} edge")
} else if has_no_effect {
"no measurable effect on the impact (this axis is provably irrelevant here)".to_string()
} else {
"no bound within the configured domain (the impact stays inside the target)".to_string()
}
}
fn render_tolerance_table(report: &ToleranceReportV1) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "tolerance -- method: {}", report.method);
let _ = writeln!(out, "range {:.3} m", report.range_m);
for axis in &report.axes {
let _ = writeln!(out);
let name = axis_kebab_name(axis.axis);
let unit = axis_unit_suffix(axis.axis);
let unit_sp = if unit.is_empty() { "" } else { " " };
let _ = writeln!(
out,
"axis {name} (nominal {:.6}{unit_sp}{unit}, target margin {:.4} m)",
axis.nominal, axis.margin_linear_m
);
if !axis.nominal_inside_target {
let _ = writeln!(
out,
" nominal OUTSIDE target -- target has no positive area; no bound was \
searched in either direction"
);
continue;
}
let _ = writeln!(
out,
" near (toward the domain's lower bound): {}",
render_tolerance_direction(
axis.near_bound,
axis.near_has_no_effect,
axis.near_limiting_boundary,
unit
)
);
let _ = writeln!(
out,
" far (toward the domain's upper bound): {}",
render_tolerance_direction(
axis.far_bound,
axis.far_has_no_effect,
axis.far_limiting_boundary,
unit
)
);
if axis.unbounded_in_domain && !(axis.near_has_no_effect && axis.far_has_no_effect) {
let _ = writeln!(
out,
" unbounded in domain: the impact stays inside the target across the whole \
configured domain"
);
}
}
if !report.unavailable_axes.is_empty() {
let _ = writeln!(out);
let _ = writeln!(out, "unavailable axes (could not be searched at all):");
for u in &report.unavailable_axes {
let name = axis_kebab_name(u.axis);
let code = format!("{:?}", u.code);
let _ = writeln!(out, " {name} [{code}] -- {}", u.reason);
}
}
let _ = writeln!(out);
let _ = writeln!(out, "assumptions:");
for a in &report.assumptions {
let _ = writeln!(out, " - {a}");
}
out
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable Clap command shape"
)]
fn handle_tolerance(
request_path: PathBuf,
range_m: f64,
target: String,
axes_raw: Vec<String>,
domains_raw: Vec<String>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
match output {
OutputFormat::Csv => {
return Err("tolerance has no CSV form; use -o table or -o json".into())
}
OutputFormat::Pdf => {
return Err("tolerance has no PDF form; use -o table or -o json".into())
}
OutputFormat::Table | OutputFormat::Json => {}
}
let axes: Vec<InputAxis> = axes_raw
.iter()
.map(|s| parse_input_axis(s))
.collect::<Result<_, _>>()?;
for (i, &axis) in axes.iter().enumerate() {
if axes[..i].contains(&axis) {
return Err(
format!("--axis {} was given more than once", axis_kebab_name(axis)).into(),
);
}
}
let mut domains: Vec<(InputAxis, (f64, f64))> = Vec::with_capacity(domains_raw.len());
for raw in &domains_raw {
let (axis, bounds) = parse_domain_arg(raw)?;
if domains.iter().any(|(a, _)| *a == axis) {
return Err(format!(
"--domain for axis '{}' was given more than once",
axis_kebab_name(axis)
)
.into());
}
domains.push((axis, bounds));
}
for &axis in &axes {
if !domains.iter().any(|(a, _)| *a == axis) {
let unit = axis_unit_suffix(axis);
let hint = if unit.is_empty() {
String::new()
} else {
format!(" (in {unit})")
};
let name = axis_kebab_name(axis);
return Err(format!(
"--axis {name} has no matching --domain; supply one as --domain {name}=LO:HI{hint}"
)
.into());
}
}
for (axis, _) in &domains {
if !axes.contains(axis) {
return Err(format!(
"--domain was given for axis '{}', which was not requested with --axis",
axis_kebab_name(*axis)
)
.into());
}
}
let resolved = load_resolved_request(&request_path)?;
let target_spec = parse_target_spec(&target, units).map_err(|e| e.to_string())?;
let geometry = match target_spec {
TargetSpec::Rect { width_m, height_m } => TargetGeometryV1::Rect { width_m, height_m },
TargetSpec::Circle { diameter_m } => TargetGeometryV1::Circle {
radius_m: diameter_m / 2.0,
},
};
let report = tolerance_envelope(&resolved, &axes, range_m, geometry, &domains)
.map_err(|e| e.to_string())?;
if matches!(output, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_tolerance_table(&report));
}
Ok(())
}
fn render_error_budget_table(report: &ErrorBudgetReportV1) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "error-budget -- method: {}", report.method);
for row in &report.rows {
let _ = writeln!(out);
let _ = writeln!(out, "range {:.3} m", row.range_m);
let _ = writeln!(
out,
" impact spread: sigma_drop {:.6} m sigma_windage {:.6} m covariance {:.6} m^2",
row.sigma_drop_m, row.sigma_windage_m, row.covariance_m2
);
let _ = writeln!(
out,
" 95% ellipse: semi-major {:.6} m semi-minor {:.6} m area {:.6} m^2",
row.ellipse_95.semi_major_m, row.ellipse_95.semi_minor_m, row.ellipse_95.area_m2
);
if let Some(p_hit) = row.p_hit {
let _ = writeln!(out, " p_hit: {:.1}%", p_hit * 100.0);
}
if !row.sources.is_empty() {
let _ = writeln!(out, " ranked sources (share of impact variance):");
for (i, s) in row.sources.iter().enumerate() {
let name = axis_kebab_name(s.axis);
let scheme_note = if s.scheme == DifferenceScheme::Central {
String::new()
} else {
format!(" [{:?}]", s.scheme)
};
let gain_note = match s.p_hit_gain_if_perfect {
Some(gain) => format!(" gain if perfected: p_hit +{:.3}%", gain * 100.0),
None => String::new(),
};
let _ = writeln!(
out,
" {:>2}. {:<28} {:>6.1}%{scheme_note}{gain_note}",
i + 1,
name,
s.variance_share * 100.0
);
}
}
let _ = writeln!(out, " priority: {}", row.priority_statement);
}
if !report.unavailable_sources.is_empty() {
let _ = writeln!(out);
let _ = writeln!(out, "unavailable sources (could not be evaluated at all):");
for u in &report.unavailable_sources {
let name = axis_kebab_name(u.axis);
let code = format!("{:?}", u.code);
let _ = writeln!(
out,
" {name} (sigma {:.6}) [{code}] -- {}",
u.sigma, u.reason
);
}
}
let _ = writeln!(out);
let _ = writeln!(out, "assumptions:");
for a in &report.assumptions {
let _ = writeln!(out, " - {a}");
}
out
}
fn handle_error_budget(
request_path: PathBuf,
ranges_m: Vec<f64>,
sigmas_raw: Vec<String>,
target: Option<String>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
match output {
OutputFormat::Csv => {
return Err("error-budget has no CSV form; use -o table or -o json".into())
}
OutputFormat::Pdf => {
return Err("error-budget has no PDF form; use -o table or -o json".into())
}
OutputFormat::Table | OutputFormat::Json => {}
}
let mut sources: Vec<(InputAxis, f64)> = Vec::with_capacity(sigmas_raw.len());
for raw in &sigmas_raw {
let (axis, sigma) = parse_sigma_arg(raw)?;
if sources.iter().any(|&(a, _)| a == axis) {
return Err(format!(
"--sigma for axis '{}' was given more than once",
axis_kebab_name(axis)
)
.into());
}
sources.push((axis, sigma));
}
let resolved = load_resolved_request(&request_path)?;
let geometry = target
.map(|spec| {
parse_target_spec(&spec, units).map(|target_spec| match target_spec {
TargetSpec::Rect { width_m, height_m } => {
TargetGeometryV1::Rect { width_m, height_m }
}
TargetSpec::Circle { diameter_m } => TargetGeometryV1::Circle {
radius_m: diameter_m / 2.0,
},
})
})
.transpose()
.map_err(|e| e.to_string())?;
let report = error_budget_with_target(&resolved, &sources, &ranges_m, geometry)
.map_err(|e| e.to_string())?;
if matches!(output, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_error_budget_table(&report));
}
Ok(())
}
#[cfg(test)]
mod decision_support_axis_tests {
use super::*;
#[test]
fn axis_kebab_names_round_trip_every_member_of_all() {
for &axis in InputAxis::ALL {
let name = axis_kebab_name(axis);
assert_eq!(
parse_input_axis(name),
Ok(axis),
"'{name}' (from {axis:?}) did not parse back to the same axis"
);
}
}
#[test]
fn every_axis_kebab_name_is_unique() {
let mut names: Vec<&str> = InputAxis::ALL
.iter()
.copied()
.map(axis_kebab_name)
.collect();
let before = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), before, "two axes share a kebab-case name");
}
#[test]
fn unknown_axis_lists_every_accepted_name() {
let err = parse_input_axis("not-a-real-axis").unwrap_err();
assert!(err.contains("unknown axis 'not-a-real-axis'"), "{err}");
for &axis in InputAxis::ALL {
assert!(
err.contains(axis_kebab_name(axis)),
"error is missing '{}': {err}",
axis_kebab_name(axis)
);
}
}
#[test]
fn domain_arg_parses_axis_and_bounds() {
assert_eq!(
parse_domain_arg("wind-speed=0:20"),
Ok((InputAxis::WindSpeed, (0.0, 20.0)))
);
assert_eq!(
parse_domain_arg("cant=-0.05:0.05"),
Ok((InputAxis::Cant, (-0.05, 0.05)))
);
}
#[test]
fn domain_arg_rejects_malformed_tokens() {
assert!(parse_domain_arg("wind-speed").is_err(), "missing '='");
assert!(parse_domain_arg("wind-speed=0").is_err(), "missing ':'");
assert!(
parse_domain_arg("wind-speed=lo:20").is_err(),
"non-numeric lower bound"
);
assert!(parse_domain_arg("nope=0:20").is_err(), "unknown axis");
}
#[test]
fn sigma_arg_parses_axis_and_value() {
assert_eq!(
parse_sigma_arg("wind-speed=1.5"),
Ok((InputAxis::WindSpeed, 1.5))
);
assert_eq!(
parse_sigma_arg("muzzle-velocity-mps=5.0"),
Ok((InputAxis::MuzzleVelocityMps, 5.0))
);
assert_eq!(parse_sigma_arg("cant=0"), Ok((InputAxis::Cant, 0.0)));
}
#[test]
fn sigma_arg_rejects_malformed_unknown_or_invalid_tokens() {
assert!(parse_sigma_arg("wind-speed").is_err(), "missing '='");
assert!(parse_sigma_arg("nope=1.5").is_err(), "unknown axis");
assert!(
parse_sigma_arg("wind-speed=abc").is_err(),
"non-numeric value"
);
let negative = parse_sigma_arg("wind-speed=-1.0").unwrap_err();
assert!(negative.contains("wind-speed"), "{negative}");
let non_finite = parse_sigma_arg("wind-speed=NaN").unwrap_err();
assert!(non_finite.contains("wind-speed"), "{non_finite}");
let infinite = parse_sigma_arg("wind-speed=inf").unwrap_err();
assert!(infinite.contains("wind-speed"), "{infinite}");
}
}
#[cfg(test)]
mod decision_support_render_tests {
use super::*;
use ballistics_engine::error_budget::{
Ellipse95V1, ErrorBudgetRowV1, SourceContributionV1, UnavailableReasonCodeV1,
UnavailableSourceV1,
};
use ballistics_engine::tolerance::{ToleranceAxisV1, UnavailableAxisV1};
#[test]
fn table_distinguishes_all_five_axis_situations() {
let report = ToleranceReportV1 {
schema_version: 1,
method: "one_variable_deterministic_bisection".to_string(),
assumptions: vec!["test assumption".to_string()],
range_m: 600.0,
unavailable_axes: vec![UnavailableAxisV1 {
axis: InputAxis::MagnusEnabled,
code: UnavailableReasonCodeV1::CategoricalAxis,
reason: "this axis is categorical and cannot be assigned a domain".to_string(),
}],
axes: vec![
ToleranceAxisV1 {
axis: InputAxis::WindSpeed,
nominal: 3.0,
nominal_inside_target: true,
near_bound: Some(1.2),
far_bound: Some(5.4),
near_limiting_boundary: Some(LimitingBoundaryV1::Left),
far_limiting_boundary: Some(LimitingBoundaryV1::Right),
unbounded_in_domain: false,
near_has_no_effect: false,
far_has_no_effect: false,
margin_linear_m: 0.15,
},
ToleranceAxisV1 {
axis: InputAxis::WindVertical,
nominal: 0.0,
nominal_inside_target: true,
near_bound: None,
far_bound: None,
near_limiting_boundary: None,
far_limiting_boundary: None,
unbounded_in_domain: true,
near_has_no_effect: false,
far_has_no_effect: false,
margin_linear_m: 0.15,
},
ToleranceAxisV1 {
axis: InputAxis::TargetDistance,
nominal: 900.0,
nominal_inside_target: true,
near_bound: None,
far_bound: None,
near_limiting_boundary: None,
far_limiting_boundary: None,
unbounded_in_domain: true,
near_has_no_effect: true,
far_has_no_effect: true,
margin_linear_m: 0.15,
},
ToleranceAxisV1 {
axis: InputAxis::Cant,
nominal: 0.01,
nominal_inside_target: false,
near_bound: None,
far_bound: None,
near_limiting_boundary: None,
far_limiting_boundary: None,
unbounded_in_domain: false,
near_has_no_effect: false,
far_has_no_effect: false,
margin_linear_m: 0.0,
},
],
};
let table = render_tolerance_table(&report);
assert_eq!(table.matches("crosses the Left edge").count(), 1, "{table}");
assert_eq!(
table.matches("crosses the Right edge").count(),
1,
"{table}"
);
assert_eq!(
table
.matches("no bound within the configured domain")
.count(),
2,
"exactly one axis (wind-vertical), both directions, is merely unbounded: {table}"
);
assert_eq!(
table.matches("no measurable effect on the impact").count(),
2,
"exactly one axis (target-distance), both directions, has no effect -- must not \
share text with the merely-unbounded axis: {table}"
);
assert_eq!(
table.matches("nominal OUTSIDE target").count(),
1,
"exactly one axis (cant) has its nominal outside the target: {table}"
);
assert_eq!(
table.matches("could not be searched at all").count(),
1,
"exactly one axis (magnus-enabled) is structurally unavailable: {table}"
);
assert_eq!(
table.matches("unbounded in domain:").count(),
1,
"only wind-vertical (unbounded WITH effect) may print the summary line -- \
target-distance (unbounded with NO effect) must not: {table}"
);
assert!(table.contains("axis wind-speed"), "{table}");
assert!(table.contains("axis wind-vertical"), "{table}");
assert!(table.contains("axis target-distance"), "{table}");
assert!(table.contains("axis cant"), "{table}");
assert!(table.contains("magnus-enabled ["), "{table}");
}
#[test]
fn error_budget_table_renders_ranked_sources_gain_and_unavailable_sources() {
let report = ErrorBudgetReportV1 {
schema_version: 1,
method: "central_difference_first_order_propagation_gl20_panelled_pm6sigma".to_string(),
assumptions: vec!["Declared sources are treated as independent.".to_string()],
unavailable_sources: vec![UnavailableSourceV1 {
axis: InputAxis::Altitude,
sigma: 50.0,
code: UnavailableReasonCodeV1::AxisUnsupportedForRequest,
reason: "the original request declared a QNH pressure_reference".to_string(),
}],
rows: vec![ErrorBudgetRowV1 {
range_m: 600.0,
sigma_drop_m: 0.2,
sigma_windage_m: 0.3,
covariance_m2: 0.01,
ellipse_95: Ellipse95V1 {
semi_major_m: 0.5,
semi_minor_m: 0.1,
rotation_rad: 0.0,
area_m2: 0.157,
},
p_hit: Some(0.873),
sources: vec![
SourceContributionV1 {
axis: InputAxis::WindSpeed,
sigma: 1.5,
d_drop_d_x: 0.0,
d_windage_d_x: 0.2,
scheme: DifferenceScheme::ForwardOneSided,
variance_share: 0.623,
ellipse_area_reduction_m2: 0.157,
p_hit_gain_if_perfect: Some(0.4416),
},
SourceContributionV1 {
axis: InputAxis::MuzzleVelocityMps,
sigma: 5.0,
d_drop_d_x: 0.001,
d_windage_d_x: 0.0,
scheme: DifferenceScheme::Central,
variance_share: 0.377,
ellipse_area_reduction_m2: 0.157,
p_hit_gain_if_perfect: Some(0.00008),
},
],
priority_statement: "WindSpeed dominates at 600 m (62.3% of impact variance)."
.to_string(),
}],
};
let table = render_error_budget_table(&report);
assert!(table.contains("wind-speed"), "{table}");
assert!(table.contains("62.3%"), "{table}");
assert!(table.contains("muzzle-velocity-mps"), "{table}");
assert!(table.contains("37.7%"), "{table}");
assert_eq!(table.matches("ForwardOneSided").count(), 1, "{table}");
assert!(!table.contains("[Central]"), "{table}");
assert!(table.contains("p_hit: 87.3%"), "{table}");
assert_eq!(table.matches("gain if perfected").count(), 2, "{table}");
assert!(
table.contains("44.160%"),
"wind-speed's own large gain: {table}"
);
assert!(
table.contains("0.008%"),
"muzzle-velocity-mps's own tiny gain: {table}"
);
assert!(table.contains("unavailable sources"), "{table}");
assert!(table.contains("altitude"), "{table}");
assert!(table.contains("QNH"), "{table}");
let ranked_section = table.split("unavailable sources").next().unwrap();
assert!(
!ranked_section.contains("altitude"),
"an unavailable source must not appear in the ranked list: {table}"
);
assert!(table.contains("priority: WindSpeed dominates"), "{table}");
}
#[test]
fn error_budget_table_omits_p_hit_and_gain_without_a_target() {
let report = ErrorBudgetReportV1 {
schema_version: 1,
method: "central_difference_first_order_propagation".to_string(),
assumptions: vec!["Declared sources are treated as independent.".to_string()],
unavailable_sources: vec![],
rows: vec![ErrorBudgetRowV1 {
range_m: 600.0,
sigma_drop_m: 0.2,
sigma_windage_m: 0.3,
covariance_m2: 0.01,
ellipse_95: Ellipse95V1 {
semi_major_m: 0.5,
semi_minor_m: 0.1,
rotation_rad: 0.0,
area_m2: 0.157,
},
p_hit: None,
sources: vec![SourceContributionV1 {
axis: InputAxis::WindSpeed,
sigma: 1.5,
d_drop_d_x: 0.0,
d_windage_d_x: 0.2,
scheme: DifferenceScheme::Central,
variance_share: 1.0,
ellipse_area_reduction_m2: 0.0,
p_hit_gain_if_perfect: None,
}],
priority_statement: "WindSpeed dominates at 600 m (100.0% of impact variance)."
.to_string(),
}],
};
let table = render_error_budget_table(&report);
assert!(!table.contains("p_hit:"), "{table}");
assert!(!table.contains("gain if perfected"), "{table}");
assert!(table.contains("wind-speed"), "{table}");
assert!(table.contains("100.0%"), "{table}");
}
}
fn dial_plan_axis_label(axis: Axis) -> &'static str {
match axis {
Axis::Elevation => "elevation",
Axis::Windage => "windage",
}
}
fn dial_plan_direction_word(direction: Direction) -> &'static str {
match direction {
Direction::Up => "UP",
Direction::Down => "DOWN",
Direction::Left => "LEFT",
Direction::Right => "RIGHT",
}
}
fn dial_plan_strategy_label(strategy: Strategy) -> &'static str {
match strategy {
Strategy::DialAll => "dial_all",
Strategy::HoldAll => "hold_all",
Strategy::Hybrid => "hybrid",
}
}
fn dial_plan_limit_kind_label(kind: LimitKind) -> &'static str {
match kind {
LimitKind::TravelExceeded => "travel_exceeded",
LimitKind::HoldBoundExceeded => "hold_bound_exceeded",
LimitKind::NoTravelData => "no_travel_data",
LimitKind::NoHoldBoundData => "no_hold_bound_data",
}
}
fn render_dial_plan_table(report: &DialPlanReportV1) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "dial-plan -- method: {}", report.method);
let _ = writeln!(out, "range {:.3} m", report.range_m);
for (i, plan) in report.plans.iter().enumerate() {
let _ = writeln!(out);
let feasible_word = if plan.feasible {
"FEASIBLE"
} else {
"INFEASIBLE"
};
let _ = writeln!(
out,
"#{} strategy: {} [{feasible_word}]",
i + 1,
dial_plan_strategy_label(plan.strategy)
);
for instr in &plan.instructions {
let axis_label = dial_plan_axis_label(instr.axis);
let dir_word = dial_plan_direction_word(instr.direction);
let revolution = match instr.end_revolution {
Some((revs, clicks_in_rev)) => {
format!(" (end at rev {revs} + {clicks_in_rev} clicks)")
}
None => String::new(),
};
let _ = writeln!(
out,
" {axis_label}: dial {dir_word} {} clicks{revolution}; hold {:.3} mil",
instr.delta_clicks.abs(),
instr.hold_mil
);
}
let _ = writeln!(
out,
" residual: elevation {:.3} mil, windage {:.3} mil -> {:.3} m at {:.3} m",
plan.instructions[0].residual_mil,
plan.instructions[1].residual_mil,
plan.residual_linear_at_range_m,
report.range_m
);
for v in &plan.limits_hit {
let axis_label = dial_plan_axis_label(v.axis);
let kind = dial_plan_limit_kind_label(v.kind);
let available = match v.available_mil {
Some(a) => format!("available {a:.3} mil"),
None => "no data declared for this axis".to_string(),
};
let _ = writeln!(
out,
" limit: {axis_label} {kind} -- needed {:.3} mil, {available}",
v.needed_mil
);
}
}
let _ = writeln!(out);
let _ = writeln!(out, "assumptions:");
for a in &report.assumptions {
let _ = writeln!(out, " - {a}");
}
out
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable dial-plan CLI command shape"
)]
fn handle_dial_plan(
elevation: Option<f64>,
windage: Option<f64>,
range: f64,
profile: Option<String>,
elevation_click: Option<String>,
windage_click: Option<String>,
clicks_per_rev: Option<u32>,
zero_stop: Option<bool>,
travel_up: Option<f64>,
travel_down: Option<f64>,
windage_travel_left: Option<f64>,
windage_travel_right: Option<f64>,
hold_up: Option<f64>,
hold_down: Option<f64>,
hold_left: Option<f64>,
hold_right: Option<f64>,
turret_elev: Option<f64>,
turret_wind: Option<f64>,
prefer_hold: bool,
max_hold: Option<f64>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
match output {
OutputFormat::Csv => {
return Err("dial-plan has no CSV form; use -o table or -o json".into())
}
OutputFormat::Pdf => {
return Err("dial-plan has no PDF form; use -o table or -o json".into())
}
OutputFormat::Table | OutputFormat::Json => {}
}
if elevation.is_none() && windage.is_none() {
return Err("at least one of --elevation or --windage is required".into());
}
let inline_flag_given = elevation_click.is_some()
|| windage_click.is_some()
|| clicks_per_rev.is_some()
|| zero_stop.is_some()
|| travel_up.is_some()
|| travel_down.is_some()
|| windage_travel_left.is_some()
|| windage_travel_right.is_some()
|| hold_up.is_some()
|| hold_down.is_some()
|| hold_left.is_some()
|| hold_right.is_some()
|| turret_elev.is_some()
|| turret_wind.is_some();
if profile.is_some() && inline_flag_given {
return Err(
"--profile cannot be combined with inline optic flags (--elevation-click and the \
rest of the turret/hold flags) -- choose one source for the optic"
.into(),
);
}
let (optic, elevation_cf, windage_cf) = match profile {
Some(name) => {
let data = load_profile(&name)?;
let optic = data
.optic_profile()
.map_err(|e| format!("profile '{name}': {e}"))?
.ok_or_else(|| {
format!(
"profile '{name}' has no optic (turret/click) data saved -- save one \
with `profile save --elevation-click <SIZE><UNIT>`"
)
})?;
(
optic,
data.elevation_cf.unwrap_or(1.0),
data.windage_cf.unwrap_or(1.0),
)
}
None => {
let Some(elev_str) = elevation_click.as_deref() else {
return Err(
"either --profile <NAME> or --elevation-click <SIZE><UNIT> is required to \
define the optic"
.into(),
);
};
let elevation_click_v = parse_click_value(elev_str)?;
let windage_click_v = match windage_click.as_deref() {
Some(s) => parse_click_value(s)?,
None => elevation_click_v,
};
let elevation_travel =
require_angular_pair("--travel-up", travel_up, "--travel-down", travel_down)?.map(
|(up, down)| TravelLimits {
up_mil: up,
down_mil: down,
},
);
let windage_travel = require_angular_pair(
"--windage-travel-left",
windage_travel_left,
"--windage-travel-right",
windage_travel_right,
)?
.map(|(left, right)| TravelLimits {
down_mil: left,
up_mil: right,
});
let turret_state =
require_angular_pair("--turret-elev", turret_elev, "--turret-wind", turret_wind)?
.map(|(elevation_mil, windage_mil)| TurretState {
elevation_mil,
windage_mil,
});
let reticle_hold_bounds =
require_hold_bounds(hold_up, hold_down, hold_left, hold_right)?;
let optic = OpticProfile {
elevation_click: elevation_click_v,
windage_click: windage_click_v,
clicks_per_revolution: clicks_per_rev,
zero_stop: zero_stop.unwrap_or(false),
elevation_travel,
windage_travel,
turret_state,
reticle_hold_bounds,
};
(optic, 1.0, 1.0)
}
};
let range_m = UnitConverter::distance_to_metric(range, units);
let correction = AngularCorrection {
elevation_mil: elevation.unwrap_or(0.0),
windage_mil: windage.unwrap_or(0.0),
};
let prefs = Preferences {
prefer_hold,
max_hold_mil: max_hold,
};
let report = plan_corrections(
correction,
&optic,
range_m,
elevation_cf,
windage_cf,
&prefs,
)
.map_err(|e| e.to_string())?;
if matches!(output, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_dial_plan_table(&report));
}
Ok(())
}
fn adaptive_card_default_budget_mil(click: Option<&ClickValue>) -> f64 {
match click {
Some(c) => click_size_mil(c) / 2.0,
None => 0.1,
}
}
fn adaptive_card_display_row(r: &CardRow, units: UnitSystem) -> CardRow {
let linear_from_meters = |meters: f64| match units {
UnitSystem::Imperial => meters / 0.0254,
UnitSystem::Metric => meters * 1000.0,
};
CardRow {
range: UnitConverter::distance_from_metric(r.range, units),
drop_linear: r.drop_linear.map(linear_from_meters),
drop_adj: r.drop_adj,
come_up: r.come_up,
wind_linear: r.wind_linear.map(linear_from_meters),
wind_adj: r.wind_adj,
velocity: r
.velocity
.map(|v| UnitConverter::velocity_from_metric(v, units)),
energy: r
.energy
.map(|v| UnitConverter::energy_from_metric(v, units)),
time: r.time,
lead_adj: r.lead_adj,
wind_columns: r.wind_columns.clone(),
}
}
fn format_adaptive_card_range(range: f64) -> String {
let rounded = range.round();
if (range - rounded).abs() < 0.05 {
format!("{:.0}", rounded)
} else {
format!("{:.1}", range)
}
}
fn adaptive_card_row_line(r: &CardRow) -> String {
format!(
"│{:>6} │{:>8.1} │{:>8.3} │{:>8.1} │{:>8.2} │{:>8.0} │{:>8.0} │{:>6.2} │",
format_adaptive_card_range(r.range),
r.drop_linear.unwrap(),
r.drop_adj.unwrap(),
r.wind_linear.unwrap(),
r.wind_adj.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap()
)
}
fn adaptive_card_error_message(err: CardError, units: UnitSystem) -> String {
let dist_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
let disp = |m: f64| UnitConverter::distance_from_metric(m, units);
match err {
CardError::EmptyOrInvertedDomain { start_m, end_m } => format!(
"--start {:.3} {dist_unit} and --end {:.3} {dist_unit} must describe a forward \
interval with a positive start",
disp(start_m),
disp(end_m)
),
CardError::AnchorOutsideDomain {
anchor_m,
start_m,
end_m,
} => format!(
"--anchor {:.3} {dist_unit} lies outside --start {:.3} {dist_unit} / --end \
{:.3} {dist_unit}",
disp(anchor_m),
disp(start_m),
disp(end_m)
),
CardError::NonPositiveBudget { axis, value } => format!(
"--{axis}-budget must be positive and finite (resolved to {value} in the chosen \
--adjustment unit)"
),
CardError::ZeroMaxRows => "--max-rows must be at least 1".to_string(),
CardError::DomainOutsideCurve {
requested_m,
curve_max_m,
} => format!(
"--end {:.3} {dist_unit} is past the solved trajectory's last reachable point at \
{:.3} {dist_unit}",
disp(requested_m),
disp(curve_max_m)
),
CardError::InvalidTrackingCf { .. } => err.to_string(),
}
}
fn adaptive_card_footer(
report: &AdaptiveCardReportV1,
units: UnitSystem,
unit_label: &str,
max_rows: usize,
) -> String {
use std::fmt::Write as _;
let dist_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
let mut out = String::new();
let _ = writeln!(out);
let _ = writeln!(
out,
"budget met: {}",
if report.budget_met { "yes" } else { "no" }
);
let capped = if report.rows_capped {
" (row cap reached)"
} else {
""
};
let _ = writeln!(out, "rows: {} of {max_rows} max{capped}", report.rows.len());
let _ = writeln!(
out,
"worst error: elevation {:.4} {unit_label}, windage {:.4} {unit_label} @ {:.3} {dist_unit}",
report.worst_elevation_error,
report.worst_windage_error,
UnitConverter::distance_from_metric(report.worst_error_range_m, units)
);
let _ = writeln!(
out,
"verification grid: {:.4} {dist_unit} step",
UnitConverter::distance_from_metric(report.verification_grid_step_m, units)
);
out
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable adaptive-card CLI command shape"
)]
#[cfg_attr(
not(feature = "pdf"),
allow(
unused_variables,
reason = "output_file is read only by the pdf-gated OutputFormat::Pdf arm below"
)
)]
fn handle_adaptive_card(
load_args: InverseSolverLoadArgs,
zero_distance: Option<f64>,
start: f64,
end: f64,
anchors: Vec<f64>,
elevation_budget: Option<f64>,
windage_budget: Option<f64>,
max_rows: usize,
adjustment: AdaptiveCardAdjustmentArg,
output_file: Option<PathBuf>,
units: UnitSystem,
output: OutputFormat,
) -> Result<(), Box<dyn Error>> {
let (load, profile_data) = load_args.resolve(zero_distance, units)?;
let optic = match &profile_data {
Some(p) => p
.optic_profile()
.map_err(|e| format!("profile '{}': {e}", p.name))?,
None => None,
};
let elevation_cf = profile_data
.as_ref()
.and_then(|p| p.elevation_cf)
.unwrap_or(1.0);
let windage_cf = profile_data
.as_ref()
.and_then(|p| p.windage_cf)
.unwrap_or(1.0);
let unit = adjustment.to_engine();
let unit_label = adjustment.label();
let unit_factor = unit.from_mil_factor();
let elevation_budget_printed = match elevation_budget {
Some(v) => v * unit_factor,
None => {
adaptive_card_default_budget_mil(optic.as_ref().map(|o| &o.elevation_click))
* unit_factor
}
};
let windage_budget_printed = match windage_budget {
Some(v) => v * unit_factor,
None => {
adaptive_card_default_budget_mil(optic.as_ref().map(|o| &o.windage_click)) * unit_factor
}
};
let start_m = UnitConverter::distance_to_metric(start, units);
let end_m = UnitConverter::distance_to_metric(end, units);
let anchors_m: Vec<f64> = anchors
.iter()
.map(|&a| UnitConverter::distance_to_metric(a, units))
.collect();
let max_solve_range_m = if end_m.is_finite() && end_m > 0.0 {
card_sample_max_range_m(end_m)
} else {
end_m
};
let curve = HoldCurve::solve(&load, max_solve_range_m).map_err(|e| -> Box<dyn Error> {
let dist_unit = match units {
UnitSystem::Imperial => "yd",
UnitSystem::Metric => "m",
};
format!("--end {end:.3} {dist_unit}: {e}").into()
})?;
let reachable_end_m = curve.max_sampled_range_m();
let truncation = if end_m.is_finite() && end_m > reachable_end_m {
if start_m > reachable_end_m {
return Err(card_no_rows_error(start, curve.reach_m(), units));
}
Some(card_truncation(
end,
UnitConverter::distance_from_metric(reachable_end_m, units),
curve.reach_m(),
units,
))
} else {
None
};
let (end_m, anchors_m) = match truncation {
Some(_) => (
reachable_end_m,
anchors_m
.into_iter()
.filter(|&a| a <= reachable_end_m)
.collect(),
),
None => (end_m, anchors_m),
};
let click = optic
.as_ref()
.map(|o| (&o.elevation_click, &o.windage_click));
let req = AdaptiveRequest {
domain_m: (start_m, end_m),
anchors_m,
budget: AdaptiveBudget {
elevation: elevation_budget_printed,
windage: windage_budget_printed,
},
max_rows,
click,
elevation_cf,
windage_cf,
bias_mil: 0.0,
};
let report =
adaptive_card(&curve, &req, unit).map_err(|e| adaptive_card_error_message(e, units))?;
let (dist_unit, drop_unit, vel_unit, energy_unit) = match units {
UnitSystem::Imperial => ("yd", "in", "fps", "ft-lb"),
UnitSystem::Metric => ("m", "mm", "m/s", "J"),
};
let display_rows: Vec<CardRow> = report
.rows
.iter()
.map(|r| adaptive_card_display_row(r, units))
.collect();
match output {
OutputFormat::Json => {
#[derive(serde::Serialize)]
struct AdaptiveCardJson<'a> {
#[serde(flatten)]
report: &'a AdaptiveCardReportV1,
#[serde(skip_serializing_if = "Option::is_none")]
truncated: Option<serde_json::Value>,
}
println!(
"{}",
serde_json::to_string_pretty(&AdaptiveCardJson {
report: &report,
truncated: truncation.as_ref().map(|t| card_truncation_json(t, units)),
})?
);
}
OutputFormat::Csv => {
let unit_lower = unit_label.to_lowercase();
println!(
"range_{dist_unit},drop_{drop_unit},drop_{unit_lower},wind_{drop_unit},wind_{unit_lower},velocity_{vel_unit},energy_{energy_unit},tof_s"
);
for r in &display_rows {
println!(
"{},{:.1},{:.3},{:.1},{:.2},{:.0},{:.0},{:.2}",
format_adaptive_card_range(r.range),
r.drop_linear.unwrap(),
r.drop_adj.unwrap(),
r.wind_linear.unwrap(),
r.wind_adj.unwrap(),
r.velocity.unwrap(),
r.energy.unwrap(),
r.time.unwrap()
);
}
eprint!(
"{}",
adaptive_card_footer(&report, units, unit_label, max_rows)
);
}
OutputFormat::Table => {
println!();
println!(
"Adaptive Range Card (zero: {:.0} {dist_unit}) -- method: {}",
UnitConverter::distance_from_metric(load.zero_distance_m, units),
report.method
);
println!(
"┌───────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬───────┐"
);
println!(
"│Range │Drop │Drop │Wind │Wind │Vel │Energy │ToF │"
);
println!(
"│({:>2}) │({:>2}) │({:>3}) │({:>2}) │({:>3}) │({:>3}) │({:>4}) │(s) │",
dist_unit, drop_unit, unit_label, drop_unit, unit_label, vel_unit, energy_unit
);
println!(
"├───────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼───────┤"
);
for r in &display_rows {
println!("{}", adaptive_card_row_line(r));
}
println!(
"└───────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴───────┘"
);
print!(
"{}",
adaptive_card_footer(&report, units, unit_label, max_rows)
);
}
#[cfg(feature = "pdf")]
OutputFormat::Pdf => {
let output_path = output_file
.as_ref()
.ok_or("PDF output requires --output-file")?;
let range_unit = match units {
UnitSystem::Imperial => RangeUnit::Yards,
UnitSystem::Metric => RangeUnit::Meters,
};
let velocity_fps =
UnitConverter::velocity_from_metric(load.velocity_mps, UnitSystem::Imperial);
let temperature_f =
UnitConverter::temperature_from_metric(load.temperature_c, UnitSystem::Imperial);
let pressure_inhg =
UnitConverter::pressure_from_metric(load.pressure_hpa, UnitSystem::Imperial);
let altitude_ft =
UnitConverter::altitude_from_metric(load.altitude_m, UnitSystem::Imperial);
let wind_speed_mph =
UnitConverter::wind_from_metric(load.wind_speed_mps, UnitSystem::Imperial);
let weight_gr = UnitConverter::mass_from_metric(load.mass_kg, UnitSystem::Imperial);
let da_ft = calculate_density_altitude(altitude_ft, pressure_inhg, temperature_f);
let rifle_name = profile_data
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_else(|| "Adaptive Card".to_string());
let bullet = profile_data
.as_ref()
.and_then(|p| p.bullet_name.clone())
.unwrap_or_default();
let pdf_config = DopeCardConfig {
rifle_name,
location: String::new(),
density_altitude_ft: da_ft,
pressure_inhg,
pressure_hpa: load.pressure_hpa,
temperature_f,
altitude_ft,
wind_speed_mph,
target_speed_mph: 0.0,
solver_mode: if cfg!(feature = "online") {
"online".to_string()
} else {
"offline".to_string()
},
powder: String::new(),
bullet,
weight_gr,
bc: load.bc,
drag_model: load.drag_model.to_string(),
velocity_fps,
font_scale: 1.0,
bold_data: false,
elevation_unit_label: unit_label.to_string(),
windage_unit_label: unit_label.to_string(),
engine_version: env!("CARGO_PKG_VERSION").to_string(),
table_version: String::new(),
truncation_note: truncation
.as_ref()
.map(|t| t.printed_note(card_distance_unit(units)))
.unwrap_or_default(),
};
let pdf_bytes = ballistics_engine::pdf_dope_card::generate_dope_card_pdf(
&pdf_config,
&display_rows,
range_unit,
)?;
std::fs::write(output_path, &pdf_bytes)?;
eprintln!(
"Adaptive range card PDF written to: {}",
output_path.display()
);
eprint!(
"{}",
adaptive_card_footer(&report, units, unit_label, max_rows)
);
}
#[cfg(not(feature = "pdf"))]
OutputFormat::Pdf => {
return Err(
"PDF output requires building with the 'pdf' feature (e.g. cargo build --features pdf)"
.into(),
);
}
}
if let Some(truncation) = &truncation {
warn_card_truncated(truncation, units);
}
Ok(())
}
#[cfg(test)]
mod adaptive_card_row_tests {
use super::*;
#[test]
fn format_adaptive_card_range_matches_the_pdf_dope_cards_pinned_examples() {
assert_eq!(format_adaptive_card_range(400.0), "400");
assert_eq!(format_adaptive_card_range(417.3), "417.3");
assert_eq!(format_adaptive_card_range(400.02), "400");
assert_eq!(format_adaptive_card_range(399.98), "400");
assert_eq!(format_adaptive_card_range(100.0 + 1e-9), "100");
assert_eq!(format_adaptive_card_range(100.5), "100.5");
assert_eq!(format_adaptive_card_range(400.08), "400.1");
}
#[test]
fn format_adaptive_card_range_does_not_silently_round_a_real_anchor() {
assert_eq!(format_adaptive_card_range(412.5), "412.5");
assert_eq!(format_adaptive_card_range(307.2384), "307.2");
}
fn sentinel_row(range: f64) -> CardRow {
CardRow {
range,
drop_linear: Some(222.1),
drop_adj: Some(333.2),
come_up: None,
wind_linear: Some(555.4),
wind_adj: Some(666.5),
velocity: Some(777.6),
energy: Some(888.7),
time: Some(9.123),
lead_adj: None,
wind_columns: Vec::new(),
}
}
#[test]
fn adaptive_card_row_line_places_each_sentinel_in_its_own_column() {
let integer_row = sentinel_row(111.0);
let line = adaptive_card_row_line(&integer_row);
let expected =
"│ 111 │ 222.1 │ 333.200 │ 555.4 │ 666.50 │ 778 │ 889 │ 9.12 │";
assert_eq!(line, expected);
let fractional_row = sentinel_row(111.4);
let line = adaptive_card_row_line(&fractional_row);
let expected =
"│ 111.4 │ 222.1 │ 333.200 │ 555.4 │ 666.50 │ 778 │ 889 │ 9.12 │";
assert_eq!(line, expected);
}
}