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