1use std::error::Error;
42
43use serde::{Deserialize, Serialize};
44
45use crate::atmosphere::{StationAtmospherePresence, StationReading};
46use crate::cli_api::UnitSystem;
47use crate::truing::{
48 DragModelArg, TruingEarthFrame, TruingEnvironment, TruingTwist, TRUING_BC_MAX, TRUING_BC_MIN,
49 TRUING_MV_MAX_FPS, TRUING_MV_MIN_FPS,
50};
51use crate::{BCSegmentData, WindConditions};
52
53pub const MPH_TO_MPS: f64 = 0.44704;
57
58pub const MAX_SOLVABLE_CROSSWIND_MPH: f64 = 100.0;
63
64pub const WIND_SOLVE_TOLERANCE_M: f64 = 1.0e-5;
69
70const WIND_SOLVE_MIN_BRACKET_MPH: f64 = 1.0e-9;
73
74const WIND_SOLVE_MAX_ITERATIONS: u32 = 60;
79
80const WIND_SENSITIVITY_STEP_MPH: f64 = 0.5;
84
85pub const MIN_WIND_SENSITIVITY_IN_PER_MPH: f64 = 0.25;
90
91#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct WindObservation {
98 pub range_m: f64,
100 pub miss_right_m: f64,
102 pub sigma_m: Option<f64>,
105}
106
107pub fn parse_wind_observation(s: &str, units: UnitSystem) -> Result<WindObservation, String> {
114 let parts: Vec<&str> = s.split(':').collect();
115 if parts.len() != 2 && parts.len() != 3 {
116 return Err(format!(
117 "invalid --miss '{s}': expected RANGE:RIGHT_IN[:SIGMA] (e.g. 600:8.5 or 600:8.5:0.75)"
118 ));
119 }
120 let range: f64 = parts[0]
121 .trim()
122 .parse()
123 .map_err(|_| format!("invalid --miss range '{}' in '{s}'", parts[0]))?;
124 let miss_in: f64 = parts[1]
125 .trim()
126 .parse()
127 .map_err(|_| format!("invalid --miss offset '{}' in '{s}'", parts[1]))?;
128 let sigma_in: Option<f64> = match parts.get(2) {
129 Some(token) => Some(
130 token
131 .trim()
132 .parse()
133 .map_err(|_| format!("invalid --miss sigma '{token}' in '{s}'"))?,
134 ),
135 None => None,
136 };
137 if !range.is_finite() || !miss_in.is_finite() || sigma_in.is_some_and(|v| !v.is_finite()) {
138 return Err(format!("invalid --miss '{s}': values must be finite"));
139 }
140 let range_m = match units {
141 UnitSystem::Imperial => range * 0.9144,
142 UnitSystem::Metric => range,
143 };
144 Ok(WindObservation {
145 range_m,
146 miss_right_m: miss_in * 0.0254,
147 sigma_m: sigma_in.map(|v| v * 0.0254),
148 })
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct WindTruingRequest {
161 pub observations: Vec<WindObservation>,
163 pub muzzle_velocity_fps: f64,
165 pub bc: f64,
167 pub drag_model: DragModelArg,
168 pub mass_gr: f64,
170 pub diameter_in: f64,
172 pub zero_distance_yd: f64,
174 pub sight_height_in: f64,
176 #[serde(
182 default = "crate::truing::default_truing_temperature_f",
183 deserialize_with = "crate::truing::deserialize_truing_temperature_f",
184 skip_serializing_if = "StationReading::is_omitted"
185 )]
186 pub temperature_f: StationReading,
187 #[serde(
194 default = "crate::truing::default_truing_pressure_inhg",
195 deserialize_with = "crate::truing::deserialize_truing_pressure_inhg",
196 skip_serializing_if = "StationReading::is_omitted"
197 )]
198 pub pressure_inhg: StationReading,
199 pub humidity_pct: f64,
201 pub altitude_ft: f64,
203 pub twist: TruingTwist,
207 pub earth: Option<TruingEarthFrame>,
210 pub called_crosswind_mph: Option<f64>,
213}
214
215impl WindTruingRequest {
216 pub fn validate(&self) -> Result<(), String> {
223 if self.observations.is_empty() {
224 return Err("at least one observed horizontal miss is required".to_string());
225 }
226 if !self.muzzle_velocity_fps.is_finite()
227 || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
228 {
229 return Err(format!(
230 "muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
231 ));
232 }
233 if !self.bc.is_finite() || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.bc) {
234 return Err(format!(
235 "ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
236 ));
237 }
238 for (name, value) in [
239 ("bullet mass", self.mass_gr),
240 ("bullet diameter", self.diameter_in),
241 ("zero distance", self.zero_distance_yd),
242 ("sight height", self.sight_height_in),
243 ("pressure", self.pressure_inhg.value),
244 ("twist rate", self.twist.rate_in),
245 ] {
246 if !value.is_finite() || value <= 0.0 {
247 return Err(format!("{name} must be positive and finite"));
248 }
249 }
250 if !self.temperature_f.value.is_finite() {
251 return Err("temperature must be finite".to_string());
252 }
253 if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
254 return Err("humidity must be finite and within 0..=100 percent".to_string());
255 }
256 if !self.altitude_ft.is_finite() {
257 return Err("altitude must be finite".to_string());
258 }
259 if let Some(earth) = self.earth {
260 if !earth.latitude_deg.is_finite() || !(-90.0..=90.0).contains(&earth.latitude_deg) {
261 return Err("latitude must be finite and within -90..=90 degrees".to_string());
262 }
263 if !earth.shot_azimuth_deg.is_finite() {
264 return Err("shot azimuth must be finite".to_string());
265 }
266 }
267 if let Some(called) = self.called_crosswind_mph {
268 if !called.is_finite() || called == 0.0 {
269 return Err(
270 "the called wind must be finite and non-zero (a zero call has no \
271 correction factor)"
272 .to_string(),
273 );
274 }
275 }
276 for observation in &self.observations {
277 if !observation.range_m.is_finite() || observation.range_m <= 0.0 {
278 return Err(format!(
279 "observation range must be a positive finite distance (got {})",
280 observation.range_m
281 ));
282 }
283 if !observation.miss_right_m.is_finite() {
284 return Err("observed horizontal miss must be finite".to_string());
285 }
286 if observation
287 .sigma_m
288 .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
289 {
290 return Err("an observed-miss sigma must be positive and finite".to_string());
291 }
292 }
293 for i in 0..self.observations.len() {
294 for j in (i + 1)..self.observations.len() {
295 if (self.observations[i].range_m - self.observations[j].range_m).abs() < 1e-6 {
296 return Err(format!(
297 "duplicate observation range ({:.3} m): each observed miss must be at a \
298 distinct range",
299 self.observations[i].range_m
300 ));
301 }
302 }
303 }
304 let with_sigma = self
305 .observations
306 .iter()
307 .filter(|o| o.sigma_m.is_some())
308 .count();
309 if with_sigma != 0 && with_sigma != self.observations.len() {
310 return Err(
311 "supply a sigma on every observed miss or on none: mixing weighted and \
312 unweighted observations would silently combine inverse-variance weights \
313 with unit weights"
314 .to_string(),
315 );
316 }
317 Ok(())
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
323pub struct WindTruingSolution {
324 pub range_m: f64,
326 pub observed_miss_right_m: f64,
328 pub sigma_m: Option<f64>,
330 pub solved_crosswind_mph: f64,
332 pub modeled_miss_right_m: f64,
335 pub residual_m: f64,
337 pub no_wind_lateral_m: f64,
341 pub sensitivity_m_per_mph: f64,
344 pub solved_sigma_mph: Option<f64>,
347 pub iterations: u32,
349 pub converged: bool,
352}
353
354const T_95_TWO_SIDED: [f64; 30] = [
358 12.706204736,
359 4.302652730,
360 3.182446305,
361 2.776445105,
362 2.570581836,
363 2.446911851,
364 2.364624252,
365 2.306004135,
366 2.262157163,
367 2.228138852,
368 2.200985160,
369 2.178812830,
370 2.160368656,
371 2.144786688,
372 2.131449546,
373 2.119905299,
374 2.109815578,
375 2.100922040,
376 2.093024054,
377 2.085963447,
378 2.079613845,
379 2.073873068,
380 2.068657610,
381 2.063898562,
382 2.059538553,
383 2.055529439,
384 2.051830516,
385 2.048407142,
386 2.045229642,
387 2.042272456,
388];
389
390const NORMAL_95_TWO_SIDED_Z: f64 = 1.959_963_984_540_054;
392
393const WIND_INTERVAL_PROBABILITY: f64 = 0.95;
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
398#[serde(rename_all = "snake_case")]
399pub enum WindUncertaintyBasisV1 {
400 EmpiricalScatter,
403 PropagatedMeasurement,
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
410#[serde(rename_all = "snake_case")]
411pub enum WindUncertaintyFailureCodeV1 {
412 SingleObservation,
415 NoUsableEstimate,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
421pub struct WindUncertaintyFailureV1 {
422 pub code: WindUncertaintyFailureCodeV1,
423 pub message: String,
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
428pub struct WindIntervalV1 {
429 pub sigma_mph: f64,
431 pub probability: f64,
433 pub low_mph: f64,
435 pub high_mph: f64,
437 pub basis: WindUncertaintyBasisV1,
439 pub empirical_sigma_mph: Option<f64>,
441 pub propagated_sigma_mph: Option<f64>,
444 pub dof: Option<u32>,
446}
447
448#[derive(Debug, Clone, PartialEq, Serialize)]
454#[serde(rename_all = "snake_case", tag = "status", content = "detail")]
455pub enum WindUncertaintyV1 {
456 Available(WindIntervalV1),
457 Unavailable(WindUncertaintyFailureV1),
458}
459
460fn build_wind_uncertainty(
467 solutions: &[WindTruingSolution],
468 mean_crosswind_mph: f64,
469 propagated_sigma_mph: Option<f64>,
470) -> WindUncertaintyV1 {
471 let n = solutions.len();
472
473 let empirical_sigma_mph = if n >= 2 {
475 let mean: f64 = solutions
476 .iter()
477 .map(|s| s.solved_crosswind_mph)
478 .sum::<f64>()
479 / n as f64;
480 let var = solutions
481 .iter()
482 .map(|s| {
483 let d = s.solved_crosswind_mph - mean;
484 d * d
485 })
486 .sum::<f64>()
487 / (n as f64 - 1.0); let se = (var / n as f64).sqrt();
489 if se.is_finite() && se > 0.0 {
490 Some(se)
491 } else {
492 None
493 }
494 } else {
495 None
496 };
497
498 let propagated = propagated_sigma_mph.filter(|v| v.is_finite() && *v > 0.0);
499
500 let (sigma_mph, basis, dof) = match (empirical_sigma_mph, propagated) {
502 (Some(e), Some(p)) if e >= p => (
503 e,
504 WindUncertaintyBasisV1::EmpiricalScatter,
505 Some(n as u32 - 1),
506 ),
507 (Some(_), Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
508 (Some(e), None) => (
509 e,
510 WindUncertaintyBasisV1::EmpiricalScatter,
511 Some(n as u32 - 1),
512 ),
513 (None, Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
514 (None, None) => {
515 let (code, message) = if n < 2 {
516 (
517 WindUncertaintyFailureCodeV1::SingleObservation,
518 "a single observation with no measurement sigma gives nothing to estimate an interval from — shoot more observations, or supply a sigma on this one"
519 .to_string(),
520 )
521 } else {
522 (
523 WindUncertaintyFailureCodeV1::NoUsableEstimate,
524 "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"
525 .to_string(),
526 )
527 };
528 return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 { code, message });
529 }
530 };
531
532 let multiplier = match dof {
533 Some(d) if d >= 1 => *T_95_TWO_SIDED
534 .get((d - 1) as usize)
535 .unwrap_or(&NORMAL_95_TWO_SIDED_Z),
536 _ => NORMAL_95_TWO_SIDED_Z,
537 };
538 let half_width = multiplier * sigma_mph;
539 if !half_width.is_finite() {
540 return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 {
541 code: WindUncertaintyFailureCodeV1::NoUsableEstimate,
542 message: "the interval half-width was not finite".to_string(),
543 });
544 }
545
546 WindUncertaintyV1::Available(WindIntervalV1 {
547 sigma_mph,
548 probability: WIND_INTERVAL_PROBABILITY,
549 low_mph: mean_crosswind_mph - half_width,
550 high_mph: mean_crosswind_mph + half_width,
551 basis,
552 empirical_sigma_mph,
553 propagated_sigma_mph: propagated,
554 dof,
555 })
556}
557
558#[derive(Debug, Clone, Serialize)]
560pub struct WindTruingReport {
561 pub solutions: Vec<WindTruingSolution>,
563 pub mean_crosswind_mph: f64,
565 pub mean_sigma_mph: Option<f64>,
569 pub uncertainty: WindUncertaintyV1,
572 pub inverse_variance_weighted: bool,
575 pub called_crosswind_mph: Option<f64>,
577 pub wind_call_factor: Option<f64>,
580 pub subtracted_effects: Vec<String>,
582 pub unsubtracted_effects: Vec<String>,
585}
586
587fn crosswind_conditions(signed_mph: f64) -> WindConditions {
595 WindConditions {
596 speed: signed_mph.abs() * MPH_TO_MPS,
597 direction: if signed_mph < 0.0 {
598 std::f64::consts::FRAC_PI_2
599 } else {
600 3.0 * std::f64::consts::FRAC_PI_2
601 },
602 vertical_speed: 0.0,
603 }
604}
605
606pub fn modeled_miss_right_m(
616 request: &WindTruingRequest,
617 crosswind_mph: f64,
618 range_m: f64,
619) -> Result<f64, Box<dyn Error>> {
620 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
621 let env = TruingEnvironment {
622 wind: crosswind_conditions(crosswind_mph),
623 twist: Some(request.twist),
624 earth: request.earth,
625 };
626 let sample = crate::truing::solve_trajectory_sample(
627 request.muzzle_velocity_fps,
628 request.bc,
629 request.drag_model,
630 request.mass_gr,
631 request.diameter_in,
632 request.zero_distance_yd,
633 range_m / 0.9144,
634 request.sight_height_in,
635 request.temperature_f.value,
636 request.pressure_inhg.value,
637 request.humidity_pct,
638 request.altitude_ft,
639 &no_bc_segments,
640 &env,
641 true, StationAtmospherePresence::from_readings(request.temperature_f, request.pressure_inhg),
643 )?;
644 Ok(sample.lateral_m)
645}
646
647pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
660 request.validate()?;
661
662 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
666
667 let environment = |crosswind_mph: f64| TruingEnvironment {
668 wind: crosswind_conditions(crosswind_mph),
669 twist: Some(request.twist),
670 earth: request.earth,
671 };
672
673 let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
675 let sample = crate::truing::solve_trajectory_sample(
676 request.muzzle_velocity_fps,
677 request.bc,
678 request.drag_model,
679 request.mass_gr,
680 request.diameter_in,
681 request.zero_distance_yd,
682 range_yd,
683 request.sight_height_in,
684 request.temperature_f.value,
685 request.pressure_inhg.value,
686 request.humidity_pct,
687 request.altitude_ft,
688 &no_bc_segments,
689 &environment(crosswind_mph),
690 true, StationAtmospherePresence::from_readings(request.temperature_f, request.pressure_inhg),
692 )?;
693 Ok(sample.lateral_m)
694 };
695
696 let mut solutions = Vec::with_capacity(request.observations.len());
697 for observation in &request.observations {
698 let range_yd = observation.range_m / 0.9144;
699 solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
700 }
701
702 let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
707 if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
708 return Err(
709 "an observation does not move with crosswind at all, so its measurement sigma \
710 cannot be expressed in wind units — drop that observation or its sigma"
711 .into(),
712 );
713 }
714 let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
715 let mut weight_sum = 0.0;
716 let mut weighted = 0.0;
717 for solution in &solutions {
718 let sigma = solution
719 .solved_sigma_mph
720 .expect("checked by inverse_variance_weighted");
721 let weight = 1.0 / (sigma * sigma);
722 weight_sum += weight;
723 weighted += weight * solution.solved_crosswind_mph;
724 }
725 if weight_sum > 0.0 && weight_sum.is_finite() {
726 (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
727 } else {
728 return Err(
729 "observed-miss sigmas produced a degenerate weighting (check that every \
730 sigma is positive and that the observations move with wind at all)"
731 .into(),
732 );
733 }
734 } else {
735 let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
736 (sum / solutions.len() as f64, None)
737 };
738
739 let wind_call_factor = request
740 .called_crosswind_mph
741 .map(|called| mean_crosswind_mph / called);
742
743 let mut subtracted_effects = vec!["spin drift".to_string()];
747 let mut unsubtracted_effects = Vec::new();
748 if request.earth.is_some() {
749 subtracted_effects.push("Coriolis".to_string());
750 } else {
751 unsubtracted_effects
752 .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
753 }
754
755 let uncertainty = build_wind_uncertainty(&solutions, mean_crosswind_mph, mean_sigma_mph);
756
757 Ok(WindTruingReport {
758 solutions,
759 mean_crosswind_mph,
760 mean_sigma_mph,
761 uncertainty,
762 inverse_variance_weighted,
763 called_crosswind_mph: request.called_crosswind_mph,
764 wind_call_factor,
765 subtracted_effects,
766 unsubtracted_effects,
767 })
768}
769
770fn solve_one_observation(
775 observation: &WindObservation,
776 range_yd: f64,
777 lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
778) -> Result<WindTruingSolution, Box<dyn Error>> {
779 let target = observation.miss_right_m;
780 let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
781 Ok(lateral_at(crosswind_mph, range_yd)? - target)
782 };
783
784 let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
785 let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
786 let mut f_low = residual(low)?;
787 let mut f_high = residual(high)?;
788 if f_low > 0.0 || f_high < 0.0 {
789 return Err(format!(
790 "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
791 miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
792 the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
793 target / 0.0254,
794 (f_low + target) / 0.0254,
795 (f_high + target) / 0.0254,
796 )
797 .into());
798 }
799
800 let mut solved = 0.0;
801 let mut f_solved = 0.0;
802 let mut iterations = 0u32;
803 let mut converged = false;
804 while iterations < WIND_SOLVE_MAX_ITERATIONS {
805 iterations += 1;
806 let denom = f_high - f_low;
807 let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
808 high - f_high * (high - low) / denom
809 } else {
810 0.5 * (low + high)
811 };
812 if !candidate.is_finite() || candidate <= low || candidate >= high {
814 candidate = 0.5 * (low + high);
815 }
816 let f = residual(candidate)?;
817 solved = candidate;
818 f_solved = f;
819 if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
820 converged = true;
821 break;
822 }
823 if f < 0.0 {
825 low = candidate;
826 f_low = f;
827 f_high *= 0.5;
828 } else {
829 high = candidate;
830 f_high = f;
831 f_low *= 0.5;
832 }
833 }
834
835 let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
837 let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
838 let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
839
840 let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
843
844 let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
845 let slope = sensitivity_m_per_mph.abs();
846 (slope > 0.0).then_some(sigma / slope)
847 });
848
849 Ok(WindTruingSolution {
850 range_m: observation.range_m,
851 observed_miss_right_m: target,
852 sigma_m: observation.sigma_m,
853 solved_crosswind_mph: solved,
854 modeled_miss_right_m: target + f_solved,
855 residual_m: f_solved,
856 no_wind_lateral_m,
857 sensitivity_m_per_mph,
858 solved_sigma_mph,
859 iterations,
860 converged,
861 })
862}
863
864#[derive(Debug, Clone, Copy, PartialEq, Eq)]
868pub enum WindTruingOutput {
869 Table,
870 Json,
871 Csv,
872}
873
874struct WindTruingUnits {
877 range_label: &'static str,
878 speed_label: &'static str,
879 range_scale: f64,
880 speed_scale: f64,
881}
882
883impl WindTruingUnits {
884 fn for_system(units: UnitSystem) -> Self {
885 match units {
886 UnitSystem::Imperial => Self {
887 range_label: "yd",
888 speed_label: "mph",
889 range_scale: 1.0 / 0.9144,
890 speed_scale: 1.0,
891 },
892 UnitSystem::Metric => Self {
893 range_label: "m",
894 speed_label: "m/s",
895 range_scale: 1.0,
896 speed_scale: MPH_TO_MPS,
897 },
898 }
899 }
900
901 fn range(&self, range_m: f64) -> f64 {
902 range_m * self.range_scale
903 }
904
905 fn speed(&self, mph: f64) -> f64 {
906 mph * self.speed_scale
907 }
908}
909
910fn inches(meters: f64) -> f64 {
912 meters / 0.0254
913}
914
915pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
922 let u = WindTruingUnits::for_system(units);
923 let observations: Vec<serde_json::Value> = report
924 .solutions
925 .iter()
926 .map(|s| {
927 serde_json::json!({
928 format!("range_{}", u.range_label): u.range(s.range_m),
929 "miss_right_in": inches(s.observed_miss_right_m),
930 "miss_sigma_in": s.sigma_m.map(inches),
931 "no_wind_lateral_in": inches(s.no_wind_lateral_m),
932 "solved_crosswind": u.speed(s.solved_crosswind_mph),
933 "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
934 "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
935 "residual_in": inches(s.residual_m),
936 "iterations": s.iterations,
937 "converged": s.converged,
938 })
939 })
940 .collect();
941
942 serde_json::json!({
943 "effective_crosswind": u.speed(report.mean_crosswind_mph),
944 "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
945 "inverse_variance_weighted": report.inverse_variance_weighted,
946 "uncertainty": match &report.uncertainty {
947 WindUncertaintyV1::Available(i) => serde_json::json!({
948 "status": "available",
949 "sigma": u.speed(i.sigma_mph),
950 "probability": i.probability,
951 "low": u.speed(i.low_mph),
952 "high": u.speed(i.high_mph),
953 "basis": match i.basis {
954 WindUncertaintyBasisV1::EmpiricalScatter => "empirical_scatter",
955 WindUncertaintyBasisV1::PropagatedMeasurement => "propagated_measurement",
956 },
957 "empirical_sigma": i.empirical_sigma_mph.map(|v| u.speed(v)),
958 "propagated_sigma": i.propagated_sigma_mph.map(|v| u.speed(v)),
959 "dof": i.dof,
960 }),
961 WindUncertaintyV1::Unavailable(f) => serde_json::json!({
962 "status": "unavailable",
963 "code": match f.code {
964 WindUncertaintyFailureCodeV1::SingleObservation => "single_observation",
965 WindUncertaintyFailureCodeV1::NoUsableEstimate => "no_usable_estimate",
966 },
967 "message": f.message,
968 }),
969 },
970 "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
971 "wind_call_factor": report.wind_call_factor,
972 "observations": observations,
973 "effects_subtracted": report.subtracted_effects,
974 "effects_not_subtracted": report.unsubtracted_effects,
975 "legend": {
976 "units": {
977 "range": u.range_label,
978 "miss": "in",
979 "wind_speed": u.speed_label,
980 },
981 "signs": "--miss positive = impact right of aim; solved crosswind positive = \
982 wind from the shooter's left (9 o'clock) pushing impacts right",
983 },
984 })
985}
986
987pub fn format_wind_truing_report(
993 report: &WindTruingReport,
994 units: UnitSystem,
995 output: WindTruingOutput,
996) -> String {
997 let u = WindTruingUnits::for_system(units);
998 match output {
999 WindTruingOutput::Json => {
1000 match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
1001 Ok(s) => format!("{s}\n"),
1002 Err(e) => format!("Error serializing JSON: {e}\n"),
1003 }
1004 }
1005 WindTruingOutput::Csv => {
1006 let mut out = String::new();
1007 out.push_str(&format!(
1008 "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
1009 sensitivity_in_per_mph,residual_in,iterations,converged\n",
1010 u.range_label, u.speed_label
1011 ));
1012 for s in &report.solutions {
1013 out.push_str(&format!(
1014 "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
1015 u.range(s.range_m),
1016 inches(s.observed_miss_right_m),
1017 match s.sigma_m {
1018 Some(sigma) => format!("{:.3}", inches(sigma)),
1019 None => String::new(),
1020 },
1021 inches(s.no_wind_lateral_m),
1022 u.speed(s.solved_crosswind_mph),
1023 inches(s.sensitivity_m_per_mph),
1024 inches(s.residual_m),
1025 s.iterations,
1026 s.converged,
1027 ));
1028 }
1029 out.push('\n');
1030 out.push_str(&format!(
1031 "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
1032 called_crosswind_{},wind_call_factor\n",
1033 u.speed_label, u.speed_label, u.speed_label
1034 ));
1035 out.push_str(&format!(
1036 "{:+.3},{},{},{},{}\n",
1037 u.speed(report.mean_crosswind_mph),
1038 match report.mean_sigma_mph {
1039 Some(sigma) => format!("{:.3}", u.speed(sigma)),
1040 None => String::new(),
1041 },
1042 report.inverse_variance_weighted,
1043 match report.called_crosswind_mph {
1044 Some(called) => format!("{:+.3}", u.speed(called)),
1045 None => String::new(),
1046 },
1047 match report.wind_call_factor {
1048 Some(factor) => format!("{factor:.4}"),
1049 None => String::new(),
1050 },
1051 ));
1052 out
1053 }
1054 WindTruingOutput::Table => {
1055 let mut out = String::new();
1056 out.push('\n');
1057 out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
1058 out.push('\n');
1059 out.push_str(&format!(
1060 " {:>10} {:>12} {:>14} {:>16} {:>10}\n",
1061 format!("Range ({})", u.range_label),
1062 "Miss (in)",
1063 "Spin/Cor (in)",
1064 format!("Wind ({})", u.speed_label),
1065 "Resid (in)",
1066 ));
1067 out.push_str(&format!(" {}\n", "-".repeat(70)));
1068 for s in &report.solutions {
1069 out.push_str(&format!(
1070 " {:>10.1} {:>+12.2} {:>+14.2} {:>+16.2} {:>+10.3}\n",
1071 u.range(s.range_m),
1072 inches(s.observed_miss_right_m),
1073 inches(s.no_wind_lateral_m),
1074 u.speed(s.solved_crosswind_mph),
1075 inches(s.residual_m),
1076 ));
1077 }
1078 out.push_str(&format!(" {}\n", "-".repeat(70)));
1079 out.push('\n');
1080 let n = report.solutions.len();
1081 out.push_str(&format!(
1082 " Effective crosswind: {:>+8.2} {}{}\n",
1083 u.speed(report.mean_crosswind_mph),
1084 u.speed_label,
1085 match report.mean_sigma_mph {
1090 Some(_) => format!(" (inverse-variance weighted over {n} observations)"),
1091 None if n > 1 => format!(" (mean of {n} observations)"),
1092 None => String::new(),
1093 }
1094 ));
1095 match &report.uncertainty {
1096 WindUncertaintyV1::Available(i) => {
1097 out.push_str(&format!(
1098 " 95% interval: [{:>+7.2}, {:>+7.2}] {} ({})\n",
1099 u.speed(i.low_mph),
1100 u.speed(i.high_mph),
1101 u.speed_label,
1102 match i.basis {
1103 WindUncertaintyBasisV1::EmpiricalScatter => match i.dof {
1104 Some(d) => format!("from shot-to-shot scatter, t with {d} dof"),
1105 None => "from shot-to-shot scatter".to_string(),
1106 },
1107 WindUncertaintyBasisV1::PropagatedMeasurement =>
1108 "from your measurement sigmas".to_string(),
1109 }
1110 ));
1111 if let (Some(e), Some(pr)) = (i.empirical_sigma_mph, i.propagated_sigma_mph) {
1114 out.push_str(&format!(
1115 " scatter sigma {:.2} {} vs measurement sigma {:.2} {} — the wider one is reported\n",
1116 u.speed(e),
1117 u.speed_label,
1118 u.speed(pr),
1119 u.speed_label
1120 ));
1121 }
1122 }
1123 WindUncertaintyV1::Unavailable(f) => {
1124 out.push_str(&format!(" 95% interval: none — {}\n", f.message));
1125 }
1126 }
1127 if let (Some(called), Some(factor)) =
1128 (report.called_crosswind_mph, report.wind_call_factor)
1129 {
1130 out.push_str(&format!(
1131 " Called wind: {:>+8.2} {} -> wind-call correction factor {:.2}\n",
1132 u.speed(called),
1133 u.speed_label,
1134 factor
1135 ));
1136 out.push_str(&format!(
1137 " (multiply your wind calls by {factor:.2} to match what actually hit)\n"
1138 ));
1139 }
1140 if !report.subtracted_effects.is_empty() {
1141 out.push_str(&format!(
1142 " Effects subtracted: {}\n",
1143 report.subtracted_effects.join(", ")
1144 ));
1145 }
1146 if !report.unsubtracted_effects.is_empty() {
1147 out.push_str(&format!(
1148 " NOT subtracted (absorbed into the solved wind): {}\n",
1149 report.unsubtracted_effects.join(", ")
1150 ));
1151 }
1152 for s in &report.solutions {
1153 if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
1154 out.push_str(&format!(
1155 " note: the observation at {:.1} {} moves only {:.2} in per mph of \
1156 crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
1157 wind fitted from it is weakly identified\n",
1158 u.range(s.range_m),
1159 u.range_label,
1160 inches(s.sensitivity_m_per_mph).abs(),
1161 ));
1162 }
1163 if !s.converged {
1164 out.push_str(&format!(
1165 " note: the fit at {:.1} {} did not fully converge after {} iterations; \
1166 the value shown is the best estimate\n",
1167 u.range(s.range_m),
1168 u.range_label,
1169 s.iterations,
1170 ));
1171 }
1172 }
1173 out.push('\n');
1174 out.push_str(
1175 " Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
1176 \x20 FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
1177 \x20 = FROM the right pushing left. Wind-FROM convention throughout\n\
1178 \x20 (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
1179 );
1180 out.push('\n');
1181 out
1182 }
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189
1190 fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
1191 WindTruingRequest {
1192 observations,
1193 muzzle_velocity_fps: 2700.0,
1194 bc: 0.475,
1195 drag_model: DragModelArg::G7,
1196 mass_gr: 168.0,
1197 diameter_in: 0.308,
1198 zero_distance_yd: 100.0,
1199 sight_height_in: 2.0,
1200 temperature_f: StationReading::omitted(59.0),
1201 pressure_inhg: StationReading::omitted(29.92),
1202 humidity_pct: 50.0,
1203 altitude_ft: 0.0,
1204 twist: TruingTwist {
1205 rate_in: 11.0,
1206 right_hand: true,
1207 },
1208 earth: None,
1209 called_crosswind_mph: None,
1210 }
1211 }
1212
1213 fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
1216 modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
1217 }
1218
1219 #[test]
1224 fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
1225 let known_mph = 7.5;
1226 let ranges_m = [274.32, 457.2, 640.08]; let template = base_request(Vec::new());
1228 let observations = ranges_m
1229 .iter()
1230 .map(|range_m| WindObservation {
1231 range_m: *range_m,
1232 miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
1233 sigma_m: None,
1234 })
1235 .collect();
1236
1237 let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
1238 assert_eq!(report.solutions.len(), 3);
1239 for solution in &report.solutions {
1240 assert!(solution.converged, "{solution:?}");
1241 assert!(
1242 (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
1243 "recovered {} mph at {} m, expected {known_mph}",
1244 solution.solved_crosswind_mph,
1245 solution.range_m
1246 );
1247 }
1248 assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
1249 assert!(!report.inverse_variance_weighted);
1250 assert!(report.mean_sigma_mph.is_none());
1251 }
1252
1253 #[test]
1257 fn miss_right_solves_positive_and_miss_left_solves_negative() {
1258 let template = base_request(Vec::new());
1259 let range_m = 457.2; let right_miss = modeled_lateral_m(&template, 8.0, range_m);
1261 let left_miss = modeled_lateral_m(&template, -8.0, range_m);
1262 assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
1263 assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
1264
1265 let right = solve_wind_truing(&base_request(vec![WindObservation {
1266 range_m,
1267 miss_right_m: right_miss,
1268 sigma_m: None,
1269 }]))
1270 .expect("right-miss fit must solve");
1271 assert!(
1272 right.mean_crosswind_mph > 0.0,
1273 "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
1274 right.mean_crosswind_mph
1275 );
1276 assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
1277
1278 let left = solve_wind_truing(&base_request(vec![WindObservation {
1279 range_m,
1280 miss_right_m: left_miss,
1281 sigma_m: None,
1282 }]))
1283 .expect("left-miss fit must solve");
1284 assert!(
1285 left.mean_crosswind_mph < 0.0,
1286 "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
1287 left.mean_crosswind_mph
1288 );
1289 assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
1290 }
1291
1292 #[test]
1296 fn pure_spin_drift_solves_to_zero_wind() {
1297 let template = base_request(Vec::new());
1298 let range_m = 640.08; let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1300 assert!(
1301 spin_only > 0.05,
1302 "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1303 );
1304
1305 let report = solve_wind_truing(&base_request(vec![WindObservation {
1306 range_m,
1307 miss_right_m: spin_only,
1308 sigma_m: None,
1309 }]))
1310 .expect("spin-only fit must solve");
1311 assert!(
1312 report.mean_crosswind_mph.abs() < 0.02,
1313 "pure spin drift must solve to ~0 wind, got {}",
1314 report.mean_crosswind_mph
1315 );
1316 assert!(report
1318 .solutions
1319 .iter()
1320 .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1321 assert!(report
1322 .subtracted_effects
1323 .iter()
1324 .any(|e| e.contains("spin drift")));
1325 }
1326
1327 #[test]
1331 fn twist_hand_changes_the_solved_wind() {
1332 let range_m = 640.08;
1333 let observation = WindObservation {
1334 range_m,
1335 miss_right_m: 0.25,
1336 sigma_m: None,
1337 };
1338 let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1339 let mut left = base_request(vec![observation]);
1340 left.twist.right_hand = false;
1341 let left_hand = solve_wind_truing(&left).expect("solve");
1342 assert!(
1343 left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1344 "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1345 left_hand.mean_crosswind_mph,
1346 right_hand.mean_crosswind_mph
1347 );
1348 }
1349
1350 #[test]
1354 fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1355 let range_m = 914.4; let observation = WindObservation {
1357 range_m,
1358 miss_right_m: 0.30,
1359 sigma_m: None,
1360 };
1361 let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1362 let mut with_earth = base_request(vec![observation]);
1363 with_earth.earth = Some(TruingEarthFrame {
1364 latitude_deg: 45.0,
1365 shot_azimuth_deg: 90.0, });
1367 let with = solve_wind_truing(&with_earth).expect("solve");
1368
1369 assert!(without
1370 .unsubtracted_effects
1371 .iter()
1372 .any(|e| e.contains("Coriolis")));
1373 assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1374 assert!(with.unsubtracted_effects.is_empty());
1375 assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1376 assert!(
1377 (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1378 > 1e-4,
1379 "modelling Coriolis must change the zero-wind lateral"
1380 );
1381 assert!(
1382 (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1383 "modelling Coriolis must change the solved wind"
1384 );
1385 }
1386
1387 #[test]
1391 fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1392 let template = base_request(Vec::new());
1393 let range_m = 457.2;
1394 let miss = modeled_lateral_m(&template, 9.0, range_m);
1395 let observation = WindObservation {
1396 range_m,
1397 miss_right_m: miss,
1398 sigma_m: None,
1399 };
1400
1401 let mut under_called = base_request(vec![observation]);
1402 under_called.called_crosswind_mph = Some(6.0);
1403 let report = solve_wind_truing(&under_called).expect("solve");
1404 let factor = report.wind_call_factor.expect("factor");
1405 assert!(
1406 (factor - 9.0 / 6.0).abs() < 0.01,
1407 "expected ~1.5, got {factor}"
1408 );
1409
1410 let mut wrong_side = base_request(vec![observation]);
1411 wrong_side.called_crosswind_mph = Some(-6.0);
1412 let flipped = solve_wind_truing(&wrong_side)
1413 .expect("solve")
1414 .wind_call_factor
1415 .expect("factor");
1416 assert!(
1417 flipped < 0.0,
1418 "a wrong-side call must read negative: {flipped}"
1419 );
1420 }
1421
1422 #[test]
1426 fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1427 let template = base_request(Vec::new());
1428 let near = 274.32;
1429 let far = 640.08;
1430 let weighted = vec![
1431 WindObservation {
1432 range_m: near,
1433 miss_right_m: modeled_lateral_m(&template, 6.0, near),
1434 sigma_m: Some(0.25 * 0.0254),
1435 },
1436 WindObservation {
1437 range_m: far,
1438 miss_right_m: modeled_lateral_m(&template, 6.0, far),
1439 sigma_m: Some(0.25 * 0.0254),
1440 },
1441 ];
1442 let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1443 assert!(report.inverse_variance_weighted);
1444 let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1445 assert!(sigma > 0.0 && sigma.is_finite());
1446 let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1449 let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1450 assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1451 assert!(sigma <= far_sigma + 1e-12);
1452
1453 let mixed = base_request(vec![
1454 WindObservation {
1455 range_m: near,
1456 miss_right_m: 0.1,
1457 sigma_m: Some(0.006),
1458 },
1459 WindObservation {
1460 range_m: far,
1461 miss_right_m: 0.2,
1462 sigma_m: None,
1463 },
1464 ]);
1465 let error = mixed.validate().unwrap_err();
1466 assert!(error.contains("every observed miss or on none"), "{error}");
1467 }
1468
1469 #[test]
1472 fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1473 let error = solve_wind_truing(&base_request(vec![WindObservation {
1474 range_m: 274.32,
1475 miss_right_m: 25.0, sigma_m: None,
1477 }]))
1478 .unwrap_err()
1479 .to_string();
1480 assert!(error.contains("no crosswind within"), "{error}");
1481 assert!(error.contains("check the sign of --miss"), "{error}");
1482 }
1483
1484 #[test]
1491 fn windage_cf_does_not_alter_the_wind_solve() {
1492 let template = base_request(Vec::new());
1493 let range_m = 457.2;
1494 let miss = modeled_lateral_m(&template, 7.0, range_m);
1495 let observation = WindObservation {
1496 range_m,
1497 miss_right_m: miss,
1498 sigma_m: None,
1499 };
1500 let solved = solve_wind_truing(&base_request(vec![observation]))
1501 .expect("solve")
1502 .mean_crosswind_mph;
1503 assert!((solved - 7.0).abs() < 0.02);
1504
1505 let windage_cf = 0.95;
1508 let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1509 range_m,
1510 miss_right_m: miss * windage_cf,
1511 sigma_m: None,
1512 }]))
1513 .expect("solve")
1514 .mean_crosswind_mph;
1515 assert!(
1516 (cf_applied - solved).abs() > 0.1,
1517 "a CF-scaled observation must NOT be equivalent to the linear one \
1518 ({cf_applied} vs {solved}); --miss therefore takes no CF"
1519 );
1520 }
1521
1522 #[test]
1525 fn json_value_nulls_absent_optional_fields() {
1526 let template = base_request(Vec::new());
1527 let range_m = 457.2;
1528 let report = solve_wind_truing(&base_request(vec![WindObservation {
1529 range_m,
1530 miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1531 sigma_m: None,
1532 }]))
1533 .expect("solve");
1534 let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1535 assert!(value["called_crosswind"].is_null());
1536 assert!(value["wind_call_factor"].is_null());
1537 assert!(value["effective_crosswind_sigma"].is_null());
1538 assert!(value["observations"][0]["miss_sigma_in"].is_null());
1539 assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1540 assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1541 assert_eq!(value["legend"]["units"]["miss"], "in");
1542 assert_eq!(
1543 value["effective_crosswind"].as_f64().expect("f64").round(),
1544 5.0
1545 );
1546
1547 let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1550 assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1551 assert_eq!(metric["legend"]["units"]["range"], "m");
1552 assert_eq!(metric["legend"]["units"]["miss"], "in");
1553 let mps = metric["effective_crosswind"].as_f64().expect("f64");
1554 let mph = value["effective_crosswind"].as_f64().expect("f64");
1555 assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1556 }
1557
1558 #[test]
1561 fn parse_wind_observation_units_and_errors() {
1562 let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1563 assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1564 assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1565 assert!(imperial.sigma_m.is_none());
1566
1567 let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1568 assert!((metric.range_m - 550.0).abs() < 1e-12);
1569 assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1570 assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1571
1572 for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1573 assert!(
1574 parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1575 "'{bad}' should not parse"
1576 );
1577 }
1578 }
1579
1580 #[test]
1582 fn validation_rejects_degenerate_requests() {
1583 assert!(base_request(Vec::new())
1584 .validate()
1585 .unwrap_err()
1586 .contains("at least one"));
1587
1588 let duplicate = base_request(vec![
1589 WindObservation {
1590 range_m: 457.2,
1591 miss_right_m: 0.2,
1592 sigma_m: None,
1593 },
1594 WindObservation {
1595 range_m: 457.2,
1596 miss_right_m: 0.3,
1597 sigma_m: None,
1598 },
1599 ]);
1600 assert!(duplicate
1601 .validate()
1602 .unwrap_err()
1603 .contains("duplicate observation range"));
1604
1605 let mut bad_twist = base_request(vec![WindObservation {
1606 range_m: 457.2,
1607 miss_right_m: 0.2,
1608 sigma_m: None,
1609 }]);
1610 bad_twist.twist.rate_in = 0.0;
1611 assert!(bad_twist
1612 .validate()
1613 .unwrap_err()
1614 .contains("twist rate must be positive"));
1615
1616 let mut zero_call = base_request(vec![WindObservation {
1617 range_m: 457.2,
1618 miss_right_m: 0.2,
1619 sigma_m: None,
1620 }]);
1621 zero_call.called_crosswind_mph = Some(0.0);
1622 assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1623 }
1624
1625 #[test]
1628 fn request_deserializes_and_report_serializes() {
1629 let json = serde_json::json!({
1630 "observations": [{"range_m": 457.2, "miss_right_m": 0.315, "sigma_m": null}],
1631 "muzzle_velocity_fps": 2700.0, "bc": 0.243, "drag_model": "g7",
1632 "mass_gr": 168.0, "diameter_in": 0.308, "zero_distance_yd": 100.0,
1633 "sight_height_in": 2.0, "temperature_f": 59.0, "pressure_inhg": 29.92,
1634 "humidity_pct": 50.0, "altitude_ft": 0.0,
1635 "twist": {"rate_in": 11.0, "right_hand": true},
1636 "earth": null, "called_crosswind_mph": null
1637 });
1638 let req: WindTruingRequest = serde_json::from_value(json).expect("request deserializes");
1639 let report = solve_wind_truing(&req).expect("solves");
1640 let out = serde_json::to_value(&report).expect("report serializes");
1641 assert!(out["mean_crosswind_mph"].is_number());
1642 assert!(out["solutions"].as_array().unwrap().len() == 1);
1643 }
1644
1645 fn sol(
1649 solved_crosswind_mph: f64,
1650 sigma_m: Option<f64>,
1651 solved_sigma_mph: Option<f64>,
1652 ) -> WindTruingSolution {
1653 WindTruingSolution {
1654 range_m: 500.0,
1655 observed_miss_right_m: 0.3,
1656 sigma_m,
1657 solved_crosswind_mph,
1658 modeled_miss_right_m: 0.3,
1659 residual_m: 0.0,
1660 no_wind_lateral_m: 0.02,
1661 sensitivity_m_per_mph: 0.05,
1662 solved_sigma_mph,
1663 iterations: 3,
1664 converged: true,
1665 }
1666 }
1667
1668 #[test]
1669 fn interval_uses_scatter_when_no_sigmas_supplied() {
1670 let sols = vec![
1673 sol(6.0, None, None),
1674 sol(8.0, None, None),
1675 sol(7.0, None, None),
1676 ];
1677 let got = build_wind_uncertainty(&sols, 7.0, None);
1678 let WindUncertaintyV1::Available(i) = got else {
1679 panic!("expected an interval, got {got:?}");
1680 };
1681 assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1682 assert_eq!(i.dof, Some(2));
1683 assert!(i.propagated_sigma_mph.is_none());
1684 let se = i.empirical_sigma_mph.expect("scatter sigma");
1686 assert!((se - 1.0 / 3f64.sqrt()).abs() < 1e-12, "se was {se}");
1687 let half = 4.302_652_730 * se;
1689 assert!((i.low_mph - (7.0 - half)).abs() < 1e-9);
1690 assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1691 assert!(i.low_mph < 7.0 && i.high_mph > 7.0);
1692 }
1693
1694 #[test]
1695 fn optimistic_supplied_sigmas_lose_to_observed_scatter() {
1696 let sols = vec![
1700 sol(4.0, Some(0.01), Some(0.02)),
1701 sol(9.0, Some(0.01), Some(0.02)),
1702 sol(6.5, Some(0.01), Some(0.02)),
1703 ];
1704 let propagated = Some(0.02 / 3f64.sqrt()); let got = build_wind_uncertainty(&sols, 6.5, propagated);
1706 let WindUncertaintyV1::Available(i) = got else {
1707 panic!("expected an interval")
1708 };
1709 assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1710 let e = i.empirical_sigma_mph.expect("scatter");
1711 let pr = i.propagated_sigma_mph.expect("propagated");
1712 assert!(e > pr, "scatter {e} should exceed propagated {pr}");
1713 assert!(
1714 (i.sigma_mph - e).abs() < 1e-12,
1715 "the wider estimate must drive the interval"
1716 );
1717 assert!(i.propagated_sigma_mph.is_some());
1719 }
1720
1721 #[test]
1722 fn tight_scatter_lets_supplied_sigmas_win() {
1723 let sols = vec![
1726 sol(7.00, Some(2.0), Some(1.5)),
1727 sol(7.01, Some(2.0), Some(1.5)),
1728 sol(6.99, Some(2.0), Some(1.5)),
1729 ];
1730 let propagated = Some(1.5 / 3f64.sqrt());
1731 let got = build_wind_uncertainty(&sols, 7.0, propagated);
1732 let WindUncertaintyV1::Available(i) = got else {
1733 panic!("expected an interval")
1734 };
1735 assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1736 assert_eq!(
1737 i.dof, None,
1738 "a supplied sigma is treated as known, so normal not t"
1739 );
1740 let half = NORMAL_95_TWO_SIDED_Z * i.sigma_mph;
1741 assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1742 }
1743
1744 #[test]
1745 fn single_observation_without_sigma_is_explained_not_omitted() {
1746 let sols = vec![sol(7.0, None, None)];
1747 let got = build_wind_uncertainty(&sols, 7.0, None);
1748 let WindUncertaintyV1::Unavailable(f) = got else {
1749 panic!("one shot with no sigma cannot yield an interval");
1750 };
1751 assert_eq!(f.code, WindUncertaintyFailureCodeV1::SingleObservation);
1752 assert!(!f.message.is_empty());
1753 }
1754
1755 #[test]
1756 fn single_observation_with_sigma_still_gets_an_interval() {
1757 let sols = vec![sol(7.0, Some(0.1), Some(2.0))];
1758 let got = build_wind_uncertainty(&sols, 7.0, Some(2.0));
1759 let WindUncertaintyV1::Available(i) = got else {
1760 panic!("expected an interval")
1761 };
1762 assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1763 assert!(i.empirical_sigma_mph.is_none(), "one shot has no scatter");
1764 }
1765
1766 #[test]
1767 fn identical_observations_without_sigma_report_zero_spread_honestly() {
1768 let sols = vec![sol(7.0, None, None), sol(7.0, None, None)];
1770 let got = build_wind_uncertainty(&sols, 7.0, None);
1771 let WindUncertaintyV1::Unavailable(f) = got else {
1772 panic!("zero spread must not be reported as a zero-width interval");
1773 };
1774 assert_eq!(f.code, WindUncertaintyFailureCodeV1::NoUsableEstimate);
1775 }
1776
1777 #[test]
1778 fn t_multiplier_table_matches_published_values() {
1779 for (dof, want) in [
1781 (1usize, 12.706205),
1782 (2, 4.302653),
1783 (5, 2.570582),
1784 (10, 2.228139),
1785 (30, 2.042272),
1786 ] {
1787 let got = T_95_TWO_SIDED[dof - 1];
1788 assert!((got - want).abs() < 5e-6, "dof {dof}: {got} vs {want}");
1789 }
1790 const { assert!(T_95_TWO_SIDED[0] > T_95_TWO_SIDED[9]) };
1793 const { assert!(T_95_TWO_SIDED[29] > NORMAL_95_TWO_SIDED_Z) };
1794 }
1795
1796 #[test]
1797 fn uncertainty_is_always_present_in_the_serialized_report() {
1798 let sols = vec![sol(6.0, None, None), sol(8.0, None, None)];
1799 let u = build_wind_uncertainty(&sols, 7.0, None);
1800 let v = serde_json::to_value(&u).expect("serializes");
1801 assert_eq!(v["status"], "available");
1802 assert!(v["detail"]["low_mph"].is_number());
1803 assert_eq!(v["detail"]["basis"], "empirical_scatter");
1804 }
1805}