1use crate::constants::GRAINS_TO_KG;
12use crate::trajectory_observation::{bracket_param, Bracket};
13use crate::truing::fallback_bullet_length_m;
14use crate::truing_dsf::DsfTable;
15use crate::{
16 trajectory_sampling, AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel,
17 TrajectorySolver, WindConditions,
18};
19use std::error::Error;
20
21#[allow(
23 clippy::too_many_arguments,
24 reason = "flat arguments preserve the shared CLI trajectory compatibility helper"
25)]
26pub fn build_trajectory_components(
27 velocity: f64,
28 bc: f64,
29 mass: f64,
30 diameter: f64,
31 drag_model: DragModel,
32 sight_height: f64,
33 temperature: f64,
34 pressure: f64,
35 humidity: f64,
36 altitude: f64,
37 wind_speed: f64,
38 wind_direction: f64,
39 max_range: f64,
40 sample_interval: f64,
41 bc_segments_data: Option<Vec<BCSegmentData>>,
46 custom_drag_table: Option<crate::drag::DragTable>,
47 zero_poi_vertical_m: f64,
50 zero_poi_horizontal_m: f64,
51 sight_offset_lateral_m: f64,
54) -> (BallisticInputs, WindConditions, AtmosphericConditions) {
55 let drag_model_enum = drag_model;
56 let wind_direction_rad = wind_direction.to_radians();
57 let use_bc_segments = bc_segments_data.is_some();
58
59 let inputs = BallisticInputs {
60 bc_value: bc,
61 bc_type: drag_model_enum,
62 bullet_mass: mass,
63 muzzle_velocity: velocity,
64 bullet_diameter: diameter,
65 bullet_length: fallback_bullet_length_m(diameter, mass),
66 muzzle_angle: 0.0,
67 target_distance: max_range,
68 sight_height,
69 sight_offset_lateral_m,
70 zero_poi_vertical_m,
71 zero_poi_horizontal_m,
72 altitude,
73 temperature,
74 pressure,
75 humidity,
76 wind_speed,
77 wind_angle: wind_direction_rad,
78 use_rk4: true, use_adaptive_rk45: true, enable_trajectory_sampling: true,
81 sample_interval,
82 caliber_inches: diameter / 0.0254,
83 weight_grains: mass / GRAINS_TO_KG,
84 twist_rate: 12.0,
85 is_twist_right: true,
86 use_bc_segments,
87 bc_segments_data,
88 custom_drag_table,
89 ..Default::default()
90 };
91
92 let wind = WindConditions {
94 speed: wind_speed,
95 direction: wind_direction_rad,
96 ..Default::default()
97 };
98
99 let atmosphere = AtmosphericConditions {
100 temperature,
101 pressure,
102 humidity,
103 altitude,
104 };
105
106 (inputs, wind, atmosphere)
107}
108
109#[allow(
111 clippy::too_many_arguments,
112 reason = "flat arguments preserve the shared sampled-trajectory compatibility helper"
113)]
114pub fn run_sampled_trajectory(
115 velocity: f64,
116 bc: f64,
117 mass: f64,
118 diameter: f64,
119 drag_model: DragModel,
120 sight_height: f64,
121 temperature: f64,
122 pressure: f64,
123 humidity: f64,
124 altitude: f64,
125 wind_speed: f64,
126 wind_direction: f64,
127 max_range: f64,
128 sample_interval: f64,
129 zero_angle_rad: f64,
130 bc_segments_data: Option<Vec<BCSegmentData>>,
132 custom_drag_table: Option<crate::drag::DragTable>,
133 dsf_table: Option<&DsfTable>,
137 zero_poi_vertical_m: f64,
143 zero_poi_horizontal_m: f64,
144 sight_offset_lateral_m: f64,
148 zero_solve_distance_m: Option<f64>,
149) -> Result<Vec<trajectory_sampling::TrajectorySample>, Box<dyn Error>> {
150 let (mut inputs, wind, atmosphere) = build_trajectory_components(
151 velocity,
152 bc,
153 mass,
154 diameter,
155 drag_model,
156 sight_height,
157 temperature,
158 pressure,
159 humidity,
160 altitude,
161 wind_speed,
162 wind_direction,
163 max_range,
164 sample_interval,
165 bc_segments_data,
166 custom_drag_table,
167 zero_poi_vertical_m,
168 zero_poi_horizontal_m,
169 sight_offset_lateral_m,
170 );
171 inputs.muzzle_angle = zero_angle_rad;
172 if let Some(zero_distance_m) = zero_solve_distance_m {
173 inputs.azimuth_angle += inputs.windage_zero_bias_rad(zero_distance_m);
174 }
175
176 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
177 solver.set_max_range(max_range);
178 solver.set_time_step(0.001);
179 let result = solver.solve()?;
180
181 let mut samples = result.sampled_points.unwrap_or_default();
182 if let Some(table) = dsf_table {
189 let station_sos = result.station_speed_of_sound_mps;
190 for s in samples.iter_mut() {
191 let mach = if station_sos > 0.0 {
192 s.velocity_mps / station_sos
193 } else {
194 0.0
195 };
196 s.drop_m *= table.factor_at(mach);
197 }
198 }
199
200 Ok(samples)
201}
202
203#[derive(Debug, Clone)]
208pub struct HoldCurveLoad {
209 pub velocity_mps: f64,
210 pub bc: f64,
211 pub mass_kg: f64,
212 pub diameter_m: f64,
213 pub drag_model: DragModel,
214 pub sight_height_m: f64,
215 pub zero_distance_m: f64,
216 pub temperature_c: f64,
217 pub pressure_hpa: f64,
218 pub humidity: f64,
219 pub altitude_m: f64,
220 pub wind_speed_mps: f64,
221 pub wind_direction_deg: f64,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq)]
226pub struct HoldPoint {
227 pub range_m: f64,
228 pub drop_mil: f64,
230 pub wind_mil: f64,
232 pub velocity_mps: f64,
233 pub energy_j: f64,
234 pub time_s: f64,
235}
236
237pub struct HoldCurve {
250 samples: Vec<trajectory_sampling::TrajectorySample>,
251}
252
253#[derive(Debug, Clone, PartialEq)]
259pub enum MarkToRangeOutcome {
260 Reached(Box<HoldPoint>),
262 InsideZero { far_zero_range_m: f64 },
268 BeyondSearch {
270 max_range_m: f64,
271 max_drop_mil: f64,
272 },
273}
274
275impl HoldCurve {
276 pub const SAMPLE_INTERVAL_M: f64 = 0.9144;
282
283 pub fn solve(load: &HoldCurveLoad, max_range_m: f64) -> Result<Self, Box<dyn Error>> {
285 if !max_range_m.is_finite() || max_range_m <= 0.0 {
286 return Err("hold curve max range must be finite and greater than zero".into());
287 }
288 let zero_inputs = BallisticInputs {
289 bc_value: load.bc,
290 bc_type: load.drag_model,
291 bullet_mass: load.mass_kg,
292 muzzle_velocity: load.velocity_mps,
293 bullet_diameter: load.diameter_m,
294 bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
295 sight_height: load.sight_height_m,
296 use_rk4: true,
297 ..Default::default()
298 };
299 let atmosphere = AtmosphericConditions {
300 temperature: load.temperature_c,
301 pressure: load.pressure_hpa,
302 humidity: load.humidity,
303 altitude: load.altitude_m,
304 };
305 let zero_angle = crate::calculate_zero_angle_with_conditions(
306 zero_inputs,
307 load.zero_distance_m,
308 load.sight_height_m,
309 WindConditions::default(),
310 atmosphere,
311 )?;
312
313 let samples = run_sampled_trajectory(
314 load.velocity_mps,
315 load.bc,
316 load.mass_kg,
317 load.diameter_m,
318 load.drag_model,
319 load.sight_height_m,
320 load.temperature_c,
321 load.pressure_hpa,
322 load.humidity,
323 load.altitude_m,
324 load.wind_speed_mps,
325 load.wind_direction_deg,
326 max_range_m,
327 Self::SAMPLE_INTERVAL_M,
328 zero_angle,
329 None,
330 None,
331 None,
332 0.0,
333 0.0,
334 0.0,
335 Some(load.zero_distance_m),
336 )?;
337 if samples.len() < 2 {
338 return Err(
339 "the trajectory produced too few sampled points to read a hold from".into(),
340 );
341 }
342 Ok(Self { samples })
343 }
344
345 pub fn max_sampled_range_m(&self) -> f64 {
347 self.samples.last().map_or(0.0, |s| s.distance_m)
348 }
349
350 pub fn sample_ranges_m(&self) -> Vec<f64> {
358 self.samples.iter().map(|s| s.distance_m).collect()
359 }
360
361 pub fn at_range(&self, range_m: f64) -> Option<HoldPoint> {
366 if !range_m.is_finite() || range_m <= 0.0 {
367 return None;
368 }
369 let samples = &self.samples;
370 let Bracket::Inside { lo, t } =
371 bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
372 else {
373 return None;
374 };
375 let hi = lo + 1;
376 let lerp = |a: f64, b: f64| a + (b - a) * t;
377 let drop_m = lerp(samples[lo].drop_m, samples[hi].drop_m);
378 let drift_m = lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m);
379 Some(HoldPoint {
380 range_m,
381 drop_mil: drop_m / range_m * 1000.0,
383 wind_mil: drift_m / range_m * 1000.0,
384 velocity_mps: lerp(samples[lo].velocity_mps, samples[hi].velocity_mps),
385 energy_j: lerp(samples[lo].energy_j, samples[hi].energy_j),
386 time_s: lerp(samples[lo].time_s, samples[hi].time_s),
387 })
388 }
389
390 pub fn far_zero_range_m(&self) -> f64 {
400 let mut far = self.samples.first().map_or(0.0, |s| s.distance_m);
401 for sample in &self.samples {
402 if sample.distance_m > 0.0 && sample.drop_m <= 0.0 {
403 far = sample.distance_m;
404 }
405 }
406 far
407 }
408
409 const INVERSE_MAX_ITERATIONS: u32 = 80;
413
414 const INVERSE_TOLERANCE_M: f64 = 1.0e-5;
417
418 pub fn range_for_angular_drop_mil(&self, target_mil: f64) -> MarkToRangeOutcome {
424 let far_zero_range_m = self.far_zero_range_m();
425 let max_range_m = self.max_sampled_range_m();
426 let drop_at = |range_m: f64| self.at_range(range_m).map(|p| p.drop_mil);
427 let max_drop_mil = drop_at(max_range_m).unwrap_or(f64::NEG_INFINITY);
428
429 if !target_mil.is_finite() || target_mil <= 0.0 {
430 return MarkToRangeOutcome::InsideZero { far_zero_range_m };
431 }
432 if target_mil > max_drop_mil {
433 return MarkToRangeOutcome::BeyondSearch {
434 max_range_m,
435 max_drop_mil,
436 };
437 }
438 let mut lo = far_zero_range_m.max(Self::SAMPLE_INTERVAL_M);
441 if drop_at(lo).is_none_or(|drop| drop >= target_mil) {
442 return MarkToRangeOutcome::InsideZero { far_zero_range_m };
443 }
444 let mut hi = max_range_m;
445 for _ in 0..Self::INVERSE_MAX_ITERATIONS {
446 if hi - lo <= Self::INVERSE_TOLERANCE_M {
447 break;
448 }
449 let mid = 0.5 * (lo + hi);
450 match drop_at(mid) {
451 Some(drop) if drop < target_mil => lo = mid,
452 Some(_) => hi = mid,
453 None => break,
454 }
455 }
456 match self.at_range(0.5 * (lo + hi)) {
457 Some(point) => MarkToRangeOutcome::Reached(Box::new(point)),
458 None => MarkToRangeOutcome::BeyondSearch {
459 max_range_m,
460 max_drop_mil,
461 },
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn oracle_test_load() -> HoldCurveLoad {
473 HoldCurveLoad {
474 velocity_mps: 800.0,
475 bc: 0.223,
476 mass_kg: 0.0109,
477 diameter_m: 0.00782,
478 drag_model: DragModel::G7,
479 sight_height_m: 0.045,
480 zero_distance_m: 100.0,
481 temperature_c: 15.0,
482 pressure_hpa: 1013.25,
483 humidity: 50.0,
484 altitude_m: 0.0,
485 wind_speed_mps: 3.0,
486 wind_direction_deg: 90.0,
487 }
488 }
489
490 fn independent_direct_solve(
495 load: &HoldCurveLoad,
496 max_range_m: f64,
497 ) -> Vec<trajectory_sampling::TrajectorySample> {
498 let zero_inputs = BallisticInputs {
499 bc_value: load.bc,
500 bc_type: load.drag_model,
501 bullet_mass: load.mass_kg,
502 muzzle_velocity: load.velocity_mps,
503 bullet_diameter: load.diameter_m,
504 bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
505 sight_height: load.sight_height_m,
506 use_rk4: true,
507 ..Default::default()
508 };
509 let atmosphere = AtmosphericConditions {
510 temperature: load.temperature_c,
511 pressure: load.pressure_hpa,
512 humidity: load.humidity,
513 altitude: load.altitude_m,
514 };
515 let zero_angle = crate::calculate_zero_angle_with_conditions(
516 zero_inputs,
517 load.zero_distance_m,
518 load.sight_height_m,
519 WindConditions::default(),
520 atmosphere,
521 )
522 .expect("zero solve should succeed for a realistic load");
523
524 run_sampled_trajectory(
525 load.velocity_mps,
526 load.bc,
527 load.mass_kg,
528 load.diameter_m,
529 load.drag_model,
530 load.sight_height_m,
531 load.temperature_c,
532 load.pressure_hpa,
533 load.humidity,
534 load.altitude_m,
535 load.wind_speed_mps,
536 load.wind_direction_deg,
537 max_range_m,
538 HoldCurve::SAMPLE_INTERVAL_M,
539 zero_angle,
540 None,
541 None,
542 None,
543 0.0,
544 0.0,
545 0.0,
546 Some(load.zero_distance_m),
547 )
548 .expect("sampled trajectory should succeed for a realistic load")
549 }
550
551 #[test]
552 fn at_range_matches_an_independent_direct_solves_sampled_observation() {
553 let load = oracle_test_load();
554 let max_range_m = 1500.0;
555
556 let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
557 let oracle_samples = independent_direct_solve(&load, max_range_m);
558
559 let probe = &oracle_samples[oracle_samples.len() / 2];
565 let probe_range_m = probe.distance_m;
566 assert!(probe_range_m > 0.0 && probe_range_m.is_finite());
567
568 let point = hold_curve
569 .at_range(probe_range_m)
570 .expect("probe range must be inside the sampled span");
571
572 let expected_drop_mil = probe.drop_m / probe_range_m * 1000.0;
573 let expected_wind_mil = probe.wind_drift_m / probe_range_m * 1000.0;
574
575 assert!((point.range_m - probe_range_m).abs() < 1e-9);
576 assert!((point.drop_mil - expected_drop_mil).abs() < 1e-9);
577 assert!((point.wind_mil - expected_wind_mil).abs() < 1e-9);
578 assert!((point.velocity_mps - probe.velocity_mps).abs() < 1e-9);
579 assert!((point.energy_j - probe.energy_j).abs() < 1e-9);
580 assert!((point.time_s - probe.time_s).abs() < 1e-9);
581 }
582
583 #[test]
584 fn at_range_outside_the_sampled_span_returns_none() {
585 let load = oracle_test_load();
586 let max_range_m = 1500.0;
587 let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
588
589 let beyond = hold_curve.max_sampled_range_m() + 10_000.0;
590 assert_eq!(hold_curve.at_range(beyond), None);
591 }
592
593 #[test]
597 fn sample_ranges_m_is_the_exact_multiples_of_the_sample_interval() {
598 let load = oracle_test_load();
599 let hold_curve = HoldCurve::solve(&load, 1500.0).expect("hold curve should solve");
600
601 let ranges = hold_curve.sample_ranges_m();
602 assert!(!ranges.is_empty());
603 assert_eq!(ranges[0], 0.0);
604 assert_eq!(*ranges.last().unwrap(), hold_curve.max_sampled_range_m());
605 for (i, &r) in ranges.iter().enumerate() {
606 let expected = i as f64 * HoldCurve::SAMPLE_INTERVAL_M;
607 assert!(
608 (r - expected).abs() < 1e-9,
609 "index {i}: got {r}, expected {expected} ({} * SAMPLE_INTERVAL_M)",
610 i
611 );
612 }
613 }
614}