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,
339 4.302652730,
340 3.182446305,
341 2.776445105,
342 2.570581836,
343 2.446911851,
344 2.364624252,
345 2.306004135,
346 2.262157163,
347 2.228138852,
348 2.200985160,
349 2.178812830,
350 2.160368656,
351 2.144786688,
352 2.131449546,
353 2.119905299,
354 2.109815578,
355 2.100922040,
356 2.093024054,
357 2.085963447,
358 2.079613845,
359 2.073873068,
360 2.068657610,
361 2.063898562,
362 2.059538553,
363 2.055529439,
364 2.051830516,
365 2.048407142,
366 2.045229642,
367 2.042272456,
368];
369
370const NORMAL_95_TWO_SIDED_Z: f64 = 1.959_963_984_540_054;
372
373const WIND_INTERVAL_PROBABILITY: f64 = 0.95;
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
378#[serde(rename_all = "snake_case")]
379pub enum WindUncertaintyBasisV1 {
380 EmpiricalScatter,
383 PropagatedMeasurement,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
390#[serde(rename_all = "snake_case")]
391pub enum WindUncertaintyFailureCodeV1 {
392 SingleObservation,
395 NoUsableEstimate,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
401pub struct WindUncertaintyFailureV1 {
402 pub code: WindUncertaintyFailureCodeV1,
403 pub message: String,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
408pub struct WindIntervalV1 {
409 pub sigma_mph: f64,
411 pub probability: f64,
413 pub low_mph: f64,
415 pub high_mph: f64,
417 pub basis: WindUncertaintyBasisV1,
419 pub empirical_sigma_mph: Option<f64>,
421 pub propagated_sigma_mph: Option<f64>,
424 pub dof: Option<u32>,
426}
427
428#[derive(Debug, Clone, PartialEq, Serialize)]
434#[serde(rename_all = "snake_case", tag = "status", content = "detail")]
435pub enum WindUncertaintyV1 {
436 Available(WindIntervalV1),
437 Unavailable(WindUncertaintyFailureV1),
438}
439
440fn build_wind_uncertainty(
447 solutions: &[WindTruingSolution],
448 mean_crosswind_mph: f64,
449 propagated_sigma_mph: Option<f64>,
450) -> WindUncertaintyV1 {
451 let n = solutions.len();
452
453 let empirical_sigma_mph = if n >= 2 {
455 let mean: f64 = solutions
456 .iter()
457 .map(|s| s.solved_crosswind_mph)
458 .sum::<f64>()
459 / n as f64;
460 let var = solutions
461 .iter()
462 .map(|s| {
463 let d = s.solved_crosswind_mph - mean;
464 d * d
465 })
466 .sum::<f64>()
467 / (n as f64 - 1.0); let se = (var / n as f64).sqrt();
469 if se.is_finite() && se > 0.0 {
470 Some(se)
471 } else {
472 None
473 }
474 } else {
475 None
476 };
477
478 let propagated = propagated_sigma_mph.filter(|v| v.is_finite() && *v > 0.0);
479
480 let (sigma_mph, basis, dof) = match (empirical_sigma_mph, propagated) {
482 (Some(e), Some(p)) if e >= p => (
483 e,
484 WindUncertaintyBasisV1::EmpiricalScatter,
485 Some(n as u32 - 1),
486 ),
487 (Some(_), Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
488 (Some(e), None) => (
489 e,
490 WindUncertaintyBasisV1::EmpiricalScatter,
491 Some(n as u32 - 1),
492 ),
493 (None, Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
494 (None, None) => {
495 let (code, message) = if n < 2 {
496 (
497 WindUncertaintyFailureCodeV1::SingleObservation,
498 "a single observation with no measurement sigma gives nothing to estimate an interval from — shoot more observations, or supply a sigma on this one"
499 .to_string(),
500 )
501 } else {
502 (
503 WindUncertaintyFailureCodeV1::NoUsableEstimate,
504 "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"
505 .to_string(),
506 )
507 };
508 return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 { code, message });
509 }
510 };
511
512 let multiplier = match dof {
513 Some(d) if d >= 1 => *T_95_TWO_SIDED
514 .get((d - 1) as usize)
515 .unwrap_or(&NORMAL_95_TWO_SIDED_Z),
516 _ => NORMAL_95_TWO_SIDED_Z,
517 };
518 let half_width = multiplier * sigma_mph;
519 if !half_width.is_finite() {
520 return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 {
521 code: WindUncertaintyFailureCodeV1::NoUsableEstimate,
522 message: "the interval half-width was not finite".to_string(),
523 });
524 }
525
526 WindUncertaintyV1::Available(WindIntervalV1 {
527 sigma_mph,
528 probability: WIND_INTERVAL_PROBABILITY,
529 low_mph: mean_crosswind_mph - half_width,
530 high_mph: mean_crosswind_mph + half_width,
531 basis,
532 empirical_sigma_mph,
533 propagated_sigma_mph: propagated,
534 dof,
535 })
536}
537
538#[derive(Debug, Clone, Serialize)]
540pub struct WindTruingReport {
541 pub solutions: Vec<WindTruingSolution>,
543 pub mean_crosswind_mph: f64,
545 pub mean_sigma_mph: Option<f64>,
549 pub uncertainty: WindUncertaintyV1,
552 pub inverse_variance_weighted: bool,
555 pub called_crosswind_mph: Option<f64>,
557 pub wind_call_factor: Option<f64>,
560 pub subtracted_effects: Vec<String>,
562 pub unsubtracted_effects: Vec<String>,
565}
566
567fn crosswind_conditions(signed_mph: f64) -> WindConditions {
575 WindConditions {
576 speed: signed_mph.abs() * MPH_TO_MPS,
577 direction: if signed_mph < 0.0 {
578 std::f64::consts::FRAC_PI_2
579 } else {
580 3.0 * std::f64::consts::FRAC_PI_2
581 },
582 vertical_speed: 0.0,
583 }
584}
585
586pub fn modeled_miss_right_m(
596 request: &WindTruingRequest,
597 crosswind_mph: f64,
598 range_m: f64,
599) -> Result<f64, Box<dyn Error>> {
600 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
601 let env = TruingEnvironment {
602 wind: crosswind_conditions(crosswind_mph),
603 twist: Some(request.twist),
604 earth: request.earth,
605 };
606 let sample = crate::truing::solve_trajectory_sample(
607 request.muzzle_velocity_fps,
608 request.bc,
609 request.drag_model,
610 request.mass_gr,
611 request.diameter_in,
612 request.zero_distance_yd,
613 range_m / 0.9144,
614 request.sight_height_in,
615 request.temperature_f,
616 request.pressure_inhg,
617 request.humidity_pct,
618 request.altitude_ft,
619 &no_bc_segments,
620 &env,
621 true, )?;
623 Ok(sample.lateral_m)
624}
625
626pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
639 request.validate()?;
640
641 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
645
646 let environment = |crosswind_mph: f64| TruingEnvironment {
647 wind: crosswind_conditions(crosswind_mph),
648 twist: Some(request.twist),
649 earth: request.earth,
650 };
651
652 let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
654 let sample = crate::truing::solve_trajectory_sample(
655 request.muzzle_velocity_fps,
656 request.bc,
657 request.drag_model,
658 request.mass_gr,
659 request.diameter_in,
660 request.zero_distance_yd,
661 range_yd,
662 request.sight_height_in,
663 request.temperature_f,
664 request.pressure_inhg,
665 request.humidity_pct,
666 request.altitude_ft,
667 &no_bc_segments,
668 &environment(crosswind_mph),
669 true, )?;
671 Ok(sample.lateral_m)
672 };
673
674 let mut solutions = Vec::with_capacity(request.observations.len());
675 for observation in &request.observations {
676 let range_yd = observation.range_m / 0.9144;
677 solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
678 }
679
680 let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
685 if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
686 return Err(
687 "an observation does not move with crosswind at all, so its measurement sigma \
688 cannot be expressed in wind units — drop that observation or its sigma"
689 .into(),
690 );
691 }
692 let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
693 let mut weight_sum = 0.0;
694 let mut weighted = 0.0;
695 for solution in &solutions {
696 let sigma = solution
697 .solved_sigma_mph
698 .expect("checked by inverse_variance_weighted");
699 let weight = 1.0 / (sigma * sigma);
700 weight_sum += weight;
701 weighted += weight * solution.solved_crosswind_mph;
702 }
703 if weight_sum > 0.0 && weight_sum.is_finite() {
704 (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
705 } else {
706 return Err(
707 "observed-miss sigmas produced a degenerate weighting (check that every \
708 sigma is positive and that the observations move with wind at all)"
709 .into(),
710 );
711 }
712 } else {
713 let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
714 (sum / solutions.len() as f64, None)
715 };
716
717 let wind_call_factor = request
718 .called_crosswind_mph
719 .map(|called| mean_crosswind_mph / called);
720
721 let mut subtracted_effects = vec!["spin drift".to_string()];
725 let mut unsubtracted_effects = Vec::new();
726 if request.earth.is_some() {
727 subtracted_effects.push("Coriolis".to_string());
728 } else {
729 unsubtracted_effects
730 .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
731 }
732
733 let uncertainty = build_wind_uncertainty(&solutions, mean_crosswind_mph, mean_sigma_mph);
734
735 Ok(WindTruingReport {
736 solutions,
737 mean_crosswind_mph,
738 mean_sigma_mph,
739 uncertainty,
740 inverse_variance_weighted,
741 called_crosswind_mph: request.called_crosswind_mph,
742 wind_call_factor,
743 subtracted_effects,
744 unsubtracted_effects,
745 })
746}
747
748fn solve_one_observation(
753 observation: &WindObservation,
754 range_yd: f64,
755 lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
756) -> Result<WindTruingSolution, Box<dyn Error>> {
757 let target = observation.miss_right_m;
758 let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
759 Ok(lateral_at(crosswind_mph, range_yd)? - target)
760 };
761
762 let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
763 let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
764 let mut f_low = residual(low)?;
765 let mut f_high = residual(high)?;
766 if f_low > 0.0 || f_high < 0.0 {
767 return Err(format!(
768 "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
769 miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
770 the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
771 target / 0.0254,
772 (f_low + target) / 0.0254,
773 (f_high + target) / 0.0254,
774 )
775 .into());
776 }
777
778 let mut solved = 0.0;
779 let mut f_solved = 0.0;
780 let mut iterations = 0u32;
781 let mut converged = false;
782 while iterations < WIND_SOLVE_MAX_ITERATIONS {
783 iterations += 1;
784 let denom = f_high - f_low;
785 let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
786 high - f_high * (high - low) / denom
787 } else {
788 0.5 * (low + high)
789 };
790 if !candidate.is_finite() || candidate <= low || candidate >= high {
792 candidate = 0.5 * (low + high);
793 }
794 let f = residual(candidate)?;
795 solved = candidate;
796 f_solved = f;
797 if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
798 converged = true;
799 break;
800 }
801 if f < 0.0 {
803 low = candidate;
804 f_low = f;
805 f_high *= 0.5;
806 } else {
807 high = candidate;
808 f_high = f;
809 f_low *= 0.5;
810 }
811 }
812
813 let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
815 let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
816 let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
817
818 let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
821
822 let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
823 let slope = sensitivity_m_per_mph.abs();
824 (slope > 0.0).then_some(sigma / slope)
825 });
826
827 Ok(WindTruingSolution {
828 range_m: observation.range_m,
829 observed_miss_right_m: target,
830 sigma_m: observation.sigma_m,
831 solved_crosswind_mph: solved,
832 modeled_miss_right_m: target + f_solved,
833 residual_m: f_solved,
834 no_wind_lateral_m,
835 sensitivity_m_per_mph,
836 solved_sigma_mph,
837 iterations,
838 converged,
839 })
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
846pub enum WindTruingOutput {
847 Table,
848 Json,
849 Csv,
850}
851
852struct WindTruingUnits {
855 range_label: &'static str,
856 speed_label: &'static str,
857 range_scale: f64,
858 speed_scale: f64,
859}
860
861impl WindTruingUnits {
862 fn for_system(units: UnitSystem) -> Self {
863 match units {
864 UnitSystem::Imperial => Self {
865 range_label: "yd",
866 speed_label: "mph",
867 range_scale: 1.0 / 0.9144,
868 speed_scale: 1.0,
869 },
870 UnitSystem::Metric => Self {
871 range_label: "m",
872 speed_label: "m/s",
873 range_scale: 1.0,
874 speed_scale: MPH_TO_MPS,
875 },
876 }
877 }
878
879 fn range(&self, range_m: f64) -> f64 {
880 range_m * self.range_scale
881 }
882
883 fn speed(&self, mph: f64) -> f64 {
884 mph * self.speed_scale
885 }
886}
887
888fn inches(meters: f64) -> f64 {
890 meters / 0.0254
891}
892
893pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
900 let u = WindTruingUnits::for_system(units);
901 let observations: Vec<serde_json::Value> = report
902 .solutions
903 .iter()
904 .map(|s| {
905 serde_json::json!({
906 format!("range_{}", u.range_label): u.range(s.range_m),
907 "miss_right_in": inches(s.observed_miss_right_m),
908 "miss_sigma_in": s.sigma_m.map(inches),
909 "no_wind_lateral_in": inches(s.no_wind_lateral_m),
910 "solved_crosswind": u.speed(s.solved_crosswind_mph),
911 "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
912 "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
913 "residual_in": inches(s.residual_m),
914 "iterations": s.iterations,
915 "converged": s.converged,
916 })
917 })
918 .collect();
919
920 serde_json::json!({
921 "effective_crosswind": u.speed(report.mean_crosswind_mph),
922 "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
923 "inverse_variance_weighted": report.inverse_variance_weighted,
924 "uncertainty": match &report.uncertainty {
925 WindUncertaintyV1::Available(i) => serde_json::json!({
926 "status": "available",
927 "sigma": u.speed(i.sigma_mph),
928 "probability": i.probability,
929 "low": u.speed(i.low_mph),
930 "high": u.speed(i.high_mph),
931 "basis": match i.basis {
932 WindUncertaintyBasisV1::EmpiricalScatter => "empirical_scatter",
933 WindUncertaintyBasisV1::PropagatedMeasurement => "propagated_measurement",
934 },
935 "empirical_sigma": i.empirical_sigma_mph.map(|v| u.speed(v)),
936 "propagated_sigma": i.propagated_sigma_mph.map(|v| u.speed(v)),
937 "dof": i.dof,
938 }),
939 WindUncertaintyV1::Unavailable(f) => serde_json::json!({
940 "status": "unavailable",
941 "code": match f.code {
942 WindUncertaintyFailureCodeV1::SingleObservation => "single_observation",
943 WindUncertaintyFailureCodeV1::NoUsableEstimate => "no_usable_estimate",
944 },
945 "message": f.message,
946 }),
947 },
948 "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
949 "wind_call_factor": report.wind_call_factor,
950 "observations": observations,
951 "effects_subtracted": report.subtracted_effects,
952 "effects_not_subtracted": report.unsubtracted_effects,
953 "legend": {
954 "units": {
955 "range": u.range_label,
956 "miss": "in",
957 "wind_speed": u.speed_label,
958 },
959 "signs": "--miss positive = impact right of aim; solved crosswind positive = \
960 wind from the shooter's left (9 o'clock) pushing impacts right",
961 },
962 })
963}
964
965pub fn format_wind_truing_report(
971 report: &WindTruingReport,
972 units: UnitSystem,
973 output: WindTruingOutput,
974) -> String {
975 let u = WindTruingUnits::for_system(units);
976 match output {
977 WindTruingOutput::Json => {
978 match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
979 Ok(s) => format!("{s}\n"),
980 Err(e) => format!("Error serializing JSON: {e}\n"),
981 }
982 }
983 WindTruingOutput::Csv => {
984 let mut out = String::new();
985 out.push_str(&format!(
986 "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
987 sensitivity_in_per_mph,residual_in,iterations,converged\n",
988 u.range_label, u.speed_label
989 ));
990 for s in &report.solutions {
991 out.push_str(&format!(
992 "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
993 u.range(s.range_m),
994 inches(s.observed_miss_right_m),
995 match s.sigma_m {
996 Some(sigma) => format!("{:.3}", inches(sigma)),
997 None => String::new(),
998 },
999 inches(s.no_wind_lateral_m),
1000 u.speed(s.solved_crosswind_mph),
1001 inches(s.sensitivity_m_per_mph),
1002 inches(s.residual_m),
1003 s.iterations,
1004 s.converged,
1005 ));
1006 }
1007 out.push('\n');
1008 out.push_str(&format!(
1009 "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
1010 called_crosswind_{},wind_call_factor\n",
1011 u.speed_label, u.speed_label, u.speed_label
1012 ));
1013 out.push_str(&format!(
1014 "{:+.3},{},{},{},{}\n",
1015 u.speed(report.mean_crosswind_mph),
1016 match report.mean_sigma_mph {
1017 Some(sigma) => format!("{:.3}", u.speed(sigma)),
1018 None => String::new(),
1019 },
1020 report.inverse_variance_weighted,
1021 match report.called_crosswind_mph {
1022 Some(called) => format!("{:+.3}", u.speed(called)),
1023 None => String::new(),
1024 },
1025 match report.wind_call_factor {
1026 Some(factor) => format!("{factor:.4}"),
1027 None => String::new(),
1028 },
1029 ));
1030 out
1031 }
1032 WindTruingOutput::Table => {
1033 let mut out = String::new();
1034 out.push('\n');
1035 out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
1036 out.push('\n');
1037 out.push_str(&format!(
1038 " {:>10} {:>12} {:>14} {:>16} {:>10}\n",
1039 format!("Range ({})", u.range_label),
1040 "Miss (in)",
1041 "Spin/Cor (in)",
1042 format!("Wind ({})", u.speed_label),
1043 "Resid (in)",
1044 ));
1045 out.push_str(&format!(" {}\n", "-".repeat(70)));
1046 for s in &report.solutions {
1047 out.push_str(&format!(
1048 " {:>10.1} {:>+12.2} {:>+14.2} {:>+16.2} {:>+10.3}\n",
1049 u.range(s.range_m),
1050 inches(s.observed_miss_right_m),
1051 inches(s.no_wind_lateral_m),
1052 u.speed(s.solved_crosswind_mph),
1053 inches(s.residual_m),
1054 ));
1055 }
1056 out.push_str(&format!(" {}\n", "-".repeat(70)));
1057 out.push('\n');
1058 let n = report.solutions.len();
1059 out.push_str(&format!(
1060 " Effective crosswind: {:>+8.2} {}{}\n",
1061 u.speed(report.mean_crosswind_mph),
1062 u.speed_label,
1063 match report.mean_sigma_mph {
1068 Some(_) => format!(" (inverse-variance weighted over {n} observations)"),
1069 None if n > 1 => format!(" (mean of {n} observations)"),
1070 None => String::new(),
1071 }
1072 ));
1073 match &report.uncertainty {
1074 WindUncertaintyV1::Available(i) => {
1075 out.push_str(&format!(
1076 " 95% interval: [{:>+7.2}, {:>+7.2}] {} ({})\n",
1077 u.speed(i.low_mph),
1078 u.speed(i.high_mph),
1079 u.speed_label,
1080 match i.basis {
1081 WindUncertaintyBasisV1::EmpiricalScatter => match i.dof {
1082 Some(d) => format!("from shot-to-shot scatter, t with {d} dof"),
1083 None => "from shot-to-shot scatter".to_string(),
1084 },
1085 WindUncertaintyBasisV1::PropagatedMeasurement =>
1086 "from your measurement sigmas".to_string(),
1087 }
1088 ));
1089 if let (Some(e), Some(pr)) = (i.empirical_sigma_mph, i.propagated_sigma_mph) {
1092 out.push_str(&format!(
1093 " scatter sigma {:.2} {} vs measurement sigma {:.2} {} — the wider one is reported\n",
1094 u.speed(e),
1095 u.speed_label,
1096 u.speed(pr),
1097 u.speed_label
1098 ));
1099 }
1100 }
1101 WindUncertaintyV1::Unavailable(f) => {
1102 out.push_str(&format!(" 95% interval: none — {}\n", f.message));
1103 }
1104 }
1105 if let (Some(called), Some(factor)) =
1106 (report.called_crosswind_mph, report.wind_call_factor)
1107 {
1108 out.push_str(&format!(
1109 " Called wind: {:>+8.2} {} -> wind-call correction factor {:.2}\n",
1110 u.speed(called),
1111 u.speed_label,
1112 factor
1113 ));
1114 out.push_str(&format!(
1115 " (multiply your wind calls by {factor:.2} to match what actually hit)\n"
1116 ));
1117 }
1118 if !report.subtracted_effects.is_empty() {
1119 out.push_str(&format!(
1120 " Effects subtracted: {}\n",
1121 report.subtracted_effects.join(", ")
1122 ));
1123 }
1124 if !report.unsubtracted_effects.is_empty() {
1125 out.push_str(&format!(
1126 " NOT subtracted (absorbed into the solved wind): {}\n",
1127 report.unsubtracted_effects.join(", ")
1128 ));
1129 }
1130 for s in &report.solutions {
1131 if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
1132 out.push_str(&format!(
1133 " note: the observation at {:.1} {} moves only {:.2} in per mph of \
1134 crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
1135 wind fitted from it is weakly identified\n",
1136 u.range(s.range_m),
1137 u.range_label,
1138 inches(s.sensitivity_m_per_mph).abs(),
1139 ));
1140 }
1141 if !s.converged {
1142 out.push_str(&format!(
1143 " note: the fit at {:.1} {} did not fully converge after {} iterations; \
1144 the value shown is the best estimate\n",
1145 u.range(s.range_m),
1146 u.range_label,
1147 s.iterations,
1148 ));
1149 }
1150 }
1151 out.push('\n');
1152 out.push_str(
1153 " Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
1154 \x20 FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
1155 \x20 = FROM the right pushing left. Wind-FROM convention throughout\n\
1156 \x20 (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
1157 );
1158 out.push('\n');
1159 out
1160 }
1161 }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167
1168 fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
1169 WindTruingRequest {
1170 observations,
1171 muzzle_velocity_fps: 2700.0,
1172 bc: 0.475,
1173 drag_model: DragModelArg::G7,
1174 mass_gr: 168.0,
1175 diameter_in: 0.308,
1176 zero_distance_yd: 100.0,
1177 sight_height_in: 2.0,
1178 temperature_f: 59.0,
1179 pressure_inhg: 29.92,
1180 humidity_pct: 50.0,
1181 altitude_ft: 0.0,
1182 twist: TruingTwist {
1183 rate_in: 11.0,
1184 right_hand: true,
1185 },
1186 earth: None,
1187 called_crosswind_mph: None,
1188 }
1189 }
1190
1191 fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
1194 modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
1195 }
1196
1197 #[test]
1202 fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
1203 let known_mph = 7.5;
1204 let ranges_m = [274.32, 457.2, 640.08]; let template = base_request(Vec::new());
1206 let observations = ranges_m
1207 .iter()
1208 .map(|range_m| WindObservation {
1209 range_m: *range_m,
1210 miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
1211 sigma_m: None,
1212 })
1213 .collect();
1214
1215 let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
1216 assert_eq!(report.solutions.len(), 3);
1217 for solution in &report.solutions {
1218 assert!(solution.converged, "{solution:?}");
1219 assert!(
1220 (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
1221 "recovered {} mph at {} m, expected {known_mph}",
1222 solution.solved_crosswind_mph,
1223 solution.range_m
1224 );
1225 }
1226 assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
1227 assert!(!report.inverse_variance_weighted);
1228 assert!(report.mean_sigma_mph.is_none());
1229 }
1230
1231 #[test]
1235 fn miss_right_solves_positive_and_miss_left_solves_negative() {
1236 let template = base_request(Vec::new());
1237 let range_m = 457.2; let right_miss = modeled_lateral_m(&template, 8.0, range_m);
1239 let left_miss = modeled_lateral_m(&template, -8.0, range_m);
1240 assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
1241 assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
1242
1243 let right = solve_wind_truing(&base_request(vec![WindObservation {
1244 range_m,
1245 miss_right_m: right_miss,
1246 sigma_m: None,
1247 }]))
1248 .expect("right-miss fit must solve");
1249 assert!(
1250 right.mean_crosswind_mph > 0.0,
1251 "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
1252 right.mean_crosswind_mph
1253 );
1254 assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
1255
1256 let left = solve_wind_truing(&base_request(vec![WindObservation {
1257 range_m,
1258 miss_right_m: left_miss,
1259 sigma_m: None,
1260 }]))
1261 .expect("left-miss fit must solve");
1262 assert!(
1263 left.mean_crosswind_mph < 0.0,
1264 "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
1265 left.mean_crosswind_mph
1266 );
1267 assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
1268 }
1269
1270 #[test]
1274 fn pure_spin_drift_solves_to_zero_wind() {
1275 let template = base_request(Vec::new());
1276 let range_m = 640.08; let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1278 assert!(
1279 spin_only > 0.05,
1280 "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1281 );
1282
1283 let report = solve_wind_truing(&base_request(vec![WindObservation {
1284 range_m,
1285 miss_right_m: spin_only,
1286 sigma_m: None,
1287 }]))
1288 .expect("spin-only fit must solve");
1289 assert!(
1290 report.mean_crosswind_mph.abs() < 0.02,
1291 "pure spin drift must solve to ~0 wind, got {}",
1292 report.mean_crosswind_mph
1293 );
1294 assert!(report
1296 .solutions
1297 .iter()
1298 .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1299 assert!(report
1300 .subtracted_effects
1301 .iter()
1302 .any(|e| e.contains("spin drift")));
1303 }
1304
1305 #[test]
1309 fn twist_hand_changes_the_solved_wind() {
1310 let range_m = 640.08;
1311 let observation = WindObservation {
1312 range_m,
1313 miss_right_m: 0.25,
1314 sigma_m: None,
1315 };
1316 let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1317 let mut left = base_request(vec![observation]);
1318 left.twist.right_hand = false;
1319 let left_hand = solve_wind_truing(&left).expect("solve");
1320 assert!(
1321 left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1322 "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1323 left_hand.mean_crosswind_mph,
1324 right_hand.mean_crosswind_mph
1325 );
1326 }
1327
1328 #[test]
1332 fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1333 let range_m = 914.4; let observation = WindObservation {
1335 range_m,
1336 miss_right_m: 0.30,
1337 sigma_m: None,
1338 };
1339 let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1340 let mut with_earth = base_request(vec![observation]);
1341 with_earth.earth = Some(TruingEarthFrame {
1342 latitude_deg: 45.0,
1343 shot_azimuth_deg: 90.0, });
1345 let with = solve_wind_truing(&with_earth).expect("solve");
1346
1347 assert!(without
1348 .unsubtracted_effects
1349 .iter()
1350 .any(|e| e.contains("Coriolis")));
1351 assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1352 assert!(with.unsubtracted_effects.is_empty());
1353 assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1354 assert!(
1355 (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1356 > 1e-4,
1357 "modelling Coriolis must change the zero-wind lateral"
1358 );
1359 assert!(
1360 (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1361 "modelling Coriolis must change the solved wind"
1362 );
1363 }
1364
1365 #[test]
1369 fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1370 let template = base_request(Vec::new());
1371 let range_m = 457.2;
1372 let miss = modeled_lateral_m(&template, 9.0, range_m);
1373 let observation = WindObservation {
1374 range_m,
1375 miss_right_m: miss,
1376 sigma_m: None,
1377 };
1378
1379 let mut under_called = base_request(vec![observation]);
1380 under_called.called_crosswind_mph = Some(6.0);
1381 let report = solve_wind_truing(&under_called).expect("solve");
1382 let factor = report.wind_call_factor.expect("factor");
1383 assert!(
1384 (factor - 9.0 / 6.0).abs() < 0.01,
1385 "expected ~1.5, got {factor}"
1386 );
1387
1388 let mut wrong_side = base_request(vec![observation]);
1389 wrong_side.called_crosswind_mph = Some(-6.0);
1390 let flipped = solve_wind_truing(&wrong_side)
1391 .expect("solve")
1392 .wind_call_factor
1393 .expect("factor");
1394 assert!(
1395 flipped < 0.0,
1396 "a wrong-side call must read negative: {flipped}"
1397 );
1398 }
1399
1400 #[test]
1404 fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1405 let template = base_request(Vec::new());
1406 let near = 274.32;
1407 let far = 640.08;
1408 let weighted = vec![
1409 WindObservation {
1410 range_m: near,
1411 miss_right_m: modeled_lateral_m(&template, 6.0, near),
1412 sigma_m: Some(0.25 * 0.0254),
1413 },
1414 WindObservation {
1415 range_m: far,
1416 miss_right_m: modeled_lateral_m(&template, 6.0, far),
1417 sigma_m: Some(0.25 * 0.0254),
1418 },
1419 ];
1420 let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1421 assert!(report.inverse_variance_weighted);
1422 let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1423 assert!(sigma > 0.0 && sigma.is_finite());
1424 let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1427 let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1428 assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1429 assert!(sigma <= far_sigma + 1e-12);
1430
1431 let mixed = base_request(vec![
1432 WindObservation {
1433 range_m: near,
1434 miss_right_m: 0.1,
1435 sigma_m: Some(0.006),
1436 },
1437 WindObservation {
1438 range_m: far,
1439 miss_right_m: 0.2,
1440 sigma_m: None,
1441 },
1442 ]);
1443 let error = mixed.validate().unwrap_err();
1444 assert!(error.contains("every observed miss or on none"), "{error}");
1445 }
1446
1447 #[test]
1450 fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1451 let error = solve_wind_truing(&base_request(vec![WindObservation {
1452 range_m: 274.32,
1453 miss_right_m: 25.0, sigma_m: None,
1455 }]))
1456 .unwrap_err()
1457 .to_string();
1458 assert!(error.contains("no crosswind within"), "{error}");
1459 assert!(error.contains("check the sign of --miss"), "{error}");
1460 }
1461
1462 #[test]
1469 fn windage_cf_does_not_alter_the_wind_solve() {
1470 let template = base_request(Vec::new());
1471 let range_m = 457.2;
1472 let miss = modeled_lateral_m(&template, 7.0, range_m);
1473 let observation = WindObservation {
1474 range_m,
1475 miss_right_m: miss,
1476 sigma_m: None,
1477 };
1478 let solved = solve_wind_truing(&base_request(vec![observation]))
1479 .expect("solve")
1480 .mean_crosswind_mph;
1481 assert!((solved - 7.0).abs() < 0.02);
1482
1483 let windage_cf = 0.95;
1486 let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1487 range_m,
1488 miss_right_m: miss * windage_cf,
1489 sigma_m: None,
1490 }]))
1491 .expect("solve")
1492 .mean_crosswind_mph;
1493 assert!(
1494 (cf_applied - solved).abs() > 0.1,
1495 "a CF-scaled observation must NOT be equivalent to the linear one \
1496 ({cf_applied} vs {solved}); --miss therefore takes no CF"
1497 );
1498 }
1499
1500 #[test]
1503 fn json_value_nulls_absent_optional_fields() {
1504 let template = base_request(Vec::new());
1505 let range_m = 457.2;
1506 let report = solve_wind_truing(&base_request(vec![WindObservation {
1507 range_m,
1508 miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1509 sigma_m: None,
1510 }]))
1511 .expect("solve");
1512 let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1513 assert!(value["called_crosswind"].is_null());
1514 assert!(value["wind_call_factor"].is_null());
1515 assert!(value["effective_crosswind_sigma"].is_null());
1516 assert!(value["observations"][0]["miss_sigma_in"].is_null());
1517 assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1518 assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1519 assert_eq!(value["legend"]["units"]["miss"], "in");
1520 assert_eq!(
1521 value["effective_crosswind"].as_f64().expect("f64").round(),
1522 5.0
1523 );
1524
1525 let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1528 assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1529 assert_eq!(metric["legend"]["units"]["range"], "m");
1530 assert_eq!(metric["legend"]["units"]["miss"], "in");
1531 let mps = metric["effective_crosswind"].as_f64().expect("f64");
1532 let mph = value["effective_crosswind"].as_f64().expect("f64");
1533 assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1534 }
1535
1536 #[test]
1539 fn parse_wind_observation_units_and_errors() {
1540 let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1541 assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1542 assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1543 assert!(imperial.sigma_m.is_none());
1544
1545 let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1546 assert!((metric.range_m - 550.0).abs() < 1e-12);
1547 assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1548 assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1549
1550 for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1551 assert!(
1552 parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1553 "'{bad}' should not parse"
1554 );
1555 }
1556 }
1557
1558 #[test]
1560 fn validation_rejects_degenerate_requests() {
1561 assert!(base_request(Vec::new())
1562 .validate()
1563 .unwrap_err()
1564 .contains("at least one"));
1565
1566 let duplicate = base_request(vec![
1567 WindObservation {
1568 range_m: 457.2,
1569 miss_right_m: 0.2,
1570 sigma_m: None,
1571 },
1572 WindObservation {
1573 range_m: 457.2,
1574 miss_right_m: 0.3,
1575 sigma_m: None,
1576 },
1577 ]);
1578 assert!(duplicate
1579 .validate()
1580 .unwrap_err()
1581 .contains("duplicate observation range"));
1582
1583 let mut bad_twist = base_request(vec![WindObservation {
1584 range_m: 457.2,
1585 miss_right_m: 0.2,
1586 sigma_m: None,
1587 }]);
1588 bad_twist.twist.rate_in = 0.0;
1589 assert!(bad_twist
1590 .validate()
1591 .unwrap_err()
1592 .contains("twist rate must be positive"));
1593
1594 let mut zero_call = base_request(vec![WindObservation {
1595 range_m: 457.2,
1596 miss_right_m: 0.2,
1597 sigma_m: None,
1598 }]);
1599 zero_call.called_crosswind_mph = Some(0.0);
1600 assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1601 }
1602
1603 #[test]
1606 fn request_deserializes_and_report_serializes() {
1607 let json = serde_json::json!({
1608 "observations": [{"range_m": 457.2, "miss_right_m": 0.315, "sigma_m": null}],
1609 "muzzle_velocity_fps": 2700.0, "bc": 0.243, "drag_model": "g7",
1610 "mass_gr": 168.0, "diameter_in": 0.308, "zero_distance_yd": 100.0,
1611 "sight_height_in": 2.0, "temperature_f": 59.0, "pressure_inhg": 29.92,
1612 "humidity_pct": 50.0, "altitude_ft": 0.0,
1613 "twist": {"rate_in": 11.0, "right_hand": true},
1614 "earth": null, "called_crosswind_mph": null
1615 });
1616 let req: WindTruingRequest = serde_json::from_value(json).expect("request deserializes");
1617 let report = solve_wind_truing(&req).expect("solves");
1618 let out = serde_json::to_value(&report).expect("report serializes");
1619 assert!(out["mean_crosswind_mph"].is_number());
1620 assert!(out["solutions"].as_array().unwrap().len() == 1);
1621 }
1622
1623 fn sol(
1627 solved_crosswind_mph: f64,
1628 sigma_m: Option<f64>,
1629 solved_sigma_mph: Option<f64>,
1630 ) -> WindTruingSolution {
1631 WindTruingSolution {
1632 range_m: 500.0,
1633 observed_miss_right_m: 0.3,
1634 sigma_m,
1635 solved_crosswind_mph,
1636 modeled_miss_right_m: 0.3,
1637 residual_m: 0.0,
1638 no_wind_lateral_m: 0.02,
1639 sensitivity_m_per_mph: 0.05,
1640 solved_sigma_mph,
1641 iterations: 3,
1642 converged: true,
1643 }
1644 }
1645
1646 #[test]
1647 fn interval_uses_scatter_when_no_sigmas_supplied() {
1648 let sols = vec![
1651 sol(6.0, None, None),
1652 sol(8.0, None, None),
1653 sol(7.0, None, None),
1654 ];
1655 let got = build_wind_uncertainty(&sols, 7.0, None);
1656 let WindUncertaintyV1::Available(i) = got else {
1657 panic!("expected an interval, got {got:?}");
1658 };
1659 assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1660 assert_eq!(i.dof, Some(2));
1661 assert!(i.propagated_sigma_mph.is_none());
1662 let se = i.empirical_sigma_mph.expect("scatter sigma");
1664 assert!((se - 1.0 / 3f64.sqrt()).abs() < 1e-12, "se was {se}");
1665 let half = 4.302_652_730 * se;
1667 assert!((i.low_mph - (7.0 - half)).abs() < 1e-9);
1668 assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1669 assert!(i.low_mph < 7.0 && i.high_mph > 7.0);
1670 }
1671
1672 #[test]
1673 fn optimistic_supplied_sigmas_lose_to_observed_scatter() {
1674 let sols = vec![
1678 sol(4.0, Some(0.01), Some(0.02)),
1679 sol(9.0, Some(0.01), Some(0.02)),
1680 sol(6.5, Some(0.01), Some(0.02)),
1681 ];
1682 let propagated = Some(0.02 / 3f64.sqrt()); let got = build_wind_uncertainty(&sols, 6.5, propagated);
1684 let WindUncertaintyV1::Available(i) = got else {
1685 panic!("expected an interval")
1686 };
1687 assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1688 let e = i.empirical_sigma_mph.expect("scatter");
1689 let pr = i.propagated_sigma_mph.expect("propagated");
1690 assert!(e > pr, "scatter {e} should exceed propagated {pr}");
1691 assert!(
1692 (i.sigma_mph - e).abs() < 1e-12,
1693 "the wider estimate must drive the interval"
1694 );
1695 assert!(i.propagated_sigma_mph.is_some());
1697 }
1698
1699 #[test]
1700 fn tight_scatter_lets_supplied_sigmas_win() {
1701 let sols = vec![
1704 sol(7.00, Some(2.0), Some(1.5)),
1705 sol(7.01, Some(2.0), Some(1.5)),
1706 sol(6.99, Some(2.0), Some(1.5)),
1707 ];
1708 let propagated = Some(1.5 / 3f64.sqrt());
1709 let got = build_wind_uncertainty(&sols, 7.0, propagated);
1710 let WindUncertaintyV1::Available(i) = got else {
1711 panic!("expected an interval")
1712 };
1713 assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1714 assert_eq!(
1715 i.dof, None,
1716 "a supplied sigma is treated as known, so normal not t"
1717 );
1718 let half = NORMAL_95_TWO_SIDED_Z * i.sigma_mph;
1719 assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1720 }
1721
1722 #[test]
1723 fn single_observation_without_sigma_is_explained_not_omitted() {
1724 let sols = vec![sol(7.0, None, None)];
1725 let got = build_wind_uncertainty(&sols, 7.0, None);
1726 let WindUncertaintyV1::Unavailable(f) = got else {
1727 panic!("one shot with no sigma cannot yield an interval");
1728 };
1729 assert_eq!(f.code, WindUncertaintyFailureCodeV1::SingleObservation);
1730 assert!(!f.message.is_empty());
1731 }
1732
1733 #[test]
1734 fn single_observation_with_sigma_still_gets_an_interval() {
1735 let sols = vec![sol(7.0, Some(0.1), Some(2.0))];
1736 let got = build_wind_uncertainty(&sols, 7.0, Some(2.0));
1737 let WindUncertaintyV1::Available(i) = got else {
1738 panic!("expected an interval")
1739 };
1740 assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1741 assert!(i.empirical_sigma_mph.is_none(), "one shot has no scatter");
1742 }
1743
1744 #[test]
1745 fn identical_observations_without_sigma_report_zero_spread_honestly() {
1746 let sols = vec![sol(7.0, None, None), sol(7.0, None, None)];
1748 let got = build_wind_uncertainty(&sols, 7.0, None);
1749 let WindUncertaintyV1::Unavailable(f) = got else {
1750 panic!("zero spread must not be reported as a zero-width interval");
1751 };
1752 assert_eq!(f.code, WindUncertaintyFailureCodeV1::NoUsableEstimate);
1753 }
1754
1755 #[test]
1756 fn t_multiplier_table_matches_published_values() {
1757 for (dof, want) in [
1759 (1usize, 12.706205),
1760 (2, 4.302653),
1761 (5, 2.570582),
1762 (10, 2.228139),
1763 (30, 2.042272),
1764 ] {
1765 let got = T_95_TWO_SIDED[dof - 1];
1766 assert!((got - want).abs() < 5e-6, "dof {dof}: {got} vs {want}");
1767 }
1768 const { assert!(T_95_TWO_SIDED[0] > T_95_TWO_SIDED[9]) };
1771 const { assert!(T_95_TWO_SIDED[29] > NORMAL_95_TWO_SIDED_Z) };
1772 }
1773
1774 #[test]
1775 fn uncertainty_is_always_present_in_the_serialized_report() {
1776 let sols = vec![sol(6.0, None, None), sol(8.0, None, None)];
1777 let u = build_wind_uncertainty(&sols, 7.0, None);
1778 let v = serde_json::to_value(&u).expect("serializes");
1779 assert_eq!(v["status"], "available");
1780 assert!(v["detail"]["low_mph"].is_number());
1781 assert_eq!(v["detail"]["basis"], "empirical_scatter");
1782 }
1783}