1use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10#[derive(Debug, Clone, Serialize)]
12pub struct TrajectoryRequest {
13 pub bc_value: f64,
15 pub bc_type: String,
17 pub bullet_mass: f64,
19 pub muzzle_velocity: f64,
21 pub target_distance: f64,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub zero_range: Option<f64>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub wind_speed: Option<f64>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub wind_angle: Option<f64>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub temperature: Option<f64>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub pressure: Option<f64>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub humidity: Option<f64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub altitude: Option<f64>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub latitude: Option<f64>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub longitude: Option<f64>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub shot_direction: Option<f64>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub shooting_angle: Option<f64>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub twist_rate: Option<f64>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub bullet_diameter: Option<f64>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub bullet_length: Option<f64>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub ground_threshold: Option<f64>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub enable_weather_zones: Option<bool>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub enable_3d_weather: Option<bool>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub wind_shear_model: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub weather_zone_interpolation: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
83 pub sample_interval: Option<f64>,
84}
85
86#[derive(Debug, Clone, Deserialize)]
88pub struct TrajectoryResponse {
89 pub trajectory: Vec<ApiTrajectoryPoint>,
91 pub zero_angle: f64,
93 pub time_of_flight: f64,
95 #[serde(default)]
97 pub bc_confidence: Option<f64>,
98 #[serde(default)]
100 pub ml_corrections_applied: Option<Vec<String>>,
101 #[serde(default)]
103 pub max_ordinate: Option<f64>,
104 #[serde(default)]
106 pub impact_velocity: Option<f64>,
107 #[serde(default)]
109 pub impact_energy: Option<f64>,
110}
111
112#[derive(Debug, Clone, Deserialize)]
114pub struct ApiTrajectoryPoint {
115 pub range: f64,
117 pub drop: f64,
119 pub drift: f64,
121 pub velocity: f64,
123 pub energy: f64,
125 pub time: f64,
127}
128
129#[derive(Debug, Clone, Serialize)]
131pub struct TrueVelocityRequest {
132 pub measured_drop_mil: f64,
134 pub range_yd: f64,
136 pub bc: f64,
138 pub drag_model: String,
140 pub weight_gr: f64,
142 pub caliber: f64,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub zero_range_yd: Option<f64>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub chrono_velocity_fps: Option<f64>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub altitude_ft: Option<f64>,
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub temperature_f: Option<f64>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub pressure_inhg: Option<f64>,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub humidity: Option<f64>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub sight_height_in: Option<f64>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub use_bc_enhancement: Option<bool>,
168}
169
170#[derive(Debug, Clone, Deserialize)]
172pub struct TrueVelocityResponse {
173 pub effective_velocity_fps: f64,
175 #[serde(default)]
177 pub velocity_adjustment_fps: Option<f64>,
178 #[serde(default)]
180 pub adjustment_percent: Option<f64>,
181 pub confidence: String,
183 pub iterations: i32,
185 pub final_error_mil: f64,
187 pub calculated_drop_mil: f64,
189}
190
191#[derive(Debug)]
193pub enum ApiError {
194 NetworkError(String),
196 Timeout,
198 InvalidResponse(String),
200 ServerError(u16, String),
202 RequestError(String),
204}
205
206impl std::fmt::Display for ApiError {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 match self {
209 ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
210 ApiError::Timeout => write!(f, "Request timed out"),
211 ApiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
212 ApiError::ServerError(code, msg) => write!(f, "Server error {}: {}", code, msg),
213 ApiError::RequestError(msg) => write!(f, "Request error: {}", msg),
214 }
215 }
216}
217
218impl std::error::Error for ApiError {}
219
220pub struct ApiClient {
222 base_url: String,
223 timeout: Duration,
224}
225
226impl ApiClient {
227 pub fn new(base_url: &str, timeout_secs: u64) -> Self {
233 let base_url = base_url.trim_end_matches('/').to_string();
235
236 Self {
237 base_url,
238 timeout: Duration::from_secs(timeout_secs),
239 }
240 }
241
242 #[cfg(feature = "online")]
251 pub fn calculate_trajectory(
252 &self,
253 request: &TrajectoryRequest,
254 ) -> Result<TrajectoryResponse, ApiError> {
255 let url = format!("{}/v1/calculate", self.base_url);
257
258 let velocity_fps = request.muzzle_velocity / 0.3048; let mass_grains = request.bullet_mass / 0.0647989; let distance_yards = request.target_distance / 0.9144; let mut req = ureq::get(&url)
264 .config()
265 .timeout_global(Some(self.timeout))
266 .build()
267 .header("Accept", "application/json")
268 .header("User-Agent", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
269 .query("bc_value", request.bc_value.to_string())
270 .query("bc_type", &request.bc_type)
271 .query("bullet_mass", format!("{:.1}", mass_grains))
272 .query("muzzle_velocity", format!("{:.1}", velocity_fps))
273 .query("target_distance", format!("{:.1}", distance_yards));
274
275 if let Some(zero_range) = request.zero_range {
277 let zero_yards = zero_range / 0.9144;
278 req = req.query("zero_distance", format!("{:.1}", zero_yards));
279 }
280 if let Some(wind_speed) = request.wind_speed {
281 let wind_mph = wind_speed * 2.23694; req = req.query("wind_speed", format!("{:.1}", wind_mph));
283 }
284 if let Some(wind_angle) = request.wind_angle {
285 req = req.query("wind_angle", format!("{:.1}", wind_angle));
286 }
287 if let Some(temp) = request.temperature {
288 let temp_f = temp * 9.0 / 5.0 + 32.0; req = req.query("temperature", format!("{:.1}", temp_f));
290 }
291 if let Some(pressure) = request.pressure {
292 let pressure_inhg = pressure / 33.8639; req = req.query("pressure", format!("{:.2}", pressure_inhg));
294 }
295 if let Some(humidity) = request.humidity {
296 req = req.query("humidity", format!("{:.1}", humidity));
297 }
298 if let Some(altitude) = request.altitude {
299 let altitude_ft = altitude / 0.3048; req = req.query("altitude", format!("{:.1}", altitude_ft));
301 }
302 if let Some(shooting_angle) = request.shooting_angle {
303 req = req.query("shooting_angle", format!("{:.1}", shooting_angle));
304 }
305 if let Some(latitude) = request.latitude {
306 req = req.query("latitude", format!("{:.2}", latitude));
307 }
308 if let Some(longitude) = request.longitude {
309 req = req.query("longitude", format!("{:.2}", longitude));
310 }
311 if let Some(shot_direction) = request.shot_direction {
312 req = req.query("shot_direction", format!("{:.1}", shot_direction));
313 }
314 if let Some(twist_rate) = request.twist_rate {
315 req = req.query("twist_rate", format!("{:.1}", twist_rate));
316 }
317 if let Some(diameter) = request.bullet_diameter {
318 let diameter_in = diameter / 0.0254; req = req.query("bullet_diameter", format!("{:.3}", diameter_in));
320 }
321 if let Some(threshold) = request.ground_threshold {
322 if threshold.is_finite() {
329 req = req.query("ground_threshold", format!("{:.1}", threshold));
330 } else if threshold == f64::NEG_INFINITY {
331 req = req.query("ground_threshold", "-1000000000.0");
332 }
333 }
334 if let Some(enable) = request.enable_weather_zones {
335 req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
336 }
337 if let Some(enable) = request.enable_3d_weather {
338 req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
339 }
340 if let Some(ref model) = request.wind_shear_model {
341 req = req.query("wind_shear_model", model);
342 }
343 if let Some(ref method) = request.weather_zone_interpolation {
344 req = req.query("weather_zone_interpolation", method);
345 }
346 if let Some(sample_interval) = request.sample_interval {
347 let step_yards = sample_interval / 0.9144;
349 req = req.query("trajectory_step", format!("{:.4}", step_yards));
350 }
351
352 let mut response = req.call().map_err(map_ureq_error)?;
353
354 let status = response.status();
358 if !status.is_success() {
359 let body = response.body_mut().read_to_string().unwrap_or_default();
360 return Err(ApiError::ServerError(status.as_u16(), body));
361 }
362
363 let body = response
364 .body_mut()
365 .read_to_string()
366 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
367
368 let api_response: serde_json::Value = serde_json::from_str(&body)
370 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
371
372 self.convert_api_response(&api_response)
374 }
375
376 #[cfg(feature = "online")]
378 fn extract_value(val: &serde_json::Value) -> Option<f64> {
379 val.get("value")
381 .and_then(|v| v.as_f64())
382 .or_else(|| val.as_f64())
383 }
384
385 #[cfg(feature = "online")]
386 fn convert_api_response(&self, api_response: &serde_json::Value) -> Result<TrajectoryResponse, ApiError> {
387 let results = api_response.get("results");
389
390 let trajectory_array = api_response.get("trajectory")
393 .and_then(|t| t.as_array())
394 .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
395
396 let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
397 .iter()
398 .filter_map(|point| {
399 let range_yards = point.get("distance")
401 .and_then(Self::extract_value)?;
402 let drop_inches = point.get("drop")
403 .and_then(Self::extract_value)
404 .unwrap_or(0.0);
405 let drift_inches = point.get("wind_drift")
406 .and_then(Self::extract_value)
407 .unwrap_or(0.0);
408 let velocity_fps = point.get("velocity")
409 .and_then(Self::extract_value)?;
410 let energy_ftlbs = point.get("energy")
411 .and_then(Self::extract_value)
412 .unwrap_or(0.0);
413 let time = point.get("time")
414 .and_then(Self::extract_value)
415 .unwrap_or(0.0);
416
417 Some(ApiTrajectoryPoint {
418 range: range_yards * 0.9144, drop: drop_inches * 0.0254, drift: drift_inches * 0.0254, velocity: velocity_fps * 0.3048, energy: energy_ftlbs * 1.35582, time,
424 })
425 })
426 .collect();
427
428 let zero_angle = results
430 .and_then(|r| r.get("barrel_angle"))
431 .and_then(Self::extract_value)
432 .unwrap_or(0.0)
433 .to_radians();
434
435 let time_of_flight = results
436 .and_then(|r| r.get("time_of_flight"))
437 .and_then(Self::extract_value)
438 .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
439
440 let bc_confidence = api_response.get("bc_confidence")
441 .and_then(|v| v.as_f64());
442
443 let ml_corrections = api_response.get("ml_corrections_applied")
444 .or_else(|| api_response.get("corrections_applied"))
445 .and_then(|v| v.as_array())
446 .map(|arr| {
447 arr.iter()
448 .filter_map(|v| v.as_str().map(String::from))
449 .collect()
450 });
451
452 let max_ordinate = results
454 .and_then(|r| r.get("max_height"))
455 .and_then(|v| v.as_f64())
456 .map(|h| h * 0.0254); let impact_velocity = results
459 .and_then(|r| r.get("final_velocity"))
460 .and_then(Self::extract_value)
461 .map(|v| v * 0.3048); let impact_energy = results
464 .and_then(|r| r.get("final_energy"))
465 .and_then(Self::extract_value)
466 .map(|e| e * 1.35582); Ok(TrajectoryResponse {
469 trajectory,
470 zero_angle,
471 time_of_flight,
472 bc_confidence,
473 ml_corrections_applied: ml_corrections,
474 max_ordinate,
475 impact_velocity,
476 impact_energy,
477 })
478 }
479
480 #[cfg(feature = "online")]
482 pub fn health_check(&self) -> Result<bool, ApiError> {
483 let url = format!("{}/health", self.base_url);
484
485 let response = ureq::get(&url)
486 .config()
487 .timeout_global(Some(Duration::from_secs(5)))
488 .build()
489 .call()
490 .map_err(map_ureq_error)?;
491
492 Ok(response.status().as_u16() == 200)
493 }
494
495 #[cfg(feature = "online")]
504 pub fn true_velocity(
505 &self,
506 request: &TrueVelocityRequest,
507 ) -> Result<TrueVelocityResponse, ApiError> {
508 let url = format!("{}/v1/true-velocity", self.base_url);
509
510 let body = serde_json::to_string(request)
511 .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
512
513 let mut response = ureq::post(&url)
514 .config()
515 .timeout_global(Some(self.timeout))
516 .build()
517 .header("Content-Type", "application/json")
518 .header("Accept", "application/json")
519 .header("User-Agent", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
520 .send(&body)
521 .map_err(map_ureq_error)?;
522
523 let status = response.status();
525 if !status.is_success() {
526 let body = response.body_mut().read_to_string().unwrap_or_default();
527 return Err(ApiError::ServerError(status.as_u16(), body));
528 }
529
530 let response_body = response
531 .body_mut()
532 .read_to_string()
533 .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
534
535 serde_json::from_str(&response_body)
536 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
537 }
538}
539
540#[cfg(feature = "online")]
547fn map_ureq_error(e: ureq::Error) -> ApiError {
548 match e {
549 ureq::Error::Timeout(_) => ApiError::Timeout,
551 ureq::Error::Io(io_err) => {
553 if io_err.kind() == std::io::ErrorKind::TimedOut {
554 ApiError::Timeout
555 } else {
556 ApiError::NetworkError(io_err.to_string())
557 }
558 }
559 other => ApiError::NetworkError(other.to_string()),
561 }
562}
563
564#[derive(Default)]
566pub struct TrajectoryRequestBuilder {
567 bc_value: Option<f64>,
568 bc_type: Option<String>,
569 bullet_mass: Option<f64>,
570 muzzle_velocity: Option<f64>,
571 target_distance: Option<f64>,
572 zero_range: Option<f64>,
573 wind_speed: Option<f64>,
574 wind_angle: Option<f64>,
575 temperature: Option<f64>,
576 pressure: Option<f64>,
577 humidity: Option<f64>,
578 altitude: Option<f64>,
579 latitude: Option<f64>,
580 longitude: Option<f64>,
581 shot_direction: Option<f64>,
582 shooting_angle: Option<f64>,
583 twist_rate: Option<f64>,
584 bullet_diameter: Option<f64>,
585 bullet_length: Option<f64>,
586 ground_threshold: Option<f64>,
587 enable_weather_zones: Option<bool>,
588 enable_3d_weather: Option<bool>,
589 wind_shear_model: Option<String>,
590 weather_zone_interpolation: Option<String>,
591 sample_interval: Option<f64>,
592}
593
594impl TrajectoryRequestBuilder {
595 pub fn new() -> Self {
596 Self::default()
597 }
598
599 pub fn bc_value(mut self, value: f64) -> Self {
600 self.bc_value = Some(value);
601 self
602 }
603
604 pub fn bc_type(mut self, value: &str) -> Self {
605 self.bc_type = Some(value.to_string());
606 self
607 }
608
609 pub fn bullet_mass(mut self, value: f64) -> Self {
610 self.bullet_mass = Some(value);
611 self
612 }
613
614 pub fn muzzle_velocity(mut self, value: f64) -> Self {
615 self.muzzle_velocity = Some(value);
616 self
617 }
618
619 pub fn target_distance(mut self, value: f64) -> Self {
620 self.target_distance = Some(value);
621 self
622 }
623
624 pub fn zero_range(mut self, value: f64) -> Self {
625 self.zero_range = Some(value);
626 self
627 }
628
629 pub fn wind_speed(mut self, value: f64) -> Self {
630 self.wind_speed = Some(value);
631 self
632 }
633
634 pub fn wind_angle(mut self, value: f64) -> Self {
635 self.wind_angle = Some(value);
636 self
637 }
638
639 pub fn temperature(mut self, value: f64) -> Self {
640 self.temperature = Some(value);
641 self
642 }
643
644 pub fn pressure(mut self, value: f64) -> Self {
645 self.pressure = Some(value);
646 self
647 }
648
649 pub fn humidity(mut self, value: f64) -> Self {
650 self.humidity = Some(value);
651 self
652 }
653
654 pub fn altitude(mut self, value: f64) -> Self {
655 self.altitude = Some(value);
656 self
657 }
658
659 pub fn latitude(mut self, value: f64) -> Self {
660 self.latitude = Some(value);
661 self
662 }
663
664 pub fn longitude(mut self, value: f64) -> Self {
665 self.longitude = Some(value);
666 self
667 }
668
669 pub fn shot_direction(mut self, value: f64) -> Self {
670 self.shot_direction = Some(value);
671 self
672 }
673
674 pub fn shooting_angle(mut self, value: f64) -> Self {
675 self.shooting_angle = Some(value);
676 self
677 }
678
679 pub fn twist_rate(mut self, value: f64) -> Self {
680 self.twist_rate = Some(value);
681 self
682 }
683
684 pub fn bullet_diameter(mut self, value: f64) -> Self {
685 self.bullet_diameter = Some(value);
686 self
687 }
688
689 pub fn bullet_length(mut self, value: f64) -> Self {
690 self.bullet_length = Some(value);
691 self
692 }
693
694 pub fn ground_threshold(mut self, value: f64) -> Self {
695 self.ground_threshold = Some(value);
696 self
697 }
698
699 pub fn enable_weather_zones(mut self, value: bool) -> Self {
700 self.enable_weather_zones = Some(value);
701 self
702 }
703
704 pub fn enable_3d_weather(mut self, value: bool) -> Self {
705 self.enable_3d_weather = Some(value);
706 self
707 }
708
709 pub fn wind_shear_model(mut self, value: &str) -> Self {
710 self.wind_shear_model = Some(value.to_string());
711 self
712 }
713
714 pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
715 self.weather_zone_interpolation = Some(value.to_string());
716 self
717 }
718
719 pub fn sample_interval(mut self, value: f64) -> Self {
720 self.sample_interval = Some(value);
721 self
722 }
723
724 pub fn build(self) -> Result<TrajectoryRequest, String> {
730 let bc_value = self.bc_value.ok_or("bc_value is required")?;
731 let bc_type = self.bc_type.ok_or("bc_type is required")?;
732 let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
733 let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
734 let target_distance = self.target_distance.ok_or("target_distance is required")?;
735
736 Ok(TrajectoryRequest {
737 bc_value,
738 bc_type,
739 bullet_mass,
740 muzzle_velocity,
741 target_distance,
742 zero_range: self.zero_range,
743 wind_speed: self.wind_speed,
744 wind_angle: self.wind_angle,
745 temperature: self.temperature,
746 pressure: self.pressure,
747 humidity: self.humidity,
748 altitude: self.altitude,
749 latitude: self.latitude,
750 longitude: self.longitude,
751 shot_direction: self.shot_direction,
752 shooting_angle: self.shooting_angle,
753 twist_rate: self.twist_rate,
754 bullet_diameter: self.bullet_diameter,
755 bullet_length: self.bullet_length,
756 ground_threshold: self.ground_threshold,
757 enable_weather_zones: self.enable_weather_zones,
758 enable_3d_weather: self.enable_3d_weather,
759 wind_shear_model: self.wind_shear_model,
760 weather_zone_interpolation: self.weather_zone_interpolation,
761 sample_interval: self.sample_interval,
762 })
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[test]
771 fn test_request_builder_required_fields() {
772 let result = TrajectoryRequestBuilder::new()
773 .bc_value(0.238)
774 .bc_type("G7")
775 .bullet_mass(9.07) .muzzle_velocity(860.0)
777 .target_distance(1000.0)
778 .build();
779
780 assert!(result.is_ok());
781 let request = result.unwrap();
782 assert_eq!(request.bc_value, 0.238);
783 assert_eq!(request.bc_type, "G7");
784 }
785
786 #[test]
787 fn test_request_builder_missing_fields() {
788 let result = TrajectoryRequestBuilder::new()
789 .bc_value(0.238)
790 .build();
791
792 assert!(result.is_err());
793 }
794
795 #[test]
796 fn test_request_builder_all_optional_fields() {
797 let result = TrajectoryRequestBuilder::new()
798 .bc_value(0.238)
799 .bc_type("G7")
800 .bullet_mass(9.07)
801 .muzzle_velocity(860.0)
802 .target_distance(1000.0)
803 .zero_range(100.0)
804 .wind_speed(5.0)
805 .wind_angle(90.0)
806 .temperature(15.0)
807 .pressure(1013.25)
808 .humidity(50.0)
809 .altitude(500.0)
810 .latitude(45.0)
811 .shooting_angle(0.0)
812 .twist_rate(10.0)
813 .bullet_diameter(0.00671)
814 .bullet_length(0.035)
815 .build();
816
817 assert!(result.is_ok());
818 let request = result.unwrap();
819 assert_eq!(request.zero_range, Some(100.0));
820 assert_eq!(request.wind_speed, Some(5.0));
821 assert_eq!(request.latitude, Some(45.0));
822 }
823
824 #[test]
825 fn test_api_client_url_normalization() {
826 let client1 = ApiClient::new("https://api.example.com/", 10);
827 assert_eq!(client1.base_url, "https://api.example.com");
828
829 let client2 = ApiClient::new("https://api.example.com", 10);
830 assert_eq!(client2.base_url, "https://api.example.com");
831 }
832
833 #[test]
834 fn test_api_error_display() {
835 assert_eq!(
836 format!("{}", ApiError::NetworkError("connection refused".to_string())),
837 "Network error: connection refused"
838 );
839 assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
840 assert_eq!(
841 format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
842 "Server error 500: Internal error"
843 );
844 }
845}