1use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use crate::truing::{
18 truing_jacobian_rows, DropUnit, TruingForwardModel, TruingModelInputsV1, TRUING_BC_MAX,
19 TRUING_BC_MIN, TRUING_MAX_CONDITION_NUMBER, TRUING_MIN_BC_SENSITIVITY_RATIO, TRUING_MV_MAX_FPS,
20 TRUING_MV_MIN_FPS,
21};
22
23pub const TRUING_UNCERTAINTY_SCHEMA_VERSION_V1: u32 = 1;
25
26pub const TRUING_UNCERTAINTY_INTERVAL_LEVEL_V1: f64 = 0.95;
28
29pub const TRUING_UNCERTAINTY_MAX_ITERS_V1: usize = 100;
35
36const NORMAL_95_TWO_SIDED_Z: f64 = 1.959_963_984_540_054;
37const MV_COORDINATE_SCALE_FPS: f64 = 100.0;
40const BC_COORDINATE_SCALE: f64 = 0.1;
41const INFORMATION_RELATIVE_EIGEN_TOLERANCE: f64 = 1.0e-12;
42const MAP_SCALED_GRADIENT_TOLERANCE: f64 = 1.0e-6;
43const MAP_OBJECTIVE_INITIAL_POLL_RADIUS: f64 = 1.0e-2;
49const MAP_OBJECTIVE_MIN_POLL_RADIUS: f64 = 1.0e-7;
50const MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE: f64 = 1.0e-8;
51const MAP_OBJECTIVE_MAX_POLL_EVALUATIONS: usize = 1_024;
52
53#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct WeightedTruingObservationV1 {
62 pub range_yd: f64,
63 pub drop: f64,
64 pub sigma: f64,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct NormalPriorV1 {
71 pub mean: f64,
72 pub sigma: f64,
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct TruingPriorsV1 {
82 pub muzzle_velocity_fps: Option<NormalPriorV1>,
83 pub ballistic_coefficient: Option<NormalPriorV1>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct TruingPredictionRequestV1 {
90 pub range_yd: f64,
91 pub future_observation_sigma: Option<f64>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct UncertaintyTruingRequestV1 {
101 pub model: TruingModelInputsV1,
102 pub drop_unit: DropUnit,
103 pub observations: Vec<WeightedTruingObservationV1>,
104 pub priors: TruingPriorsV1,
105 pub predictions: Vec<TruingPredictionRequestV1>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
110pub struct GaussianIntervalV1 {
111 pub estimate: f64,
112 pub standard_deviation: f64,
113 pub lower: f64,
114 pub upper: f64,
115 pub probability: f64,
116}
117
118impl GaussianIntervalV1 {
119 fn from_variance(estimate: f64, variance: f64) -> Option<Self> {
120 if !estimate.is_finite() || !variance.is_finite() || variance < 0.0 {
121 return None;
122 }
123 let standard_deviation = variance.sqrt();
124 let half_width = NORMAL_95_TWO_SIDED_Z * standard_deviation;
125 Some(Self {
126 estimate,
127 standard_deviation,
128 lower: estimate - half_width,
129 upper: estimate + half_width,
130 probability: TRUING_UNCERTAINTY_INTERVAL_LEVEL_V1,
131 })
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
137pub struct TruingCovarianceV1 {
138 pub mv_variance_fps2: f64,
139 pub mv_bc_covariance_fps: f64,
140 pub bc_variance: f64,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
145pub struct TruingGaussianApproximationV1 {
146 pub covariance: TruingCovarianceV1,
147 pub muzzle_velocity_interval_95: GaussianIntervalV1,
148 pub ballistic_coefficient_interval_95: GaussianIntervalV1,
149 pub mv_bc_correlation: f64,
151 pub scaled_information_condition_number: f64,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
159#[serde(rename_all = "snake_case")]
160pub enum TruingApproximationFailureCodeV1 {
161 OptimizerDidNotConverge,
162 MapAtParameterBound,
163 RankDeficientInformation,
164 NonFiniteInformation,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct TruingApproximationFailureV1 {
171 pub code: TruingApproximationFailureCodeV1,
172 pub message: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize)]
177#[serde(rename_all = "snake_case", tag = "status", content = "details")]
178pub enum TruingApproximationV1 {
179 Available(TruingGaussianApproximationV1),
180 Unavailable(TruingApproximationFailureV1),
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
185pub struct WeightedTruingObservationResultV1 {
186 pub range_yd: f64,
187 pub observed_drop: f64,
188 pub sigma: f64,
189 pub predicted_drop: f64,
190 pub residual: f64,
191 pub standardized_residual: f64,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize)]
205pub struct TruingBaselineV1 {
206 pub muzzle_velocity_fps: f64,
210 pub ballistic_coefficient: f64,
213 pub chi_square: f64,
219 pub observations: Vec<WeightedTruingObservationResultV1>,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
225pub struct TruingPredictiveBandV1 {
226 pub range_yd: f64,
227 pub predicted_drop: f64,
228 pub latent_interval_95: Option<GaussianIntervalV1>,
231 pub future_observation_interval_95: Option<GaussianIntervalV1>,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
239#[serde(rename_all = "snake_case")]
240pub enum TruingUncertaintyWarningCodeV1 {
241 OptimizerDidNotConverge,
242 ObjectiveMeshConvergence,
243 WeakBcSensitivity,
244 IllConditionedData,
245 MvPriorDominated,
246 BcPriorDominated,
247 GaussianApproximationUnavailable,
248 IntervalCrossesFitBounds,
249 LowEffectiveDegreesOfFreedom,
250 PredictionOutsideObservedDomain,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
255#[serde(rename_all = "snake_case")]
256pub enum TruingMapConvergenceCriterionV1 {
257 ScaledGradient,
259 ObjectiveMesh,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
266pub struct TruingUncertaintyWarningV1 {
267 pub code: TruingUncertaintyWarningCodeV1,
268 pub message: String,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
273pub struct TruingUncertaintyDiagnosticsV1 {
274 pub chi_square: f64,
276 pub prior_penalty: f64,
278 pub penalized_chi_square: f64,
280 pub effective_parameter_count: Option<f64>,
283 pub effective_degrees_of_freedom: Option<f64>,
285 pub reduced_chi_square: Option<f64>,
287 pub bc_sensitivity_ratio: f64,
289 pub data_condition_number: f64,
291 pub map_scaled_gradient_inf_norm: f64,
299 pub map_convergence_criterion: Option<TruingMapConvergenceCriterionV1>,
302 pub map_objective_poll_radius: Option<f64>,
306 pub map_max_objective_poll_improvement: Option<f64>,
310 pub map_objective_poll_evaluations: usize,
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize)]
316pub struct UncertaintyTruingReportV1 {
317 pub schema_version: u32,
318 pub drop_unit: DropUnit,
319 pub map_muzzle_velocity_fps: f64,
320 pub map_ballistic_coefficient: f64,
321 pub iterations: usize,
322 pub converged: bool,
323 pub priors: TruingPriorsV1,
324 pub observations: Vec<WeightedTruingObservationResultV1>,
325 pub baseline: Option<TruingBaselineV1>,
335 pub diagnostics: TruingUncertaintyDiagnosticsV1,
336 pub approximation: TruingApproximationV1,
337 pub predictive_bands: Vec<TruingPredictiveBandV1>,
338 pub warnings: Vec<TruingUncertaintyWarningV1>,
339}
340
341#[derive(Debug, Error, Clone, PartialEq, Eq)]
343pub enum UncertaintyTruingErrorV1 {
344 #[error("invalid uncertainty-truing request: {0}")]
345 InvalidInput(String),
346 #[error("truing forward model failed: {0}")]
347 ForwardModel(String),
348}
349
350#[derive(Debug, Clone, Copy, Default)]
363pub(crate) struct Symmetric2 {
364 pub(crate) a00: f64,
365 pub(crate) a01: f64,
366 pub(crate) a11: f64,
367}
368
369impl Symmetric2 {
370 fn determinant(self) -> f64 {
371 self.a00 * self.a11 - self.a01 * self.a01
372 }
373
374 pub(crate) fn add_assign(&mut self, rhs: Self) {
375 self.a00 += rhs.a00;
376 self.a01 += rhs.a01;
377 self.a11 += rhs.a11;
378 }
379
380 pub(crate) fn largest_smallest_eigenvalues(self) -> (f64, f64) {
398 let trace = self.a00 + self.a11;
399 let discriminant = (self.a00 - self.a11).hypot(2.0 * self.a01);
400 let largest = (0.5 * (trace + discriminant)).max(0.0);
401 let determinant = self.determinant();
402 let smallest = if largest > 0.0 {
403 (determinant / largest).max(0.0)
404 } else {
405 0.0
406 };
407 (largest, smallest)
408 }
409
410 fn inverse_with_condition(self) -> Result<(Self, f64), TruingApproximationFailureCodeV1> {
411 if !self.a00.is_finite() || !self.a01.is_finite() || !self.a11.is_finite() {
412 return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
413 }
414 let trace = self.a00 + self.a11;
415 let discriminant = (self.a00 - self.a11).hypot(2.0 * self.a01);
416 let largest = 0.5 * (trace + discriminant);
417 let determinant = self.determinant();
418 if !largest.is_finite() || !determinant.is_finite() {
419 return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
420 }
421 if largest <= 0.0 || determinant <= 0.0 {
422 return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
423 }
424 let smallest = determinant / largest;
427 if !smallest.is_finite()
428 || smallest <= 0.0
429 || smallest / largest <= INFORMATION_RELATIVE_EIGEN_TOLERANCE
430 {
431 return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
432 }
433 let inverse = Self {
434 a00: self.a11 / determinant,
435 a01: -self.a01 / determinant,
436 a11: self.a00 / determinant,
437 };
438 if !inverse.a00.is_finite() || !inverse.a01.is_finite() || !inverse.a11.is_finite() {
439 return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
440 }
441 Ok((inverse, largest / smallest))
442 }
443}
444
445struct Evaluation {
446 data_information: Symmetric2,
447 posterior_information: Symmetric2,
448 gradient: [f64; 2],
449 observation_results: Vec<WeightedTruingObservationResultV1>,
450 chi_square: f64,
451 prior_penalty: f64,
452 bc_sensitivity_ratio: f64,
453 data_condition_number: f64,
454}
455
456#[derive(Debug, Clone, Copy)]
457struct MapFitResult {
458 mv: f64,
459 bc: f64,
460 iterations: usize,
461 convergence_criterion: Option<TruingMapConvergenceCriterionV1>,
462 objective_poll_radius: Option<f64>,
463 max_objective_poll_improvement: Option<f64>,
464 objective_poll_evaluations: usize,
465}
466
467#[derive(Debug, Clone, Copy)]
468struct ObjectivePollResult {
469 mv: f64,
470 bc: f64,
471 converged: bool,
472 final_radius: f64,
473 max_final_improvement: f64,
474 evaluations: usize,
475}
476
477impl Evaluation {
478 fn penalized_chi_square(&self) -> f64 {
479 self.chi_square + self.prior_penalty
480 }
481}
482
483pub fn run_uncertainty_truing_v1(
491 request: &UncertaintyTruingRequestV1,
492) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
493 validate_request(request)?;
494
495 request
496 .model
497 .with_forward_model(request.drop_unit, |model| run_with_model(request, model))
498}
499
500fn run_with_model(
501 request: &UncertaintyTruingRequestV1,
502 model: &TruingForwardModel<'_>,
503) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
504 let fit = fit_map(request, model)?;
505 let map_mv = fit.mv;
506 let map_bc = fit.bc;
507 let iterations = fit.iterations;
508 let converged = fit.convergence_criterion.is_some();
509 let evaluation = evaluate(request, model, map_mv, map_bc)?;
510 let mut warnings = Vec::new();
511
512 if !converged {
513 let gradient_norm = evaluation.gradient[0]
514 .abs()
515 .max(evaluation.gradient[1].abs());
516 push_warning(
517 &mut warnings,
518 TruingUncertaintyWarningCodeV1::OptimizerDidNotConverge,
519 format!(
520 "joint MV+BC MAP optimizer stopped after {iterations} iterations with scaled gradient {gradient_norm:.3e} above {MAP_SCALED_GRADIENT_TOLERANCE:.1e}"
521 ),
522 );
523 } else if fit.convergence_criterion == Some(TruingMapConvergenceCriterionV1::ObjectiveMesh) {
524 let radius = fit.objective_poll_radius.unwrap_or(f64::NAN);
525 let improvement = fit.max_objective_poll_improvement.unwrap_or(f64::NAN);
526 push_warning(
527 &mut warnings,
528 TruingUncertaintyWarningCodeV1::ObjectiveMeshConvergence,
529 format!(
530 "LM's broad-stencil gradient test stalled (final norm {:.3e}); a direct-objective pattern search found no penalized-chi-square improvement above {:.1e} at scaled radius {radius:.1e} (largest observed {improvement:.3e})",
531 evaluation.gradient[0]
532 .abs()
533 .max(evaluation.gradient[1].abs()),
534 MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE,
535 ),
536 );
537 }
538 if evaluation.bc_sensitivity_ratio < TRUING_MIN_BC_SENSITIVITY_RATIO {
539 push_warning(
540 &mut warnings,
541 TruingUncertaintyWarningCodeV1::WeakBcSensitivity,
542 format!(
543 "observations weakly constrain BC: fractional sensitivity ratio {:.4} is below {:.2}",
544 evaluation.bc_sensitivity_ratio, TRUING_MIN_BC_SENSITIVITY_RATIO
545 ),
546 );
547 }
548 if !evaluation.data_condition_number.is_finite()
549 || evaluation.data_condition_number > TRUING_MAX_CONDITION_NUMBER
550 {
551 push_warning(
552 &mut warnings,
553 TruingUncertaintyWarningCodeV1::IllConditionedData,
554 format!(
555 "weighted observation Jacobian cannot cleanly separate MV from BC (condition {:.3e})",
556 evaluation.data_condition_number
557 ),
558 );
559 }
560
561 let at_bound = parameter_at_bound(map_mv, map_bc);
562 let approximation_result = if !converged {
563 Err(TruingApproximationFailureV1 {
564 code: TruingApproximationFailureCodeV1::OptimizerDidNotConverge,
565 message: "MAP optimizer did not converge; covariance around an unverified stationary point is withheld".to_string(),
566 })
567 } else if at_bound {
568 Err(TruingApproximationFailureV1 {
569 code: TruingApproximationFailureCodeV1::MapAtParameterBound,
570 message: "MAP lies on or numerically near a fit bound; an unconstrained Gaussian approximation would be misleading".to_string(),
571 })
572 } else {
573 build_approximation(map_mv, map_bc, evaluation.posterior_information)
574 };
575
576 let (approximation, covariance_q) = match approximation_result {
577 Ok((gaussian, covariance_q)) => {
578 warn_prior_dominance(request, &gaussian, &mut warnings);
579 if gaussian.muzzle_velocity_interval_95.lower < TRUING_MV_MIN_FPS
580 || gaussian.muzzle_velocity_interval_95.upper > TRUING_MV_MAX_FPS
581 || gaussian.ballistic_coefficient_interval_95.lower < TRUING_BC_MIN
582 || gaussian.ballistic_coefficient_interval_95.upper > TRUING_BC_MAX
583 {
584 push_warning(
585 &mut warnings,
586 TruingUncertaintyWarningCodeV1::IntervalCrossesFitBounds,
587 "local Gaussian interval crosses a constrained fit bound; interpret its tails cautiously".to_string(),
588 );
589 }
590 (
591 TruingApproximationV1::Available(gaussian),
592 Some(covariance_q),
593 )
594 }
595 Err(failure) => {
596 push_warning(
597 &mut warnings,
598 TruingUncertaintyWarningCodeV1::GaussianApproximationUnavailable,
599 failure.message.clone(),
600 );
601 (TruingApproximationV1::Unavailable(failure), None)
602 }
603 };
604
605 let effective_parameter_count = covariance_q.map(|covariance| {
606 (evaluation.data_information.a00 * covariance.a00
607 + 2.0 * evaluation.data_information.a01 * covariance.a01
608 + evaluation.data_information.a11 * covariance.a11)
609 .clamp(0.0, 2.0)
610 });
611 let effective_degrees_of_freedom =
612 effective_parameter_count.map(|count| request.observations.len() as f64 - count);
613 let reduced_chi_square = effective_degrees_of_freedom
614 .filter(|dof| *dof > f64::EPSILON)
615 .map(|dof| evaluation.chi_square / dof);
616 if effective_degrees_of_freedom.is_some_and(|dof| dof <= 1.0) {
617 push_warning(
618 &mut warnings,
619 TruingUncertaintyWarningCodeV1::LowEffectiveDegreesOfFreedom,
620 "one or fewer effective residual degrees of freedom: residual fit quality is weakly validated".to_string(),
621 );
622 }
623
624 let predictive_bands =
625 build_predictive_bands(request, model, map_mv, map_bc, covariance_q, &mut warnings)?;
626
627 let penalized_chi_square = evaluation.penalized_chi_square();
628
629 Ok(UncertaintyTruingReportV1 {
630 schema_version: TRUING_UNCERTAINTY_SCHEMA_VERSION_V1,
631 drop_unit: request.drop_unit,
632 map_muzzle_velocity_fps: map_mv,
633 map_ballistic_coefficient: map_bc,
634 iterations,
635 converged,
636 priors: request.priors,
637 observations: evaluation.observation_results,
638 baseline: baseline_at_entered_parameters(request, model),
639 diagnostics: TruingUncertaintyDiagnosticsV1 {
640 chi_square: evaluation.chi_square,
641 prior_penalty: evaluation.prior_penalty,
642 penalized_chi_square,
643 effective_parameter_count,
644 effective_degrees_of_freedom,
645 reduced_chi_square,
646 bc_sensitivity_ratio: evaluation.bc_sensitivity_ratio,
647 data_condition_number: evaluation.data_condition_number,
648 map_scaled_gradient_inf_norm: evaluation.gradient[0]
649 .abs()
650 .max(evaluation.gradient[1].abs()),
651 map_convergence_criterion: fit.convergence_criterion,
652 map_objective_poll_radius: fit.objective_poll_radius,
653 map_max_objective_poll_improvement: fit.max_objective_poll_improvement,
654 map_objective_poll_evaluations: fit.objective_poll_evaluations,
655 },
656 approximation,
657 predictive_bands,
658 warnings,
659 })
660}
661
662fn validate_request(request: &UncertaintyTruingRequestV1) -> Result<(), UncertaintyTruingErrorV1> {
663 request
664 .model
665 .validate()
666 .map_err(UncertaintyTruingErrorV1::InvalidInput)?;
667 if request.observations.len() < 2 {
668 return Err(UncertaintyTruingErrorV1::InvalidInput(
669 "at least two weighted observations are required for a joint MV+BC fit".to_string(),
670 ));
671 }
672 for (index, observation) in request.observations.iter().enumerate() {
673 if !observation.range_yd.is_finite() || observation.range_yd <= 0.0 {
674 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
675 "observation {} range must be positive and finite",
676 index + 1
677 )));
678 }
679 if !observation.drop.is_finite() {
680 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
681 "observation {} drop must be finite",
682 index + 1
683 )));
684 }
685 if !observation.sigma.is_finite() || observation.sigma <= 0.0 {
686 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
687 "observation {} sigma must be positive and finite",
688 index + 1
689 )));
690 }
691 }
692 validate_prior(
693 "muzzle-velocity",
694 request.priors.muzzle_velocity_fps,
695 TRUING_MV_MIN_FPS,
696 TRUING_MV_MAX_FPS,
697 )?;
698 validate_prior(
699 "ballistic-coefficient",
700 request.priors.ballistic_coefficient,
701 TRUING_BC_MIN,
702 TRUING_BC_MAX,
703 )?;
704 for (index, prediction) in request.predictions.iter().enumerate() {
705 if !prediction.range_yd.is_finite() || prediction.range_yd <= 0.0 {
706 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
707 "prediction {} range must be positive and finite",
708 index + 1
709 )));
710 }
711 if prediction
712 .future_observation_sigma
713 .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
714 {
715 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
716 "prediction {} future-observation sigma must be positive and finite",
717 index + 1
718 )));
719 }
720 }
721 Ok(())
722}
723
724fn validate_prior(
725 name: &str,
726 prior: Option<NormalPriorV1>,
727 lower: f64,
728 upper: f64,
729) -> Result<(), UncertaintyTruingErrorV1> {
730 let Some(prior) = prior else {
731 return Ok(());
732 };
733 if !prior.mean.is_finite() || !(lower..=upper).contains(&prior.mean) {
734 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
735 "{name} prior mean must be finite and within {lower}..={upper}"
736 )));
737 }
738 if !prior.sigma.is_finite() || prior.sigma <= 0.0 {
739 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
740 "{name} prior sigma must be positive and finite"
741 )));
742 }
743 Ok(())
744}
745
746fn fit_map(
747 request: &UncertaintyTruingRequestV1,
748 model: &TruingForwardModel<'_>,
749) -> Result<MapFitResult, UncertaintyTruingErrorV1> {
750 let mut mv = request.model.muzzle_velocity_fps;
751 let mut bc = request.model.ballistic_coefficient;
752 let mut lambda = 1.0e-6;
753 let mut current = objective(request, model, mv, bc)?;
754 let mut iterations = 0;
755 let mut convergence_criterion = None;
756
757 for _ in 0..TRUING_UNCERTAINTY_MAX_ITERS_V1 {
758 iterations += 1;
759 let evaluation = evaluate(request, model, mv, bc)?;
760 if evaluation.gradient[0]
761 .abs()
762 .max(evaluation.gradient[1].abs())
763 <= MAP_SCALED_GRADIENT_TOLERANCE
764 {
765 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
766 break;
767 }
768
769 let mut accepted = false;
770 for _ in 0..30 {
771 let information = evaluation.posterior_information;
772 let damped = Symmetric2 {
773 a00: information.a00 + lambda * information.a00.max(1.0e-12),
774 a01: information.a01,
775 a11: information.a11 + lambda * information.a11.max(1.0e-12),
776 };
777 let determinant = damped.determinant();
778 if !determinant.is_finite() || determinant.abs() < 1.0e-24 {
779 lambda *= 10.0;
780 continue;
781 }
782 let delta_mv_coordinate = -(damped.a11 * evaluation.gradient[0]
783 - damped.a01 * evaluation.gradient[1])
784 / determinant;
785 let delta_bc_coordinate = -(-damped.a01 * evaluation.gradient[0]
786 + damped.a00 * evaluation.gradient[1])
787 / determinant;
788 let next_mv = (mv + MV_COORDINATE_SCALE_FPS * delta_mv_coordinate)
789 .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
790 let next_bc = (bc + BC_COORDINATE_SCALE * delta_bc_coordinate)
791 .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
792 let next = objective(request, model, next_mv, next_bc)?;
793 if objective_improvement_is_material(current, next) {
794 mv = next_mv;
795 bc = next_bc;
796 current = next;
797 lambda = (lambda * 0.5).max(1.0e-12);
798 accepted = true;
799 break;
800 }
801 if next_mv == mv
806 && next_bc == bc
807 && evaluation.gradient[0]
808 .abs()
809 .max(evaluation.gradient[1].abs())
810 <= MAP_SCALED_GRADIENT_TOLERANCE
811 {
812 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
813 break;
814 }
815 lambda *= 4.0;
816 }
817 if convergence_criterion.is_some() {
818 break;
819 }
820 if !accepted {
821 break;
822 }
823 }
824 if convergence_criterion.is_none() {
825 let final_evaluation = evaluate(request, model, mv, bc)?;
826 if final_evaluation.gradient[0]
827 .abs()
828 .max(final_evaluation.gradient[1].abs())
829 <= MAP_SCALED_GRADIENT_TOLERANCE
830 {
831 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
832 }
833 }
834
835 let mut objective_poll_radius = None;
836 let mut max_objective_poll_improvement = None;
837 let mut objective_poll_evaluations = 0;
838 if convergence_criterion.is_none() {
839 let poll = polish_and_verify_objective_mesh(request, model, mv, bc, current)?;
840 mv = poll.mv;
841 bc = poll.bc;
842 objective_poll_evaluations = poll.evaluations;
843 if poll.converged {
844 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ObjectiveMesh);
845 objective_poll_radius = Some(poll.final_radius);
846 max_objective_poll_improvement = Some(poll.max_final_improvement);
847 }
848 }
849
850 Ok(MapFitResult {
851 mv,
852 bc,
853 iterations,
854 convergence_criterion,
855 objective_poll_radius,
856 max_objective_poll_improvement,
857 objective_poll_evaluations,
858 })
859}
860
861fn objective_improvement_is_material(current: f64, candidate: f64) -> bool {
862 current - candidate > MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE
863}
864
865fn polish_and_verify_objective_mesh(
874 request: &UncertaintyTruingRequestV1,
875 model: &TruingForwardModel<'_>,
876 mut mv: f64,
877 mut bc: f64,
878 mut current: f64,
879) -> Result<ObjectivePollResult, UncertaintyTruingErrorV1> {
880 let information = evaluate(request, model, mv, bc)?.posterior_information;
881 let angle = 0.5 * (2.0 * information.a01).atan2(information.a00 - information.a11);
882 let (sin, cos) = angle.sin_cos();
883 let directions = [
886 (1.0, 0.0),
887 (-1.0, 0.0),
888 (0.0, 1.0),
889 (0.0, -1.0),
890 (cos, sin),
891 (-cos, -sin),
892 (-sin, cos),
893 (sin, -cos),
894 ];
895
896 let mut radius = MAP_OBJECTIVE_INITIAL_POLL_RADIUS;
897 let mut evaluations = 0;
898 let mut max_final_improvement = f64::INFINITY;
899 while evaluations < MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
900 let mut best = current;
901 let mut best_point = (mv, bc);
902 for (mv_direction, bc_direction) in directions {
903 if evaluations >= MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
904 break;
905 }
906 let candidate_mv = (mv + radius * mv_direction * MV_COORDINATE_SCALE_FPS)
907 .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
908 let candidate_bc = (bc + radius * bc_direction * BC_COORDINATE_SCALE)
909 .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
910 if candidate_mv == mv && candidate_bc == bc {
911 continue;
912 }
913 let candidate = objective(request, model, candidate_mv, candidate_bc)?;
914 evaluations += 1;
915 if candidate < best {
916 best = candidate;
917 best_point = (candidate_mv, candidate_bc);
918 }
919 }
920
921 let improvement = (current - best).max(0.0);
922 if objective_improvement_is_material(current, best) {
923 mv = best_point.0;
924 bc = best_point.1;
925 current = best;
926 continue;
927 }
928 max_final_improvement = improvement;
929 if radius <= MAP_OBJECTIVE_MIN_POLL_RADIUS {
930 return Ok(ObjectivePollResult {
931 mv,
932 bc,
933 converged: true,
934 final_radius: radius,
935 max_final_improvement,
936 evaluations,
937 });
938 }
939 radius = (radius * 0.25).max(MAP_OBJECTIVE_MIN_POLL_RADIUS);
940 }
941
942 Ok(ObjectivePollResult {
943 mv,
944 bc,
945 converged: false,
946 final_radius: radius,
947 max_final_improvement,
948 evaluations,
949 })
950}
951
952fn objective(
953 request: &UncertaintyTruingRequestV1,
954 model: &TruingForwardModel<'_>,
955 mv: f64,
956 bc: f64,
957) -> Result<f64, UncertaintyTruingErrorV1> {
958 let mut objective = 0.0;
959 let ranges: Vec<f64> = request
960 .observations
961 .iter()
962 .map(|observation| observation.range_yd)
963 .collect();
964 let predictions = model
965 .predict_many_in_unit(mv, bc, &ranges, request.drop_unit)
966 .map_err(forward_error)?;
967 for (observation, prediction) in request.observations.iter().zip(predictions) {
968 let prediction = prediction.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
969 let standardized = (prediction - observation.drop) / observation.sigma;
970 objective += standardized * standardized;
971 }
972 if let Some(prior) = request.priors.muzzle_velocity_fps {
973 let standardized = (mv - prior.mean) / prior.sigma;
974 objective += standardized * standardized;
975 }
976 if let Some(prior) = request.priors.ballistic_coefficient {
977 let standardized = (bc - prior.mean) / prior.sigma;
978 objective += standardized * standardized;
979 }
980 if objective.is_finite() {
981 Ok(objective)
982 } else {
983 Err(UncertaintyTruingErrorV1::ForwardModel(
984 "non-finite penalized chi-square".to_string(),
985 ))
986 }
987}
988
989fn evaluate(
990 request: &UncertaintyTruingRequestV1,
991 model: &TruingForwardModel<'_>,
992 mv: f64,
993 bc: f64,
994) -> Result<Evaluation, UncertaintyTruingErrorV1> {
995 let mut data_information = Symmetric2::default();
996 let mut gradient = [0.0, 0.0];
997 let mut results = Vec::with_capacity(request.observations.len());
998 let mut chi_square = 0.0;
999
1000 let (mut fractional_mv_norm2, mut fractional_bc_norm2): (f64, f64) = (0.0, 0.0);
1003 let ranges: Vec<f64> = request
1004 .observations
1005 .iter()
1006 .map(|observation| observation.range_yd)
1007 .collect();
1008 let rows =
1009 truing_jacobian_rows(model, mv, bc, &ranges, request.drop_unit).map_err(forward_error)?;
1010 for (observation, row) in request.observations.iter().zip(rows) {
1011 let row = row.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
1012 let residual = row.predicted_drop - observation.drop;
1013 let standardized_residual = residual / observation.sigma;
1014 let j_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS / observation.sigma;
1015 let j_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE / observation.sigma;
1016 data_information.a00 += j_mv * j_mv;
1017 data_information.a01 += j_mv * j_bc;
1018 data_information.a11 += j_bc * j_bc;
1019 gradient[0] += j_mv * standardized_residual;
1020 gradient[1] += j_bc * standardized_residual;
1021 chi_square += standardized_residual * standardized_residual;
1022 fractional_mv_norm2 += (row.d_drop_d_mv * mv / observation.sigma).powi(2);
1023 fractional_bc_norm2 += (row.d_drop_d_bc * bc / observation.sigma).powi(2);
1024 results.push(WeightedTruingObservationResultV1 {
1025 range_yd: observation.range_yd,
1026 observed_drop: observation.drop,
1027 sigma: observation.sigma,
1028 predicted_drop: row.predicted_drop,
1029 residual,
1030 standardized_residual,
1031 });
1032 }
1033
1034 let mut prior_information = Symmetric2::default();
1035 let mut prior_penalty = 0.0;
1036 if let Some(prior) = request.priors.muzzle_velocity_fps {
1037 let j = MV_COORDINATE_SCALE_FPS / prior.sigma;
1038 let standardized = (mv - prior.mean) / prior.sigma;
1039 prior_information.a00 += j * j;
1040 gradient[0] += j * standardized;
1041 prior_penalty += standardized * standardized;
1042 }
1043 if let Some(prior) = request.priors.ballistic_coefficient {
1044 let j = BC_COORDINATE_SCALE / prior.sigma;
1045 let standardized = (bc - prior.mean) / prior.sigma;
1046 prior_information.a11 += j * j;
1047 gradient[1] += j * standardized;
1048 prior_penalty += standardized * standardized;
1049 }
1050 let mut posterior_information = data_information;
1051 posterior_information.add_assign(prior_information);
1052
1053 let bc_sensitivity_ratio = if fractional_mv_norm2 > 0.0 {
1054 (fractional_bc_norm2 / fractional_mv_norm2).sqrt()
1055 } else {
1056 0.0
1057 };
1058 let data_condition_number = column_condition(data_information);
1059
1060 Ok(Evaluation {
1061 data_information,
1062 posterior_information,
1063 gradient,
1064 observation_results: results,
1065 chi_square,
1066 prior_penalty,
1067 bc_sensitivity_ratio,
1068 data_condition_number,
1069 })
1070}
1071
1072fn baseline_at_entered_parameters(
1084 request: &UncertaintyTruingRequestV1,
1085 model: &TruingForwardModel<'_>,
1086) -> Option<TruingBaselineV1> {
1087 let muzzle_velocity_fps = request.model.muzzle_velocity_fps;
1088 let ballistic_coefficient = request.model.ballistic_coefficient;
1089 let ranges: Vec<f64> = request
1090 .observations
1091 .iter()
1092 .map(|observation| observation.range_yd)
1093 .collect();
1094 let predictions = model
1095 .predict_many_in_unit(
1096 muzzle_velocity_fps,
1097 ballistic_coefficient,
1098 &ranges,
1099 request.drop_unit,
1100 )
1101 .ok()?;
1102
1103 let mut chi_square = 0.0;
1104 let mut observations = Vec::with_capacity(request.observations.len());
1105 for (observation, predicted_drop) in request.observations.iter().zip(predictions) {
1106 let predicted_drop = predicted_drop?;
1107 let residual = predicted_drop - observation.drop;
1108 let standardized_residual = residual / observation.sigma;
1109 chi_square += standardized_residual * standardized_residual;
1110 observations.push(WeightedTruingObservationResultV1 {
1111 range_yd: observation.range_yd,
1112 observed_drop: observation.drop,
1113 sigma: observation.sigma,
1114 predicted_drop,
1115 residual,
1116 standardized_residual,
1117 });
1118 }
1119 if !chi_square.is_finite() {
1120 return None;
1121 }
1122
1123 Some(TruingBaselineV1 {
1124 muzzle_velocity_fps,
1125 ballistic_coefficient,
1126 chi_square,
1127 observations,
1128 })
1129}
1130
1131fn column_condition(information: Symmetric2) -> f64 {
1132 if information.a00 <= 0.0 || information.a11 <= 0.0 {
1133 return f64::INFINITY;
1134 }
1135 let correlation = (information.a01 / (information.a00 * information.a11).sqrt())
1136 .clamp(-1.0, 1.0)
1137 .abs();
1138 if 1.0 - correlation <= 1.0e-15 {
1139 f64::INFINITY
1140 } else {
1141 (1.0 + correlation) / (1.0 - correlation)
1142 }
1143}
1144
1145fn build_approximation(
1146 mv: f64,
1147 bc: f64,
1148 information: Symmetric2,
1149) -> Result<(TruingGaussianApproximationV1, Symmetric2), TruingApproximationFailureV1> {
1150 let (covariance_q, condition) = information
1151 .inverse_with_condition()
1152 .map_err(|code| TruingApproximationFailureV1 {
1153 code,
1154 message: match code {
1155 TruingApproximationFailureCodeV1::OptimizerDidNotConverge => {
1156 "MAP optimizer did not converge".to_string()
1157 }
1158 TruingApproximationFailureCodeV1::MapAtParameterBound => {
1159 "MAP is at a constrained parameter bound".to_string()
1160 }
1161 TruingApproximationFailureCodeV1::RankDeficientInformation => {
1162 "likelihood-plus-prior information is rank deficient or numerically singular; collect more separated ranges or add an explicit prior".to_string()
1163 }
1164 TruingApproximationFailureCodeV1::NonFiniteInformation => {
1165 "likelihood-plus-prior information or its inverse is non-finite".to_string()
1166 }
1167 },
1168 })?;
1169 let covariance = TruingCovarianceV1 {
1170 mv_variance_fps2: covariance_q.a00 * MV_COORDINATE_SCALE_FPS.powi(2),
1171 mv_bc_covariance_fps: covariance_q.a01 * MV_COORDINATE_SCALE_FPS * BC_COORDINATE_SCALE,
1172 bc_variance: covariance_q.a11 * BC_COORDINATE_SCALE.powi(2),
1173 };
1174 let mv_interval = GaussianIntervalV1::from_variance(mv, covariance.mv_variance_fps2)
1175 .ok_or_else(|| TruingApproximationFailureV1 {
1176 code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1177 message: "MV marginal variance is invalid".to_string(),
1178 })?;
1179 let bc_interval =
1180 GaussianIntervalV1::from_variance(bc, covariance.bc_variance).ok_or_else(|| {
1181 TruingApproximationFailureV1 {
1182 code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1183 message: "BC marginal variance is invalid".to_string(),
1184 }
1185 })?;
1186 let correlation = covariance.mv_bc_covariance_fps
1187 / (covariance.mv_variance_fps2 * covariance.bc_variance).sqrt();
1188 if !correlation.is_finite() {
1189 return Err(TruingApproximationFailureV1 {
1190 code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1191 message: "MV/BC posterior correlation is non-finite".to_string(),
1192 });
1193 }
1194 Ok((
1195 TruingGaussianApproximationV1 {
1196 covariance,
1197 muzzle_velocity_interval_95: mv_interval,
1198 ballistic_coefficient_interval_95: bc_interval,
1199 mv_bc_correlation: correlation.clamp(-1.0, 1.0),
1200 scaled_information_condition_number: condition,
1201 },
1202 covariance_q,
1203 ))
1204}
1205
1206fn build_predictive_bands(
1207 request: &UncertaintyTruingRequestV1,
1208 model: &TruingForwardModel<'_>,
1209 mv: f64,
1210 bc: f64,
1211 covariance_q: Option<Symmetric2>,
1212 warnings: &mut Vec<TruingUncertaintyWarningV1>,
1213) -> Result<Vec<TruingPredictiveBandV1>, UncertaintyTruingErrorV1> {
1214 let observed_min = request
1215 .observations
1216 .iter()
1217 .map(|observation| observation.range_yd)
1218 .fold(f64::INFINITY, f64::min);
1219 let observed_max = request
1220 .observations
1221 .iter()
1222 .map(|observation| observation.range_yd)
1223 .fold(f64::NEG_INFINITY, f64::max);
1224 let mut warned_extrapolation = false;
1225 let mut bands = Vec::with_capacity(request.predictions.len());
1226 let prediction_ranges: Vec<f64> = request
1227 .predictions
1228 .iter()
1229 .map(|prediction| prediction.range_yd)
1230 .collect();
1231 let rows = truing_jacobian_rows(model, mv, bc, &prediction_ranges, request.drop_unit)
1232 .map_err(forward_error)?;
1233 for (prediction, row) in request.predictions.iter().zip(rows) {
1234 let row = row.ok_or_else(|| unreachable_range_error(prediction.range_yd))?;
1235 if !warned_extrapolation
1236 && (prediction.range_yd < observed_min || prediction.range_yd > observed_max)
1237 {
1238 push_warning(
1239 warnings,
1240 TruingUncertaintyWarningCodeV1::PredictionOutsideObservedDomain,
1241 "one or more predictive ranges lie outside the observed range domain; local linear uncertainty may understate nonlinear extrapolation risk".to_string(),
1242 );
1243 warned_extrapolation = true;
1244 }
1245 let latent_interval_95 = covariance_q.and_then(|covariance| {
1246 let g_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS;
1247 let g_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE;
1248 let variance = g_mv * g_mv * covariance.a00
1249 + 2.0 * g_mv * g_bc * covariance.a01
1250 + g_bc * g_bc * covariance.a11;
1251 let tolerance = 1.0e-12
1254 * (g_mv * g_mv * covariance.a00)
1255 .abs()
1256 .max((g_bc * g_bc * covariance.a11).abs())
1257 .max(1.0);
1258 let variance = if variance >= 0.0 {
1259 variance
1260 } else if variance >= -tolerance {
1261 0.0
1262 } else {
1263 return None;
1264 };
1265 GaussianIntervalV1::from_variance(row.predicted_drop, variance)
1266 });
1267 let future_observation_interval_95 =
1268 match (latent_interval_95, prediction.future_observation_sigma) {
1269 (Some(latent), Some(sigma)) => GaussianIntervalV1::from_variance(
1270 row.predicted_drop,
1271 latent.standard_deviation.powi(2) + sigma.powi(2),
1272 ),
1273 _ => None,
1274 };
1275 bands.push(TruingPredictiveBandV1 {
1276 range_yd: prediction.range_yd,
1277 predicted_drop: row.predicted_drop,
1278 latent_interval_95,
1279 future_observation_interval_95,
1280 });
1281 }
1282 Ok(bands)
1283}
1284
1285fn parameter_at_bound(mv: f64, bc: f64) -> bool {
1286 let mv_tolerance = 1.0e-6 * (TRUING_MV_MAX_FPS - TRUING_MV_MIN_FPS);
1287 let bc_tolerance = 1.0e-6 * (TRUING_BC_MAX - TRUING_BC_MIN);
1288 mv - TRUING_MV_MIN_FPS <= mv_tolerance
1289 || TRUING_MV_MAX_FPS - mv <= mv_tolerance
1290 || bc - TRUING_BC_MIN <= bc_tolerance
1291 || TRUING_BC_MAX - bc <= bc_tolerance
1292}
1293
1294fn warn_prior_dominance(
1295 request: &UncertaintyTruingRequestV1,
1296 approximation: &TruingGaussianApproximationV1,
1297 warnings: &mut Vec<TruingUncertaintyWarningV1>,
1298) {
1299 if let Some(prior) = request.priors.muzzle_velocity_fps {
1300 if approximation.covariance.mv_variance_fps2 >= 0.8 * prior.sigma.powi(2) {
1301 push_warning(
1302 warnings,
1303 TruingUncertaintyWarningCodeV1::MvPriorDominated,
1304 "MV posterior width remains close to its explicit prior width; observations add little marginal MV information".to_string(),
1305 );
1306 }
1307 }
1308 if let Some(prior) = request.priors.ballistic_coefficient {
1309 if approximation.covariance.bc_variance >= 0.8 * prior.sigma.powi(2) {
1310 push_warning(
1311 warnings,
1312 TruingUncertaintyWarningCodeV1::BcPriorDominated,
1313 "BC posterior width remains close to its explicit prior width; observations add little marginal BC information".to_string(),
1314 );
1315 }
1316 }
1317}
1318
1319fn push_warning(
1320 warnings: &mut Vec<TruingUncertaintyWarningV1>,
1321 code: TruingUncertaintyWarningCodeV1,
1322 message: String,
1323) {
1324 if warnings.iter().any(|warning| warning.code == code) {
1325 return;
1326 }
1327 warnings.push(TruingUncertaintyWarningV1 { code, message });
1328}
1329
1330fn forward_error(error: Box<dyn std::error::Error>) -> UncertaintyTruingErrorV1 {
1331 UncertaintyTruingErrorV1::ForwardModel(error.to_string())
1332}
1333
1334fn unreachable_range_error(range_yd: f64) -> UncertaintyTruingErrorV1 {
1335 UncertaintyTruingErrorV1::ForwardModel(format!(
1336 "trajectory did not reach requested range {range_yd:.3} yd"
1337 ))
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343
1344 #[test]
1345 fn request_deserializes_from_the_wire_and_rejects_unknown_fields() {
1346 let json = serde_json::json!({
1347 "model": {
1348 "muzzle_velocity_fps": 2700.0, "ballistic_coefficient": 0.243,
1349 "drag_model": "g7", "mass_gr": 168.0, "diameter_in": 0.308,
1350 "zero_distance_yd": 100.0, "sight_height_in": 2.0,
1351 "temperature_f": 59.0, "pressure_inhg": 29.92,
1352 "humidity_pct": 50.0, "altitude_ft": 0.0
1353 },
1354 "drop_unit": "mil",
1355 "observations": [{"range_yd": 600.0, "drop": 4.2, "sigma": 0.1}],
1356 "priors": {"muzzle_velocity_fps": {"mean": 2700.0, "sigma": 15.0},
1357 "ballistic_coefficient": null},
1358 "predictions": [{"range_yd": 800.0, "future_observation_sigma": null}]
1359 });
1360 let req: UncertaintyTruingRequestV1 =
1361 serde_json::from_value(json.clone()).expect("valid request deserializes");
1362 assert_eq!(req.observations.len(), 1);
1363 assert_eq!(req.model.muzzle_velocity_fps, 2700.0);
1364
1365 let mut bad = json;
1367 bad["observations"][0]["sigmaa"] = serde_json::json!(0.2);
1368 assert!(serde_json::from_value::<UncertaintyTruingRequestV1>(bad).is_err());
1369 }
1370}