1use std::error::Error;
42
43use serde::{Deserialize, Serialize};
44
45use crate::cli_api::UnitSystem;
46use crate::truing::{
47 DragModelArg, TruingEarthFrame, TruingEnvironment, TruingTwist, TRUING_BC_MAX, TRUING_BC_MIN,
48 TRUING_MV_MAX_FPS, TRUING_MV_MIN_FPS,
49};
50use crate::{BCSegmentData, WindConditions};
51
52pub const MPH_TO_MPS: f64 = 0.44704;
56
57pub const MAX_SOLVABLE_CROSSWIND_MPH: f64 = 100.0;
62
63pub const WIND_SOLVE_TOLERANCE_M: f64 = 1.0e-5;
68
69const WIND_SOLVE_MIN_BRACKET_MPH: f64 = 1.0e-9;
72
73const WIND_SOLVE_MAX_ITERATIONS: u32 = 60;
78
79const WIND_SENSITIVITY_STEP_MPH: f64 = 0.5;
83
84pub const MIN_WIND_SENSITIVITY_IN_PER_MPH: f64 = 0.25;
89
90#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct WindObservation {
97 pub range_m: f64,
99 pub miss_right_m: f64,
101 pub sigma_m: Option<f64>,
104}
105
106pub fn parse_wind_observation(s: &str, units: UnitSystem) -> Result<WindObservation, String> {
113 let parts: Vec<&str> = s.split(':').collect();
114 if parts.len() != 2 && parts.len() != 3 {
115 return Err(format!(
116 "invalid --miss '{s}': expected RANGE:RIGHT_IN[:SIGMA] (e.g. 600:8.5 or 600:8.5:0.75)"
117 ));
118 }
119 let range: f64 = parts[0]
120 .trim()
121 .parse()
122 .map_err(|_| format!("invalid --miss range '{}' in '{s}'", parts[0]))?;
123 let miss_in: f64 = parts[1]
124 .trim()
125 .parse()
126 .map_err(|_| format!("invalid --miss offset '{}' in '{s}'", parts[1]))?;
127 let sigma_in: Option<f64> = match parts.get(2) {
128 Some(token) => Some(
129 token
130 .trim()
131 .parse()
132 .map_err(|_| format!("invalid --miss sigma '{token}' in '{s}'"))?,
133 ),
134 None => None,
135 };
136 if !range.is_finite() || !miss_in.is_finite() || sigma_in.is_some_and(|v| !v.is_finite()) {
137 return Err(format!("invalid --miss '{s}': values must be finite"));
138 }
139 let range_m = match units {
140 UnitSystem::Imperial => range * 0.9144,
141 UnitSystem::Metric => range,
142 };
143 Ok(WindObservation {
144 range_m,
145 miss_right_m: miss_in * 0.0254,
146 sigma_m: sigma_in.map(|v| v * 0.0254),
147 })
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct WindTruingRequest {
160 pub observations: Vec<WindObservation>,
162 pub muzzle_velocity_fps: f64,
164 pub bc: f64,
166 pub drag_model: DragModelArg,
167 pub mass_gr: f64,
169 pub diameter_in: f64,
171 pub zero_distance_yd: f64,
173 pub sight_height_in: f64,
175 pub temperature_f: f64,
177 pub pressure_inhg: f64,
179 pub humidity_pct: f64,
181 pub altitude_ft: f64,
183 pub twist: TruingTwist,
187 pub earth: Option<TruingEarthFrame>,
190 pub called_crosswind_mph: Option<f64>,
193}
194
195impl WindTruingRequest {
196 pub fn validate(&self) -> Result<(), String> {
203 if self.observations.is_empty() {
204 return Err("at least one observed horizontal miss is required".to_string());
205 }
206 if !self.muzzle_velocity_fps.is_finite()
207 || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
208 {
209 return Err(format!(
210 "muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
211 ));
212 }
213 if !self.bc.is_finite() || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.bc) {
214 return Err(format!(
215 "ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
216 ));
217 }
218 for (name, value) in [
219 ("bullet mass", self.mass_gr),
220 ("bullet diameter", self.diameter_in),
221 ("zero distance", self.zero_distance_yd),
222 ("sight height", self.sight_height_in),
223 ("pressure", self.pressure_inhg),
224 ("twist rate", self.twist.rate_in),
225 ] {
226 if !value.is_finite() || value <= 0.0 {
227 return Err(format!("{name} must be positive and finite"));
228 }
229 }
230 if !self.temperature_f.is_finite() {
231 return Err("temperature must be finite".to_string());
232 }
233 if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
234 return Err("humidity must be finite and within 0..=100 percent".to_string());
235 }
236 if !self.altitude_ft.is_finite() {
237 return Err("altitude must be finite".to_string());
238 }
239 if let Some(earth) = self.earth {
240 if !earth.latitude_deg.is_finite() || !(-90.0..=90.0).contains(&earth.latitude_deg) {
241 return Err("latitude must be finite and within -90..=90 degrees".to_string());
242 }
243 if !earth.shot_azimuth_deg.is_finite() {
244 return Err("shot azimuth must be finite".to_string());
245 }
246 }
247 if let Some(called) = self.called_crosswind_mph {
248 if !called.is_finite() || called == 0.0 {
249 return Err(
250 "the called wind must be finite and non-zero (a zero call has no \
251 correction factor)"
252 .to_string(),
253 );
254 }
255 }
256 for observation in &self.observations {
257 if !observation.range_m.is_finite() || observation.range_m <= 0.0 {
258 return Err(format!(
259 "observation range must be a positive finite distance (got {})",
260 observation.range_m
261 ));
262 }
263 if !observation.miss_right_m.is_finite() {
264 return Err("observed horizontal miss must be finite".to_string());
265 }
266 if observation
267 .sigma_m
268 .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
269 {
270 return Err("an observed-miss sigma must be positive and finite".to_string());
271 }
272 }
273 for i in 0..self.observations.len() {
274 for j in (i + 1)..self.observations.len() {
275 if (self.observations[i].range_m - self.observations[j].range_m).abs() < 1e-6 {
276 return Err(format!(
277 "duplicate observation range ({:.3} m): each observed miss must be at a \
278 distinct range",
279 self.observations[i].range_m
280 ));
281 }
282 }
283 }
284 let with_sigma = self
285 .observations
286 .iter()
287 .filter(|o| o.sigma_m.is_some())
288 .count();
289 if with_sigma != 0 && with_sigma != self.observations.len() {
290 return Err(
291 "supply a sigma on every observed miss or on none: mixing weighted and \
292 unweighted observations would silently combine inverse-variance weights \
293 with unit weights"
294 .to_string(),
295 );
296 }
297 Ok(())
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
303pub struct WindTruingSolution {
304 pub range_m: f64,
306 pub observed_miss_right_m: f64,
308 pub sigma_m: Option<f64>,
310 pub solved_crosswind_mph: f64,
312 pub modeled_miss_right_m: f64,
315 pub residual_m: f64,
317 pub no_wind_lateral_m: f64,
321 pub sensitivity_m_per_mph: f64,
324 pub solved_sigma_mph: Option<f64>,
327 pub iterations: u32,
329 pub converged: bool,
332}
333
334const T_95_TWO_SIDED: [f64; 30] = [
338 12.706204736, 4.302652730, 3.182446305, 2.776445105, 2.570581836, 2.446911851,
339 2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.200985160, 2.178812830,
340 2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.100922040,
341 2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.068657610, 2.063898562,
342 2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456,
343];
344
345const NORMAL_95_TWO_SIDED_Z: f64 = 1.959_963_984_540_054;
347
348const WIND_INTERVAL_PROBABILITY: f64 = 0.95;
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
353#[serde(rename_all = "snake_case")]
354pub enum WindUncertaintyBasisV1 {
355 EmpiricalScatter,
358 PropagatedMeasurement,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
365#[serde(rename_all = "snake_case")]
366pub enum WindUncertaintyFailureCodeV1 {
367 SingleObservation,
370 NoUsableEstimate,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
376pub struct WindUncertaintyFailureV1 {
377 pub code: WindUncertaintyFailureCodeV1,
378 pub message: String,
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
383pub struct WindIntervalV1 {
384 pub sigma_mph: f64,
386 pub probability: f64,
388 pub low_mph: f64,
390 pub high_mph: f64,
392 pub basis: WindUncertaintyBasisV1,
394 pub empirical_sigma_mph: Option<f64>,
396 pub propagated_sigma_mph: Option<f64>,
399 pub dof: Option<u32>,
401}
402
403#[derive(Debug, Clone, PartialEq, Serialize)]
409#[serde(rename_all = "snake_case", tag = "status", content = "detail")]
410pub enum WindUncertaintyV1 {
411 Available(WindIntervalV1),
412 Unavailable(WindUncertaintyFailureV1),
413}
414
415fn build_wind_uncertainty(
422 solutions: &[WindTruingSolution],
423 mean_crosswind_mph: f64,
424 propagated_sigma_mph: Option<f64>,
425) -> WindUncertaintyV1 {
426 let n = solutions.len();
427
428 let empirical_sigma_mph = if n >= 2 {
430 let mean: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum::<f64>() / n as f64;
431 let var = solutions
432 .iter()
433 .map(|s| {
434 let d = s.solved_crosswind_mph - mean;
435 d * d
436 })
437 .sum::<f64>()
438 / (n as f64 - 1.0); let se = (var / n as f64).sqrt();
440 if se.is_finite() && se > 0.0 {
441 Some(se)
442 } else {
443 None
444 }
445 } else {
446 None
447 };
448
449 let propagated = propagated_sigma_mph.filter(|v| v.is_finite() && *v > 0.0);
450
451 let (sigma_mph, basis, dof) = match (empirical_sigma_mph, propagated) {
453 (Some(e), Some(p)) if e >= p => (e, WindUncertaintyBasisV1::EmpiricalScatter, Some(n as u32 - 1)),
454 (Some(_), Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
455 (Some(e), None) => (e, WindUncertaintyBasisV1::EmpiricalScatter, Some(n as u32 - 1)),
456 (None, Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
457 (None, None) => {
458 let (code, message) = if n < 2 {
459 (
460 WindUncertaintyFailureCodeV1::SingleObservation,
461 "a single observation with no measurement sigma gives nothing to estimate an interval from — shoot more observations, or supply a sigma on this one"
462 .to_string(),
463 )
464 } else {
465 (
466 WindUncertaintyFailureCodeV1::NoUsableEstimate,
467 "the observations agree exactly and no measurement sigma was supplied, so the spread is zero — this reflects too few distinct observations, not a perfectly known wind"
468 .to_string(),
469 )
470 };
471 return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 { code, message });
472 }
473 };
474
475 let multiplier = match dof {
476 Some(d) if d >= 1 => *T_95_TWO_SIDED
477 .get((d - 1) as usize)
478 .unwrap_or(&NORMAL_95_TWO_SIDED_Z),
479 _ => NORMAL_95_TWO_SIDED_Z,
480 };
481 let half_width = multiplier * sigma_mph;
482 if !half_width.is_finite() {
483 return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 {
484 code: WindUncertaintyFailureCodeV1::NoUsableEstimate,
485 message: "the interval half-width was not finite".to_string(),
486 });
487 }
488
489 WindUncertaintyV1::Available(WindIntervalV1 {
490 sigma_mph,
491 probability: WIND_INTERVAL_PROBABILITY,
492 low_mph: mean_crosswind_mph - half_width,
493 high_mph: mean_crosswind_mph + half_width,
494 basis,
495 empirical_sigma_mph,
496 propagated_sigma_mph: propagated,
497 dof,
498 })
499}
500
501#[derive(Debug, Clone, Serialize)]
503pub struct WindTruingReport {
504 pub solutions: Vec<WindTruingSolution>,
506 pub mean_crosswind_mph: f64,
508 pub mean_sigma_mph: Option<f64>,
512 pub uncertainty: WindUncertaintyV1,
515 pub inverse_variance_weighted: bool,
518 pub called_crosswind_mph: Option<f64>,
520 pub wind_call_factor: Option<f64>,
523 pub subtracted_effects: Vec<String>,
525 pub unsubtracted_effects: Vec<String>,
528}
529
530fn crosswind_conditions(signed_mph: f64) -> WindConditions {
538 WindConditions {
539 speed: signed_mph.abs() * MPH_TO_MPS,
540 direction: if signed_mph < 0.0 {
541 std::f64::consts::FRAC_PI_2
542 } else {
543 3.0 * std::f64::consts::FRAC_PI_2
544 },
545 vertical_speed: 0.0,
546 }
547}
548
549pub fn modeled_miss_right_m(
559 request: &WindTruingRequest,
560 crosswind_mph: f64,
561 range_m: f64,
562) -> Result<f64, Box<dyn Error>> {
563 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
564 let env = TruingEnvironment {
565 wind: crosswind_conditions(crosswind_mph),
566 twist: Some(request.twist),
567 earth: request.earth,
568 };
569 let sample = crate::truing::solve_trajectory_sample(
570 request.muzzle_velocity_fps,
571 request.bc,
572 request.drag_model,
573 request.mass_gr,
574 request.diameter_in,
575 request.zero_distance_yd,
576 range_m / 0.9144,
577 request.sight_height_in,
578 request.temperature_f,
579 request.pressure_inhg,
580 request.humidity_pct,
581 request.altitude_ft,
582 &no_bc_segments,
583 &env,
584 true, )?;
586 Ok(sample.lateral_m)
587}
588
589pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
602 request.validate()?;
603
604 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
608
609 let environment = |crosswind_mph: f64| TruingEnvironment {
610 wind: crosswind_conditions(crosswind_mph),
611 twist: Some(request.twist),
612 earth: request.earth,
613 };
614
615 let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
617 let sample = crate::truing::solve_trajectory_sample(
618 request.muzzle_velocity_fps,
619 request.bc,
620 request.drag_model,
621 request.mass_gr,
622 request.diameter_in,
623 request.zero_distance_yd,
624 range_yd,
625 request.sight_height_in,
626 request.temperature_f,
627 request.pressure_inhg,
628 request.humidity_pct,
629 request.altitude_ft,
630 &no_bc_segments,
631 &environment(crosswind_mph),
632 true, )?;
634 Ok(sample.lateral_m)
635 };
636
637 let mut solutions = Vec::with_capacity(request.observations.len());
638 for observation in &request.observations {
639 let range_yd = observation.range_m / 0.9144;
640 solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
641 }
642
643 let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
648 if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
649 return Err(
650 "an observation does not move with crosswind at all, so its measurement sigma \
651 cannot be expressed in wind units — drop that observation or its sigma"
652 .into(),
653 );
654 }
655 let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
656 let mut weight_sum = 0.0;
657 let mut weighted = 0.0;
658 for solution in &solutions {
659 let sigma = solution
660 .solved_sigma_mph
661 .expect("checked by inverse_variance_weighted");
662 let weight = 1.0 / (sigma * sigma);
663 weight_sum += weight;
664 weighted += weight * solution.solved_crosswind_mph;
665 }
666 if weight_sum > 0.0 && weight_sum.is_finite() {
667 (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
668 } else {
669 return Err(
670 "observed-miss sigmas produced a degenerate weighting (check that every \
671 sigma is positive and that the observations move with wind at all)"
672 .into(),
673 );
674 }
675 } else {
676 let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
677 (sum / solutions.len() as f64, None)
678 };
679
680 let wind_call_factor = request
681 .called_crosswind_mph
682 .map(|called| mean_crosswind_mph / called);
683
684 let mut subtracted_effects = vec!["spin drift".to_string()];
688 let mut unsubtracted_effects = Vec::new();
689 if request.earth.is_some() {
690 subtracted_effects.push("Coriolis".to_string());
691 } else {
692 unsubtracted_effects
693 .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
694 }
695
696 let uncertainty = build_wind_uncertainty(&solutions, mean_crosswind_mph, mean_sigma_mph);
697
698 Ok(WindTruingReport {
699 solutions,
700 mean_crosswind_mph,
701 mean_sigma_mph,
702 uncertainty,
703 inverse_variance_weighted,
704 called_crosswind_mph: request.called_crosswind_mph,
705 wind_call_factor,
706 subtracted_effects,
707 unsubtracted_effects,
708 })
709}
710
711fn solve_one_observation(
716 observation: &WindObservation,
717 range_yd: f64,
718 lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
719) -> Result<WindTruingSolution, Box<dyn Error>> {
720 let target = observation.miss_right_m;
721 let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
722 Ok(lateral_at(crosswind_mph, range_yd)? - target)
723 };
724
725 let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
726 let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
727 let mut f_low = residual(low)?;
728 let mut f_high = residual(high)?;
729 if f_low > 0.0 || f_high < 0.0 {
730 return Err(format!(
731 "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
732 miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
733 the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
734 target / 0.0254,
735 (f_low + target) / 0.0254,
736 (f_high + target) / 0.0254,
737 )
738 .into());
739 }
740
741 let mut solved = 0.0;
742 let mut f_solved = 0.0;
743 let mut iterations = 0u32;
744 let mut converged = false;
745 while iterations < WIND_SOLVE_MAX_ITERATIONS {
746 iterations += 1;
747 let denom = f_high - f_low;
748 let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
749 high - f_high * (high - low) / denom
750 } else {
751 0.5 * (low + high)
752 };
753 if !candidate.is_finite() || candidate <= low || candidate >= high {
755 candidate = 0.5 * (low + high);
756 }
757 let f = residual(candidate)?;
758 solved = candidate;
759 f_solved = f;
760 if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
761 converged = true;
762 break;
763 }
764 if f < 0.0 {
766 low = candidate;
767 f_low = f;
768 f_high *= 0.5;
769 } else {
770 high = candidate;
771 f_high = f;
772 f_low *= 0.5;
773 }
774 }
775
776 let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
778 let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
779 let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
780
781 let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
784
785 let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
786 let slope = sensitivity_m_per_mph.abs();
787 (slope > 0.0).then_some(sigma / slope)
788 });
789
790 Ok(WindTruingSolution {
791 range_m: observation.range_m,
792 observed_miss_right_m: target,
793 sigma_m: observation.sigma_m,
794 solved_crosswind_mph: solved,
795 modeled_miss_right_m: target + f_solved,
796 residual_m: f_solved,
797 no_wind_lateral_m,
798 sensitivity_m_per_mph,
799 solved_sigma_mph,
800 iterations,
801 converged,
802 })
803}
804
805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
809pub enum WindTruingOutput {
810 Table,
811 Json,
812 Csv,
813}
814
815struct WindTruingUnits {
818 range_label: &'static str,
819 speed_label: &'static str,
820 range_scale: f64,
821 speed_scale: f64,
822}
823
824impl WindTruingUnits {
825 fn for_system(units: UnitSystem) -> Self {
826 match units {
827 UnitSystem::Imperial => Self {
828 range_label: "yd",
829 speed_label: "mph",
830 range_scale: 1.0 / 0.9144,
831 speed_scale: 1.0,
832 },
833 UnitSystem::Metric => Self {
834 range_label: "m",
835 speed_label: "m/s",
836 range_scale: 1.0,
837 speed_scale: MPH_TO_MPS,
838 },
839 }
840 }
841
842 fn range(&self, range_m: f64) -> f64 {
843 range_m * self.range_scale
844 }
845
846 fn speed(&self, mph: f64) -> f64 {
847 mph * self.speed_scale
848 }
849}
850
851fn inches(meters: f64) -> f64 {
853 meters / 0.0254
854}
855
856pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
863 let u = WindTruingUnits::for_system(units);
864 let observations: Vec<serde_json::Value> = report
865 .solutions
866 .iter()
867 .map(|s| {
868 serde_json::json!({
869 format!("range_{}", u.range_label): u.range(s.range_m),
870 "miss_right_in": inches(s.observed_miss_right_m),
871 "miss_sigma_in": s.sigma_m.map(inches),
872 "no_wind_lateral_in": inches(s.no_wind_lateral_m),
873 "solved_crosswind": u.speed(s.solved_crosswind_mph),
874 "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
875 "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
876 "residual_in": inches(s.residual_m),
877 "iterations": s.iterations,
878 "converged": s.converged,
879 })
880 })
881 .collect();
882
883 serde_json::json!({
884 "effective_crosswind": u.speed(report.mean_crosswind_mph),
885 "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
886 "inverse_variance_weighted": report.inverse_variance_weighted,
887 "uncertainty": match &report.uncertainty {
888 WindUncertaintyV1::Available(i) => serde_json::json!({
889 "status": "available",
890 "sigma": u.speed(i.sigma_mph),
891 "probability": i.probability,
892 "low": u.speed(i.low_mph),
893 "high": u.speed(i.high_mph),
894 "basis": match i.basis {
895 WindUncertaintyBasisV1::EmpiricalScatter => "empirical_scatter",
896 WindUncertaintyBasisV1::PropagatedMeasurement => "propagated_measurement",
897 },
898 "empirical_sigma": i.empirical_sigma_mph.map(|v| u.speed(v)),
899 "propagated_sigma": i.propagated_sigma_mph.map(|v| u.speed(v)),
900 "dof": i.dof,
901 }),
902 WindUncertaintyV1::Unavailable(f) => serde_json::json!({
903 "status": "unavailable",
904 "code": match f.code {
905 WindUncertaintyFailureCodeV1::SingleObservation => "single_observation",
906 WindUncertaintyFailureCodeV1::NoUsableEstimate => "no_usable_estimate",
907 },
908 "message": f.message,
909 }),
910 },
911 "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
912 "wind_call_factor": report.wind_call_factor,
913 "observations": observations,
914 "effects_subtracted": report.subtracted_effects,
915 "effects_not_subtracted": report.unsubtracted_effects,
916 "legend": {
917 "units": {
918 "range": u.range_label,
919 "miss": "in",
920 "wind_speed": u.speed_label,
921 },
922 "signs": "--miss positive = impact right of aim; solved crosswind positive = \
923 wind from the shooter's left (9 o'clock) pushing impacts right",
924 },
925 })
926}
927
928pub fn format_wind_truing_report(
934 report: &WindTruingReport,
935 units: UnitSystem,
936 output: WindTruingOutput,
937) -> String {
938 let u = WindTruingUnits::for_system(units);
939 match output {
940 WindTruingOutput::Json => {
941 match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
942 Ok(s) => format!("{s}\n"),
943 Err(e) => format!("Error serializing JSON: {e}\n"),
944 }
945 }
946 WindTruingOutput::Csv => {
947 let mut out = String::new();
948 out.push_str(&format!(
949 "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
950 sensitivity_in_per_mph,residual_in,iterations,converged\n",
951 u.range_label, u.speed_label
952 ));
953 for s in &report.solutions {
954 out.push_str(&format!(
955 "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
956 u.range(s.range_m),
957 inches(s.observed_miss_right_m),
958 match s.sigma_m {
959 Some(sigma) => format!("{:.3}", inches(sigma)),
960 None => String::new(),
961 },
962 inches(s.no_wind_lateral_m),
963 u.speed(s.solved_crosswind_mph),
964 inches(s.sensitivity_m_per_mph),
965 inches(s.residual_m),
966 s.iterations,
967 s.converged,
968 ));
969 }
970 out.push('\n');
971 out.push_str(&format!(
972 "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
973 called_crosswind_{},wind_call_factor\n",
974 u.speed_label, u.speed_label, u.speed_label
975 ));
976 out.push_str(&format!(
977 "{:+.3},{},{},{},{}\n",
978 u.speed(report.mean_crosswind_mph),
979 match report.mean_sigma_mph {
980 Some(sigma) => format!("{:.3}", u.speed(sigma)),
981 None => String::new(),
982 },
983 report.inverse_variance_weighted,
984 match report.called_crosswind_mph {
985 Some(called) => format!("{:+.3}", u.speed(called)),
986 None => String::new(),
987 },
988 match report.wind_call_factor {
989 Some(factor) => format!("{factor:.4}"),
990 None => String::new(),
991 },
992 ));
993 out
994 }
995 WindTruingOutput::Table => {
996 let mut out = String::new();
997 out.push('\n');
998 out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
999 out.push('\n');
1000 out.push_str(&format!(
1001 " {:>10} {:>12} {:>14} {:>16} {:>10}\n",
1002 format!("Range ({})", u.range_label),
1003 "Miss (in)",
1004 "Spin/Cor (in)",
1005 format!("Wind ({})", u.speed_label),
1006 "Resid (in)",
1007 ));
1008 out.push_str(&format!(" {}\n", "-".repeat(70)));
1009 for s in &report.solutions {
1010 out.push_str(&format!(
1011 " {:>10.1} {:>+12.2} {:>+14.2} {:>+16.2} {:>+10.3}\n",
1012 u.range(s.range_m),
1013 inches(s.observed_miss_right_m),
1014 inches(s.no_wind_lateral_m),
1015 u.speed(s.solved_crosswind_mph),
1016 inches(s.residual_m),
1017 ));
1018 }
1019 out.push_str(&format!(" {}\n", "-".repeat(70)));
1020 out.push('\n');
1021 let n = report.solutions.len();
1022 out.push_str(&format!(
1023 " Effective crosswind: {:>+8.2} {}{}\n",
1024 u.speed(report.mean_crosswind_mph),
1025 u.speed_label,
1026 match report.mean_sigma_mph {
1031 Some(_) => format!(" (inverse-variance weighted over {n} observations)"),
1032 None if n > 1 => format!(" (mean of {n} observations)"),
1033 None => String::new(),
1034 }
1035 ));
1036 match &report.uncertainty {
1037 WindUncertaintyV1::Available(i) => {
1038 out.push_str(&format!(
1039 " 95% interval: [{:>+7.2}, {:>+7.2}] {} ({})\n",
1040 u.speed(i.low_mph),
1041 u.speed(i.high_mph),
1042 u.speed_label,
1043 match i.basis {
1044 WindUncertaintyBasisV1::EmpiricalScatter => match i.dof {
1045 Some(d) => format!("from shot-to-shot scatter, t with {d} dof"),
1046 None => "from shot-to-shot scatter".to_string(),
1047 },
1048 WindUncertaintyBasisV1::PropagatedMeasurement =>
1049 "from your measurement sigmas".to_string(),
1050 }
1051 ));
1052 if let (Some(e), Some(pr)) = (i.empirical_sigma_mph, i.propagated_sigma_mph) {
1055 out.push_str(&format!(
1056 " scatter sigma {:.2} {} vs measurement sigma {:.2} {} — the wider one is reported\n",
1057 u.speed(e),
1058 u.speed_label,
1059 u.speed(pr),
1060 u.speed_label
1061 ));
1062 }
1063 }
1064 WindUncertaintyV1::Unavailable(f) => {
1065 out.push_str(&format!(" 95% interval: none — {}\n", f.message));
1066 }
1067 }
1068 if let (Some(called), Some(factor)) =
1069 (report.called_crosswind_mph, report.wind_call_factor)
1070 {
1071 out.push_str(&format!(
1072 " Called wind: {:>+8.2} {} -> wind-call correction factor {:.2}\n",
1073 u.speed(called),
1074 u.speed_label,
1075 factor
1076 ));
1077 out.push_str(&format!(
1078 " (multiply your wind calls by {factor:.2} to match what actually hit)\n"
1079 ));
1080 }
1081 if !report.subtracted_effects.is_empty() {
1082 out.push_str(&format!(
1083 " Effects subtracted: {}\n",
1084 report.subtracted_effects.join(", ")
1085 ));
1086 }
1087 if !report.unsubtracted_effects.is_empty() {
1088 out.push_str(&format!(
1089 " NOT subtracted (absorbed into the solved wind): {}\n",
1090 report.unsubtracted_effects.join(", ")
1091 ));
1092 }
1093 for s in &report.solutions {
1094 if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
1095 out.push_str(&format!(
1096 " note: the observation at {:.1} {} moves only {:.2} in per mph of \
1097 crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
1098 wind fitted from it is weakly identified\n",
1099 u.range(s.range_m),
1100 u.range_label,
1101 inches(s.sensitivity_m_per_mph).abs(),
1102 ));
1103 }
1104 if !s.converged {
1105 out.push_str(&format!(
1106 " note: the fit at {:.1} {} did not fully converge after {} iterations; \
1107 the value shown is the best estimate\n",
1108 u.range(s.range_m),
1109 u.range_label,
1110 s.iterations,
1111 ));
1112 }
1113 }
1114 out.push('\n');
1115 out.push_str(
1116 " Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
1117 \x20 FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
1118 \x20 = FROM the right pushing left. Wind-FROM convention throughout\n\
1119 \x20 (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
1120 );
1121 out.push('\n');
1122 out
1123 }
1124 }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
1132 WindTruingRequest {
1133 observations,
1134 muzzle_velocity_fps: 2700.0,
1135 bc: 0.475,
1136 drag_model: DragModelArg::G7,
1137 mass_gr: 168.0,
1138 diameter_in: 0.308,
1139 zero_distance_yd: 100.0,
1140 sight_height_in: 2.0,
1141 temperature_f: 59.0,
1142 pressure_inhg: 29.92,
1143 humidity_pct: 50.0,
1144 altitude_ft: 0.0,
1145 twist: TruingTwist {
1146 rate_in: 11.0,
1147 right_hand: true,
1148 },
1149 earth: None,
1150 called_crosswind_mph: None,
1151 }
1152 }
1153
1154 fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
1157 modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
1158 }
1159
1160 #[test]
1165 fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
1166 let known_mph = 7.5;
1167 let ranges_m = [274.32, 457.2, 640.08]; let template = base_request(Vec::new());
1169 let observations = ranges_m
1170 .iter()
1171 .map(|range_m| WindObservation {
1172 range_m: *range_m,
1173 miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
1174 sigma_m: None,
1175 })
1176 .collect();
1177
1178 let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
1179 assert_eq!(report.solutions.len(), 3);
1180 for solution in &report.solutions {
1181 assert!(solution.converged, "{solution:?}");
1182 assert!(
1183 (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
1184 "recovered {} mph at {} m, expected {known_mph}",
1185 solution.solved_crosswind_mph,
1186 solution.range_m
1187 );
1188 }
1189 assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
1190 assert!(!report.inverse_variance_weighted);
1191 assert!(report.mean_sigma_mph.is_none());
1192 }
1193
1194 #[test]
1198 fn miss_right_solves_positive_and_miss_left_solves_negative() {
1199 let template = base_request(Vec::new());
1200 let range_m = 457.2; let right_miss = modeled_lateral_m(&template, 8.0, range_m);
1202 let left_miss = modeled_lateral_m(&template, -8.0, range_m);
1203 assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
1204 assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
1205
1206 let right = solve_wind_truing(&base_request(vec![WindObservation {
1207 range_m,
1208 miss_right_m: right_miss,
1209 sigma_m: None,
1210 }]))
1211 .expect("right-miss fit must solve");
1212 assert!(
1213 right.mean_crosswind_mph > 0.0,
1214 "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
1215 right.mean_crosswind_mph
1216 );
1217 assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
1218
1219 let left = solve_wind_truing(&base_request(vec![WindObservation {
1220 range_m,
1221 miss_right_m: left_miss,
1222 sigma_m: None,
1223 }]))
1224 .expect("left-miss fit must solve");
1225 assert!(
1226 left.mean_crosswind_mph < 0.0,
1227 "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
1228 left.mean_crosswind_mph
1229 );
1230 assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
1231 }
1232
1233 #[test]
1237 fn pure_spin_drift_solves_to_zero_wind() {
1238 let template = base_request(Vec::new());
1239 let range_m = 640.08; let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1241 assert!(
1242 spin_only > 0.05,
1243 "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1244 );
1245
1246 let report = solve_wind_truing(&base_request(vec![WindObservation {
1247 range_m,
1248 miss_right_m: spin_only,
1249 sigma_m: None,
1250 }]))
1251 .expect("spin-only fit must solve");
1252 assert!(
1253 report.mean_crosswind_mph.abs() < 0.02,
1254 "pure spin drift must solve to ~0 wind, got {}",
1255 report.mean_crosswind_mph
1256 );
1257 assert!(report
1259 .solutions
1260 .iter()
1261 .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1262 assert!(report
1263 .subtracted_effects
1264 .iter()
1265 .any(|e| e.contains("spin drift")));
1266 }
1267
1268 #[test]
1272 fn twist_hand_changes_the_solved_wind() {
1273 let range_m = 640.08;
1274 let observation = WindObservation {
1275 range_m,
1276 miss_right_m: 0.25,
1277 sigma_m: None,
1278 };
1279 let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1280 let mut left = base_request(vec![observation]);
1281 left.twist.right_hand = false;
1282 let left_hand = solve_wind_truing(&left).expect("solve");
1283 assert!(
1284 left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1285 "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1286 left_hand.mean_crosswind_mph,
1287 right_hand.mean_crosswind_mph
1288 );
1289 }
1290
1291 #[test]
1295 fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1296 let range_m = 914.4; let observation = WindObservation {
1298 range_m,
1299 miss_right_m: 0.30,
1300 sigma_m: None,
1301 };
1302 let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1303 let mut with_earth = base_request(vec![observation]);
1304 with_earth.earth = Some(TruingEarthFrame {
1305 latitude_deg: 45.0,
1306 shot_azimuth_deg: 90.0, });
1308 let with = solve_wind_truing(&with_earth).expect("solve");
1309
1310 assert!(without
1311 .unsubtracted_effects
1312 .iter()
1313 .any(|e| e.contains("Coriolis")));
1314 assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1315 assert!(with.unsubtracted_effects.is_empty());
1316 assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1317 assert!(
1318 (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1319 > 1e-4,
1320 "modelling Coriolis must change the zero-wind lateral"
1321 );
1322 assert!(
1323 (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1324 "modelling Coriolis must change the solved wind"
1325 );
1326 }
1327
1328 #[test]
1332 fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1333 let template = base_request(Vec::new());
1334 let range_m = 457.2;
1335 let miss = modeled_lateral_m(&template, 9.0, range_m);
1336 let observation = WindObservation {
1337 range_m,
1338 miss_right_m: miss,
1339 sigma_m: None,
1340 };
1341
1342 let mut under_called = base_request(vec![observation]);
1343 under_called.called_crosswind_mph = Some(6.0);
1344 let report = solve_wind_truing(&under_called).expect("solve");
1345 let factor = report.wind_call_factor.expect("factor");
1346 assert!(
1347 (factor - 9.0 / 6.0).abs() < 0.01,
1348 "expected ~1.5, got {factor}"
1349 );
1350
1351 let mut wrong_side = base_request(vec![observation]);
1352 wrong_side.called_crosswind_mph = Some(-6.0);
1353 let flipped = solve_wind_truing(&wrong_side)
1354 .expect("solve")
1355 .wind_call_factor
1356 .expect("factor");
1357 assert!(flipped < 0.0, "a wrong-side call must read negative: {flipped}");
1358 }
1359
1360 #[test]
1364 fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1365 let template = base_request(Vec::new());
1366 let near = 274.32;
1367 let far = 640.08;
1368 let weighted = vec![
1369 WindObservation {
1370 range_m: near,
1371 miss_right_m: modeled_lateral_m(&template, 6.0, near),
1372 sigma_m: Some(0.25 * 0.0254),
1373 },
1374 WindObservation {
1375 range_m: far,
1376 miss_right_m: modeled_lateral_m(&template, 6.0, far),
1377 sigma_m: Some(0.25 * 0.0254),
1378 },
1379 ];
1380 let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1381 assert!(report.inverse_variance_weighted);
1382 let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1383 assert!(sigma > 0.0 && sigma.is_finite());
1384 let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1387 let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1388 assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1389 assert!(sigma <= far_sigma + 1e-12);
1390
1391 let mixed = base_request(vec![
1392 WindObservation {
1393 range_m: near,
1394 miss_right_m: 0.1,
1395 sigma_m: Some(0.006),
1396 },
1397 WindObservation {
1398 range_m: far,
1399 miss_right_m: 0.2,
1400 sigma_m: None,
1401 },
1402 ]);
1403 let error = mixed.validate().unwrap_err();
1404 assert!(error.contains("every observed miss or on none"), "{error}");
1405 }
1406
1407 #[test]
1410 fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1411 let error = solve_wind_truing(&base_request(vec![WindObservation {
1412 range_m: 274.32,
1413 miss_right_m: 25.0, sigma_m: None,
1415 }]))
1416 .unwrap_err()
1417 .to_string();
1418 assert!(error.contains("no crosswind within"), "{error}");
1419 assert!(error.contains("check the sign of --miss"), "{error}");
1420 }
1421
1422 #[test]
1429 fn windage_cf_does_not_alter_the_wind_solve() {
1430 let template = base_request(Vec::new());
1431 let range_m = 457.2;
1432 let miss = modeled_lateral_m(&template, 7.0, range_m);
1433 let observation = WindObservation {
1434 range_m,
1435 miss_right_m: miss,
1436 sigma_m: None,
1437 };
1438 let solved = solve_wind_truing(&base_request(vec![observation]))
1439 .expect("solve")
1440 .mean_crosswind_mph;
1441 assert!((solved - 7.0).abs() < 0.02);
1442
1443 let windage_cf = 0.95;
1446 let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1447 range_m,
1448 miss_right_m: miss * windage_cf,
1449 sigma_m: None,
1450 }]))
1451 .expect("solve")
1452 .mean_crosswind_mph;
1453 assert!(
1454 (cf_applied - solved).abs() > 0.1,
1455 "a CF-scaled observation must NOT be equivalent to the linear one \
1456 ({cf_applied} vs {solved}); --miss therefore takes no CF"
1457 );
1458 }
1459
1460 #[test]
1463 fn json_value_nulls_absent_optional_fields() {
1464 let template = base_request(Vec::new());
1465 let range_m = 457.2;
1466 let report = solve_wind_truing(&base_request(vec![WindObservation {
1467 range_m,
1468 miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1469 sigma_m: None,
1470 }]))
1471 .expect("solve");
1472 let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1473 assert!(value["called_crosswind"].is_null());
1474 assert!(value["wind_call_factor"].is_null());
1475 assert!(value["effective_crosswind_sigma"].is_null());
1476 assert!(value["observations"][0]["miss_sigma_in"].is_null());
1477 assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1478 assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1479 assert_eq!(value["legend"]["units"]["miss"], "in");
1480 assert_eq!(
1481 value["effective_crosswind"].as_f64().expect("f64").round(),
1482 5.0
1483 );
1484
1485 let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1488 assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1489 assert_eq!(metric["legend"]["units"]["range"], "m");
1490 assert_eq!(metric["legend"]["units"]["miss"], "in");
1491 let mps = metric["effective_crosswind"].as_f64().expect("f64");
1492 let mph = value["effective_crosswind"].as_f64().expect("f64");
1493 assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1494 }
1495
1496 #[test]
1499 fn parse_wind_observation_units_and_errors() {
1500 let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1501 assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1502 assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1503 assert!(imperial.sigma_m.is_none());
1504
1505 let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1506 assert!((metric.range_m - 550.0).abs() < 1e-12);
1507 assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1508 assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1509
1510 for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1511 assert!(
1512 parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1513 "'{bad}' should not parse"
1514 );
1515 }
1516 }
1517
1518 #[test]
1520 fn validation_rejects_degenerate_requests() {
1521 assert!(base_request(Vec::new())
1522 .validate()
1523 .unwrap_err()
1524 .contains("at least one"));
1525
1526 let duplicate = base_request(vec![
1527 WindObservation {
1528 range_m: 457.2,
1529 miss_right_m: 0.2,
1530 sigma_m: None,
1531 },
1532 WindObservation {
1533 range_m: 457.2,
1534 miss_right_m: 0.3,
1535 sigma_m: None,
1536 },
1537 ]);
1538 assert!(duplicate
1539 .validate()
1540 .unwrap_err()
1541 .contains("duplicate observation range"));
1542
1543 let mut bad_twist = base_request(vec![WindObservation {
1544 range_m: 457.2,
1545 miss_right_m: 0.2,
1546 sigma_m: None,
1547 }]);
1548 bad_twist.twist.rate_in = 0.0;
1549 assert!(bad_twist
1550 .validate()
1551 .unwrap_err()
1552 .contains("twist rate must be positive"));
1553
1554 let mut zero_call = base_request(vec![WindObservation {
1555 range_m: 457.2,
1556 miss_right_m: 0.2,
1557 sigma_m: None,
1558 }]);
1559 zero_call.called_crosswind_mph = Some(0.0);
1560 assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1561 }
1562
1563 #[test]
1566 fn request_deserializes_and_report_serializes() {
1567 let json = serde_json::json!({
1568 "observations": [{"range_m": 457.2, "miss_right_m": 0.315, "sigma_m": null}],
1569 "muzzle_velocity_fps": 2700.0, "bc": 0.243, "drag_model": "g7",
1570 "mass_gr": 168.0, "diameter_in": 0.308, "zero_distance_yd": 100.0,
1571 "sight_height_in": 2.0, "temperature_f": 59.0, "pressure_inhg": 29.92,
1572 "humidity_pct": 50.0, "altitude_ft": 0.0,
1573 "twist": {"rate_in": 11.0, "right_hand": true},
1574 "earth": null, "called_crosswind_mph": null
1575 });
1576 let req: WindTruingRequest =
1577 serde_json::from_value(json).expect("request deserializes");
1578 let report = solve_wind_truing(&req).expect("solves");
1579 let out = serde_json::to_value(&report).expect("report serializes");
1580 assert!(out["mean_crosswind_mph"].is_number());
1581 assert!(out["solutions"].as_array().unwrap().len() == 1);
1582 }
1583
1584 fn sol(solved_crosswind_mph: f64, sigma_m: Option<f64>, solved_sigma_mph: Option<f64>) -> WindTruingSolution {
1588 WindTruingSolution {
1589 range_m: 500.0,
1590 observed_miss_right_m: 0.3,
1591 sigma_m,
1592 solved_crosswind_mph,
1593 modeled_miss_right_m: 0.3,
1594 residual_m: 0.0,
1595 no_wind_lateral_m: 0.02,
1596 sensitivity_m_per_mph: 0.05,
1597 solved_sigma_mph,
1598 iterations: 3,
1599 converged: true,
1600 }
1601 }
1602
1603 #[test]
1604 fn interval_uses_scatter_when_no_sigmas_supplied() {
1605 let sols = vec![sol(6.0, None, None), sol(8.0, None, None), sol(7.0, None, None)];
1608 let got = build_wind_uncertainty(&sols, 7.0, None);
1609 let WindUncertaintyV1::Available(i) = got else {
1610 panic!("expected an interval, got {got:?}");
1611 };
1612 assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1613 assert_eq!(i.dof, Some(2));
1614 assert!(i.propagated_sigma_mph.is_none());
1615 let se = i.empirical_sigma_mph.expect("scatter sigma");
1617 assert!((se - 1.0 / 3f64.sqrt()).abs() < 1e-12, "se was {se}");
1618 let half = 4.302_652_730 * se;
1620 assert!((i.low_mph - (7.0 - half)).abs() < 1e-9);
1621 assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1622 assert!(i.low_mph < 7.0 && i.high_mph > 7.0);
1623 }
1624
1625 #[test]
1626 fn optimistic_supplied_sigmas_lose_to_observed_scatter() {
1627 let sols = vec![
1631 sol(4.0, Some(0.01), Some(0.02)),
1632 sol(9.0, Some(0.01), Some(0.02)),
1633 sol(6.5, Some(0.01), Some(0.02)),
1634 ];
1635 let propagated = Some(0.02 / 3f64.sqrt()); let got = build_wind_uncertainty(&sols, 6.5, propagated);
1637 let WindUncertaintyV1::Available(i) = got else { panic!("expected an interval") };
1638 assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1639 let e = i.empirical_sigma_mph.expect("scatter");
1640 let pr = i.propagated_sigma_mph.expect("propagated");
1641 assert!(e > pr, "scatter {e} should exceed propagated {pr}");
1642 assert!((i.sigma_mph - e).abs() < 1e-12, "the wider estimate must drive the interval");
1643 assert!(i.propagated_sigma_mph.is_some());
1645 }
1646
1647 #[test]
1648 fn tight_scatter_lets_supplied_sigmas_win() {
1649 let sols = vec![
1652 sol(7.00, Some(2.0), Some(1.5)),
1653 sol(7.01, Some(2.0), Some(1.5)),
1654 sol(6.99, Some(2.0), Some(1.5)),
1655 ];
1656 let propagated = Some(1.5 / 3f64.sqrt());
1657 let got = build_wind_uncertainty(&sols, 7.0, propagated);
1658 let WindUncertaintyV1::Available(i) = got else { panic!("expected an interval") };
1659 assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1660 assert_eq!(i.dof, None, "a supplied sigma is treated as known, so normal not t");
1661 let half = NORMAL_95_TWO_SIDED_Z * i.sigma_mph;
1662 assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1663 }
1664
1665 #[test]
1666 fn single_observation_without_sigma_is_explained_not_omitted() {
1667 let sols = vec![sol(7.0, None, None)];
1668 let got = build_wind_uncertainty(&sols, 7.0, None);
1669 let WindUncertaintyV1::Unavailable(f) = got else {
1670 panic!("one shot with no sigma cannot yield an interval");
1671 };
1672 assert_eq!(f.code, WindUncertaintyFailureCodeV1::SingleObservation);
1673 assert!(!f.message.is_empty());
1674 }
1675
1676 #[test]
1677 fn single_observation_with_sigma_still_gets_an_interval() {
1678 let sols = vec![sol(7.0, Some(0.1), Some(2.0))];
1679 let got = build_wind_uncertainty(&sols, 7.0, Some(2.0));
1680 let WindUncertaintyV1::Available(i) = got else { panic!("expected an interval") };
1681 assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1682 assert!(i.empirical_sigma_mph.is_none(), "one shot has no scatter");
1683 }
1684
1685 #[test]
1686 fn identical_observations_without_sigma_report_zero_spread_honestly() {
1687 let sols = vec![sol(7.0, None, None), sol(7.0, None, None)];
1689 let got = build_wind_uncertainty(&sols, 7.0, None);
1690 let WindUncertaintyV1::Unavailable(f) = got else {
1691 panic!("zero spread must not be reported as a zero-width interval");
1692 };
1693 assert_eq!(f.code, WindUncertaintyFailureCodeV1::NoUsableEstimate);
1694 }
1695
1696 #[test]
1697 fn t_multiplier_table_matches_published_values() {
1698 for (dof, want) in [(1usize, 12.706205), (2, 4.302653), (5, 2.570582), (10, 2.228139), (30, 2.042272)] {
1700 let got = T_95_TWO_SIDED[dof - 1];
1701 assert!((got - want).abs() < 5e-6, "dof {dof}: {got} vs {want}");
1702 }
1703 const { assert!(T_95_TWO_SIDED[0] > T_95_TWO_SIDED[9]) };
1706 const { assert!(T_95_TWO_SIDED[29] > NORMAL_95_TWO_SIDED_Z) };
1707 }
1708
1709 #[test]
1710 fn uncertainty_is_always_present_in_the_serialized_report() {
1711 let sols = vec![sol(6.0, None, None), sol(8.0, None, None)];
1712 let u = build_wind_uncertainty(&sols, 7.0, None);
1713 let v = serde_json::to_value(&u).expect("serializes");
1714 assert_eq!(v["status"], "available");
1715 assert!(v["detail"]["low_mph"].is_number());
1716 assert_eq!(v["detail"]["basis"], "empirical_scatter");
1717 }
1718}