1use std::error::Error;
42
43use crate::cli_api::UnitSystem;
44use crate::truing::{
45 DragModelArg, TruingEarthFrame, TruingEnvironment, TruingTwist, TRUING_BC_MAX, TRUING_BC_MIN,
46 TRUING_MV_MAX_FPS, TRUING_MV_MIN_FPS,
47};
48use crate::{BCSegmentData, WindConditions};
49
50pub const MPH_TO_MPS: f64 = 0.44704;
54
55pub const MAX_SOLVABLE_CROSSWIND_MPH: f64 = 100.0;
60
61pub const WIND_SOLVE_TOLERANCE_M: f64 = 1.0e-5;
66
67const WIND_SOLVE_MIN_BRACKET_MPH: f64 = 1.0e-9;
70
71const WIND_SOLVE_MAX_ITERATIONS: u32 = 60;
76
77const WIND_SENSITIVITY_STEP_MPH: f64 = 0.5;
81
82pub const MIN_WIND_SENSITIVITY_IN_PER_MPH: f64 = 0.25;
87
88#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct WindObservation {
94 pub range_m: f64,
96 pub miss_right_m: f64,
98 pub sigma_m: Option<f64>,
101}
102
103pub fn parse_wind_observation(s: &str, units: UnitSystem) -> Result<WindObservation, String> {
110 let parts: Vec<&str> = s.split(':').collect();
111 if parts.len() != 2 && parts.len() != 3 {
112 return Err(format!(
113 "invalid --miss '{s}': expected RANGE:RIGHT_IN[:SIGMA] (e.g. 600:8.5 or 600:8.5:0.75)"
114 ));
115 }
116 let range: f64 = parts[0]
117 .trim()
118 .parse()
119 .map_err(|_| format!("invalid --miss range '{}' in '{s}'", parts[0]))?;
120 let miss_in: f64 = parts[1]
121 .trim()
122 .parse()
123 .map_err(|_| format!("invalid --miss offset '{}' in '{s}'", parts[1]))?;
124 let sigma_in: Option<f64> = match parts.get(2) {
125 Some(token) => Some(
126 token
127 .trim()
128 .parse()
129 .map_err(|_| format!("invalid --miss sigma '{token}' in '{s}'"))?,
130 ),
131 None => None,
132 };
133 if !range.is_finite() || !miss_in.is_finite() || sigma_in.is_some_and(|v| !v.is_finite()) {
134 return Err(format!("invalid --miss '{s}': values must be finite"));
135 }
136 let range_m = match units {
137 UnitSystem::Imperial => range * 0.9144,
138 UnitSystem::Metric => range,
139 };
140 Ok(WindObservation {
141 range_m,
142 miss_right_m: miss_in * 0.0254,
143 sigma_m: sigma_in.map(|v| v * 0.0254),
144 })
145}
146
147#[derive(Debug, Clone)]
155pub struct WindTruingRequest {
156 pub observations: Vec<WindObservation>,
158 pub muzzle_velocity_fps: f64,
160 pub bc: f64,
162 pub drag_model: DragModelArg,
163 pub mass_gr: f64,
165 pub diameter_in: f64,
167 pub zero_distance_yd: f64,
169 pub sight_height_in: f64,
171 pub temperature_f: f64,
173 pub pressure_inhg: f64,
175 pub humidity_pct: f64,
177 pub altitude_ft: f64,
179 pub twist: TruingTwist,
183 pub earth: Option<TruingEarthFrame>,
186 pub called_crosswind_mph: Option<f64>,
189}
190
191impl WindTruingRequest {
192 pub fn validate(&self) -> Result<(), String> {
199 if self.observations.is_empty() {
200 return Err("at least one observed horizontal miss is required".to_string());
201 }
202 if !self.muzzle_velocity_fps.is_finite()
203 || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
204 {
205 return Err(format!(
206 "muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
207 ));
208 }
209 if !self.bc.is_finite() || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.bc) {
210 return Err(format!(
211 "ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
212 ));
213 }
214 for (name, value) in [
215 ("bullet mass", self.mass_gr),
216 ("bullet diameter", self.diameter_in),
217 ("zero distance", self.zero_distance_yd),
218 ("sight height", self.sight_height_in),
219 ("pressure", self.pressure_inhg),
220 ("twist rate", self.twist.rate_in),
221 ] {
222 if !value.is_finite() || value <= 0.0 {
223 return Err(format!("{name} must be positive and finite"));
224 }
225 }
226 if !self.temperature_f.is_finite() {
227 return Err("temperature must be finite".to_string());
228 }
229 if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
230 return Err("humidity must be finite and within 0..=100 percent".to_string());
231 }
232 if !self.altitude_ft.is_finite() {
233 return Err("altitude must be finite".to_string());
234 }
235 if let Some(earth) = self.earth {
236 if !earth.latitude_deg.is_finite() || !(-90.0..=90.0).contains(&earth.latitude_deg) {
237 return Err("latitude must be finite and within -90..=90 degrees".to_string());
238 }
239 if !earth.shot_azimuth_deg.is_finite() {
240 return Err("shot azimuth must be finite".to_string());
241 }
242 }
243 if let Some(called) = self.called_crosswind_mph {
244 if !called.is_finite() || called == 0.0 {
245 return Err(
246 "the called wind must be finite and non-zero (a zero call has no \
247 correction factor)"
248 .to_string(),
249 );
250 }
251 }
252 for observation in &self.observations {
253 if !observation.range_m.is_finite() || observation.range_m <= 0.0 {
254 return Err(format!(
255 "observation range must be a positive finite distance (got {})",
256 observation.range_m
257 ));
258 }
259 if !observation.miss_right_m.is_finite() {
260 return Err("observed horizontal miss must be finite".to_string());
261 }
262 if observation
263 .sigma_m
264 .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
265 {
266 return Err("an observed-miss sigma must be positive and finite".to_string());
267 }
268 }
269 for i in 0..self.observations.len() {
270 for j in (i + 1)..self.observations.len() {
271 if (self.observations[i].range_m - self.observations[j].range_m).abs() < 1e-6 {
272 return Err(format!(
273 "duplicate observation range ({:.3} m): each observed miss must be at a \
274 distinct range",
275 self.observations[i].range_m
276 ));
277 }
278 }
279 }
280 let with_sigma = self
281 .observations
282 .iter()
283 .filter(|o| o.sigma_m.is_some())
284 .count();
285 if with_sigma != 0 && with_sigma != self.observations.len() {
286 return Err(
287 "supply a sigma on every observed miss or on none: mixing weighted and \
288 unweighted observations would silently combine inverse-variance weights \
289 with unit weights"
290 .to_string(),
291 );
292 }
293 Ok(())
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq)]
299pub struct WindTruingSolution {
300 pub range_m: f64,
302 pub observed_miss_right_m: f64,
304 pub sigma_m: Option<f64>,
306 pub solved_crosswind_mph: f64,
308 pub modeled_miss_right_m: f64,
311 pub residual_m: f64,
313 pub no_wind_lateral_m: f64,
317 pub sensitivity_m_per_mph: f64,
320 pub solved_sigma_mph: Option<f64>,
323 pub iterations: u32,
325 pub converged: bool,
328}
329
330#[derive(Debug, Clone)]
332pub struct WindTruingReport {
333 pub solutions: Vec<WindTruingSolution>,
335 pub mean_crosswind_mph: f64,
337 pub mean_sigma_mph: Option<f64>,
340 pub inverse_variance_weighted: bool,
343 pub called_crosswind_mph: Option<f64>,
345 pub wind_call_factor: Option<f64>,
348 pub subtracted_effects: Vec<String>,
350 pub unsubtracted_effects: Vec<String>,
353}
354
355fn crosswind_conditions(signed_mph: f64) -> WindConditions {
363 WindConditions {
364 speed: signed_mph.abs() * MPH_TO_MPS,
365 direction: if signed_mph < 0.0 {
366 std::f64::consts::FRAC_PI_2
367 } else {
368 3.0 * std::f64::consts::FRAC_PI_2
369 },
370 vertical_speed: 0.0,
371 }
372}
373
374pub fn modeled_miss_right_m(
384 request: &WindTruingRequest,
385 crosswind_mph: f64,
386 range_m: f64,
387) -> Result<f64, Box<dyn Error>> {
388 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
389 let env = TruingEnvironment {
390 wind: crosswind_conditions(crosswind_mph),
391 twist: Some(request.twist),
392 earth: request.earth,
393 };
394 let sample = crate::truing::solve_trajectory_sample(
395 request.muzzle_velocity_fps,
396 request.bc,
397 request.drag_model,
398 request.mass_gr,
399 request.diameter_in,
400 request.zero_distance_yd,
401 range_m / 0.9144,
402 request.sight_height_in,
403 request.temperature_f,
404 request.pressure_inhg,
405 request.humidity_pct,
406 request.altitude_ft,
407 &no_bc_segments,
408 &env,
409 true, )?;
411 Ok(sample.lateral_m)
412}
413
414pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
427 request.validate()?;
428
429 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
433
434 let environment = |crosswind_mph: f64| TruingEnvironment {
435 wind: crosswind_conditions(crosswind_mph),
436 twist: Some(request.twist),
437 earth: request.earth,
438 };
439
440 let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
442 let sample = crate::truing::solve_trajectory_sample(
443 request.muzzle_velocity_fps,
444 request.bc,
445 request.drag_model,
446 request.mass_gr,
447 request.diameter_in,
448 request.zero_distance_yd,
449 range_yd,
450 request.sight_height_in,
451 request.temperature_f,
452 request.pressure_inhg,
453 request.humidity_pct,
454 request.altitude_ft,
455 &no_bc_segments,
456 &environment(crosswind_mph),
457 true, )?;
459 Ok(sample.lateral_m)
460 };
461
462 let mut solutions = Vec::with_capacity(request.observations.len());
463 for observation in &request.observations {
464 let range_yd = observation.range_m / 0.9144;
465 solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
466 }
467
468 let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
473 if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
474 return Err(
475 "an observation does not move with crosswind at all, so its measurement sigma \
476 cannot be expressed in wind units — drop that observation or its sigma"
477 .into(),
478 );
479 }
480 let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
481 let mut weight_sum = 0.0;
482 let mut weighted = 0.0;
483 for solution in &solutions {
484 let sigma = solution
485 .solved_sigma_mph
486 .expect("checked by inverse_variance_weighted");
487 let weight = 1.0 / (sigma * sigma);
488 weight_sum += weight;
489 weighted += weight * solution.solved_crosswind_mph;
490 }
491 if weight_sum > 0.0 && weight_sum.is_finite() {
492 (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
493 } else {
494 return Err(
495 "observed-miss sigmas produced a degenerate weighting (check that every \
496 sigma is positive and that the observations move with wind at all)"
497 .into(),
498 );
499 }
500 } else {
501 let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
502 (sum / solutions.len() as f64, None)
503 };
504
505 let wind_call_factor = request
506 .called_crosswind_mph
507 .map(|called| mean_crosswind_mph / called);
508
509 let mut subtracted_effects = vec!["spin drift".to_string()];
513 let mut unsubtracted_effects = Vec::new();
514 if request.earth.is_some() {
515 subtracted_effects.push("Coriolis".to_string());
516 } else {
517 unsubtracted_effects
518 .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
519 }
520
521 Ok(WindTruingReport {
522 solutions,
523 mean_crosswind_mph,
524 mean_sigma_mph,
525 inverse_variance_weighted,
526 called_crosswind_mph: request.called_crosswind_mph,
527 wind_call_factor,
528 subtracted_effects,
529 unsubtracted_effects,
530 })
531}
532
533fn solve_one_observation(
538 observation: &WindObservation,
539 range_yd: f64,
540 lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
541) -> Result<WindTruingSolution, Box<dyn Error>> {
542 let target = observation.miss_right_m;
543 let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
544 Ok(lateral_at(crosswind_mph, range_yd)? - target)
545 };
546
547 let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
548 let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
549 let mut f_low = residual(low)?;
550 let mut f_high = residual(high)?;
551 if f_low > 0.0 || f_high < 0.0 {
552 return Err(format!(
553 "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
554 miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
555 the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
556 target / 0.0254,
557 (f_low + target) / 0.0254,
558 (f_high + target) / 0.0254,
559 )
560 .into());
561 }
562
563 let mut solved = 0.0;
564 let mut f_solved = 0.0;
565 let mut iterations = 0u32;
566 let mut converged = false;
567 while iterations < WIND_SOLVE_MAX_ITERATIONS {
568 iterations += 1;
569 let denom = f_high - f_low;
570 let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
571 high - f_high * (high - low) / denom
572 } else {
573 0.5 * (low + high)
574 };
575 if !candidate.is_finite() || candidate <= low || candidate >= high {
577 candidate = 0.5 * (low + high);
578 }
579 let f = residual(candidate)?;
580 solved = candidate;
581 f_solved = f;
582 if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
583 converged = true;
584 break;
585 }
586 if f < 0.0 {
588 low = candidate;
589 f_low = f;
590 f_high *= 0.5;
591 } else {
592 high = candidate;
593 f_high = f;
594 f_low *= 0.5;
595 }
596 }
597
598 let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
600 let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
601 let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
602
603 let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
606
607 let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
608 let slope = sensitivity_m_per_mph.abs();
609 (slope > 0.0).then_some(sigma / slope)
610 });
611
612 Ok(WindTruingSolution {
613 range_m: observation.range_m,
614 observed_miss_right_m: target,
615 sigma_m: observation.sigma_m,
616 solved_crosswind_mph: solved,
617 modeled_miss_right_m: target + f_solved,
618 residual_m: f_solved,
619 no_wind_lateral_m,
620 sensitivity_m_per_mph,
621 solved_sigma_mph,
622 iterations,
623 converged,
624 })
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631pub enum WindTruingOutput {
632 Table,
633 Json,
634 Csv,
635}
636
637struct WindTruingUnits {
640 range_label: &'static str,
641 speed_label: &'static str,
642 range_scale: f64,
643 speed_scale: f64,
644}
645
646impl WindTruingUnits {
647 fn for_system(units: UnitSystem) -> Self {
648 match units {
649 UnitSystem::Imperial => Self {
650 range_label: "yd",
651 speed_label: "mph",
652 range_scale: 1.0 / 0.9144,
653 speed_scale: 1.0,
654 },
655 UnitSystem::Metric => Self {
656 range_label: "m",
657 speed_label: "m/s",
658 range_scale: 1.0,
659 speed_scale: MPH_TO_MPS,
660 },
661 }
662 }
663
664 fn range(&self, range_m: f64) -> f64 {
665 range_m * self.range_scale
666 }
667
668 fn speed(&self, mph: f64) -> f64 {
669 mph * self.speed_scale
670 }
671}
672
673fn inches(meters: f64) -> f64 {
675 meters / 0.0254
676}
677
678pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
685 let u = WindTruingUnits::for_system(units);
686 let observations: Vec<serde_json::Value> = report
687 .solutions
688 .iter()
689 .map(|s| {
690 serde_json::json!({
691 format!("range_{}", u.range_label): u.range(s.range_m),
692 "miss_right_in": inches(s.observed_miss_right_m),
693 "miss_sigma_in": s.sigma_m.map(inches),
694 "no_wind_lateral_in": inches(s.no_wind_lateral_m),
695 "solved_crosswind": u.speed(s.solved_crosswind_mph),
696 "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
697 "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
698 "residual_in": inches(s.residual_m),
699 "iterations": s.iterations,
700 "converged": s.converged,
701 })
702 })
703 .collect();
704
705 serde_json::json!({
706 "effective_crosswind": u.speed(report.mean_crosswind_mph),
707 "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
708 "inverse_variance_weighted": report.inverse_variance_weighted,
709 "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
710 "wind_call_factor": report.wind_call_factor,
711 "observations": observations,
712 "effects_subtracted": report.subtracted_effects,
713 "effects_not_subtracted": report.unsubtracted_effects,
714 "legend": {
715 "units": {
716 "range": u.range_label,
717 "miss": "in",
718 "wind_speed": u.speed_label,
719 },
720 "signs": "--miss positive = impact right of aim; solved crosswind positive = \
721 wind from the shooter's left (9 o'clock) pushing impacts right",
722 },
723 })
724}
725
726pub fn format_wind_truing_report(
732 report: &WindTruingReport,
733 units: UnitSystem,
734 output: WindTruingOutput,
735) -> String {
736 let u = WindTruingUnits::for_system(units);
737 match output {
738 WindTruingOutput::Json => {
739 match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
740 Ok(s) => format!("{s}\n"),
741 Err(e) => format!("Error serializing JSON: {e}\n"),
742 }
743 }
744 WindTruingOutput::Csv => {
745 let mut out = String::new();
746 out.push_str(&format!(
747 "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
748 sensitivity_in_per_mph,residual_in,iterations,converged\n",
749 u.range_label, u.speed_label
750 ));
751 for s in &report.solutions {
752 out.push_str(&format!(
753 "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
754 u.range(s.range_m),
755 inches(s.observed_miss_right_m),
756 match s.sigma_m {
757 Some(sigma) => format!("{:.3}", inches(sigma)),
758 None => String::new(),
759 },
760 inches(s.no_wind_lateral_m),
761 u.speed(s.solved_crosswind_mph),
762 inches(s.sensitivity_m_per_mph),
763 inches(s.residual_m),
764 s.iterations,
765 s.converged,
766 ));
767 }
768 out.push('\n');
769 out.push_str(&format!(
770 "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
771 called_crosswind_{},wind_call_factor\n",
772 u.speed_label, u.speed_label, u.speed_label
773 ));
774 out.push_str(&format!(
775 "{:+.3},{},{},{},{}\n",
776 u.speed(report.mean_crosswind_mph),
777 match report.mean_sigma_mph {
778 Some(sigma) => format!("{:.3}", u.speed(sigma)),
779 None => String::new(),
780 },
781 report.inverse_variance_weighted,
782 match report.called_crosswind_mph {
783 Some(called) => format!("{:+.3}", u.speed(called)),
784 None => String::new(),
785 },
786 match report.wind_call_factor {
787 Some(factor) => format!("{factor:.4}"),
788 None => String::new(),
789 },
790 ));
791 out
792 }
793 WindTruingOutput::Table => {
794 let mut out = String::new();
795 out.push('\n');
796 out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
797 out.push('\n');
798 out.push_str(&format!(
799 " {:>10} {:>12} {:>14} {:>16} {:>10}\n",
800 format!("Range ({})", u.range_label),
801 "Miss (in)",
802 "Spin/Cor (in)",
803 format!("Wind ({})", u.speed_label),
804 "Resid (in)",
805 ));
806 out.push_str(&format!(" {}\n", "-".repeat(70)));
807 for s in &report.solutions {
808 out.push_str(&format!(
809 " {:>10.1} {:>+12.2} {:>+14.2} {:>+16.2} {:>+10.3}\n",
810 u.range(s.range_m),
811 inches(s.observed_miss_right_m),
812 inches(s.no_wind_lateral_m),
813 u.speed(s.solved_crosswind_mph),
814 inches(s.residual_m),
815 ));
816 }
817 out.push_str(&format!(" {}\n", "-".repeat(70)));
818 out.push('\n');
819 let n = report.solutions.len();
820 out.push_str(&format!(
821 " Effective crosswind: {:>+8.2} {}{}\n",
822 u.speed(report.mean_crosswind_mph),
823 u.speed_label,
824 match report.mean_sigma_mph {
825 Some(sigma) => format!(
826 " +/- {:.2} {} (inverse-variance weighted over {n} observations)",
827 u.speed(sigma),
828 u.speed_label
829 ),
830 None if n > 1 => format!(" (mean of {n} observations)"),
831 None => String::new(),
832 }
833 ));
834 if let (Some(called), Some(factor)) =
835 (report.called_crosswind_mph, report.wind_call_factor)
836 {
837 out.push_str(&format!(
838 " Called wind: {:>+8.2} {} -> wind-call correction factor {:.2}\n",
839 u.speed(called),
840 u.speed_label,
841 factor
842 ));
843 out.push_str(&format!(
844 " (multiply your wind calls by {factor:.2} to match what actually hit)\n"
845 ));
846 }
847 if !report.subtracted_effects.is_empty() {
848 out.push_str(&format!(
849 " Effects subtracted: {}\n",
850 report.subtracted_effects.join(", ")
851 ));
852 }
853 if !report.unsubtracted_effects.is_empty() {
854 out.push_str(&format!(
855 " NOT subtracted (absorbed into the solved wind): {}\n",
856 report.unsubtracted_effects.join(", ")
857 ));
858 }
859 for s in &report.solutions {
860 if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
861 out.push_str(&format!(
862 " note: the observation at {:.1} {} moves only {:.2} in per mph of \
863 crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
864 wind fitted from it is weakly identified\n",
865 u.range(s.range_m),
866 u.range_label,
867 inches(s.sensitivity_m_per_mph).abs(),
868 ));
869 }
870 if !s.converged {
871 out.push_str(&format!(
872 " note: the fit at {:.1} {} did not fully converge after {} iterations; \
873 the value shown is the best estimate\n",
874 u.range(s.range_m),
875 u.range_label,
876 s.iterations,
877 ));
878 }
879 }
880 out.push('\n');
881 out.push_str(
882 " Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
883 \x20 FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
884 \x20 = FROM the right pushing left. Wind-FROM convention throughout\n\
885 \x20 (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
886 );
887 out.push('\n');
888 out
889 }
890 }
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896
897 fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
898 WindTruingRequest {
899 observations,
900 muzzle_velocity_fps: 2700.0,
901 bc: 0.475,
902 drag_model: DragModelArg::G7,
903 mass_gr: 168.0,
904 diameter_in: 0.308,
905 zero_distance_yd: 100.0,
906 sight_height_in: 2.0,
907 temperature_f: 59.0,
908 pressure_inhg: 29.92,
909 humidity_pct: 50.0,
910 altitude_ft: 0.0,
911 twist: TruingTwist {
912 rate_in: 11.0,
913 right_hand: true,
914 },
915 earth: None,
916 called_crosswind_mph: None,
917 }
918 }
919
920 fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
923 modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
924 }
925
926 #[test]
931 fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
932 let known_mph = 7.5;
933 let ranges_m = [274.32, 457.2, 640.08]; let template = base_request(Vec::new());
935 let observations = ranges_m
936 .iter()
937 .map(|range_m| WindObservation {
938 range_m: *range_m,
939 miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
940 sigma_m: None,
941 })
942 .collect();
943
944 let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
945 assert_eq!(report.solutions.len(), 3);
946 for solution in &report.solutions {
947 assert!(solution.converged, "{solution:?}");
948 assert!(
949 (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
950 "recovered {} mph at {} m, expected {known_mph}",
951 solution.solved_crosswind_mph,
952 solution.range_m
953 );
954 }
955 assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
956 assert!(!report.inverse_variance_weighted);
957 assert!(report.mean_sigma_mph.is_none());
958 }
959
960 #[test]
964 fn miss_right_solves_positive_and_miss_left_solves_negative() {
965 let template = base_request(Vec::new());
966 let range_m = 457.2; let right_miss = modeled_lateral_m(&template, 8.0, range_m);
968 let left_miss = modeled_lateral_m(&template, -8.0, range_m);
969 assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
970 assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
971
972 let right = solve_wind_truing(&base_request(vec![WindObservation {
973 range_m,
974 miss_right_m: right_miss,
975 sigma_m: None,
976 }]))
977 .expect("right-miss fit must solve");
978 assert!(
979 right.mean_crosswind_mph > 0.0,
980 "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
981 right.mean_crosswind_mph
982 );
983 assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
984
985 let left = solve_wind_truing(&base_request(vec![WindObservation {
986 range_m,
987 miss_right_m: left_miss,
988 sigma_m: None,
989 }]))
990 .expect("left-miss fit must solve");
991 assert!(
992 left.mean_crosswind_mph < 0.0,
993 "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
994 left.mean_crosswind_mph
995 );
996 assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
997 }
998
999 #[test]
1003 fn pure_spin_drift_solves_to_zero_wind() {
1004 let template = base_request(Vec::new());
1005 let range_m = 640.08; let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1007 assert!(
1008 spin_only > 0.05,
1009 "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1010 );
1011
1012 let report = solve_wind_truing(&base_request(vec![WindObservation {
1013 range_m,
1014 miss_right_m: spin_only,
1015 sigma_m: None,
1016 }]))
1017 .expect("spin-only fit must solve");
1018 assert!(
1019 report.mean_crosswind_mph.abs() < 0.02,
1020 "pure spin drift must solve to ~0 wind, got {}",
1021 report.mean_crosswind_mph
1022 );
1023 assert!(report
1025 .solutions
1026 .iter()
1027 .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1028 assert!(report
1029 .subtracted_effects
1030 .iter()
1031 .any(|e| e.contains("spin drift")));
1032 }
1033
1034 #[test]
1038 fn twist_hand_changes_the_solved_wind() {
1039 let range_m = 640.08;
1040 let observation = WindObservation {
1041 range_m,
1042 miss_right_m: 0.25,
1043 sigma_m: None,
1044 };
1045 let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1046 let mut left = base_request(vec![observation]);
1047 left.twist.right_hand = false;
1048 let left_hand = solve_wind_truing(&left).expect("solve");
1049 assert!(
1050 left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1051 "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1052 left_hand.mean_crosswind_mph,
1053 right_hand.mean_crosswind_mph
1054 );
1055 }
1056
1057 #[test]
1061 fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1062 let range_m = 914.4; let observation = WindObservation {
1064 range_m,
1065 miss_right_m: 0.30,
1066 sigma_m: None,
1067 };
1068 let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1069 let mut with_earth = base_request(vec![observation]);
1070 with_earth.earth = Some(TruingEarthFrame {
1071 latitude_deg: 45.0,
1072 shot_azimuth_deg: 90.0, });
1074 let with = solve_wind_truing(&with_earth).expect("solve");
1075
1076 assert!(without
1077 .unsubtracted_effects
1078 .iter()
1079 .any(|e| e.contains("Coriolis")));
1080 assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1081 assert!(with.unsubtracted_effects.is_empty());
1082 assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1083 assert!(
1084 (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1085 > 1e-4,
1086 "modelling Coriolis must change the zero-wind lateral"
1087 );
1088 assert!(
1089 (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1090 "modelling Coriolis must change the solved wind"
1091 );
1092 }
1093
1094 #[test]
1098 fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1099 let template = base_request(Vec::new());
1100 let range_m = 457.2;
1101 let miss = modeled_lateral_m(&template, 9.0, range_m);
1102 let observation = WindObservation {
1103 range_m,
1104 miss_right_m: miss,
1105 sigma_m: None,
1106 };
1107
1108 let mut under_called = base_request(vec![observation]);
1109 under_called.called_crosswind_mph = Some(6.0);
1110 let report = solve_wind_truing(&under_called).expect("solve");
1111 let factor = report.wind_call_factor.expect("factor");
1112 assert!(
1113 (factor - 9.0 / 6.0).abs() < 0.01,
1114 "expected ~1.5, got {factor}"
1115 );
1116
1117 let mut wrong_side = base_request(vec![observation]);
1118 wrong_side.called_crosswind_mph = Some(-6.0);
1119 let flipped = solve_wind_truing(&wrong_side)
1120 .expect("solve")
1121 .wind_call_factor
1122 .expect("factor");
1123 assert!(flipped < 0.0, "a wrong-side call must read negative: {flipped}");
1124 }
1125
1126 #[test]
1130 fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1131 let template = base_request(Vec::new());
1132 let near = 274.32;
1133 let far = 640.08;
1134 let weighted = vec![
1135 WindObservation {
1136 range_m: near,
1137 miss_right_m: modeled_lateral_m(&template, 6.0, near),
1138 sigma_m: Some(0.25 * 0.0254),
1139 },
1140 WindObservation {
1141 range_m: far,
1142 miss_right_m: modeled_lateral_m(&template, 6.0, far),
1143 sigma_m: Some(0.25 * 0.0254),
1144 },
1145 ];
1146 let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1147 assert!(report.inverse_variance_weighted);
1148 let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1149 assert!(sigma > 0.0 && sigma.is_finite());
1150 let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1153 let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1154 assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1155 assert!(sigma <= far_sigma + 1e-12);
1156
1157 let mixed = base_request(vec![
1158 WindObservation {
1159 range_m: near,
1160 miss_right_m: 0.1,
1161 sigma_m: Some(0.006),
1162 },
1163 WindObservation {
1164 range_m: far,
1165 miss_right_m: 0.2,
1166 sigma_m: None,
1167 },
1168 ]);
1169 let error = mixed.validate().unwrap_err();
1170 assert!(error.contains("every observed miss or on none"), "{error}");
1171 }
1172
1173 #[test]
1176 fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1177 let error = solve_wind_truing(&base_request(vec![WindObservation {
1178 range_m: 274.32,
1179 miss_right_m: 25.0, sigma_m: None,
1181 }]))
1182 .unwrap_err()
1183 .to_string();
1184 assert!(error.contains("no crosswind within"), "{error}");
1185 assert!(error.contains("check the sign of --miss"), "{error}");
1186 }
1187
1188 #[test]
1195 fn windage_cf_does_not_alter_the_wind_solve() {
1196 let template = base_request(Vec::new());
1197 let range_m = 457.2;
1198 let miss = modeled_lateral_m(&template, 7.0, range_m);
1199 let observation = WindObservation {
1200 range_m,
1201 miss_right_m: miss,
1202 sigma_m: None,
1203 };
1204 let solved = solve_wind_truing(&base_request(vec![observation]))
1205 .expect("solve")
1206 .mean_crosswind_mph;
1207 assert!((solved - 7.0).abs() < 0.02);
1208
1209 let windage_cf = 0.95;
1212 let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1213 range_m,
1214 miss_right_m: miss * windage_cf,
1215 sigma_m: None,
1216 }]))
1217 .expect("solve")
1218 .mean_crosswind_mph;
1219 assert!(
1220 (cf_applied - solved).abs() > 0.1,
1221 "a CF-scaled observation must NOT be equivalent to the linear one \
1222 ({cf_applied} vs {solved}); --miss therefore takes no CF"
1223 );
1224 }
1225
1226 #[test]
1229 fn json_value_nulls_absent_optional_fields() {
1230 let template = base_request(Vec::new());
1231 let range_m = 457.2;
1232 let report = solve_wind_truing(&base_request(vec![WindObservation {
1233 range_m,
1234 miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1235 sigma_m: None,
1236 }]))
1237 .expect("solve");
1238 let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1239 assert!(value["called_crosswind"].is_null());
1240 assert!(value["wind_call_factor"].is_null());
1241 assert!(value["effective_crosswind_sigma"].is_null());
1242 assert!(value["observations"][0]["miss_sigma_in"].is_null());
1243 assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1244 assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1245 assert_eq!(value["legend"]["units"]["miss"], "in");
1246 assert_eq!(
1247 value["effective_crosswind"].as_f64().expect("f64").round(),
1248 5.0
1249 );
1250
1251 let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1254 assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1255 assert_eq!(metric["legend"]["units"]["range"], "m");
1256 assert_eq!(metric["legend"]["units"]["miss"], "in");
1257 let mps = metric["effective_crosswind"].as_f64().expect("f64");
1258 let mph = value["effective_crosswind"].as_f64().expect("f64");
1259 assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1260 }
1261
1262 #[test]
1265 fn parse_wind_observation_units_and_errors() {
1266 let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1267 assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1268 assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1269 assert!(imperial.sigma_m.is_none());
1270
1271 let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1272 assert!((metric.range_m - 550.0).abs() < 1e-12);
1273 assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1274 assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1275
1276 for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1277 assert!(
1278 parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1279 "'{bad}' should not parse"
1280 );
1281 }
1282 }
1283
1284 #[test]
1286 fn validation_rejects_degenerate_requests() {
1287 assert!(base_request(Vec::new())
1288 .validate()
1289 .unwrap_err()
1290 .contains("at least one"));
1291
1292 let duplicate = base_request(vec![
1293 WindObservation {
1294 range_m: 457.2,
1295 miss_right_m: 0.2,
1296 sigma_m: None,
1297 },
1298 WindObservation {
1299 range_m: 457.2,
1300 miss_right_m: 0.3,
1301 sigma_m: None,
1302 },
1303 ]);
1304 assert!(duplicate
1305 .validate()
1306 .unwrap_err()
1307 .contains("duplicate observation range"));
1308
1309 let mut bad_twist = base_request(vec![WindObservation {
1310 range_m: 457.2,
1311 miss_right_m: 0.2,
1312 sigma_m: None,
1313 }]);
1314 bad_twist.twist.rate_in = 0.0;
1315 assert!(bad_twist
1316 .validate()
1317 .unwrap_err()
1318 .contains("twist rate must be positive"));
1319
1320 let mut zero_call = base_request(vec![WindObservation {
1321 range_m: 457.2,
1322 miss_right_m: 0.2,
1323 sigma_m: None,
1324 }]);
1325 zero_call.called_crosswind_mph = Some(0.0);
1326 assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1327 }
1328}