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
334#[derive(Debug, Clone, Serialize)]
336pub struct WindTruingReport {
337 pub solutions: Vec<WindTruingSolution>,
339 pub mean_crosswind_mph: f64,
341 pub mean_sigma_mph: Option<f64>,
344 pub inverse_variance_weighted: bool,
347 pub called_crosswind_mph: Option<f64>,
349 pub wind_call_factor: Option<f64>,
352 pub subtracted_effects: Vec<String>,
354 pub unsubtracted_effects: Vec<String>,
357}
358
359fn crosswind_conditions(signed_mph: f64) -> WindConditions {
367 WindConditions {
368 speed: signed_mph.abs() * MPH_TO_MPS,
369 direction: if signed_mph < 0.0 {
370 std::f64::consts::FRAC_PI_2
371 } else {
372 3.0 * std::f64::consts::FRAC_PI_2
373 },
374 vertical_speed: 0.0,
375 }
376}
377
378pub fn modeled_miss_right_m(
388 request: &WindTruingRequest,
389 crosswind_mph: f64,
390 range_m: f64,
391) -> Result<f64, Box<dyn Error>> {
392 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
393 let env = TruingEnvironment {
394 wind: crosswind_conditions(crosswind_mph),
395 twist: Some(request.twist),
396 earth: request.earth,
397 };
398 let sample = crate::truing::solve_trajectory_sample(
399 request.muzzle_velocity_fps,
400 request.bc,
401 request.drag_model,
402 request.mass_gr,
403 request.diameter_in,
404 request.zero_distance_yd,
405 range_m / 0.9144,
406 request.sight_height_in,
407 request.temperature_f,
408 request.pressure_inhg,
409 request.humidity_pct,
410 request.altitude_ft,
411 &no_bc_segments,
412 &env,
413 true, )?;
415 Ok(sample.lateral_m)
416}
417
418pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
431 request.validate()?;
432
433 let no_bc_segments: Option<Vec<BCSegmentData>> = None;
437
438 let environment = |crosswind_mph: f64| TruingEnvironment {
439 wind: crosswind_conditions(crosswind_mph),
440 twist: Some(request.twist),
441 earth: request.earth,
442 };
443
444 let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
446 let sample = crate::truing::solve_trajectory_sample(
447 request.muzzle_velocity_fps,
448 request.bc,
449 request.drag_model,
450 request.mass_gr,
451 request.diameter_in,
452 request.zero_distance_yd,
453 range_yd,
454 request.sight_height_in,
455 request.temperature_f,
456 request.pressure_inhg,
457 request.humidity_pct,
458 request.altitude_ft,
459 &no_bc_segments,
460 &environment(crosswind_mph),
461 true, )?;
463 Ok(sample.lateral_m)
464 };
465
466 let mut solutions = Vec::with_capacity(request.observations.len());
467 for observation in &request.observations {
468 let range_yd = observation.range_m / 0.9144;
469 solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
470 }
471
472 let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
477 if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
478 return Err(
479 "an observation does not move with crosswind at all, so its measurement sigma \
480 cannot be expressed in wind units — drop that observation or its sigma"
481 .into(),
482 );
483 }
484 let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
485 let mut weight_sum = 0.0;
486 let mut weighted = 0.0;
487 for solution in &solutions {
488 let sigma = solution
489 .solved_sigma_mph
490 .expect("checked by inverse_variance_weighted");
491 let weight = 1.0 / (sigma * sigma);
492 weight_sum += weight;
493 weighted += weight * solution.solved_crosswind_mph;
494 }
495 if weight_sum > 0.0 && weight_sum.is_finite() {
496 (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
497 } else {
498 return Err(
499 "observed-miss sigmas produced a degenerate weighting (check that every \
500 sigma is positive and that the observations move with wind at all)"
501 .into(),
502 );
503 }
504 } else {
505 let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
506 (sum / solutions.len() as f64, None)
507 };
508
509 let wind_call_factor = request
510 .called_crosswind_mph
511 .map(|called| mean_crosswind_mph / called);
512
513 let mut subtracted_effects = vec!["spin drift".to_string()];
517 let mut unsubtracted_effects = Vec::new();
518 if request.earth.is_some() {
519 subtracted_effects.push("Coriolis".to_string());
520 } else {
521 unsubtracted_effects
522 .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
523 }
524
525 Ok(WindTruingReport {
526 solutions,
527 mean_crosswind_mph,
528 mean_sigma_mph,
529 inverse_variance_weighted,
530 called_crosswind_mph: request.called_crosswind_mph,
531 wind_call_factor,
532 subtracted_effects,
533 unsubtracted_effects,
534 })
535}
536
537fn solve_one_observation(
542 observation: &WindObservation,
543 range_yd: f64,
544 lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
545) -> Result<WindTruingSolution, Box<dyn Error>> {
546 let target = observation.miss_right_m;
547 let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
548 Ok(lateral_at(crosswind_mph, range_yd)? - target)
549 };
550
551 let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
552 let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
553 let mut f_low = residual(low)?;
554 let mut f_high = residual(high)?;
555 if f_low > 0.0 || f_high < 0.0 {
556 return Err(format!(
557 "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
558 miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
559 the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
560 target / 0.0254,
561 (f_low + target) / 0.0254,
562 (f_high + target) / 0.0254,
563 )
564 .into());
565 }
566
567 let mut solved = 0.0;
568 let mut f_solved = 0.0;
569 let mut iterations = 0u32;
570 let mut converged = false;
571 while iterations < WIND_SOLVE_MAX_ITERATIONS {
572 iterations += 1;
573 let denom = f_high - f_low;
574 let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
575 high - f_high * (high - low) / denom
576 } else {
577 0.5 * (low + high)
578 };
579 if !candidate.is_finite() || candidate <= low || candidate >= high {
581 candidate = 0.5 * (low + high);
582 }
583 let f = residual(candidate)?;
584 solved = candidate;
585 f_solved = f;
586 if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
587 converged = true;
588 break;
589 }
590 if f < 0.0 {
592 low = candidate;
593 f_low = f;
594 f_high *= 0.5;
595 } else {
596 high = candidate;
597 f_high = f;
598 f_low *= 0.5;
599 }
600 }
601
602 let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
604 let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
605 let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
606
607 let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
610
611 let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
612 let slope = sensitivity_m_per_mph.abs();
613 (slope > 0.0).then_some(sigma / slope)
614 });
615
616 Ok(WindTruingSolution {
617 range_m: observation.range_m,
618 observed_miss_right_m: target,
619 sigma_m: observation.sigma_m,
620 solved_crosswind_mph: solved,
621 modeled_miss_right_m: target + f_solved,
622 residual_m: f_solved,
623 no_wind_lateral_m,
624 sensitivity_m_per_mph,
625 solved_sigma_mph,
626 iterations,
627 converged,
628 })
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum WindTruingOutput {
636 Table,
637 Json,
638 Csv,
639}
640
641struct WindTruingUnits {
644 range_label: &'static str,
645 speed_label: &'static str,
646 range_scale: f64,
647 speed_scale: f64,
648}
649
650impl WindTruingUnits {
651 fn for_system(units: UnitSystem) -> Self {
652 match units {
653 UnitSystem::Imperial => Self {
654 range_label: "yd",
655 speed_label: "mph",
656 range_scale: 1.0 / 0.9144,
657 speed_scale: 1.0,
658 },
659 UnitSystem::Metric => Self {
660 range_label: "m",
661 speed_label: "m/s",
662 range_scale: 1.0,
663 speed_scale: MPH_TO_MPS,
664 },
665 }
666 }
667
668 fn range(&self, range_m: f64) -> f64 {
669 range_m * self.range_scale
670 }
671
672 fn speed(&self, mph: f64) -> f64 {
673 mph * self.speed_scale
674 }
675}
676
677fn inches(meters: f64) -> f64 {
679 meters / 0.0254
680}
681
682pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
689 let u = WindTruingUnits::for_system(units);
690 let observations: Vec<serde_json::Value> = report
691 .solutions
692 .iter()
693 .map(|s| {
694 serde_json::json!({
695 format!("range_{}", u.range_label): u.range(s.range_m),
696 "miss_right_in": inches(s.observed_miss_right_m),
697 "miss_sigma_in": s.sigma_m.map(inches),
698 "no_wind_lateral_in": inches(s.no_wind_lateral_m),
699 "solved_crosswind": u.speed(s.solved_crosswind_mph),
700 "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
701 "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
702 "residual_in": inches(s.residual_m),
703 "iterations": s.iterations,
704 "converged": s.converged,
705 })
706 })
707 .collect();
708
709 serde_json::json!({
710 "effective_crosswind": u.speed(report.mean_crosswind_mph),
711 "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
712 "inverse_variance_weighted": report.inverse_variance_weighted,
713 "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
714 "wind_call_factor": report.wind_call_factor,
715 "observations": observations,
716 "effects_subtracted": report.subtracted_effects,
717 "effects_not_subtracted": report.unsubtracted_effects,
718 "legend": {
719 "units": {
720 "range": u.range_label,
721 "miss": "in",
722 "wind_speed": u.speed_label,
723 },
724 "signs": "--miss positive = impact right of aim; solved crosswind positive = \
725 wind from the shooter's left (9 o'clock) pushing impacts right",
726 },
727 })
728}
729
730pub fn format_wind_truing_report(
736 report: &WindTruingReport,
737 units: UnitSystem,
738 output: WindTruingOutput,
739) -> String {
740 let u = WindTruingUnits::for_system(units);
741 match output {
742 WindTruingOutput::Json => {
743 match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
744 Ok(s) => format!("{s}\n"),
745 Err(e) => format!("Error serializing JSON: {e}\n"),
746 }
747 }
748 WindTruingOutput::Csv => {
749 let mut out = String::new();
750 out.push_str(&format!(
751 "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
752 sensitivity_in_per_mph,residual_in,iterations,converged\n",
753 u.range_label, u.speed_label
754 ));
755 for s in &report.solutions {
756 out.push_str(&format!(
757 "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
758 u.range(s.range_m),
759 inches(s.observed_miss_right_m),
760 match s.sigma_m {
761 Some(sigma) => format!("{:.3}", inches(sigma)),
762 None => String::new(),
763 },
764 inches(s.no_wind_lateral_m),
765 u.speed(s.solved_crosswind_mph),
766 inches(s.sensitivity_m_per_mph),
767 inches(s.residual_m),
768 s.iterations,
769 s.converged,
770 ));
771 }
772 out.push('\n');
773 out.push_str(&format!(
774 "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
775 called_crosswind_{},wind_call_factor\n",
776 u.speed_label, u.speed_label, u.speed_label
777 ));
778 out.push_str(&format!(
779 "{:+.3},{},{},{},{}\n",
780 u.speed(report.mean_crosswind_mph),
781 match report.mean_sigma_mph {
782 Some(sigma) => format!("{:.3}", u.speed(sigma)),
783 None => String::new(),
784 },
785 report.inverse_variance_weighted,
786 match report.called_crosswind_mph {
787 Some(called) => format!("{:+.3}", u.speed(called)),
788 None => String::new(),
789 },
790 match report.wind_call_factor {
791 Some(factor) => format!("{factor:.4}"),
792 None => String::new(),
793 },
794 ));
795 out
796 }
797 WindTruingOutput::Table => {
798 let mut out = String::new();
799 out.push('\n');
800 out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
801 out.push('\n');
802 out.push_str(&format!(
803 " {:>10} {:>12} {:>14} {:>16} {:>10}\n",
804 format!("Range ({})", u.range_label),
805 "Miss (in)",
806 "Spin/Cor (in)",
807 format!("Wind ({})", u.speed_label),
808 "Resid (in)",
809 ));
810 out.push_str(&format!(" {}\n", "-".repeat(70)));
811 for s in &report.solutions {
812 out.push_str(&format!(
813 " {:>10.1} {:>+12.2} {:>+14.2} {:>+16.2} {:>+10.3}\n",
814 u.range(s.range_m),
815 inches(s.observed_miss_right_m),
816 inches(s.no_wind_lateral_m),
817 u.speed(s.solved_crosswind_mph),
818 inches(s.residual_m),
819 ));
820 }
821 out.push_str(&format!(" {}\n", "-".repeat(70)));
822 out.push('\n');
823 let n = report.solutions.len();
824 out.push_str(&format!(
825 " Effective crosswind: {:>+8.2} {}{}\n",
826 u.speed(report.mean_crosswind_mph),
827 u.speed_label,
828 match report.mean_sigma_mph {
829 Some(sigma) => format!(
830 " +/- {:.2} {} (inverse-variance weighted over {n} observations)",
831 u.speed(sigma),
832 u.speed_label
833 ),
834 None if n > 1 => format!(" (mean of {n} observations)"),
835 None => String::new(),
836 }
837 ));
838 if let (Some(called), Some(factor)) =
839 (report.called_crosswind_mph, report.wind_call_factor)
840 {
841 out.push_str(&format!(
842 " Called wind: {:>+8.2} {} -> wind-call correction factor {:.2}\n",
843 u.speed(called),
844 u.speed_label,
845 factor
846 ));
847 out.push_str(&format!(
848 " (multiply your wind calls by {factor:.2} to match what actually hit)\n"
849 ));
850 }
851 if !report.subtracted_effects.is_empty() {
852 out.push_str(&format!(
853 " Effects subtracted: {}\n",
854 report.subtracted_effects.join(", ")
855 ));
856 }
857 if !report.unsubtracted_effects.is_empty() {
858 out.push_str(&format!(
859 " NOT subtracted (absorbed into the solved wind): {}\n",
860 report.unsubtracted_effects.join(", ")
861 ));
862 }
863 for s in &report.solutions {
864 if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
865 out.push_str(&format!(
866 " note: the observation at {:.1} {} moves only {:.2} in per mph of \
867 crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
868 wind fitted from it is weakly identified\n",
869 u.range(s.range_m),
870 u.range_label,
871 inches(s.sensitivity_m_per_mph).abs(),
872 ));
873 }
874 if !s.converged {
875 out.push_str(&format!(
876 " note: the fit at {:.1} {} did not fully converge after {} iterations; \
877 the value shown is the best estimate\n",
878 u.range(s.range_m),
879 u.range_label,
880 s.iterations,
881 ));
882 }
883 }
884 out.push('\n');
885 out.push_str(
886 " Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
887 \x20 FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
888 \x20 = FROM the right pushing left. Wind-FROM convention throughout\n\
889 \x20 (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
890 );
891 out.push('\n');
892 out
893 }
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900
901 fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
902 WindTruingRequest {
903 observations,
904 muzzle_velocity_fps: 2700.0,
905 bc: 0.475,
906 drag_model: DragModelArg::G7,
907 mass_gr: 168.0,
908 diameter_in: 0.308,
909 zero_distance_yd: 100.0,
910 sight_height_in: 2.0,
911 temperature_f: 59.0,
912 pressure_inhg: 29.92,
913 humidity_pct: 50.0,
914 altitude_ft: 0.0,
915 twist: TruingTwist {
916 rate_in: 11.0,
917 right_hand: true,
918 },
919 earth: None,
920 called_crosswind_mph: None,
921 }
922 }
923
924 fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
927 modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
928 }
929
930 #[test]
935 fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
936 let known_mph = 7.5;
937 let ranges_m = [274.32, 457.2, 640.08]; let template = base_request(Vec::new());
939 let observations = ranges_m
940 .iter()
941 .map(|range_m| WindObservation {
942 range_m: *range_m,
943 miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
944 sigma_m: None,
945 })
946 .collect();
947
948 let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
949 assert_eq!(report.solutions.len(), 3);
950 for solution in &report.solutions {
951 assert!(solution.converged, "{solution:?}");
952 assert!(
953 (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
954 "recovered {} mph at {} m, expected {known_mph}",
955 solution.solved_crosswind_mph,
956 solution.range_m
957 );
958 }
959 assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
960 assert!(!report.inverse_variance_weighted);
961 assert!(report.mean_sigma_mph.is_none());
962 }
963
964 #[test]
968 fn miss_right_solves_positive_and_miss_left_solves_negative() {
969 let template = base_request(Vec::new());
970 let range_m = 457.2; let right_miss = modeled_lateral_m(&template, 8.0, range_m);
972 let left_miss = modeled_lateral_m(&template, -8.0, range_m);
973 assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
974 assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
975
976 let right = solve_wind_truing(&base_request(vec![WindObservation {
977 range_m,
978 miss_right_m: right_miss,
979 sigma_m: None,
980 }]))
981 .expect("right-miss fit must solve");
982 assert!(
983 right.mean_crosswind_mph > 0.0,
984 "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
985 right.mean_crosswind_mph
986 );
987 assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
988
989 let left = solve_wind_truing(&base_request(vec![WindObservation {
990 range_m,
991 miss_right_m: left_miss,
992 sigma_m: None,
993 }]))
994 .expect("left-miss fit must solve");
995 assert!(
996 left.mean_crosswind_mph < 0.0,
997 "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
998 left.mean_crosswind_mph
999 );
1000 assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
1001 }
1002
1003 #[test]
1007 fn pure_spin_drift_solves_to_zero_wind() {
1008 let template = base_request(Vec::new());
1009 let range_m = 640.08; let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1011 assert!(
1012 spin_only > 0.05,
1013 "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1014 );
1015
1016 let report = solve_wind_truing(&base_request(vec![WindObservation {
1017 range_m,
1018 miss_right_m: spin_only,
1019 sigma_m: None,
1020 }]))
1021 .expect("spin-only fit must solve");
1022 assert!(
1023 report.mean_crosswind_mph.abs() < 0.02,
1024 "pure spin drift must solve to ~0 wind, got {}",
1025 report.mean_crosswind_mph
1026 );
1027 assert!(report
1029 .solutions
1030 .iter()
1031 .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1032 assert!(report
1033 .subtracted_effects
1034 .iter()
1035 .any(|e| e.contains("spin drift")));
1036 }
1037
1038 #[test]
1042 fn twist_hand_changes_the_solved_wind() {
1043 let range_m = 640.08;
1044 let observation = WindObservation {
1045 range_m,
1046 miss_right_m: 0.25,
1047 sigma_m: None,
1048 };
1049 let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1050 let mut left = base_request(vec![observation]);
1051 left.twist.right_hand = false;
1052 let left_hand = solve_wind_truing(&left).expect("solve");
1053 assert!(
1054 left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1055 "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1056 left_hand.mean_crosswind_mph,
1057 right_hand.mean_crosswind_mph
1058 );
1059 }
1060
1061 #[test]
1065 fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1066 let range_m = 914.4; let observation = WindObservation {
1068 range_m,
1069 miss_right_m: 0.30,
1070 sigma_m: None,
1071 };
1072 let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1073 let mut with_earth = base_request(vec![observation]);
1074 with_earth.earth = Some(TruingEarthFrame {
1075 latitude_deg: 45.0,
1076 shot_azimuth_deg: 90.0, });
1078 let with = solve_wind_truing(&with_earth).expect("solve");
1079
1080 assert!(without
1081 .unsubtracted_effects
1082 .iter()
1083 .any(|e| e.contains("Coriolis")));
1084 assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1085 assert!(with.unsubtracted_effects.is_empty());
1086 assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1087 assert!(
1088 (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1089 > 1e-4,
1090 "modelling Coriolis must change the zero-wind lateral"
1091 );
1092 assert!(
1093 (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1094 "modelling Coriolis must change the solved wind"
1095 );
1096 }
1097
1098 #[test]
1102 fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1103 let template = base_request(Vec::new());
1104 let range_m = 457.2;
1105 let miss = modeled_lateral_m(&template, 9.0, range_m);
1106 let observation = WindObservation {
1107 range_m,
1108 miss_right_m: miss,
1109 sigma_m: None,
1110 };
1111
1112 let mut under_called = base_request(vec![observation]);
1113 under_called.called_crosswind_mph = Some(6.0);
1114 let report = solve_wind_truing(&under_called).expect("solve");
1115 let factor = report.wind_call_factor.expect("factor");
1116 assert!(
1117 (factor - 9.0 / 6.0).abs() < 0.01,
1118 "expected ~1.5, got {factor}"
1119 );
1120
1121 let mut wrong_side = base_request(vec![observation]);
1122 wrong_side.called_crosswind_mph = Some(-6.0);
1123 let flipped = solve_wind_truing(&wrong_side)
1124 .expect("solve")
1125 .wind_call_factor
1126 .expect("factor");
1127 assert!(flipped < 0.0, "a wrong-side call must read negative: {flipped}");
1128 }
1129
1130 #[test]
1134 fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1135 let template = base_request(Vec::new());
1136 let near = 274.32;
1137 let far = 640.08;
1138 let weighted = vec![
1139 WindObservation {
1140 range_m: near,
1141 miss_right_m: modeled_lateral_m(&template, 6.0, near),
1142 sigma_m: Some(0.25 * 0.0254),
1143 },
1144 WindObservation {
1145 range_m: far,
1146 miss_right_m: modeled_lateral_m(&template, 6.0, far),
1147 sigma_m: Some(0.25 * 0.0254),
1148 },
1149 ];
1150 let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1151 assert!(report.inverse_variance_weighted);
1152 let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1153 assert!(sigma > 0.0 && sigma.is_finite());
1154 let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1157 let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1158 assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1159 assert!(sigma <= far_sigma + 1e-12);
1160
1161 let mixed = base_request(vec![
1162 WindObservation {
1163 range_m: near,
1164 miss_right_m: 0.1,
1165 sigma_m: Some(0.006),
1166 },
1167 WindObservation {
1168 range_m: far,
1169 miss_right_m: 0.2,
1170 sigma_m: None,
1171 },
1172 ]);
1173 let error = mixed.validate().unwrap_err();
1174 assert!(error.contains("every observed miss or on none"), "{error}");
1175 }
1176
1177 #[test]
1180 fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1181 let error = solve_wind_truing(&base_request(vec![WindObservation {
1182 range_m: 274.32,
1183 miss_right_m: 25.0, sigma_m: None,
1185 }]))
1186 .unwrap_err()
1187 .to_string();
1188 assert!(error.contains("no crosswind within"), "{error}");
1189 assert!(error.contains("check the sign of --miss"), "{error}");
1190 }
1191
1192 #[test]
1199 fn windage_cf_does_not_alter_the_wind_solve() {
1200 let template = base_request(Vec::new());
1201 let range_m = 457.2;
1202 let miss = modeled_lateral_m(&template, 7.0, range_m);
1203 let observation = WindObservation {
1204 range_m,
1205 miss_right_m: miss,
1206 sigma_m: None,
1207 };
1208 let solved = solve_wind_truing(&base_request(vec![observation]))
1209 .expect("solve")
1210 .mean_crosswind_mph;
1211 assert!((solved - 7.0).abs() < 0.02);
1212
1213 let windage_cf = 0.95;
1216 let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1217 range_m,
1218 miss_right_m: miss * windage_cf,
1219 sigma_m: None,
1220 }]))
1221 .expect("solve")
1222 .mean_crosswind_mph;
1223 assert!(
1224 (cf_applied - solved).abs() > 0.1,
1225 "a CF-scaled observation must NOT be equivalent to the linear one \
1226 ({cf_applied} vs {solved}); --miss therefore takes no CF"
1227 );
1228 }
1229
1230 #[test]
1233 fn json_value_nulls_absent_optional_fields() {
1234 let template = base_request(Vec::new());
1235 let range_m = 457.2;
1236 let report = solve_wind_truing(&base_request(vec![WindObservation {
1237 range_m,
1238 miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1239 sigma_m: None,
1240 }]))
1241 .expect("solve");
1242 let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1243 assert!(value["called_crosswind"].is_null());
1244 assert!(value["wind_call_factor"].is_null());
1245 assert!(value["effective_crosswind_sigma"].is_null());
1246 assert!(value["observations"][0]["miss_sigma_in"].is_null());
1247 assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1248 assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1249 assert_eq!(value["legend"]["units"]["miss"], "in");
1250 assert_eq!(
1251 value["effective_crosswind"].as_f64().expect("f64").round(),
1252 5.0
1253 );
1254
1255 let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1258 assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1259 assert_eq!(metric["legend"]["units"]["range"], "m");
1260 assert_eq!(metric["legend"]["units"]["miss"], "in");
1261 let mps = metric["effective_crosswind"].as_f64().expect("f64");
1262 let mph = value["effective_crosswind"].as_f64().expect("f64");
1263 assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1264 }
1265
1266 #[test]
1269 fn parse_wind_observation_units_and_errors() {
1270 let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1271 assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1272 assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1273 assert!(imperial.sigma_m.is_none());
1274
1275 let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1276 assert!((metric.range_m - 550.0).abs() < 1e-12);
1277 assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1278 assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1279
1280 for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1281 assert!(
1282 parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1283 "'{bad}' should not parse"
1284 );
1285 }
1286 }
1287
1288 #[test]
1290 fn validation_rejects_degenerate_requests() {
1291 assert!(base_request(Vec::new())
1292 .validate()
1293 .unwrap_err()
1294 .contains("at least one"));
1295
1296 let duplicate = base_request(vec![
1297 WindObservation {
1298 range_m: 457.2,
1299 miss_right_m: 0.2,
1300 sigma_m: None,
1301 },
1302 WindObservation {
1303 range_m: 457.2,
1304 miss_right_m: 0.3,
1305 sigma_m: None,
1306 },
1307 ]);
1308 assert!(duplicate
1309 .validate()
1310 .unwrap_err()
1311 .contains("duplicate observation range"));
1312
1313 let mut bad_twist = base_request(vec![WindObservation {
1314 range_m: 457.2,
1315 miss_right_m: 0.2,
1316 sigma_m: None,
1317 }]);
1318 bad_twist.twist.rate_in = 0.0;
1319 assert!(bad_twist
1320 .validate()
1321 .unwrap_err()
1322 .contains("twist rate must be positive"));
1323
1324 let mut zero_call = base_request(vec![WindObservation {
1325 range_m: 457.2,
1326 miss_right_m: 0.2,
1327 sigma_m: None,
1328 }]);
1329 zero_call.called_crosswind_mph = Some(0.0);
1330 assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1331 }
1332
1333 #[test]
1336 fn request_deserializes_and_report_serializes() {
1337 let json = serde_json::json!({
1338 "observations": [{"range_m": 457.2, "miss_right_m": 0.315, "sigma_m": null}],
1339 "muzzle_velocity_fps": 2700.0, "bc": 0.243, "drag_model": "g7",
1340 "mass_gr": 168.0, "diameter_in": 0.308, "zero_distance_yd": 100.0,
1341 "sight_height_in": 2.0, "temperature_f": 59.0, "pressure_inhg": 29.92,
1342 "humidity_pct": 50.0, "altitude_ft": 0.0,
1343 "twist": {"rate_in": 11.0, "right_hand": true},
1344 "earth": null, "called_crosswind_mph": null
1345 });
1346 let req: WindTruingRequest =
1347 serde_json::from_value(json).expect("request deserializes");
1348 let report = solve_wind_truing(&req).expect("solves");
1349 let out = serde_json::to_value(&report).expect("report serializes");
1350 assert!(out["mean_crosswind_mph"].is_number());
1351 assert!(out["solutions"].as_array().unwrap().len() == 1);
1352 }
1353}