1use std::error::Error;
12
13use serde::Serialize;
14
15use crate::cli_api::UnitSystem;
16use crate::constants::GRAINS_TO_KG;
17use crate::{
18 AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel, TrajectorySolver,
19 WindConditions,
20};
21
22#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
28#[serde(rename_all = "lowercase")]
29#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
30pub enum DragModelArg {
31 G1,
32 G7,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "lowercase")]
41#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
42pub enum DropUnit {
43 Mil,
45 Moa,
47 In,
49}
50
51impl DropUnit {
52 pub fn label(self) -> &'static str {
54 match self {
55 DropUnit::Mil => "mil",
56 DropUnit::Moa => "moa",
57 DropUnit::In => "in",
58 }
59 }
60
61 pub fn parse(s: &str) -> Result<Self, String> {
65 match s.to_ascii_lowercase().as_str() {
66 "mil" => Ok(DropUnit::Mil),
67 "moa" => Ok(DropUnit::Moa),
68 "in" => Ok(DropUnit::In),
69 _ => Err(format!("invalid drop unit '{s}': expected mil, moa, or in")),
70 }
71 }
72
73 pub fn express_drop_m(self, drop_m: f64, z_m: f64) -> f64 {
79 match self {
80 DropUnit::Mil => (drop_m / z_m) * 1000.0,
82 DropUnit::Moa => (drop_m / z_m) * (180.0 / std::f64::consts::PI) * 60.0,
84 DropUnit::In => drop_m / 0.0254,
86 }
87 }
88}
89
90pub fn fallback_bullet_length_m(diameter_m: f64, mass_kg: f64) -> f64 {
96 let est = crate::stability::estimate_bullet_length_m(diameter_m, mass_kg);
97 if est > 0.0 {
98 est
99 } else {
100 diameter_m * 4.5
101 }
102}
103
104#[allow(
111 clippy::too_many_arguments,
112 reason = "flat arguments preserve the existing velocity-truing compatibility helper"
113)]
114fn solve_truing_trajectory(
115 velocity_fps: f64,
116 bc: f64,
117 drag_model: DragModelArg,
118 mass_gr: f64,
119 diameter_in: f64,
120 zero_distance_yd: f64,
121 range_yd: f64,
122 sight_height_in: f64,
123 temperature_f: f64,
124 pressure_inhg: f64,
125 humidity: f64,
126 altitude_ft: f64,
127 bc_segments: &Option<Vec<BCSegmentData>>,
128) -> Result<crate::TrajectoryResult, Box<dyn Error>> {
129 let velocity_ms = velocity_fps * 0.3048;
131 let mass_kg = mass_gr * GRAINS_TO_KG;
132 let diameter_m = diameter_in * 0.0254;
133 let zero_m = zero_distance_yd * 0.9144;
134 let range_m = range_yd * 0.9144;
135 let sight_height_m = sight_height_in * 0.0254;
136 let altitude_m = altitude_ft * 0.3048;
137 let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
138 let pressure_hpa = pressure_inhg * 33.8639; let drag_model_enum = match drag_model {
141 DragModelArg::G1 => DragModel::G1,
142 DragModelArg::G7 => DragModel::G7,
143 };
144
145 let mut inputs = BallisticInputs {
147 muzzle_velocity: velocity_ms,
148 bc_value: bc,
149 bc_type: drag_model_enum,
150 bullet_mass: mass_kg,
151 bullet_diameter: diameter_m,
152 bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), sight_height: sight_height_m,
154 target_distance: range_m + 100.0, use_bc_segments: bc_segments.is_some(),
156 bc_segments_data: bc_segments.clone(),
157 use_rk4: true,
158 muzzle_angle: 0.0, ..Default::default() };
161
162 let atmosphere = AtmosphericConditions {
165 temperature: temperature_c,
166 pressure: pressure_hpa,
167 humidity, altitude: altitude_m,
169 };
170
171 let wind = WindConditions::default();
172
173 let zero_angle = crate::calculate_zero_angle_with_conditions(
178 inputs.clone(),
179 zero_m,
180 sight_height_m, wind.clone(),
182 atmosphere.clone(),
183 )?;
184
185 inputs.muzzle_angle = zero_angle;
187
188 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
190 solver.set_max_range(range_m + 100.0);
191 solver.set_time_step(0.0001);
192
193 Ok(solver.solve()?)
194}
195
196#[allow(
213 clippy::too_many_arguments,
214 reason = "flat arguments preserve the existing velocity-truing compatibility helper"
215)]
216pub(crate) fn solve_trajectory_drop(
217 velocity_fps: f64,
218 bc: f64,
219 drag_model: DragModelArg,
220 mass_gr: f64,
221 diameter_in: f64,
222 zero_distance_yd: f64,
223 range_yd: f64,
224 sight_height_in: f64,
225 temperature_f: f64,
226 pressure_inhg: f64,
227 humidity: f64,
228 altitude_ft: f64,
229 bc_segments: &Option<Vec<BCSegmentData>>,
230 interpolate: bool,
231) -> Result<(f64, f64), Box<dyn Error>> {
232 let range_m = range_yd * 0.9144;
233 let sight_height_m = sight_height_in * 0.0254;
234
235 let result = solve_truing_trajectory(
236 velocity_fps,
237 bc,
238 drag_model,
239 mass_gr,
240 diameter_in,
241 zero_distance_yd,
242 range_yd,
243 sight_height_in,
244 temperature_f,
245 pressure_inhg,
246 humidity,
247 altitude_ft,
248 bc_segments,
249 )?;
250
251 let idx = result
253 .points
254 .iter()
255 .position(|p| p.position.x >= range_m)
256 .ok_or("Trajectory didn't reach target range")?;
257
258 let (z, bullet_y) = if interpolate && idx > 0 {
260 let p0 = &result.points[idx - 1];
263 let p1 = &result.points[idx];
264 let x0 = p0.position.x;
265 let x1 = p1.position.x;
266 let denom = x1 - x0;
267 if denom.abs() < f64::EPSILON {
268 (p1.position.x, p1.position.y)
269 } else {
270 let frac = (range_m - x0) / denom;
271 let y = p0.position.y + frac * (p1.position.y - p0.position.y);
272 (range_m, y)
273 }
274 } else {
275 let p = &result.points[idx];
276 (p.position.x, p.position.y)
277 };
278
279 let drop_m = sight_height_m - bullet_y;
283
284 Ok((drop_m, z))
285}
286
287#[allow(
290 clippy::too_many_arguments,
291 reason = "flat arguments preserve the existing velocity-truing compatibility helper"
292)]
293pub(crate) fn calculate_drop_at_velocity(
294 velocity_fps: f64,
295 bc: f64,
296 drag_model: DragModelArg,
297 mass_gr: f64,
298 diameter_in: f64,
299 zero_distance_yd: f64,
300 range_yd: f64,
301 sight_height_in: f64,
302 temperature_f: f64,
303 pressure_inhg: f64,
304 humidity: f64,
305 altitude_ft: f64,
306 bc_segments: &Option<Vec<BCSegmentData>>,
307) -> Result<f64, Box<dyn Error>> {
308 let (drop_m, z) = solve_trajectory_drop(
312 velocity_fps,
313 bc,
314 drag_model,
315 mass_gr,
316 diameter_in,
317 zero_distance_yd,
318 range_yd,
319 sight_height_in,
320 temperature_f,
321 pressure_inhg,
322 humidity,
323 altitude_ft,
324 bc_segments,
325 false,
326 )?;
327
328 let drop_mil = (drop_m / z) * 1000.0;
331
332 Ok(drop_mil)
333}
334
335#[derive(Debug, Clone)]
338pub struct TrueVelocityLocalResult {
339 pub effective_velocity_fps: f64,
341 pub iterations: i32,
343 pub final_error_mil: f64,
348 pub calculated_drop_mil: f64,
350 pub confidence: String,
354}
355
356#[allow(
369 clippy::too_many_arguments,
370 reason = "flat arguments mirror the stable true-velocity CLI command shape"
371)]
372pub fn calculate_true_velocity_local(
373 measured_drop_mil: f64,
374 range_yd: f64,
375 bc: f64,
376 drag_model: DragModelArg,
377 mass_gr: f64,
378 diameter_in: f64,
379 zero_distance_yd: f64,
380 sight_height_in: f64,
381 temperature_f: f64,
382 pressure_inhg: f64,
383 humidity: f64,
384 altitude_ft: f64,
385 bc_segments: &Option<Vec<BCSegmentData>>,
386) -> Result<TrueVelocityLocalResult, Box<dyn Error>> {
387 if !range_yd.is_finite() || range_yd <= 0.0 {
391 return Err("range must be positive and finite".into());
392 }
393 if !measured_drop_mil.is_finite() {
394 return Err("measured drop must be finite".into());
395 }
396
397 let mut velocity_low = 1500.0;
399 let mut velocity_high = 4500.0;
400 let tolerance_mil = 0.01; let max_iterations = 50;
402
403 let mut iterations = 0;
404 let mut last_error = 0.0;
405 let mut last_calculated_drop = 0.0;
406
407 for i in 0..max_iterations {
408 iterations = i + 1;
409 let test_velocity = (velocity_low + velocity_high) / 2.0;
410
411 let calculated_drop_mil = calculate_drop_at_velocity(
413 test_velocity,
414 bc,
415 drag_model,
416 mass_gr,
417 diameter_in,
418 zero_distance_yd,
419 range_yd,
420 sight_height_in,
421 temperature_f,
422 pressure_inhg,
423 humidity,
424 altitude_ft,
425 bc_segments,
426 )?;
427
428 last_calculated_drop = calculated_drop_mil;
429 let error = calculated_drop_mil - measured_drop_mil;
430 last_error = error;
431
432 if error.abs() < tolerance_mil {
433 let confidence = if error.abs() < 0.005 {
435 "high"
436 } else {
437 "medium"
438 };
439
440 return Ok(TrueVelocityLocalResult {
441 effective_velocity_fps: test_velocity,
442 iterations,
443 final_error_mil: error,
444 calculated_drop_mil,
445 confidence: confidence.to_string(),
446 });
447 }
448
449 if calculated_drop_mil > measured_drop_mil {
452 velocity_low = test_velocity;
455 } else {
456 velocity_high = test_velocity;
459 }
460
461 if (velocity_high - velocity_low).abs() < 0.5 {
463 break;
464 }
465 }
466
467 let final_velocity = (velocity_low + velocity_high) / 2.0;
469 let confidence = if last_error.abs() < 0.1 {
470 "medium"
471 } else {
472 "low"
473 };
474
475 Ok(TrueVelocityLocalResult {
476 effective_velocity_fps: final_velocity,
477 iterations,
478 final_error_mil: last_error,
479 calculated_drop_mil: last_calculated_drop,
480 confidence: confidence.to_string(),
481 })
482}
483
484pub(crate) const TRUING_MV_MIN_FPS: f64 = 1000.0;
509pub(crate) const TRUING_MV_MAX_FPS: f64 = 5000.0;
511pub(crate) const TRUING_BC_MIN: f64 = 0.05;
513pub(crate) const TRUING_BC_MAX: f64 = 2.0;
515pub(crate) const TRUING_MAX_ITERS: usize = 40;
517
518pub(crate) const TRUING_MIN_BC_SENSITIVITY_RATIO: f64 = 0.20;
524pub(crate) const TRUING_MAX_CONDITION_NUMBER: f64 = 1.0e3;
530
531#[derive(Debug, Clone, Copy)]
534pub struct TruingObservation {
535 pub range_yd: f64,
536 pub drop: f64,
537}
538
539#[derive(Debug, Clone, Copy, Serialize)]
548pub struct TruingModelInputsV1 {
549 pub muzzle_velocity_fps: f64,
551 pub ballistic_coefficient: f64,
553 pub drag_model: DragModelArg,
554 pub mass_gr: f64,
556 pub diameter_in: f64,
558 pub zero_distance_yd: f64,
560 pub sight_height_in: f64,
562 pub temperature_f: f64,
564 pub pressure_inhg: f64,
566 pub humidity_pct: f64,
568 pub altitude_ft: f64,
570}
571
572impl TruingModelInputsV1 {
573 pub fn validate(&self) -> Result<(), String> {
577 if !self.muzzle_velocity_fps.is_finite()
578 || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
579 {
580 return Err(format!(
581 "nominal muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
582 ));
583 }
584 if !self.ballistic_coefficient.is_finite()
585 || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.ballistic_coefficient)
586 {
587 return Err(format!(
588 "nominal ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
589 ));
590 }
591 for (name, value) in [
592 ("bullet mass", self.mass_gr),
593 ("bullet diameter", self.diameter_in),
594 ("zero distance", self.zero_distance_yd),
595 ("sight height", self.sight_height_in),
596 ("pressure", self.pressure_inhg),
597 ] {
598 if !value.is_finite() || value <= 0.0 {
599 return Err(format!("{name} must be positive and finite"));
600 }
601 }
602 if !self.temperature_f.is_finite() {
603 return Err("temperature must be finite".to_string());
604 }
605 if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
606 return Err("humidity must be finite and within 0..=100 percent".to_string());
607 }
608 if !self.altitude_ft.is_finite() {
609 return Err("altitude must be finite".to_string());
610 }
611 Ok(())
612 }
613
614 pub(crate) fn with_forward_model<T>(
618 &self,
619 drop_unit: DropUnit,
620 f: impl FnOnce(&TruingForwardModel<'_>) -> T,
621 ) -> T {
622 let no_bc_segments = None;
623 let model = TruingForwardModel {
624 drag_model: self.drag_model,
625 mass_gr: self.mass_gr,
626 diameter_in: self.diameter_in,
627 zero_yd: self.zero_distance_yd,
628 sight_in: self.sight_height_in,
629 temp_f: self.temperature_f,
630 press_inhg: self.pressure_inhg,
631 humidity: self.humidity_pct,
632 alt_ft: self.altitude_ft,
633 bc_segments: &no_bc_segments,
634 drop_unit,
635 };
636 f(&model)
637 }
638}
639
640pub fn parse_truing_observation(s: &str, units: UnitSystem) -> Result<TruingObservation, String> {
645 let parts: Vec<&str> = s.split(':').collect();
646 if parts.len() != 2 {
647 return Err(format!(
648 "invalid --observed '{s}': expected RANGE:DROP (e.g. 600:5.1)"
649 ));
650 }
651 let range: f64 = parts[0]
652 .trim()
653 .parse()
654 .map_err(|_| format!("invalid --observed range '{}' in '{s}'", parts[0]))?;
655 let drop: f64 = parts[1]
656 .trim()
657 .parse()
658 .map_err(|_| format!("invalid --observed drop '{}' in '{s}'", parts[1]))?;
659 if !range.is_finite() || !drop.is_finite() {
660 return Err(format!("invalid --observed '{s}': values must be finite"));
661 }
662 let range_yd = match units {
663 UnitSystem::Imperial => range,
664 UnitSystem::Metric => range / 0.9144,
665 };
666 Ok(TruingObservation { range_yd, drop })
667}
668
669pub fn validate_truing_observations(observations: &[TruingObservation]) -> Result<(), String> {
677 for o in observations {
678 if !o.range_yd.is_finite() || o.range_yd <= 0.0 {
679 return Err(format!(
680 "observation range must be a positive finite distance (got {})",
681 o.range_yd
682 ));
683 }
684 if !o.drop.is_finite() || o.drop == 0.0 {
685 return Err(
686 "observation drop must be non-zero (a zero drop carries no truing information)"
687 .to_string(),
688 );
689 }
690 }
691 for i in 0..observations.len() {
692 for j in (i + 1)..observations.len() {
693 if (observations[i].range_yd - observations[j].range_yd).abs() < 1e-6 {
694 return Err(format!(
695 "duplicate observation range ({:.3} yd internal): each observation must be at a distinct range",
696 observations[i].range_yd
697 ));
698 }
699 }
700 }
701 Ok(())
702}
703
704pub(crate) struct TruingForwardModel<'a> {
707 pub drag_model: DragModelArg,
708 pub mass_gr: f64,
709 pub diameter_in: f64,
710 pub zero_yd: f64,
711 pub sight_in: f64,
712 pub temp_f: f64,
713 pub press_inhg: f64,
714 pub humidity: f64,
715 pub alt_ft: f64,
716 pub bc_segments: &'a Option<Vec<BCSegmentData>>,
717 pub drop_unit: DropUnit,
718}
719
720impl TruingForwardModel<'_> {
721 pub fn predict(&self, mv_fps: f64, bc: f64, range_yd: f64) -> Result<f64, Box<dyn Error>> {
724 self.predict_in_unit(mv_fps, bc, range_yd, self.drop_unit)
725 }
726
727 pub fn predict_in_unit(
735 &self,
736 mv_fps: f64,
737 bc: f64,
738 range_yd: f64,
739 unit: DropUnit,
740 ) -> Result<f64, Box<dyn Error>> {
741 let (drop_m, z_m) = solve_trajectory_drop(
742 mv_fps,
743 bc,
744 self.drag_model,
745 self.mass_gr,
746 self.diameter_in,
747 self.zero_yd,
748 range_yd,
749 self.sight_in,
750 self.temp_f,
751 self.press_inhg,
752 self.humidity,
753 self.alt_ft,
754 self.bc_segments,
755 true, )?;
757 Ok(unit.express_drop_m(drop_m, z_m))
758 }
759
760 pub(crate) fn predict_many_in_unit(
769 &self,
770 mv_fps: f64,
771 bc: f64,
772 ranges_yd: &[f64],
773 unit: DropUnit,
774 ) -> Result<Vec<Option<f64>>, Box<dyn Error>> {
775 if ranges_yd.is_empty() {
776 return Ok(Vec::new());
777 }
778 let max_range_yd = ranges_yd
779 .iter()
780 .copied()
781 .filter(|r| r.is_finite() && *r > 0.0)
782 .fold(0.0_f64, f64::max);
783 if max_range_yd <= 0.0 {
784 return Ok(vec![None; ranges_yd.len()]);
785 }
786
787 let velocity_ms = mv_fps * 0.3048;
788 let mass_kg = self.mass_gr * GRAINS_TO_KG;
789 let diameter_m = self.diameter_in * 0.0254;
790 let zero_m = self.zero_yd * 0.9144;
791 let max_range_m = max_range_yd * 0.9144;
792 let sight_height_m = self.sight_in * 0.0254;
793 let altitude_m = self.alt_ft * 0.3048;
794 let temperature_c = (self.temp_f - 32.0) * 5.0 / 9.0;
795 let pressure_hpa = self.press_inhg * 33.8639;
796 let drag_model = match self.drag_model {
797 DragModelArg::G1 => DragModel::G1,
798 DragModelArg::G7 => DragModel::G7,
799 };
800
801 let mut inputs = BallisticInputs {
802 muzzle_velocity: velocity_ms,
803 bc_value: bc,
804 bc_type: drag_model,
805 bullet_mass: mass_kg,
806 bullet_diameter: diameter_m,
807 bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
808 sight_height: sight_height_m,
809 target_distance: max_range_m + 100.0,
810 use_bc_segments: self.bc_segments.is_some(),
811 bc_segments_data: self.bc_segments.clone(),
812 use_rk4: true,
813 muzzle_angle: 0.0,
814 ..Default::default()
815 };
816 let atmosphere = AtmosphericConditions {
817 temperature: temperature_c,
818 pressure: pressure_hpa,
819 humidity: self.humidity,
820 altitude: altitude_m,
821 };
822 let wind = WindConditions::default();
823 inputs.muzzle_angle = crate::calculate_zero_angle_with_conditions(
824 inputs.clone(),
825 zero_m,
826 sight_height_m,
827 wind.clone(),
828 atmosphere.clone(),
829 )?;
830
831 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
832 solver.set_max_range(max_range_m + 100.0);
833 solver.set_time_step(0.0001);
834 let result = solver.solve()?;
835
836 let predictions = ranges_yd
837 .iter()
838 .map(|range_yd| {
839 if !range_yd.is_finite() || *range_yd <= 0.0 {
840 return None;
841 }
842 let range_m = *range_yd * 0.9144;
843 let idx = result.points.iter().position(|p| p.position.x >= range_m)?;
844 let (z_m, bullet_y) = if idx > 0 {
845 let p0 = &result.points[idx - 1];
846 let p1 = &result.points[idx];
847 let span = p1.position.x - p0.position.x;
848 if span.abs() < f64::EPSILON {
849 (p1.position.x, p1.position.y)
850 } else {
851 let fraction = (range_m - p0.position.x) / span;
852 (
853 range_m,
854 p0.position.y + fraction * (p1.position.y - p0.position.y),
855 )
856 }
857 } else {
858 let p = &result.points[idx];
859 (p.position.x, p.position.y)
860 };
861 let drop_m = sight_height_m - bullet_y;
862 Some(unit.express_drop_m(drop_m, z_m))
863 })
864 .collect();
865 Ok(predictions)
866 }
867
868 pub fn cost(&self, mv: f64, bc: f64, obs: &[TruingObservation]) -> Result<f64, Box<dyn Error>> {
870 let mut c = 0.0;
871 for o in obs {
872 let r = self.predict(mv, bc, o.range_yd)? - o.drop;
873 c += r * r;
874 }
875 Ok(c)
876 }
877}
878
879#[derive(Debug, Clone, Copy)]
884pub(crate) struct TruingJacobianRow {
885 pub predicted_drop: f64,
886 pub d_drop_d_mv: f64,
887 pub d_drop_d_bc: f64,
888}
889
890pub(crate) fn truing_jacobian_row(
891 model: &TruingForwardModel<'_>,
892 mv_fps: f64,
893 bc: f64,
894 range_yd: f64,
895 unit: DropUnit,
896) -> Result<TruingJacobianRow, Box<dyn Error>> {
897 let hmv = (mv_fps * 1e-3).max(0.5);
898 let hbc = (bc * 1e-3).max(1e-4);
899 let predicted_drop = model.predict_in_unit(mv_fps, bc, range_yd, unit)?;
900 let d_drop_d_mv = (model.predict_in_unit(mv_fps + hmv, bc, range_yd, unit)?
901 - model.predict_in_unit(mv_fps - hmv, bc, range_yd, unit)?)
902 / (2.0 * hmv);
903 let d_drop_d_bc = (model.predict_in_unit(mv_fps, bc + hbc, range_yd, unit)?
904 - model.predict_in_unit(mv_fps, bc - hbc, range_yd, unit)?)
905 / (2.0 * hbc);
906 Ok(TruingJacobianRow {
907 predicted_drop,
908 d_drop_d_mv,
909 d_drop_d_bc,
910 })
911}
912
913pub(crate) fn truing_jacobian_rows(
918 model: &TruingForwardModel<'_>,
919 mv_fps: f64,
920 bc: f64,
921 ranges_yd: &[f64],
922 unit: DropUnit,
923) -> Result<Vec<Option<TruingJacobianRow>>, Box<dyn Error>> {
924 let hmv = (mv_fps * 1e-3).max(0.5);
925 let hbc = (bc * 1e-3).max(1e-4);
926 let nominal = model.predict_many_in_unit(mv_fps, bc, ranges_yd, unit)?;
927 let mv_plus = model.predict_many_in_unit(mv_fps + hmv, bc, ranges_yd, unit)?;
928 let mv_minus = model.predict_many_in_unit(mv_fps - hmv, bc, ranges_yd, unit)?;
929 let bc_plus = model.predict_many_in_unit(mv_fps, bc + hbc, ranges_yd, unit)?;
930 let bc_minus = model.predict_many_in_unit(mv_fps, bc - hbc, ranges_yd, unit)?;
931
932 Ok((0..ranges_yd.len())
933 .map(|i| {
934 Some(TruingJacobianRow {
935 predicted_drop: nominal[i]?,
936 d_drop_d_mv: (mv_plus[i]? - mv_minus[i]?) / (2.0 * hmv),
937 d_drop_d_bc: (bc_plus[i]? - bc_minus[i]?) / (2.0 * hbc),
938 })
939 })
940 .collect())
941}
942
943pub(crate) fn fit_truing_mv_only(
947 model: &TruingForwardModel<'_>,
948 obs: &[TruingObservation],
949 bc: f64,
950 mv_init: f64,
951) -> Result<(f64, usize, bool), Box<dyn Error>> {
952 let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
953 let mut converged = false;
954 let mut iters = 0;
955 for i in 0..TRUING_MAX_ITERS {
956 iters = i + 1;
957 let h = (mv * 1e-3).max(0.5);
958 let mut num = 0.0;
959 let mut den = 0.0;
960 for o in obs {
961 let r = model.predict(mv, bc, o.range_yd)? - o.drop;
962 let dp = model.predict(mv + h, bc, o.range_yd)?;
963 let dm = model.predict(mv - h, bc, o.range_yd)?;
964 let j = (dp - dm) / (2.0 * h);
965 num += j * r;
966 den += j * j;
967 }
968 if den < 1e-12 {
969 break;
970 }
971 let step = (-num / den).clamp(-300.0, 300.0);
973 let new_mv = (mv + step).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
974 if (new_mv - mv).abs() < 0.05 {
975 mv = new_mv;
976 converged = true;
977 break;
978 }
979 mv = new_mv;
980 }
981 Ok((mv, iters, converged))
982}
983
984pub(crate) fn truing_identifiability(
998 model: &TruingForwardModel<'_>,
999 obs: &[TruingObservation],
1000 mv: f64,
1001 bc: f64,
1002) -> Result<(f64, f64), Box<dyn Error>> {
1003 let (mut n_mv, mut n_bc, mut cross) = (0.0, 0.0, 0.0);
1004 let unit = DropUnit::Mil;
1009 for o in obs {
1010 let row = truing_jacobian_row(model, mv, bc, o.range_yd, unit)?;
1011 n_mv += row.d_drop_d_mv * row.d_drop_d_mv;
1012 n_bc += row.d_drop_d_bc * row.d_drop_d_bc;
1013 cross += row.d_drop_d_mv * row.d_drop_d_bc;
1014 }
1015 let norm_mv = n_mv.sqrt();
1016 let norm_bc = n_bc.sqrt();
1017 let sensitivity_ratio = if mv * norm_mv > 0.0 {
1018 (bc * norm_bc) / (mv * norm_mv)
1019 } else {
1020 0.0
1021 };
1022 let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
1024 let c = (cross / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
1025 if (1.0 - c) > 1e-15 {
1026 (1.0 + c) / (1.0 - c)
1027 } else {
1028 f64::INFINITY
1029 }
1030 } else {
1031 f64::INFINITY
1033 };
1034 Ok((sensitivity_ratio, condition_number))
1035}
1036
1037pub(crate) fn fit_truing_joint(
1042 model: &TruingForwardModel<'_>,
1043 obs: &[TruingObservation],
1044 mv_init: f64,
1045 bc_init: f64,
1046) -> Result<(f64, f64, usize, bool), Box<dyn Error>> {
1047 let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1048 let mut bc = bc_init.clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1049 let mut lambda = 1e-3;
1050 let mut cur_cost = model.cost(mv, bc, obs)?;
1051 let mut converged = false;
1052 let mut iters = 0;
1053
1054 for it in 0..TRUING_MAX_ITERS {
1055 iters = it + 1;
1056 let (mut a00, mut a01, mut a11) = (0.0, 0.0, 0.0);
1058 let (mut g0, mut g1) = (0.0, 0.0);
1059 for o in obs {
1060 let row = truing_jacobian_row(model, mv, bc, o.range_yd, model.drop_unit)?;
1061 let r = row.predicted_drop - o.drop;
1062 let jmv = row.d_drop_d_mv;
1063 let jbc = row.d_drop_d_bc;
1064 a00 += jmv * jmv;
1065 a01 += jmv * jbc;
1066 a11 += jbc * jbc;
1067 g0 += jmv * r;
1068 g1 += jbc * r;
1069 }
1070
1071 let mut accepted = false;
1073 for _ in 0..30 {
1074 let m00 = a00 + lambda * a00.max(1e-12);
1075 let m11 = a11 + lambda * a11.max(1e-12);
1076 let det = m00 * m11 - a01 * a01;
1077 if det.abs() < 1e-20 {
1078 lambda *= 10.0;
1079 continue;
1080 }
1081 let dmv = -(m11 * g0 - a01 * g1) / det;
1083 let dbc = -(-a01 * g0 + m00 * g1) / det;
1084 let nmv = (mv + dmv).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1085 let nbc = (bc + dbc).clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1086 let nc = model.cost(nmv, nbc, obs)?;
1087 if nc < cur_cost {
1088 let rel_change =
1089 (nmv - mv).abs() / mv.max(1.0) + (nbc - bc).abs() / bc.max(1e-3);
1090 mv = nmv;
1091 bc = nbc;
1092 cur_cost = nc;
1093 lambda = (lambda * 0.5).max(1e-9);
1094 accepted = true;
1095 if rel_change < 1e-6 {
1096 converged = true;
1097 }
1098 break;
1099 }
1100 lambda *= 4.0;
1101 if lambda > 1e12 {
1102 break;
1103 }
1104 }
1105 if !accepted {
1106 converged = true;
1109 break;
1110 }
1111 if converged {
1112 break;
1113 }
1114 }
1115 Ok((mv, bc, iters, converged))
1116}
1117
1118#[derive(Debug, Clone)]
1120pub struct MultiTruingReport {
1121 pub fitted_mv_fps: f64,
1122 pub fitted_bc: f64,
1123 pub bc_input: f64,
1124 pub bc_fitted: bool,
1125 pub observations: Vec<TruingObservation>,
1126 pub predicted: Vec<f64>,
1127 pub residuals: Vec<f64>,
1128 pub rms: f64,
1129 pub iterations: usize,
1130 pub converged: bool,
1131 pub sensitivity_ratio: f64,
1132 pub condition_number: f64,
1133 pub quality: String,
1134 pub reason: String,
1135 pub mach_1_2_distance_m: Option<f64>,
1143 pub window_solved_range_m: f64,
1147 pub muzzle_mach: f64,
1155}
1156
1157#[allow(
1167 clippy::too_many_arguments,
1168 reason = "flat arguments mirror the stable true-velocity CLI command shape"
1169)]
1170pub fn run_multi_observation_truing_core(
1171 observations: &[TruingObservation],
1172 drop_unit: DropUnit,
1173 bc_input: f64,
1174 drag_model: DragModelArg,
1175 mass_gr: f64,
1176 diameter_in: f64,
1177 zero_yd: f64,
1178 sight_in: f64,
1179 temp_f: f64,
1180 press_inhg: f64,
1181 humidity: f64,
1182 alt_ft: f64,
1183 bc_segments: &Option<Vec<BCSegmentData>>,
1184) -> Result<MultiTruingReport, Box<dyn Error>> {
1185 validate_truing_observations(observations)?;
1187 let observations: Vec<TruingObservation> = observations.to_vec();
1188
1189 let model = TruingForwardModel {
1190 drag_model,
1191 mass_gr,
1192 diameter_in,
1193 zero_yd,
1194 sight_in,
1195 temp_f,
1196 press_inhg,
1197 humidity,
1198 alt_ft,
1199 bc_segments,
1200 drop_unit,
1201 };
1202
1203 let mv_init = (TRUING_MV_MIN_FPS + TRUING_MV_MAX_FPS) / 2.0;
1207 let (mv0, mv_iters, mv_conv) = fit_truing_mv_only(&model, &observations, bc_input, mv_init)?;
1208 let rms_mv_only = rms_at(&model, &observations, mv0, bc_input)?;
1209
1210 let (sensitivity_ratio, condition_number) =
1212 truing_identifiability(&model, &observations, mv0, bc_input)?;
1213 let bc_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
1214 && condition_number <= TRUING_MAX_CONDITION_NUMBER
1215 && condition_number.is_finite();
1216
1217 let mut fitted_mv = mv0;
1220 let mut fitted_bc = bc_input;
1221 let mut bc_fitted = false;
1222 let mut iterations = mv_iters;
1223 let mut converged = mv_conv;
1227 let mut reason = String::new();
1228
1229 if bc_identifiable {
1230 let (mv_j, bc_j, iters_j, conv_j) =
1231 fit_truing_joint(&model, &observations, mv0, bc_input)?;
1232 let rms_joint = rms_at(&model, &observations, mv_j, bc_j)?;
1233 let bc_at_bound = bc_j <= TRUING_BC_MIN * 1.001 || bc_j >= TRUING_BC_MAX * 0.999;
1234 if !bc_at_bound && rms_joint <= rms_mv_only + 1e-9 {
1235 fitted_mv = mv_j;
1236 fitted_bc = bc_j;
1237 bc_fitted = true;
1238 iterations = iters_j;
1239 converged = conv_j;
1240 } else {
1241 reason = if bc_at_bound {
1244 format!(
1245 "joint fit drove BC to a bound ({bc_j:.3}); BC held at input {bc_input:.3}"
1246 )
1247 } else {
1248 format!(
1249 "joint fit did not improve on the MV-only solution; BC held at input {bc_input:.3}"
1250 )
1251 };
1252 }
1253 } else {
1254 reason = if !condition_number.is_finite() || condition_number > TRUING_MAX_CONDITION_NUMBER
1255 {
1256 format!(
1257 "observation ranges are too similar to separate MV from BC (condition {condition_number:.3e} > {TRUING_MAX_CONDITION_NUMBER:.0e}); BC held at input {bc_input:.3}"
1258 )
1259 } else {
1260 format!(
1261 "observations do not constrain BC (BC sensitivity ratio {sensitivity_ratio:.4} < {TRUING_MIN_BC_SENSITIVITY_RATIO:.2} threshold); BC held at input {bc_input:.3}. Add a longer-range / transonic observation to fit BC."
1262 )
1263 };
1264 }
1265
1266 let mut predicted = Vec::with_capacity(observations.len());
1271 let mut residuals = Vec::with_capacity(observations.len());
1272 let mut sse = 0.0;
1273 let mut sse_mil = 0.0;
1274 for o in &observations {
1275 let p = model.predict(fitted_mv, fitted_bc, o.range_yd)?;
1276 let r = p - o.drop;
1277 let r_mil = match drop_unit {
1278 DropUnit::Mil => r,
1279 DropUnit::Moa => r / ((180.0 / std::f64::consts::PI) * 60.0 / 1000.0),
1281 DropUnit::In => r * 0.0254 / (o.range_yd * 0.9144) * 1000.0,
1283 };
1284 predicted.push(p);
1285 residuals.push(r);
1286 sse += r * r;
1287 sse_mil += r_mil * r_mil;
1288 }
1289 let rms = (sse / observations.len() as f64).sqrt();
1290 let rms_mil = (sse_mil / observations.len() as f64).sqrt();
1291
1292 let quality = truing_quality_line(
1293 bc_fitted,
1294 rms,
1295 rms_mil,
1296 drop_unit,
1297 condition_number,
1298 converged,
1299 observations.len(),
1300 );
1301
1302 let (mach_1_2_distance_m, window_solved_range_m, muzzle_mach) =
1306 truing_mv_window_mach_1_2_crossing(&model, fitted_mv, fitted_bc)?;
1307
1308 let report = MultiTruingReport {
1309 fitted_mv_fps: fitted_mv,
1310 fitted_bc,
1311 bc_input,
1312 bc_fitted,
1313 observations,
1314 predicted,
1315 residuals,
1316 rms,
1317 iterations,
1318 converged,
1319 sensitivity_ratio,
1320 condition_number,
1321 quality,
1322 reason,
1323 mach_1_2_distance_m,
1324 window_solved_range_m,
1325 muzzle_mach,
1326 };
1327
1328 Ok(report)
1329}
1330
1331const MV_WINDOW_SOLVE_MAX_RANGE_YD: f64 = 3000.0;
1337
1338fn truing_mv_window_mach_1_2_crossing(
1350 model: &TruingForwardModel<'_>,
1351 mv_fps: f64,
1352 bc: f64,
1353) -> Result<(Option<f64>, f64, f64), Box<dyn Error>> {
1354 let result = solve_truing_trajectory(
1355 mv_fps,
1356 bc,
1357 model.drag_model,
1358 model.mass_gr,
1359 model.diameter_in,
1360 model.zero_yd,
1361 MV_WINDOW_SOLVE_MAX_RANGE_YD,
1362 model.sight_in,
1363 model.temp_f,
1364 model.press_inhg,
1365 model.humidity,
1366 model.alt_ft,
1367 model.bc_segments,
1368 )?;
1369 let muzzle_mach = result.points.first().map_or(0.0, |p| {
1370 if result.station_speed_of_sound_mps > 0.0 {
1371 p.velocity_magnitude / result.station_speed_of_sound_mps
1372 } else {
1373 0.0
1374 }
1375 });
1376 Ok((result.mach_1_2_distance_m, result.max_range, muzzle_mach))
1377}
1378
1379pub(crate) fn rms_at(
1381 model: &TruingForwardModel<'_>,
1382 obs: &[TruingObservation],
1383 mv: f64,
1384 bc: f64,
1385) -> Result<f64, Box<dyn Error>> {
1386 let mut sse = 0.0;
1387 for o in obs {
1388 let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1389 sse += r * r;
1390 }
1391 Ok((sse / obs.len() as f64).sqrt())
1392}
1393
1394pub(crate) fn truing_quality_line(
1398 bc_fitted: bool,
1399 rms: f64,
1400 rms_mil: f64,
1401 drop_unit: DropUnit,
1402 condition_number: f64,
1403 converged: bool,
1404 n_obs: usize,
1405) -> String {
1406 let unit = drop_unit.label();
1407 let n_params = if bc_fitted { 2 } else { 1 };
1408 if n_obs == n_params {
1411 return format!(
1412 "{} fit is exactly determined ({n_obs} observations, {n_params} fitted \
1413 parameters): residuals are zero by construction and do not validate the \
1414 fit; add an observation to assess quality",
1415 if bc_fitted { "Joint MV+BC" } else { "MV-only" }
1416 );
1417 }
1418 let quality = if rms_mil < 0.05 {
1419 "excellent"
1420 } else if rms_mil < 0.15 {
1421 "good"
1422 } else if rms_mil < 0.4 {
1423 "fair"
1424 } else {
1425 "poor (observations may be inconsistent)"
1426 };
1427 let nonconv = if converged { "" } else { " (did not fully converge)" };
1428 if bc_fitted {
1429 let cond = if condition_number.is_finite() {
1430 format!("{condition_number:.0}")
1431 } else {
1432 "inf".to_string()
1433 };
1434 format!(
1435 "Joint MV+BC fit, {quality}: RMS residual {rms:.3} {unit}, conditioning {cond}{nonconv}"
1436 )
1437 } else {
1438 format!("MV-only fit, {quality}: RMS residual {rms:.3} {unit} (BC held fixed){nonconv}")
1439 }
1440}
1441
1442const MV_CALIBRATION_WINDOW_START_FRACTION: f64 = 0.90;
1446
1447const DSF_WINDOW_START_FRACTION: f64 = 0.90;
1451
1452pub fn mv_calibration_window(mach_1_2_distance_m: Option<f64>) -> Option<(f64, f64)> {
1459 let d = mach_1_2_distance_m?;
1460 Some((MV_CALIBRATION_WINDOW_START_FRACTION * d, d))
1461}
1462
1463pub fn dsf_window_start(mach_0_9_distance_m: Option<f64>) -> Option<f64> {
1468 Some(DSF_WINDOW_START_FRACTION * mach_0_9_distance_m?)
1469}
1470
1471#[cfg(test)]
1472mod window_helper_tests {
1473 use super::*;
1474
1475 #[test]
1476 fn mv_calibration_window_is_90_to_100_percent_of_the_1_2_crossing() {
1477 assert_eq!(mv_calibration_window(Some(1000.0)), Some((900.0, 1000.0)));
1478 assert_eq!(mv_calibration_window(Some(671.7257336844475)), Some((0.9 * 671.7257336844475, 671.7257336844475)));
1479 }
1480
1481 #[test]
1482 fn mv_calibration_window_passes_through_none() {
1483 assert_eq!(mv_calibration_window(None), None);
1484 }
1485
1486 #[test]
1487 fn dsf_window_start_is_90_percent_of_the_0_9_crossing() {
1488 assert_eq!(dsf_window_start(Some(1000.0)), Some(900.0));
1489 assert_eq!(dsf_window_start(Some(806.5709746782849)), Some(0.9 * 806.5709746782849));
1490 }
1491
1492 #[test]
1493 fn dsf_window_start_passes_through_none() {
1494 assert_eq!(dsf_window_start(None), None);
1495 }
1496
1497 #[test]
1498 fn mv_calibration_window_exact_arithmetic_at_a_representative_distance() {
1499 let (start, end) = mv_calibration_window(Some(500.0)).expect("Some");
1503 assert_eq!(start.to_bits(), 450.0_f64.to_bits());
1504 assert_eq!(end.to_bits(), 500.0_f64.to_bits());
1505 }
1506
1507 #[test]
1508 fn dsf_window_start_exact_arithmetic_at_a_representative_distance() {
1509 let start = dsf_window_start(Some(500.0)).expect("Some");
1510 assert_eq!(start.to_bits(), 450.0_f64.to_bits());
1511 }
1512}