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