1use serde::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)]
60pub struct WeightedTruingObservationV1 {
61 pub range_yd: f64,
62 pub drop: f64,
63 pub sigma: f64,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
68pub struct NormalPriorV1 {
69 pub mean: f64,
70 pub sigma: f64,
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
78pub struct TruingPriorsV1 {
79 pub muzzle_velocity_fps: Option<NormalPriorV1>,
80 pub ballistic_coefficient: Option<NormalPriorV1>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
85pub struct TruingPredictionRequestV1 {
86 pub range_yd: f64,
87 pub future_observation_sigma: Option<f64>,
91}
92
93#[derive(Debug, Clone, Serialize)]
95pub struct UncertaintyTruingRequestV1 {
96 pub model: TruingModelInputsV1,
97 pub drop_unit: DropUnit,
98 pub observations: Vec<WeightedTruingObservationV1>,
99 pub priors: TruingPriorsV1,
100 pub predictions: Vec<TruingPredictionRequestV1>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
105pub struct GaussianIntervalV1 {
106 pub estimate: f64,
107 pub standard_deviation: f64,
108 pub lower: f64,
109 pub upper: f64,
110 pub probability: f64,
111}
112
113impl GaussianIntervalV1 {
114 fn from_variance(estimate: f64, variance: f64) -> Option<Self> {
115 if !estimate.is_finite() || !variance.is_finite() || variance < 0.0 {
116 return None;
117 }
118 let standard_deviation = variance.sqrt();
119 let half_width = NORMAL_95_TWO_SIDED_Z * standard_deviation;
120 Some(Self {
121 estimate,
122 standard_deviation,
123 lower: estimate - half_width,
124 upper: estimate + half_width,
125 probability: TRUING_UNCERTAINTY_INTERVAL_LEVEL_V1,
126 })
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
132pub struct TruingCovarianceV1 {
133 pub mv_variance_fps2: f64,
134 pub mv_bc_covariance_fps: f64,
135 pub bc_variance: f64,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
140pub struct TruingGaussianApproximationV1 {
141 pub covariance: TruingCovarianceV1,
142 pub muzzle_velocity_interval_95: GaussianIntervalV1,
143 pub ballistic_coefficient_interval_95: GaussianIntervalV1,
144 pub mv_bc_correlation: f64,
146 pub scaled_information_condition_number: f64,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "snake_case")]
155pub enum TruingApproximationFailureCodeV1 {
156 OptimizerDidNotConverge,
157 MapAtParameterBound,
158 RankDeficientInformation,
159 NonFiniteInformation,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct TruingApproximationFailureV1 {
166 pub code: TruingApproximationFailureCodeV1,
167 pub message: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize)]
172#[serde(rename_all = "snake_case", tag = "status", content = "details")]
173pub enum TruingApproximationV1 {
174 Available(TruingGaussianApproximationV1),
175 Unavailable(TruingApproximationFailureV1),
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
180pub struct WeightedTruingObservationResultV1 {
181 pub range_yd: f64,
182 pub observed_drop: f64,
183 pub sigma: f64,
184 pub predicted_drop: f64,
185 pub residual: f64,
186 pub standardized_residual: f64,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
191pub struct TruingPredictiveBandV1 {
192 pub range_yd: f64,
193 pub predicted_drop: f64,
194 pub latent_interval_95: Option<GaussianIntervalV1>,
197 pub future_observation_interval_95: Option<GaussianIntervalV1>,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
205#[serde(rename_all = "snake_case")]
206pub enum TruingUncertaintyWarningCodeV1 {
207 OptimizerDidNotConverge,
208 ObjectiveMeshConvergence,
209 WeakBcSensitivity,
210 IllConditionedData,
211 MvPriorDominated,
212 BcPriorDominated,
213 GaussianApproximationUnavailable,
214 IntervalCrossesFitBounds,
215 LowEffectiveDegreesOfFreedom,
216 PredictionOutsideObservedDomain,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "snake_case")]
222pub enum TruingMapConvergenceCriterionV1 {
223 ScaledGradient,
225 ObjectiveMesh,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
232pub struct TruingUncertaintyWarningV1 {
233 pub code: TruingUncertaintyWarningCodeV1,
234 pub message: String,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
239pub struct TruingUncertaintyDiagnosticsV1 {
240 pub chi_square: f64,
242 pub prior_penalty: f64,
244 pub penalized_chi_square: f64,
246 pub effective_parameter_count: Option<f64>,
249 pub effective_degrees_of_freedom: Option<f64>,
251 pub reduced_chi_square: Option<f64>,
253 pub bc_sensitivity_ratio: f64,
255 pub data_condition_number: f64,
257 pub map_scaled_gradient_inf_norm: f64,
265 pub map_convergence_criterion: Option<TruingMapConvergenceCriterionV1>,
268 pub map_objective_poll_radius: Option<f64>,
272 pub map_max_objective_poll_improvement: Option<f64>,
276 pub map_objective_poll_evaluations: usize,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize)]
282pub struct UncertaintyTruingReportV1 {
283 pub schema_version: u32,
284 pub drop_unit: DropUnit,
285 pub map_muzzle_velocity_fps: f64,
286 pub map_ballistic_coefficient: f64,
287 pub iterations: usize,
288 pub converged: bool,
289 pub priors: TruingPriorsV1,
290 pub observations: Vec<WeightedTruingObservationResultV1>,
291 pub diagnostics: TruingUncertaintyDiagnosticsV1,
292 pub approximation: TruingApproximationV1,
293 pub predictive_bands: Vec<TruingPredictiveBandV1>,
294 pub warnings: Vec<TruingUncertaintyWarningV1>,
295}
296
297#[derive(Debug, Error, Clone, PartialEq, Eq)]
299pub enum UncertaintyTruingErrorV1 {
300 #[error("invalid uncertainty-truing request: {0}")]
301 InvalidInput(String),
302 #[error("truing forward model failed: {0}")]
303 ForwardModel(String),
304}
305
306#[derive(Debug, Clone, Copy, Default)]
319pub(crate) struct Symmetric2 {
320 pub(crate) a00: f64,
321 pub(crate) a01: f64,
322 pub(crate) a11: f64,
323}
324
325impl Symmetric2 {
326 fn determinant(self) -> f64 {
327 self.a00 * self.a11 - self.a01 * self.a01
328 }
329
330 pub(crate) fn add_assign(&mut self, rhs: Self) {
331 self.a00 += rhs.a00;
332 self.a01 += rhs.a01;
333 self.a11 += rhs.a11;
334 }
335
336 pub(crate) fn largest_smallest_eigenvalues(self) -> (f64, f64) {
354 let trace = self.a00 + self.a11;
355 let discriminant = (self.a00 - self.a11).hypot(2.0 * self.a01);
356 let largest = (0.5 * (trace + discriminant)).max(0.0);
357 let determinant = self.determinant();
358 let smallest = if largest > 0.0 {
359 (determinant / largest).max(0.0)
360 } else {
361 0.0
362 };
363 (largest, smallest)
364 }
365
366 fn inverse_with_condition(self) -> Result<(Self, f64), TruingApproximationFailureCodeV1> {
367 if !self.a00.is_finite() || !self.a01.is_finite() || !self.a11.is_finite() {
368 return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
369 }
370 let trace = self.a00 + self.a11;
371 let discriminant = (self.a00 - self.a11).hypot(2.0 * self.a01);
372 let largest = 0.5 * (trace + discriminant);
373 let determinant = self.determinant();
374 if !largest.is_finite() || !determinant.is_finite() {
375 return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
376 }
377 if largest <= 0.0 || determinant <= 0.0 {
378 return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
379 }
380 let smallest = determinant / largest;
383 if !smallest.is_finite()
384 || smallest <= 0.0
385 || smallest / largest <= INFORMATION_RELATIVE_EIGEN_TOLERANCE
386 {
387 return Err(TruingApproximationFailureCodeV1::RankDeficientInformation);
388 }
389 let inverse = Self {
390 a00: self.a11 / determinant,
391 a01: -self.a01 / determinant,
392 a11: self.a00 / determinant,
393 };
394 if !inverse.a00.is_finite() || !inverse.a01.is_finite() || !inverse.a11.is_finite() {
395 return Err(TruingApproximationFailureCodeV1::NonFiniteInformation);
396 }
397 Ok((inverse, largest / smallest))
398 }
399}
400
401struct Evaluation {
402 data_information: Symmetric2,
403 posterior_information: Symmetric2,
404 gradient: [f64; 2],
405 observation_results: Vec<WeightedTruingObservationResultV1>,
406 chi_square: f64,
407 prior_penalty: f64,
408 bc_sensitivity_ratio: f64,
409 data_condition_number: f64,
410}
411
412#[derive(Debug, Clone, Copy)]
413struct MapFitResult {
414 mv: f64,
415 bc: f64,
416 iterations: usize,
417 convergence_criterion: Option<TruingMapConvergenceCriterionV1>,
418 objective_poll_radius: Option<f64>,
419 max_objective_poll_improvement: Option<f64>,
420 objective_poll_evaluations: usize,
421}
422
423#[derive(Debug, Clone, Copy)]
424struct ObjectivePollResult {
425 mv: f64,
426 bc: f64,
427 converged: bool,
428 final_radius: f64,
429 max_final_improvement: f64,
430 evaluations: usize,
431}
432
433impl Evaluation {
434 fn penalized_chi_square(&self) -> f64 {
435 self.chi_square + self.prior_penalty
436 }
437}
438
439pub fn run_uncertainty_truing_v1(
447 request: &UncertaintyTruingRequestV1,
448) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
449 validate_request(request)?;
450
451 request
452 .model
453 .with_forward_model(request.drop_unit, |model| run_with_model(request, model))
454}
455
456fn run_with_model(
457 request: &UncertaintyTruingRequestV1,
458 model: &TruingForwardModel<'_>,
459) -> Result<UncertaintyTruingReportV1, UncertaintyTruingErrorV1> {
460 let fit = fit_map(request, model)?;
461 let map_mv = fit.mv;
462 let map_bc = fit.bc;
463 let iterations = fit.iterations;
464 let converged = fit.convergence_criterion.is_some();
465 let evaluation = evaluate(request, model, map_mv, map_bc)?;
466 let mut warnings = Vec::new();
467
468 if !converged {
469 let gradient_norm = evaluation.gradient[0]
470 .abs()
471 .max(evaluation.gradient[1].abs());
472 push_warning(
473 &mut warnings,
474 TruingUncertaintyWarningCodeV1::OptimizerDidNotConverge,
475 format!(
476 "joint MV+BC MAP optimizer stopped after {iterations} iterations with scaled gradient {gradient_norm:.3e} above {MAP_SCALED_GRADIENT_TOLERANCE:.1e}"
477 ),
478 );
479 } else if fit.convergence_criterion == Some(TruingMapConvergenceCriterionV1::ObjectiveMesh) {
480 let radius = fit.objective_poll_radius.unwrap_or(f64::NAN);
481 let improvement = fit.max_objective_poll_improvement.unwrap_or(f64::NAN);
482 push_warning(
483 &mut warnings,
484 TruingUncertaintyWarningCodeV1::ObjectiveMeshConvergence,
485 format!(
486 "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})",
487 evaluation.gradient[0]
488 .abs()
489 .max(evaluation.gradient[1].abs()),
490 MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE,
491 ),
492 );
493 }
494 if evaluation.bc_sensitivity_ratio < TRUING_MIN_BC_SENSITIVITY_RATIO {
495 push_warning(
496 &mut warnings,
497 TruingUncertaintyWarningCodeV1::WeakBcSensitivity,
498 format!(
499 "observations weakly constrain BC: fractional sensitivity ratio {:.4} is below {:.2}",
500 evaluation.bc_sensitivity_ratio, TRUING_MIN_BC_SENSITIVITY_RATIO
501 ),
502 );
503 }
504 if !evaluation.data_condition_number.is_finite()
505 || evaluation.data_condition_number > TRUING_MAX_CONDITION_NUMBER
506 {
507 push_warning(
508 &mut warnings,
509 TruingUncertaintyWarningCodeV1::IllConditionedData,
510 format!(
511 "weighted observation Jacobian cannot cleanly separate MV from BC (condition {:.3e})",
512 evaluation.data_condition_number
513 ),
514 );
515 }
516
517 let at_bound = parameter_at_bound(map_mv, map_bc);
518 let approximation_result = if !converged {
519 Err(TruingApproximationFailureV1 {
520 code: TruingApproximationFailureCodeV1::OptimizerDidNotConverge,
521 message: "MAP optimizer did not converge; covariance around an unverified stationary point is withheld".to_string(),
522 })
523 } else if at_bound {
524 Err(TruingApproximationFailureV1 {
525 code: TruingApproximationFailureCodeV1::MapAtParameterBound,
526 message: "MAP lies on or numerically near a fit bound; an unconstrained Gaussian approximation would be misleading".to_string(),
527 })
528 } else {
529 build_approximation(map_mv, map_bc, evaluation.posterior_information)
530 };
531
532 let (approximation, covariance_q) = match approximation_result {
533 Ok((gaussian, covariance_q)) => {
534 warn_prior_dominance(request, &gaussian, &mut warnings);
535 if gaussian.muzzle_velocity_interval_95.lower < TRUING_MV_MIN_FPS
536 || gaussian.muzzle_velocity_interval_95.upper > TRUING_MV_MAX_FPS
537 || gaussian.ballistic_coefficient_interval_95.lower < TRUING_BC_MIN
538 || gaussian.ballistic_coefficient_interval_95.upper > TRUING_BC_MAX
539 {
540 push_warning(
541 &mut warnings,
542 TruingUncertaintyWarningCodeV1::IntervalCrossesFitBounds,
543 "local Gaussian interval crosses a constrained fit bound; interpret its tails cautiously".to_string(),
544 );
545 }
546 (
547 TruingApproximationV1::Available(gaussian),
548 Some(covariance_q),
549 )
550 }
551 Err(failure) => {
552 push_warning(
553 &mut warnings,
554 TruingUncertaintyWarningCodeV1::GaussianApproximationUnavailable,
555 failure.message.clone(),
556 );
557 (TruingApproximationV1::Unavailable(failure), None)
558 }
559 };
560
561 let effective_parameter_count = covariance_q.map(|covariance| {
562 (evaluation.data_information.a00 * covariance.a00
563 + 2.0 * evaluation.data_information.a01 * covariance.a01
564 + evaluation.data_information.a11 * covariance.a11)
565 .clamp(0.0, 2.0)
566 });
567 let effective_degrees_of_freedom =
568 effective_parameter_count.map(|count| request.observations.len() as f64 - count);
569 let reduced_chi_square = effective_degrees_of_freedom
570 .filter(|dof| *dof > f64::EPSILON)
571 .map(|dof| evaluation.chi_square / dof);
572 if effective_degrees_of_freedom.is_some_and(|dof| dof <= 1.0) {
573 push_warning(
574 &mut warnings,
575 TruingUncertaintyWarningCodeV1::LowEffectiveDegreesOfFreedom,
576 "one or fewer effective residual degrees of freedom: residual fit quality is weakly validated".to_string(),
577 );
578 }
579
580 let predictive_bands =
581 build_predictive_bands(request, model, map_mv, map_bc, covariance_q, &mut warnings)?;
582
583 let penalized_chi_square = evaluation.penalized_chi_square();
584
585 Ok(UncertaintyTruingReportV1 {
586 schema_version: TRUING_UNCERTAINTY_SCHEMA_VERSION_V1,
587 drop_unit: request.drop_unit,
588 map_muzzle_velocity_fps: map_mv,
589 map_ballistic_coefficient: map_bc,
590 iterations,
591 converged,
592 priors: request.priors,
593 observations: evaluation.observation_results,
594 diagnostics: TruingUncertaintyDiagnosticsV1 {
595 chi_square: evaluation.chi_square,
596 prior_penalty: evaluation.prior_penalty,
597 penalized_chi_square,
598 effective_parameter_count,
599 effective_degrees_of_freedom,
600 reduced_chi_square,
601 bc_sensitivity_ratio: evaluation.bc_sensitivity_ratio,
602 data_condition_number: evaluation.data_condition_number,
603 map_scaled_gradient_inf_norm: evaluation.gradient[0]
604 .abs()
605 .max(evaluation.gradient[1].abs()),
606 map_convergence_criterion: fit.convergence_criterion,
607 map_objective_poll_radius: fit.objective_poll_radius,
608 map_max_objective_poll_improvement: fit.max_objective_poll_improvement,
609 map_objective_poll_evaluations: fit.objective_poll_evaluations,
610 },
611 approximation,
612 predictive_bands,
613 warnings,
614 })
615}
616
617fn validate_request(request: &UncertaintyTruingRequestV1) -> Result<(), UncertaintyTruingErrorV1> {
618 request
619 .model
620 .validate()
621 .map_err(UncertaintyTruingErrorV1::InvalidInput)?;
622 if request.observations.len() < 2 {
623 return Err(UncertaintyTruingErrorV1::InvalidInput(
624 "at least two weighted observations are required for a joint MV+BC fit".to_string(),
625 ));
626 }
627 for (index, observation) in request.observations.iter().enumerate() {
628 if !observation.range_yd.is_finite() || observation.range_yd <= 0.0 {
629 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
630 "observation {} range must be positive and finite",
631 index + 1
632 )));
633 }
634 if !observation.drop.is_finite() {
635 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
636 "observation {} drop must be finite",
637 index + 1
638 )));
639 }
640 if !observation.sigma.is_finite() || observation.sigma <= 0.0 {
641 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
642 "observation {} sigma must be positive and finite",
643 index + 1
644 )));
645 }
646 }
647 validate_prior(
648 "muzzle-velocity",
649 request.priors.muzzle_velocity_fps,
650 TRUING_MV_MIN_FPS,
651 TRUING_MV_MAX_FPS,
652 )?;
653 validate_prior(
654 "ballistic-coefficient",
655 request.priors.ballistic_coefficient,
656 TRUING_BC_MIN,
657 TRUING_BC_MAX,
658 )?;
659 for (index, prediction) in request.predictions.iter().enumerate() {
660 if !prediction.range_yd.is_finite() || prediction.range_yd <= 0.0 {
661 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
662 "prediction {} range must be positive and finite",
663 index + 1
664 )));
665 }
666 if prediction
667 .future_observation_sigma
668 .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
669 {
670 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
671 "prediction {} future-observation sigma must be positive and finite",
672 index + 1
673 )));
674 }
675 }
676 Ok(())
677}
678
679fn validate_prior(
680 name: &str,
681 prior: Option<NormalPriorV1>,
682 lower: f64,
683 upper: f64,
684) -> Result<(), UncertaintyTruingErrorV1> {
685 let Some(prior) = prior else {
686 return Ok(());
687 };
688 if !prior.mean.is_finite() || !(lower..=upper).contains(&prior.mean) {
689 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
690 "{name} prior mean must be finite and within {lower}..={upper}"
691 )));
692 }
693 if !prior.sigma.is_finite() || prior.sigma <= 0.0 {
694 return Err(UncertaintyTruingErrorV1::InvalidInput(format!(
695 "{name} prior sigma must be positive and finite"
696 )));
697 }
698 Ok(())
699}
700
701fn fit_map(
702 request: &UncertaintyTruingRequestV1,
703 model: &TruingForwardModel<'_>,
704) -> Result<MapFitResult, UncertaintyTruingErrorV1> {
705 let mut mv = request.model.muzzle_velocity_fps;
706 let mut bc = request.model.ballistic_coefficient;
707 let mut lambda = 1.0e-6;
708 let mut current = objective(request, model, mv, bc)?;
709 let mut iterations = 0;
710 let mut convergence_criterion = None;
711
712 for _ in 0..TRUING_UNCERTAINTY_MAX_ITERS_V1 {
713 iterations += 1;
714 let evaluation = evaluate(request, model, mv, bc)?;
715 if evaluation.gradient[0]
716 .abs()
717 .max(evaluation.gradient[1].abs())
718 <= MAP_SCALED_GRADIENT_TOLERANCE
719 {
720 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
721 break;
722 }
723
724 let mut accepted = false;
725 for _ in 0..30 {
726 let information = evaluation.posterior_information;
727 let damped = Symmetric2 {
728 a00: information.a00 + lambda * information.a00.max(1.0e-12),
729 a01: information.a01,
730 a11: information.a11 + lambda * information.a11.max(1.0e-12),
731 };
732 let determinant = damped.determinant();
733 if !determinant.is_finite() || determinant.abs() < 1.0e-24 {
734 lambda *= 10.0;
735 continue;
736 }
737 let delta_mv_coordinate = -(damped.a11 * evaluation.gradient[0]
738 - damped.a01 * evaluation.gradient[1])
739 / determinant;
740 let delta_bc_coordinate = -(-damped.a01 * evaluation.gradient[0]
741 + damped.a00 * evaluation.gradient[1])
742 / determinant;
743 let next_mv = (mv + MV_COORDINATE_SCALE_FPS * delta_mv_coordinate)
744 .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
745 let next_bc = (bc + BC_COORDINATE_SCALE * delta_bc_coordinate)
746 .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
747 let next = objective(request, model, next_mv, next_bc)?;
748 if objective_improvement_is_material(current, next) {
749 mv = next_mv;
750 bc = next_bc;
751 current = next;
752 lambda = (lambda * 0.5).max(1.0e-12);
753 accepted = true;
754 break;
755 }
756 if next_mv == mv
761 && next_bc == bc
762 && evaluation.gradient[0]
763 .abs()
764 .max(evaluation.gradient[1].abs())
765 <= MAP_SCALED_GRADIENT_TOLERANCE
766 {
767 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
768 break;
769 }
770 lambda *= 4.0;
771 }
772 if convergence_criterion.is_some() {
773 break;
774 }
775 if !accepted {
776 break;
777 }
778 }
779 if convergence_criterion.is_none() {
780 let final_evaluation = evaluate(request, model, mv, bc)?;
781 if final_evaluation.gradient[0]
782 .abs()
783 .max(final_evaluation.gradient[1].abs())
784 <= MAP_SCALED_GRADIENT_TOLERANCE
785 {
786 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ScaledGradient);
787 }
788 }
789
790 let mut objective_poll_radius = None;
791 let mut max_objective_poll_improvement = None;
792 let mut objective_poll_evaluations = 0;
793 if convergence_criterion.is_none() {
794 let poll = polish_and_verify_objective_mesh(request, model, mv, bc, current)?;
795 mv = poll.mv;
796 bc = poll.bc;
797 objective_poll_evaluations = poll.evaluations;
798 if poll.converged {
799 convergence_criterion = Some(TruingMapConvergenceCriterionV1::ObjectiveMesh);
800 objective_poll_radius = Some(poll.final_radius);
801 max_objective_poll_improvement = Some(poll.max_final_improvement);
802 }
803 }
804
805 Ok(MapFitResult {
806 mv,
807 bc,
808 iterations,
809 convergence_criterion,
810 objective_poll_radius,
811 max_objective_poll_improvement,
812 objective_poll_evaluations,
813 })
814}
815
816fn objective_improvement_is_material(current: f64, candidate: f64) -> bool {
817 current - candidate > MAP_OBJECTIVE_IMPROVEMENT_TOLERANCE
818}
819
820fn polish_and_verify_objective_mesh(
829 request: &UncertaintyTruingRequestV1,
830 model: &TruingForwardModel<'_>,
831 mut mv: f64,
832 mut bc: f64,
833 mut current: f64,
834) -> Result<ObjectivePollResult, UncertaintyTruingErrorV1> {
835 let information = evaluate(request, model, mv, bc)?.posterior_information;
836 let angle = 0.5 * (2.0 * information.a01).atan2(information.a00 - information.a11);
837 let (sin, cos) = angle.sin_cos();
838 let directions = [
841 (1.0, 0.0),
842 (-1.0, 0.0),
843 (0.0, 1.0),
844 (0.0, -1.0),
845 (cos, sin),
846 (-cos, -sin),
847 (-sin, cos),
848 (sin, -cos),
849 ];
850
851 let mut radius = MAP_OBJECTIVE_INITIAL_POLL_RADIUS;
852 let mut evaluations = 0;
853 let mut max_final_improvement = f64::INFINITY;
854 while evaluations < MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
855 let mut best = current;
856 let mut best_point = (mv, bc);
857 for (mv_direction, bc_direction) in directions {
858 if evaluations >= MAP_OBJECTIVE_MAX_POLL_EVALUATIONS {
859 break;
860 }
861 let candidate_mv = (mv + radius * mv_direction * MV_COORDINATE_SCALE_FPS)
862 .clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
863 let candidate_bc = (bc + radius * bc_direction * BC_COORDINATE_SCALE)
864 .clamp(TRUING_BC_MIN, TRUING_BC_MAX);
865 if candidate_mv == mv && candidate_bc == bc {
866 continue;
867 }
868 let candidate = objective(request, model, candidate_mv, candidate_bc)?;
869 evaluations += 1;
870 if candidate < best {
871 best = candidate;
872 best_point = (candidate_mv, candidate_bc);
873 }
874 }
875
876 let improvement = (current - best).max(0.0);
877 if objective_improvement_is_material(current, best) {
878 mv = best_point.0;
879 bc = best_point.1;
880 current = best;
881 continue;
882 }
883 max_final_improvement = improvement;
884 if radius <= MAP_OBJECTIVE_MIN_POLL_RADIUS {
885 return Ok(ObjectivePollResult {
886 mv,
887 bc,
888 converged: true,
889 final_radius: radius,
890 max_final_improvement,
891 evaluations,
892 });
893 }
894 radius = (radius * 0.25).max(MAP_OBJECTIVE_MIN_POLL_RADIUS);
895 }
896
897 Ok(ObjectivePollResult {
898 mv,
899 bc,
900 converged: false,
901 final_radius: radius,
902 max_final_improvement,
903 evaluations,
904 })
905}
906
907fn objective(
908 request: &UncertaintyTruingRequestV1,
909 model: &TruingForwardModel<'_>,
910 mv: f64,
911 bc: f64,
912) -> Result<f64, UncertaintyTruingErrorV1> {
913 let mut objective = 0.0;
914 let ranges: Vec<f64> = request
915 .observations
916 .iter()
917 .map(|observation| observation.range_yd)
918 .collect();
919 let predictions = model
920 .predict_many_in_unit(mv, bc, &ranges, request.drop_unit)
921 .map_err(forward_error)?;
922 for (observation, prediction) in request.observations.iter().zip(predictions) {
923 let prediction = prediction.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
924 let standardized = (prediction - observation.drop) / observation.sigma;
925 objective += standardized * standardized;
926 }
927 if let Some(prior) = request.priors.muzzle_velocity_fps {
928 let standardized = (mv - prior.mean) / prior.sigma;
929 objective += standardized * standardized;
930 }
931 if let Some(prior) = request.priors.ballistic_coefficient {
932 let standardized = (bc - prior.mean) / prior.sigma;
933 objective += standardized * standardized;
934 }
935 if objective.is_finite() {
936 Ok(objective)
937 } else {
938 Err(UncertaintyTruingErrorV1::ForwardModel(
939 "non-finite penalized chi-square".to_string(),
940 ))
941 }
942}
943
944fn evaluate(
945 request: &UncertaintyTruingRequestV1,
946 model: &TruingForwardModel<'_>,
947 mv: f64,
948 bc: f64,
949) -> Result<Evaluation, UncertaintyTruingErrorV1> {
950 let mut data_information = Symmetric2::default();
951 let mut gradient = [0.0, 0.0];
952 let mut results = Vec::with_capacity(request.observations.len());
953 let mut chi_square = 0.0;
954
955 let (mut fractional_mv_norm2, mut fractional_bc_norm2): (f64, f64) = (0.0, 0.0);
958 let ranges: Vec<f64> = request
959 .observations
960 .iter()
961 .map(|observation| observation.range_yd)
962 .collect();
963 let rows =
964 truing_jacobian_rows(model, mv, bc, &ranges, request.drop_unit).map_err(forward_error)?;
965 for (observation, row) in request.observations.iter().zip(rows) {
966 let row = row.ok_or_else(|| unreachable_range_error(observation.range_yd))?;
967 let residual = row.predicted_drop - observation.drop;
968 let standardized_residual = residual / observation.sigma;
969 let j_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS / observation.sigma;
970 let j_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE / observation.sigma;
971 data_information.a00 += j_mv * j_mv;
972 data_information.a01 += j_mv * j_bc;
973 data_information.a11 += j_bc * j_bc;
974 gradient[0] += j_mv * standardized_residual;
975 gradient[1] += j_bc * standardized_residual;
976 chi_square += standardized_residual * standardized_residual;
977 fractional_mv_norm2 += (row.d_drop_d_mv * mv / observation.sigma).powi(2);
978 fractional_bc_norm2 += (row.d_drop_d_bc * bc / observation.sigma).powi(2);
979 results.push(WeightedTruingObservationResultV1 {
980 range_yd: observation.range_yd,
981 observed_drop: observation.drop,
982 sigma: observation.sigma,
983 predicted_drop: row.predicted_drop,
984 residual,
985 standardized_residual,
986 });
987 }
988
989 let mut prior_information = Symmetric2::default();
990 let mut prior_penalty = 0.0;
991 if let Some(prior) = request.priors.muzzle_velocity_fps {
992 let j = MV_COORDINATE_SCALE_FPS / prior.sigma;
993 let standardized = (mv - prior.mean) / prior.sigma;
994 prior_information.a00 += j * j;
995 gradient[0] += j * standardized;
996 prior_penalty += standardized * standardized;
997 }
998 if let Some(prior) = request.priors.ballistic_coefficient {
999 let j = BC_COORDINATE_SCALE / prior.sigma;
1000 let standardized = (bc - prior.mean) / prior.sigma;
1001 prior_information.a11 += j * j;
1002 gradient[1] += j * standardized;
1003 prior_penalty += standardized * standardized;
1004 }
1005 let mut posterior_information = data_information;
1006 posterior_information.add_assign(prior_information);
1007
1008 let bc_sensitivity_ratio = if fractional_mv_norm2 > 0.0 {
1009 (fractional_bc_norm2 / fractional_mv_norm2).sqrt()
1010 } else {
1011 0.0
1012 };
1013 let data_condition_number = column_condition(data_information);
1014
1015 Ok(Evaluation {
1016 data_information,
1017 posterior_information,
1018 gradient,
1019 observation_results: results,
1020 chi_square,
1021 prior_penalty,
1022 bc_sensitivity_ratio,
1023 data_condition_number,
1024 })
1025}
1026
1027fn column_condition(information: Symmetric2) -> f64 {
1028 if information.a00 <= 0.0 || information.a11 <= 0.0 {
1029 return f64::INFINITY;
1030 }
1031 let correlation = (information.a01 / (information.a00 * information.a11).sqrt())
1032 .clamp(-1.0, 1.0)
1033 .abs();
1034 if 1.0 - correlation <= 1.0e-15 {
1035 f64::INFINITY
1036 } else {
1037 (1.0 + correlation) / (1.0 - correlation)
1038 }
1039}
1040
1041fn build_approximation(
1042 mv: f64,
1043 bc: f64,
1044 information: Symmetric2,
1045) -> Result<(TruingGaussianApproximationV1, Symmetric2), TruingApproximationFailureV1> {
1046 let (covariance_q, condition) = information
1047 .inverse_with_condition()
1048 .map_err(|code| TruingApproximationFailureV1 {
1049 code,
1050 message: match code {
1051 TruingApproximationFailureCodeV1::OptimizerDidNotConverge => {
1052 "MAP optimizer did not converge".to_string()
1053 }
1054 TruingApproximationFailureCodeV1::MapAtParameterBound => {
1055 "MAP is at a constrained parameter bound".to_string()
1056 }
1057 TruingApproximationFailureCodeV1::RankDeficientInformation => {
1058 "likelihood-plus-prior information is rank deficient or numerically singular; collect more separated ranges or add an explicit prior".to_string()
1059 }
1060 TruingApproximationFailureCodeV1::NonFiniteInformation => {
1061 "likelihood-plus-prior information or its inverse is non-finite".to_string()
1062 }
1063 },
1064 })?;
1065 let covariance = TruingCovarianceV1 {
1066 mv_variance_fps2: covariance_q.a00 * MV_COORDINATE_SCALE_FPS.powi(2),
1067 mv_bc_covariance_fps: covariance_q.a01 * MV_COORDINATE_SCALE_FPS * BC_COORDINATE_SCALE,
1068 bc_variance: covariance_q.a11 * BC_COORDINATE_SCALE.powi(2),
1069 };
1070 let mv_interval = GaussianIntervalV1::from_variance(mv, covariance.mv_variance_fps2)
1071 .ok_or_else(|| TruingApproximationFailureV1 {
1072 code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1073 message: "MV marginal variance is invalid".to_string(),
1074 })?;
1075 let bc_interval =
1076 GaussianIntervalV1::from_variance(bc, covariance.bc_variance).ok_or_else(|| {
1077 TruingApproximationFailureV1 {
1078 code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1079 message: "BC marginal variance is invalid".to_string(),
1080 }
1081 })?;
1082 let correlation = covariance.mv_bc_covariance_fps
1083 / (covariance.mv_variance_fps2 * covariance.bc_variance).sqrt();
1084 if !correlation.is_finite() {
1085 return Err(TruingApproximationFailureV1 {
1086 code: TruingApproximationFailureCodeV1::NonFiniteInformation,
1087 message: "MV/BC posterior correlation is non-finite".to_string(),
1088 });
1089 }
1090 Ok((
1091 TruingGaussianApproximationV1 {
1092 covariance,
1093 muzzle_velocity_interval_95: mv_interval,
1094 ballistic_coefficient_interval_95: bc_interval,
1095 mv_bc_correlation: correlation.clamp(-1.0, 1.0),
1096 scaled_information_condition_number: condition,
1097 },
1098 covariance_q,
1099 ))
1100}
1101
1102fn build_predictive_bands(
1103 request: &UncertaintyTruingRequestV1,
1104 model: &TruingForwardModel<'_>,
1105 mv: f64,
1106 bc: f64,
1107 covariance_q: Option<Symmetric2>,
1108 warnings: &mut Vec<TruingUncertaintyWarningV1>,
1109) -> Result<Vec<TruingPredictiveBandV1>, UncertaintyTruingErrorV1> {
1110 let observed_min = request
1111 .observations
1112 .iter()
1113 .map(|observation| observation.range_yd)
1114 .fold(f64::INFINITY, f64::min);
1115 let observed_max = request
1116 .observations
1117 .iter()
1118 .map(|observation| observation.range_yd)
1119 .fold(f64::NEG_INFINITY, f64::max);
1120 let mut warned_extrapolation = false;
1121 let mut bands = Vec::with_capacity(request.predictions.len());
1122 let prediction_ranges: Vec<f64> = request
1123 .predictions
1124 .iter()
1125 .map(|prediction| prediction.range_yd)
1126 .collect();
1127 let rows = truing_jacobian_rows(model, mv, bc, &prediction_ranges, request.drop_unit)
1128 .map_err(forward_error)?;
1129 for (prediction, row) in request.predictions.iter().zip(rows) {
1130 let row = row.ok_or_else(|| unreachable_range_error(prediction.range_yd))?;
1131 if !warned_extrapolation
1132 && (prediction.range_yd < observed_min || prediction.range_yd > observed_max)
1133 {
1134 push_warning(
1135 warnings,
1136 TruingUncertaintyWarningCodeV1::PredictionOutsideObservedDomain,
1137 "one or more predictive ranges lie outside the observed range domain; local linear uncertainty may understate nonlinear extrapolation risk".to_string(),
1138 );
1139 warned_extrapolation = true;
1140 }
1141 let latent_interval_95 = covariance_q.and_then(|covariance| {
1142 let g_mv = row.d_drop_d_mv * MV_COORDINATE_SCALE_FPS;
1143 let g_bc = row.d_drop_d_bc * BC_COORDINATE_SCALE;
1144 let variance = g_mv * g_mv * covariance.a00
1145 + 2.0 * g_mv * g_bc * covariance.a01
1146 + g_bc * g_bc * covariance.a11;
1147 let tolerance = 1.0e-12
1150 * (g_mv * g_mv * covariance.a00)
1151 .abs()
1152 .max((g_bc * g_bc * covariance.a11).abs())
1153 .max(1.0);
1154 let variance = if variance >= 0.0 {
1155 variance
1156 } else if variance >= -tolerance {
1157 0.0
1158 } else {
1159 return None;
1160 };
1161 GaussianIntervalV1::from_variance(row.predicted_drop, variance)
1162 });
1163 let future_observation_interval_95 =
1164 match (latent_interval_95, prediction.future_observation_sigma) {
1165 (Some(latent), Some(sigma)) => GaussianIntervalV1::from_variance(
1166 row.predicted_drop,
1167 latent.standard_deviation.powi(2) + sigma.powi(2),
1168 ),
1169 _ => None,
1170 };
1171 bands.push(TruingPredictiveBandV1 {
1172 range_yd: prediction.range_yd,
1173 predicted_drop: row.predicted_drop,
1174 latent_interval_95,
1175 future_observation_interval_95,
1176 });
1177 }
1178 Ok(bands)
1179}
1180
1181fn parameter_at_bound(mv: f64, bc: f64) -> bool {
1182 let mv_tolerance = 1.0e-6 * (TRUING_MV_MAX_FPS - TRUING_MV_MIN_FPS);
1183 let bc_tolerance = 1.0e-6 * (TRUING_BC_MAX - TRUING_BC_MIN);
1184 mv - TRUING_MV_MIN_FPS <= mv_tolerance
1185 || TRUING_MV_MAX_FPS - mv <= mv_tolerance
1186 || bc - TRUING_BC_MIN <= bc_tolerance
1187 || TRUING_BC_MAX - bc <= bc_tolerance
1188}
1189
1190fn warn_prior_dominance(
1191 request: &UncertaintyTruingRequestV1,
1192 approximation: &TruingGaussianApproximationV1,
1193 warnings: &mut Vec<TruingUncertaintyWarningV1>,
1194) {
1195 if let Some(prior) = request.priors.muzzle_velocity_fps {
1196 if approximation.covariance.mv_variance_fps2 >= 0.8 * prior.sigma.powi(2) {
1197 push_warning(
1198 warnings,
1199 TruingUncertaintyWarningCodeV1::MvPriorDominated,
1200 "MV posterior width remains close to its explicit prior width; observations add little marginal MV information".to_string(),
1201 );
1202 }
1203 }
1204 if let Some(prior) = request.priors.ballistic_coefficient {
1205 if approximation.covariance.bc_variance >= 0.8 * prior.sigma.powi(2) {
1206 push_warning(
1207 warnings,
1208 TruingUncertaintyWarningCodeV1::BcPriorDominated,
1209 "BC posterior width remains close to its explicit prior width; observations add little marginal BC information".to_string(),
1210 );
1211 }
1212 }
1213}
1214
1215fn push_warning(
1216 warnings: &mut Vec<TruingUncertaintyWarningV1>,
1217 code: TruingUncertaintyWarningCodeV1,
1218 message: String,
1219) {
1220 if warnings.iter().any(|warning| warning.code == code) {
1221 return;
1222 }
1223 warnings.push(TruingUncertaintyWarningV1 { code, message });
1224}
1225
1226fn forward_error(error: Box<dyn std::error::Error>) -> UncertaintyTruingErrorV1 {
1227 UncertaintyTruingErrorV1::ForwardModel(error.to_string())
1228}
1229
1230fn unreachable_range_error(range_yd: f64) -> UncertaintyTruingErrorV1 {
1231 UncertaintyTruingErrorV1::ForwardModel(format!(
1232 "trajectory did not reach requested range {range_yd:.3} yd"
1233 ))
1234}