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(
121 clippy::too_many_arguments,
122 reason = "flat arguments preserve the existing velocity-truing compatibility helper"
123)]
124pub(crate) fn solve_trajectory_drop(
125 velocity_fps: f64,
126 bc: f64,
127 drag_model: DragModelArg,
128 mass_gr: f64,
129 diameter_in: f64,
130 zero_distance_yd: f64,
131 range_yd: f64,
132 sight_height_in: f64,
133 temperature_f: f64,
134 pressure_inhg: f64,
135 humidity: f64,
136 altitude_ft: f64,
137 bc_segments: &Option<Vec<BCSegmentData>>,
138 interpolate: bool,
139) -> Result<(f64, f64), Box<dyn Error>> {
140 let velocity_ms = velocity_fps * 0.3048;
142 let mass_kg = mass_gr * GRAINS_TO_KG;
143 let diameter_m = diameter_in * 0.0254;
144 let zero_m = zero_distance_yd * 0.9144;
145 let range_m = range_yd * 0.9144;
146 let sight_height_m = sight_height_in * 0.0254;
147 let altitude_m = altitude_ft * 0.3048;
148 let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
149 let pressure_hpa = pressure_inhg * 33.8639; let drag_model_enum = match drag_model {
152 DragModelArg::G1 => DragModel::G1,
153 DragModelArg::G7 => DragModel::G7,
154 };
155
156 let mut inputs = BallisticInputs {
158 muzzle_velocity: velocity_ms,
159 bc_value: bc,
160 bc_type: drag_model_enum,
161 bullet_mass: mass_kg,
162 bullet_diameter: diameter_m,
163 bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), sight_height: sight_height_m,
165 target_distance: range_m + 100.0, use_bc_segments: bc_segments.is_some(),
167 bc_segments_data: bc_segments.clone(),
168 use_rk4: true,
169 muzzle_angle: 0.0, ..Default::default() };
172
173 let atmosphere = AtmosphericConditions {
176 temperature: temperature_c,
177 pressure: pressure_hpa,
178 humidity, altitude: altitude_m,
180 };
181
182 let wind = WindConditions::default();
183
184 let zero_angle = crate::calculate_zero_angle_with_conditions(
189 inputs.clone(),
190 zero_m,
191 sight_height_m, wind.clone(),
193 atmosphere.clone(),
194 )?;
195
196 inputs.muzzle_angle = zero_angle;
198
199 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
201 solver.set_max_range(range_m + 100.0);
202 solver.set_time_step(0.0001);
203
204 let result = solver.solve()?;
205
206 let idx = result
208 .points
209 .iter()
210 .position(|p| p.position.x >= range_m)
211 .ok_or("Trajectory didn't reach target range")?;
212
213 let (z, bullet_y) = if interpolate && idx > 0 {
215 let p0 = &result.points[idx - 1];
218 let p1 = &result.points[idx];
219 let x0 = p0.position.x;
220 let x1 = p1.position.x;
221 let denom = x1 - x0;
222 if denom.abs() < f64::EPSILON {
223 (p1.position.x, p1.position.y)
224 } else {
225 let frac = (range_m - x0) / denom;
226 let y = p0.position.y + frac * (p1.position.y - p0.position.y);
227 (range_m, y)
228 }
229 } else {
230 let p = &result.points[idx];
231 (p.position.x, p.position.y)
232 };
233
234 let drop_m = sight_height_m - bullet_y;
238
239 Ok((drop_m, z))
240}
241
242#[allow(
245 clippy::too_many_arguments,
246 reason = "flat arguments preserve the existing velocity-truing compatibility helper"
247)]
248pub(crate) fn calculate_drop_at_velocity(
249 velocity_fps: f64,
250 bc: f64,
251 drag_model: DragModelArg,
252 mass_gr: f64,
253 diameter_in: f64,
254 zero_distance_yd: f64,
255 range_yd: f64,
256 sight_height_in: f64,
257 temperature_f: f64,
258 pressure_inhg: f64,
259 humidity: f64,
260 altitude_ft: f64,
261 bc_segments: &Option<Vec<BCSegmentData>>,
262) -> Result<f64, Box<dyn Error>> {
263 let (drop_m, z) = solve_trajectory_drop(
267 velocity_fps,
268 bc,
269 drag_model,
270 mass_gr,
271 diameter_in,
272 zero_distance_yd,
273 range_yd,
274 sight_height_in,
275 temperature_f,
276 pressure_inhg,
277 humidity,
278 altitude_ft,
279 bc_segments,
280 false,
281 )?;
282
283 let drop_mil = (drop_m / z) * 1000.0;
286
287 Ok(drop_mil)
288}
289
290#[derive(Debug, Clone)]
293pub struct TrueVelocityLocalResult {
294 pub effective_velocity_fps: f64,
296 pub iterations: i32,
298 pub final_error_mil: f64,
303 pub calculated_drop_mil: f64,
305 pub confidence: String,
309}
310
311#[allow(
324 clippy::too_many_arguments,
325 reason = "flat arguments mirror the stable true-velocity CLI command shape"
326)]
327pub fn calculate_true_velocity_local(
328 measured_drop_mil: f64,
329 range_yd: f64,
330 bc: f64,
331 drag_model: DragModelArg,
332 mass_gr: f64,
333 diameter_in: f64,
334 zero_distance_yd: f64,
335 sight_height_in: f64,
336 temperature_f: f64,
337 pressure_inhg: f64,
338 humidity: f64,
339 altitude_ft: f64,
340 bc_segments: &Option<Vec<BCSegmentData>>,
341) -> Result<TrueVelocityLocalResult, Box<dyn Error>> {
342 if !range_yd.is_finite() || range_yd <= 0.0 {
346 return Err("range must be positive and finite".into());
347 }
348 if !measured_drop_mil.is_finite() {
349 return Err("measured drop must be finite".into());
350 }
351
352 let mut velocity_low = 1500.0;
354 let mut velocity_high = 4500.0;
355 let tolerance_mil = 0.01; let max_iterations = 50;
357
358 let mut iterations = 0;
359 let mut last_error = 0.0;
360 let mut last_calculated_drop = 0.0;
361
362 for i in 0..max_iterations {
363 iterations = i + 1;
364 let test_velocity = (velocity_low + velocity_high) / 2.0;
365
366 let calculated_drop_mil = calculate_drop_at_velocity(
368 test_velocity,
369 bc,
370 drag_model,
371 mass_gr,
372 diameter_in,
373 zero_distance_yd,
374 range_yd,
375 sight_height_in,
376 temperature_f,
377 pressure_inhg,
378 humidity,
379 altitude_ft,
380 bc_segments,
381 )?;
382
383 last_calculated_drop = calculated_drop_mil;
384 let error = calculated_drop_mil - measured_drop_mil;
385 last_error = error;
386
387 if error.abs() < tolerance_mil {
388 let confidence = if error.abs() < 0.005 {
390 "high"
391 } else {
392 "medium"
393 };
394
395 return Ok(TrueVelocityLocalResult {
396 effective_velocity_fps: test_velocity,
397 iterations,
398 final_error_mil: error,
399 calculated_drop_mil,
400 confidence: confidence.to_string(),
401 });
402 }
403
404 if calculated_drop_mil > measured_drop_mil {
407 velocity_low = test_velocity;
410 } else {
411 velocity_high = test_velocity;
414 }
415
416 if (velocity_high - velocity_low).abs() < 0.5 {
418 break;
419 }
420 }
421
422 let final_velocity = (velocity_low + velocity_high) / 2.0;
424 let confidence = if last_error.abs() < 0.1 {
425 "medium"
426 } else {
427 "low"
428 };
429
430 Ok(TrueVelocityLocalResult {
431 effective_velocity_fps: final_velocity,
432 iterations,
433 final_error_mil: last_error,
434 calculated_drop_mil: last_calculated_drop,
435 confidence: confidence.to_string(),
436 })
437}
438
439pub(crate) const TRUING_MV_MIN_FPS: f64 = 1000.0;
464pub(crate) const TRUING_MV_MAX_FPS: f64 = 5000.0;
466pub(crate) const TRUING_BC_MIN: f64 = 0.05;
468pub(crate) const TRUING_BC_MAX: f64 = 2.0;
470pub(crate) const TRUING_MAX_ITERS: usize = 40;
472
473pub(crate) const TRUING_MIN_BC_SENSITIVITY_RATIO: f64 = 0.20;
479pub(crate) const TRUING_MAX_CONDITION_NUMBER: f64 = 1.0e3;
485
486#[derive(Debug, Clone, Copy)]
489pub struct TruingObservation {
490 pub range_yd: f64,
491 pub drop: f64,
492}
493
494#[derive(Debug, Clone, Copy, Serialize)]
503pub struct TruingModelInputsV1 {
504 pub muzzle_velocity_fps: f64,
506 pub ballistic_coefficient: f64,
508 pub drag_model: DragModelArg,
509 pub mass_gr: f64,
511 pub diameter_in: f64,
513 pub zero_distance_yd: f64,
515 pub sight_height_in: f64,
517 pub temperature_f: f64,
519 pub pressure_inhg: f64,
521 pub humidity_pct: f64,
523 pub altitude_ft: f64,
525}
526
527impl TruingModelInputsV1 {
528 pub fn validate(&self) -> Result<(), String> {
532 if !self.muzzle_velocity_fps.is_finite()
533 || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
534 {
535 return Err(format!(
536 "nominal muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
537 ));
538 }
539 if !self.ballistic_coefficient.is_finite()
540 || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.ballistic_coefficient)
541 {
542 return Err(format!(
543 "nominal ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
544 ));
545 }
546 for (name, value) in [
547 ("bullet mass", self.mass_gr),
548 ("bullet diameter", self.diameter_in),
549 ("zero distance", self.zero_distance_yd),
550 ("sight height", self.sight_height_in),
551 ("pressure", self.pressure_inhg),
552 ] {
553 if !value.is_finite() || value <= 0.0 {
554 return Err(format!("{name} must be positive and finite"));
555 }
556 }
557 if !self.temperature_f.is_finite() {
558 return Err("temperature must be finite".to_string());
559 }
560 if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
561 return Err("humidity must be finite and within 0..=100 percent".to_string());
562 }
563 if !self.altitude_ft.is_finite() {
564 return Err("altitude must be finite".to_string());
565 }
566 Ok(())
567 }
568
569 pub(crate) fn with_forward_model<T>(
573 &self,
574 drop_unit: DropUnit,
575 f: impl FnOnce(&TruingForwardModel<'_>) -> T,
576 ) -> T {
577 let no_bc_segments = None;
578 let model = TruingForwardModel {
579 drag_model: self.drag_model,
580 mass_gr: self.mass_gr,
581 diameter_in: self.diameter_in,
582 zero_yd: self.zero_distance_yd,
583 sight_in: self.sight_height_in,
584 temp_f: self.temperature_f,
585 press_inhg: self.pressure_inhg,
586 humidity: self.humidity_pct,
587 alt_ft: self.altitude_ft,
588 bc_segments: &no_bc_segments,
589 drop_unit,
590 };
591 f(&model)
592 }
593}
594
595pub fn parse_truing_observation(s: &str, units: UnitSystem) -> Result<TruingObservation, String> {
600 let parts: Vec<&str> = s.split(':').collect();
601 if parts.len() != 2 {
602 return Err(format!(
603 "invalid --observed '{s}': expected RANGE:DROP (e.g. 600:5.1)"
604 ));
605 }
606 let range: f64 = parts[0]
607 .trim()
608 .parse()
609 .map_err(|_| format!("invalid --observed range '{}' in '{s}'", parts[0]))?;
610 let drop: f64 = parts[1]
611 .trim()
612 .parse()
613 .map_err(|_| format!("invalid --observed drop '{}' in '{s}'", parts[1]))?;
614 if !range.is_finite() || !drop.is_finite() {
615 return Err(format!("invalid --observed '{s}': values must be finite"));
616 }
617 let range_yd = match units {
618 UnitSystem::Imperial => range,
619 UnitSystem::Metric => range / 0.9144,
620 };
621 Ok(TruingObservation { range_yd, drop })
622}
623
624pub fn validate_truing_observations(observations: &[TruingObservation]) -> Result<(), String> {
632 for o in observations {
633 if !o.range_yd.is_finite() || o.range_yd <= 0.0 {
634 return Err(format!(
635 "observation range must be a positive finite distance (got {})",
636 o.range_yd
637 ));
638 }
639 if !o.drop.is_finite() || o.drop == 0.0 {
640 return Err(
641 "observation drop must be non-zero (a zero drop carries no truing information)"
642 .to_string(),
643 );
644 }
645 }
646 for i in 0..observations.len() {
647 for j in (i + 1)..observations.len() {
648 if (observations[i].range_yd - observations[j].range_yd).abs() < 1e-6 {
649 return Err(format!(
650 "duplicate observation range ({:.3} yd internal): each observation must be at a distinct range",
651 observations[i].range_yd
652 ));
653 }
654 }
655 }
656 Ok(())
657}
658
659pub(crate) struct TruingForwardModel<'a> {
662 pub drag_model: DragModelArg,
663 pub mass_gr: f64,
664 pub diameter_in: f64,
665 pub zero_yd: f64,
666 pub sight_in: f64,
667 pub temp_f: f64,
668 pub press_inhg: f64,
669 pub humidity: f64,
670 pub alt_ft: f64,
671 pub bc_segments: &'a Option<Vec<BCSegmentData>>,
672 pub drop_unit: DropUnit,
673}
674
675impl TruingForwardModel<'_> {
676 pub fn predict(&self, mv_fps: f64, bc: f64, range_yd: f64) -> Result<f64, Box<dyn Error>> {
679 self.predict_in_unit(mv_fps, bc, range_yd, self.drop_unit)
680 }
681
682 pub fn predict_in_unit(
690 &self,
691 mv_fps: f64,
692 bc: f64,
693 range_yd: f64,
694 unit: DropUnit,
695 ) -> Result<f64, Box<dyn Error>> {
696 let (drop_m, z_m) = solve_trajectory_drop(
697 mv_fps,
698 bc,
699 self.drag_model,
700 self.mass_gr,
701 self.diameter_in,
702 self.zero_yd,
703 range_yd,
704 self.sight_in,
705 self.temp_f,
706 self.press_inhg,
707 self.humidity,
708 self.alt_ft,
709 self.bc_segments,
710 true, )?;
712 Ok(unit.express_drop_m(drop_m, z_m))
713 }
714
715 pub(crate) fn predict_many_in_unit(
724 &self,
725 mv_fps: f64,
726 bc: f64,
727 ranges_yd: &[f64],
728 unit: DropUnit,
729 ) -> Result<Vec<Option<f64>>, Box<dyn Error>> {
730 if ranges_yd.is_empty() {
731 return Ok(Vec::new());
732 }
733 let max_range_yd = ranges_yd
734 .iter()
735 .copied()
736 .filter(|r| r.is_finite() && *r > 0.0)
737 .fold(0.0_f64, f64::max);
738 if max_range_yd <= 0.0 {
739 return Ok(vec![None; ranges_yd.len()]);
740 }
741
742 let velocity_ms = mv_fps * 0.3048;
743 let mass_kg = self.mass_gr * GRAINS_TO_KG;
744 let diameter_m = self.diameter_in * 0.0254;
745 let zero_m = self.zero_yd * 0.9144;
746 let max_range_m = max_range_yd * 0.9144;
747 let sight_height_m = self.sight_in * 0.0254;
748 let altitude_m = self.alt_ft * 0.3048;
749 let temperature_c = (self.temp_f - 32.0) * 5.0 / 9.0;
750 let pressure_hpa = self.press_inhg * 33.8639;
751 let drag_model = match self.drag_model {
752 DragModelArg::G1 => DragModel::G1,
753 DragModelArg::G7 => DragModel::G7,
754 };
755
756 let mut inputs = BallisticInputs {
757 muzzle_velocity: velocity_ms,
758 bc_value: bc,
759 bc_type: drag_model,
760 bullet_mass: mass_kg,
761 bullet_diameter: diameter_m,
762 bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
763 sight_height: sight_height_m,
764 target_distance: max_range_m + 100.0,
765 use_bc_segments: self.bc_segments.is_some(),
766 bc_segments_data: self.bc_segments.clone(),
767 use_rk4: true,
768 muzzle_angle: 0.0,
769 ..Default::default()
770 };
771 let atmosphere = AtmosphericConditions {
772 temperature: temperature_c,
773 pressure: pressure_hpa,
774 humidity: self.humidity,
775 altitude: altitude_m,
776 };
777 let wind = WindConditions::default();
778 inputs.muzzle_angle = crate::calculate_zero_angle_with_conditions(
779 inputs.clone(),
780 zero_m,
781 sight_height_m,
782 wind.clone(),
783 atmosphere.clone(),
784 )?;
785
786 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
787 solver.set_max_range(max_range_m + 100.0);
788 solver.set_time_step(0.0001);
789 let result = solver.solve()?;
790
791 let predictions = ranges_yd
792 .iter()
793 .map(|range_yd| {
794 if !range_yd.is_finite() || *range_yd <= 0.0 {
795 return None;
796 }
797 let range_m = *range_yd * 0.9144;
798 let idx = result.points.iter().position(|p| p.position.x >= range_m)?;
799 let (z_m, bullet_y) = if idx > 0 {
800 let p0 = &result.points[idx - 1];
801 let p1 = &result.points[idx];
802 let span = p1.position.x - p0.position.x;
803 if span.abs() < f64::EPSILON {
804 (p1.position.x, p1.position.y)
805 } else {
806 let fraction = (range_m - p0.position.x) / span;
807 (
808 range_m,
809 p0.position.y + fraction * (p1.position.y - p0.position.y),
810 )
811 }
812 } else {
813 let p = &result.points[idx];
814 (p.position.x, p.position.y)
815 };
816 let drop_m = sight_height_m - bullet_y;
817 Some(unit.express_drop_m(drop_m, z_m))
818 })
819 .collect();
820 Ok(predictions)
821 }
822
823 pub fn cost(&self, mv: f64, bc: f64, obs: &[TruingObservation]) -> Result<f64, Box<dyn Error>> {
825 let mut c = 0.0;
826 for o in obs {
827 let r = self.predict(mv, bc, o.range_yd)? - o.drop;
828 c += r * r;
829 }
830 Ok(c)
831 }
832}
833
834#[derive(Debug, Clone, Copy)]
839pub(crate) struct TruingJacobianRow {
840 pub predicted_drop: f64,
841 pub d_drop_d_mv: f64,
842 pub d_drop_d_bc: f64,
843}
844
845pub(crate) fn truing_jacobian_row(
846 model: &TruingForwardModel<'_>,
847 mv_fps: f64,
848 bc: f64,
849 range_yd: f64,
850 unit: DropUnit,
851) -> Result<TruingJacobianRow, Box<dyn Error>> {
852 let hmv = (mv_fps * 1e-3).max(0.5);
853 let hbc = (bc * 1e-3).max(1e-4);
854 let predicted_drop = model.predict_in_unit(mv_fps, bc, range_yd, unit)?;
855 let d_drop_d_mv = (model.predict_in_unit(mv_fps + hmv, bc, range_yd, unit)?
856 - model.predict_in_unit(mv_fps - hmv, bc, range_yd, unit)?)
857 / (2.0 * hmv);
858 let d_drop_d_bc = (model.predict_in_unit(mv_fps, bc + hbc, range_yd, unit)?
859 - model.predict_in_unit(mv_fps, bc - hbc, range_yd, unit)?)
860 / (2.0 * hbc);
861 Ok(TruingJacobianRow {
862 predicted_drop,
863 d_drop_d_mv,
864 d_drop_d_bc,
865 })
866}
867
868pub(crate) fn truing_jacobian_rows(
873 model: &TruingForwardModel<'_>,
874 mv_fps: f64,
875 bc: f64,
876 ranges_yd: &[f64],
877 unit: DropUnit,
878) -> Result<Vec<Option<TruingJacobianRow>>, Box<dyn Error>> {
879 let hmv = (mv_fps * 1e-3).max(0.5);
880 let hbc = (bc * 1e-3).max(1e-4);
881 let nominal = model.predict_many_in_unit(mv_fps, bc, ranges_yd, unit)?;
882 let mv_plus = model.predict_many_in_unit(mv_fps + hmv, bc, ranges_yd, unit)?;
883 let mv_minus = model.predict_many_in_unit(mv_fps - hmv, bc, ranges_yd, unit)?;
884 let bc_plus = model.predict_many_in_unit(mv_fps, bc + hbc, ranges_yd, unit)?;
885 let bc_minus = model.predict_many_in_unit(mv_fps, bc - hbc, ranges_yd, unit)?;
886
887 Ok((0..ranges_yd.len())
888 .map(|i| {
889 Some(TruingJacobianRow {
890 predicted_drop: nominal[i]?,
891 d_drop_d_mv: (mv_plus[i]? - mv_minus[i]?) / (2.0 * hmv),
892 d_drop_d_bc: (bc_plus[i]? - bc_minus[i]?) / (2.0 * hbc),
893 })
894 })
895 .collect())
896}
897
898pub(crate) fn fit_truing_mv_only(
902 model: &TruingForwardModel<'_>,
903 obs: &[TruingObservation],
904 bc: f64,
905 mv_init: f64,
906) -> Result<(f64, usize, bool), Box<dyn Error>> {
907 let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
908 let mut converged = false;
909 let mut iters = 0;
910 for i in 0..TRUING_MAX_ITERS {
911 iters = i + 1;
912 let h = (mv * 1e-3).max(0.5);
913 let mut num = 0.0;
914 let mut den = 0.0;
915 for o in obs {
916 let r = model.predict(mv, bc, o.range_yd)? - o.drop;
917 let dp = model.predict(mv + h, bc, o.range_yd)?;
918 let dm = model.predict(mv - h, bc, o.range_yd)?;
919 let j = (dp - dm) / (2.0 * h);
920 num += j * r;
921 den += j * j;
922 }
923 if den < 1e-12 {
924 break;
925 }
926 let step = (-num / den).clamp(-300.0, 300.0);
928 let new_mv = (mv + step).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
929 if (new_mv - mv).abs() < 0.05 {
930 mv = new_mv;
931 converged = true;
932 break;
933 }
934 mv = new_mv;
935 }
936 Ok((mv, iters, converged))
937}
938
939pub(crate) fn truing_identifiability(
953 model: &TruingForwardModel<'_>,
954 obs: &[TruingObservation],
955 mv: f64,
956 bc: f64,
957) -> Result<(f64, f64), Box<dyn Error>> {
958 let (mut n_mv, mut n_bc, mut cross) = (0.0, 0.0, 0.0);
959 let unit = DropUnit::Mil;
964 for o in obs {
965 let row = truing_jacobian_row(model, mv, bc, o.range_yd, unit)?;
966 n_mv += row.d_drop_d_mv * row.d_drop_d_mv;
967 n_bc += row.d_drop_d_bc * row.d_drop_d_bc;
968 cross += row.d_drop_d_mv * row.d_drop_d_bc;
969 }
970 let norm_mv = n_mv.sqrt();
971 let norm_bc = n_bc.sqrt();
972 let sensitivity_ratio = if mv * norm_mv > 0.0 {
973 (bc * norm_bc) / (mv * norm_mv)
974 } else {
975 0.0
976 };
977 let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
979 let c = (cross / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
980 if (1.0 - c) > 1e-15 {
981 (1.0 + c) / (1.0 - c)
982 } else {
983 f64::INFINITY
984 }
985 } else {
986 f64::INFINITY
988 };
989 Ok((sensitivity_ratio, condition_number))
990}
991
992pub(crate) fn fit_truing_joint(
997 model: &TruingForwardModel<'_>,
998 obs: &[TruingObservation],
999 mv_init: f64,
1000 bc_init: f64,
1001) -> Result<(f64, f64, usize, bool), Box<dyn Error>> {
1002 let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1003 let mut bc = bc_init.clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1004 let mut lambda = 1e-3;
1005 let mut cur_cost = model.cost(mv, bc, obs)?;
1006 let mut converged = false;
1007 let mut iters = 0;
1008
1009 for it in 0..TRUING_MAX_ITERS {
1010 iters = it + 1;
1011 let (mut a00, mut a01, mut a11) = (0.0, 0.0, 0.0);
1013 let (mut g0, mut g1) = (0.0, 0.0);
1014 for o in obs {
1015 let row = truing_jacobian_row(model, mv, bc, o.range_yd, model.drop_unit)?;
1016 let r = row.predicted_drop - o.drop;
1017 let jmv = row.d_drop_d_mv;
1018 let jbc = row.d_drop_d_bc;
1019 a00 += jmv * jmv;
1020 a01 += jmv * jbc;
1021 a11 += jbc * jbc;
1022 g0 += jmv * r;
1023 g1 += jbc * r;
1024 }
1025
1026 let mut accepted = false;
1028 for _ in 0..30 {
1029 let m00 = a00 + lambda * a00.max(1e-12);
1030 let m11 = a11 + lambda * a11.max(1e-12);
1031 let det = m00 * m11 - a01 * a01;
1032 if det.abs() < 1e-20 {
1033 lambda *= 10.0;
1034 continue;
1035 }
1036 let dmv = -(m11 * g0 - a01 * g1) / det;
1038 let dbc = -(-a01 * g0 + m00 * g1) / det;
1039 let nmv = (mv + dmv).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1040 let nbc = (bc + dbc).clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1041 let nc = model.cost(nmv, nbc, obs)?;
1042 if nc < cur_cost {
1043 let rel_change =
1044 (nmv - mv).abs() / mv.max(1.0) + (nbc - bc).abs() / bc.max(1e-3);
1045 mv = nmv;
1046 bc = nbc;
1047 cur_cost = nc;
1048 lambda = (lambda * 0.5).max(1e-9);
1049 accepted = true;
1050 if rel_change < 1e-6 {
1051 converged = true;
1052 }
1053 break;
1054 }
1055 lambda *= 4.0;
1056 if lambda > 1e12 {
1057 break;
1058 }
1059 }
1060 if !accepted {
1061 converged = true;
1064 break;
1065 }
1066 if converged {
1067 break;
1068 }
1069 }
1070 Ok((mv, bc, iters, converged))
1071}
1072
1073#[derive(Debug, Clone)]
1075pub struct MultiTruingReport {
1076 pub fitted_mv_fps: f64,
1077 pub fitted_bc: f64,
1078 pub bc_input: f64,
1079 pub bc_fitted: bool,
1080 pub observations: Vec<TruingObservation>,
1081 pub predicted: Vec<f64>,
1082 pub residuals: Vec<f64>,
1083 pub rms: f64,
1084 pub iterations: usize,
1085 pub converged: bool,
1086 pub sensitivity_ratio: f64,
1087 pub condition_number: f64,
1088 pub quality: String,
1089 pub reason: String,
1090}
1091
1092#[allow(
1102 clippy::too_many_arguments,
1103 reason = "flat arguments mirror the stable true-velocity CLI command shape"
1104)]
1105pub fn run_multi_observation_truing_core(
1106 observations: &[TruingObservation],
1107 drop_unit: DropUnit,
1108 bc_input: f64,
1109 drag_model: DragModelArg,
1110 mass_gr: f64,
1111 diameter_in: f64,
1112 zero_yd: f64,
1113 sight_in: f64,
1114 temp_f: f64,
1115 press_inhg: f64,
1116 humidity: f64,
1117 alt_ft: f64,
1118 bc_segments: &Option<Vec<BCSegmentData>>,
1119) -> Result<MultiTruingReport, Box<dyn Error>> {
1120 validate_truing_observations(observations)?;
1122 let observations: Vec<TruingObservation> = observations.to_vec();
1123
1124 let model = TruingForwardModel {
1125 drag_model,
1126 mass_gr,
1127 diameter_in,
1128 zero_yd,
1129 sight_in,
1130 temp_f,
1131 press_inhg,
1132 humidity,
1133 alt_ft,
1134 bc_segments,
1135 drop_unit,
1136 };
1137
1138 let mv_init = (TRUING_MV_MIN_FPS + TRUING_MV_MAX_FPS) / 2.0;
1142 let (mv0, mv_iters, mv_conv) = fit_truing_mv_only(&model, &observations, bc_input, mv_init)?;
1143 let rms_mv_only = rms_at(&model, &observations, mv0, bc_input)?;
1144
1145 let (sensitivity_ratio, condition_number) =
1147 truing_identifiability(&model, &observations, mv0, bc_input)?;
1148 let bc_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
1149 && condition_number <= TRUING_MAX_CONDITION_NUMBER
1150 && condition_number.is_finite();
1151
1152 let mut fitted_mv = mv0;
1155 let mut fitted_bc = bc_input;
1156 let mut bc_fitted = false;
1157 let mut iterations = mv_iters;
1158 let mut converged = mv_conv;
1162 let mut reason = String::new();
1163
1164 if bc_identifiable {
1165 let (mv_j, bc_j, iters_j, conv_j) =
1166 fit_truing_joint(&model, &observations, mv0, bc_input)?;
1167 let rms_joint = rms_at(&model, &observations, mv_j, bc_j)?;
1168 let bc_at_bound = bc_j <= TRUING_BC_MIN * 1.001 || bc_j >= TRUING_BC_MAX * 0.999;
1169 if !bc_at_bound && rms_joint <= rms_mv_only + 1e-9 {
1170 fitted_mv = mv_j;
1171 fitted_bc = bc_j;
1172 bc_fitted = true;
1173 iterations = iters_j;
1174 converged = conv_j;
1175 } else {
1176 reason = if bc_at_bound {
1179 format!(
1180 "joint fit drove BC to a bound ({bc_j:.3}); BC held at input {bc_input:.3}"
1181 )
1182 } else {
1183 format!(
1184 "joint fit did not improve on the MV-only solution; BC held at input {bc_input:.3}"
1185 )
1186 };
1187 }
1188 } else {
1189 reason = if !condition_number.is_finite() || condition_number > TRUING_MAX_CONDITION_NUMBER
1190 {
1191 format!(
1192 "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}"
1193 )
1194 } else {
1195 format!(
1196 "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."
1197 )
1198 };
1199 }
1200
1201 let mut predicted = Vec::with_capacity(observations.len());
1206 let mut residuals = Vec::with_capacity(observations.len());
1207 let mut sse = 0.0;
1208 let mut sse_mil = 0.0;
1209 for o in &observations {
1210 let p = model.predict(fitted_mv, fitted_bc, o.range_yd)?;
1211 let r = p - o.drop;
1212 let r_mil = match drop_unit {
1213 DropUnit::Mil => r,
1214 DropUnit::Moa => r / ((180.0 / std::f64::consts::PI) * 60.0 / 1000.0),
1216 DropUnit::In => r * 0.0254 / (o.range_yd * 0.9144) * 1000.0,
1218 };
1219 predicted.push(p);
1220 residuals.push(r);
1221 sse += r * r;
1222 sse_mil += r_mil * r_mil;
1223 }
1224 let rms = (sse / observations.len() as f64).sqrt();
1225 let rms_mil = (sse_mil / observations.len() as f64).sqrt();
1226
1227 let quality = truing_quality_line(
1228 bc_fitted,
1229 rms,
1230 rms_mil,
1231 drop_unit,
1232 condition_number,
1233 converged,
1234 observations.len(),
1235 );
1236
1237 let report = MultiTruingReport {
1238 fitted_mv_fps: fitted_mv,
1239 fitted_bc,
1240 bc_input,
1241 bc_fitted,
1242 observations,
1243 predicted,
1244 residuals,
1245 rms,
1246 iterations,
1247 converged,
1248 sensitivity_ratio,
1249 condition_number,
1250 quality,
1251 reason,
1252 };
1253
1254 Ok(report)
1255}
1256
1257pub(crate) fn rms_at(
1259 model: &TruingForwardModel<'_>,
1260 obs: &[TruingObservation],
1261 mv: f64,
1262 bc: f64,
1263) -> Result<f64, Box<dyn Error>> {
1264 let mut sse = 0.0;
1265 for o in obs {
1266 let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1267 sse += r * r;
1268 }
1269 Ok((sse / obs.len() as f64).sqrt())
1270}
1271
1272pub(crate) fn truing_quality_line(
1276 bc_fitted: bool,
1277 rms: f64,
1278 rms_mil: f64,
1279 drop_unit: DropUnit,
1280 condition_number: f64,
1281 converged: bool,
1282 n_obs: usize,
1283) -> String {
1284 let unit = drop_unit.label();
1285 let n_params = if bc_fitted { 2 } else { 1 };
1286 if n_obs == n_params {
1289 return format!(
1290 "{} fit is exactly determined ({n_obs} observations, {n_params} fitted \
1291 parameters): residuals are zero by construction and do not validate the \
1292 fit; add an observation to assess quality",
1293 if bc_fitted { "Joint MV+BC" } else { "MV-only" }
1294 );
1295 }
1296 let quality = if rms_mil < 0.05 {
1297 "excellent"
1298 } else if rms_mil < 0.15 {
1299 "good"
1300 } else if rms_mil < 0.4 {
1301 "fair"
1302 } else {
1303 "poor (observations may be inconsistent)"
1304 };
1305 let nonconv = if converged { "" } else { " (did not fully converge)" };
1306 if bc_fitted {
1307 let cond = if condition_number.is_finite() {
1308 format!("{condition_number:.0}")
1309 } else {
1310 "inf".to_string()
1311 };
1312 format!(
1313 "Joint MV+BC fit, {quality}: RMS residual {rms:.3} {unit}, conditioning {cond}{nonconv}"
1314 )
1315 } else {
1316 format!("MV-only fit, {quality}: RMS residual {rms:.3} {unit} (BC held fixed){nonconv}")
1317 }
1318}