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)]
131pub enum ApiError {
132 NetworkError(String),
134 Timeout,
136 InvalidResponse(String),
138 ServerError(u16, String),
140 RequestError(String),
142}
143
144impl std::fmt::Display for ApiError {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 match self {
147 ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
148 ApiError::Timeout => write!(f, "Request timed out"),
149 ApiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
150 ApiError::ServerError(code, msg) => write!(f, "Server error {}: {}", code, msg),
151 ApiError::RequestError(msg) => write!(f, "Request error: {}", msg),
152 }
153 }
154}
155
156impl std::error::Error for ApiError {}
157
158pub struct ApiClient {
160 base_url: String,
161 timeout: Duration,
162}
163
164impl ApiClient {
165 pub fn new(base_url: &str, timeout_secs: u64) -> Self {
171 let base_url = base_url.trim_end_matches('/').to_string();
173
174 Self {
175 base_url,
176 timeout: Duration::from_secs(timeout_secs),
177 }
178 }
179
180 #[cfg(feature = "online")]
189 pub fn calculate_trajectory(
190 &self,
191 request: &TrajectoryRequest,
192 ) -> Result<TrajectoryResponse, ApiError> {
193 let url = format!("{}/v1/calculate", self.base_url);
195
196 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)
202 .set("Accept", "application/json")
203 .set("User-Agent", "ballistics-cli/0.13.31")
204 .timeout(self.timeout)
205 .query("bc_value", &request.bc_value.to_string())
206 .query("bc_type", &request.bc_type)
207 .query("bullet_mass", &format!("{:.1}", mass_grains))
208 .query("muzzle_velocity", &format!("{:.1}", velocity_fps))
209 .query("target_distance", &format!("{:.1}", distance_yards));
210
211 if let Some(zero_range) = request.zero_range {
213 let zero_yards = zero_range / 0.9144;
214 req = req.query("zero_distance", &format!("{:.1}", zero_yards));
215 }
216 if let Some(wind_speed) = request.wind_speed {
217 let wind_mph = wind_speed * 2.23694; req = req.query("wind_speed", &format!("{:.1}", wind_mph));
219 }
220 if let Some(wind_angle) = request.wind_angle {
221 req = req.query("wind_angle", &format!("{:.1}", wind_angle));
222 }
223 if let Some(temp) = request.temperature {
224 let temp_f = temp * 9.0 / 5.0 + 32.0; req = req.query("temperature", &format!("{:.1}", temp_f));
226 }
227 if let Some(pressure) = request.pressure {
228 let pressure_inhg = pressure / 33.8639; req = req.query("pressure", &format!("{:.2}", pressure_inhg));
230 }
231 if let Some(humidity) = request.humidity {
232 req = req.query("humidity", &format!("{:.1}", humidity));
233 }
234 if let Some(altitude) = request.altitude {
235 let altitude_ft = altitude / 0.3048; req = req.query("altitude", &format!("{:.1}", altitude_ft));
237 }
238 if let Some(shooting_angle) = request.shooting_angle {
239 req = req.query("shooting_angle", &format!("{:.1}", shooting_angle));
240 }
241 if let Some(latitude) = request.latitude {
242 req = req.query("latitude", &format!("{:.2}", latitude));
243 }
244 if let Some(longitude) = request.longitude {
245 req = req.query("longitude", &format!("{:.2}", longitude));
246 }
247 if let Some(shot_direction) = request.shot_direction {
248 req = req.query("shot_direction", &format!("{:.1}", shot_direction));
249 }
250 if let Some(twist_rate) = request.twist_rate {
251 req = req.query("twist_rate", &format!("{:.1}", twist_rate));
252 }
253 if let Some(diameter) = request.bullet_diameter {
254 let diameter_in = diameter / 0.0254; req = req.query("bullet_diameter", &format!("{:.3}", diameter_in));
256 }
257 if let Some(threshold) = request.ground_threshold {
258 req = req.query("ground_threshold", &format!("{:.1}", threshold));
261 }
262 if let Some(enable) = request.enable_weather_zones {
263 req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
264 }
265 if let Some(enable) = request.enable_3d_weather {
266 req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
267 }
268 if let Some(ref model) = request.wind_shear_model {
269 req = req.query("wind_shear_model", model);
270 }
271 if let Some(ref method) = request.weather_zone_interpolation {
272 req = req.query("weather_zone_interpolation", method);
273 }
274 if let Some(sample_interval) = request.sample_interval {
275 let step_yards = sample_interval / 0.9144;
277 req = req.query("trajectory_step", &format!("{:.4}", step_yards));
278 }
279
280 let response = req.call().map_err(|e| match e {
281 ureq::Error::Status(code, response) => {
282 let body = response.into_string().unwrap_or_default();
283 ApiError::ServerError(code, body)
284 }
285 ureq::Error::Transport(transport) => {
286 let msg = transport.to_string();
288 if msg.contains("timed out") || msg.contains("timeout") {
289 ApiError::Timeout
290 } else {
291 ApiError::NetworkError(msg)
292 }
293 }
294 })?;
295
296 let body = response
297 .into_string()
298 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
299
300 let api_response: serde_json::Value = serde_json::from_str(&body)
302 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
303
304 self.convert_api_response(&api_response)
306 }
307
308 #[cfg(feature = "online")]
310 fn extract_value(val: &serde_json::Value) -> Option<f64> {
311 val.get("value")
313 .and_then(|v| v.as_f64())
314 .or_else(|| val.as_f64())
315 }
316
317 #[cfg(feature = "online")]
318 fn convert_api_response(&self, api_response: &serde_json::Value) -> Result<TrajectoryResponse, ApiError> {
319 let results = api_response.get("results");
321
322 let trajectory_array = api_response.get("trajectory")
325 .and_then(|t| t.as_array())
326 .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
327
328 let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
329 .iter()
330 .filter_map(|point| {
331 let range_yards = point.get("distance")
333 .and_then(Self::extract_value)?;
334 let drop_inches = point.get("drop")
335 .and_then(Self::extract_value)
336 .unwrap_or(0.0);
337 let drift_inches = point.get("wind_drift")
338 .and_then(Self::extract_value)
339 .unwrap_or(0.0);
340 let velocity_fps = point.get("velocity")
341 .and_then(Self::extract_value)?;
342 let energy_ftlbs = point.get("energy")
343 .and_then(Self::extract_value)
344 .unwrap_or(0.0);
345 let time = point.get("time")
346 .and_then(Self::extract_value)
347 .unwrap_or(0.0);
348
349 Some(ApiTrajectoryPoint {
350 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,
356 })
357 })
358 .collect();
359
360 let zero_angle = results
362 .and_then(|r| r.get("barrel_angle"))
363 .and_then(Self::extract_value)
364 .unwrap_or(0.0)
365 .to_radians();
366
367 let time_of_flight = results
368 .and_then(|r| r.get("time_of_flight"))
369 .and_then(Self::extract_value)
370 .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
371
372 let bc_confidence = api_response.get("bc_confidence")
373 .and_then(|v| v.as_f64());
374
375 let ml_corrections = api_response.get("ml_corrections_applied")
376 .or_else(|| api_response.get("corrections_applied"))
377 .and_then(|v| v.as_array())
378 .map(|arr| {
379 arr.iter()
380 .filter_map(|v| v.as_str().map(String::from))
381 .collect()
382 });
383
384 let max_ordinate = results
386 .and_then(|r| r.get("max_height"))
387 .and_then(|v| v.as_f64())
388 .map(|h| h * 0.0254); let impact_velocity = results
391 .and_then(|r| r.get("final_velocity"))
392 .and_then(Self::extract_value)
393 .map(|v| v * 0.3048); let impact_energy = results
396 .and_then(|r| r.get("final_energy"))
397 .and_then(Self::extract_value)
398 .map(|e| e * 1.35582); Ok(TrajectoryResponse {
401 trajectory,
402 zero_angle,
403 time_of_flight,
404 bc_confidence,
405 ml_corrections_applied: ml_corrections,
406 max_ordinate,
407 impact_velocity,
408 impact_energy,
409 })
410 }
411
412 #[cfg(feature = "online")]
414 pub fn health_check(&self) -> Result<bool, ApiError> {
415 let url = format!("{}/health", self.base_url);
416
417 let response = ureq::get(&url)
418 .timeout(Duration::from_secs(5))
419 .call()
420 .map_err(|e| match e {
421 ureq::Error::Status(code, response) => {
422 let body = response.into_string().unwrap_or_default();
423 ApiError::ServerError(code, body)
424 }
425 ureq::Error::Transport(transport) => {
426 let msg = transport.to_string();
427 if msg.contains("timed out") || msg.contains("timeout") {
428 ApiError::Timeout
429 } else {
430 ApiError::NetworkError(msg)
431 }
432 }
433 })?;
434
435 Ok(response.status() == 200)
436 }
437}
438
439#[derive(Default)]
441pub struct TrajectoryRequestBuilder {
442 bc_value: Option<f64>,
443 bc_type: Option<String>,
444 bullet_mass: Option<f64>,
445 muzzle_velocity: Option<f64>,
446 target_distance: Option<f64>,
447 zero_range: Option<f64>,
448 wind_speed: Option<f64>,
449 wind_angle: Option<f64>,
450 temperature: Option<f64>,
451 pressure: Option<f64>,
452 humidity: Option<f64>,
453 altitude: Option<f64>,
454 latitude: Option<f64>,
455 longitude: Option<f64>,
456 shot_direction: Option<f64>,
457 shooting_angle: Option<f64>,
458 twist_rate: Option<f64>,
459 bullet_diameter: Option<f64>,
460 bullet_length: Option<f64>,
461 ground_threshold: Option<f64>,
462 enable_weather_zones: Option<bool>,
463 enable_3d_weather: Option<bool>,
464 wind_shear_model: Option<String>,
465 weather_zone_interpolation: Option<String>,
466 sample_interval: Option<f64>,
467}
468
469impl TrajectoryRequestBuilder {
470 pub fn new() -> Self {
471 Self::default()
472 }
473
474 pub fn bc_value(mut self, value: f64) -> Self {
475 self.bc_value = Some(value);
476 self
477 }
478
479 pub fn bc_type(mut self, value: &str) -> Self {
480 self.bc_type = Some(value.to_string());
481 self
482 }
483
484 pub fn bullet_mass(mut self, value: f64) -> Self {
485 self.bullet_mass = Some(value);
486 self
487 }
488
489 pub fn muzzle_velocity(mut self, value: f64) -> Self {
490 self.muzzle_velocity = Some(value);
491 self
492 }
493
494 pub fn target_distance(mut self, value: f64) -> Self {
495 self.target_distance = Some(value);
496 self
497 }
498
499 pub fn zero_range(mut self, value: f64) -> Self {
500 self.zero_range = Some(value);
501 self
502 }
503
504 pub fn wind_speed(mut self, value: f64) -> Self {
505 self.wind_speed = Some(value);
506 self
507 }
508
509 pub fn wind_angle(mut self, value: f64) -> Self {
510 self.wind_angle = Some(value);
511 self
512 }
513
514 pub fn temperature(mut self, value: f64) -> Self {
515 self.temperature = Some(value);
516 self
517 }
518
519 pub fn pressure(mut self, value: f64) -> Self {
520 self.pressure = Some(value);
521 self
522 }
523
524 pub fn humidity(mut self, value: f64) -> Self {
525 self.humidity = Some(value);
526 self
527 }
528
529 pub fn altitude(mut self, value: f64) -> Self {
530 self.altitude = Some(value);
531 self
532 }
533
534 pub fn latitude(mut self, value: f64) -> Self {
535 self.latitude = Some(value);
536 self
537 }
538
539 pub fn longitude(mut self, value: f64) -> Self {
540 self.longitude = Some(value);
541 self
542 }
543
544 pub fn shot_direction(mut self, value: f64) -> Self {
545 self.shot_direction = Some(value);
546 self
547 }
548
549 pub fn shooting_angle(mut self, value: f64) -> Self {
550 self.shooting_angle = Some(value);
551 self
552 }
553
554 pub fn twist_rate(mut self, value: f64) -> Self {
555 self.twist_rate = Some(value);
556 self
557 }
558
559 pub fn bullet_diameter(mut self, value: f64) -> Self {
560 self.bullet_diameter = Some(value);
561 self
562 }
563
564 pub fn bullet_length(mut self, value: f64) -> Self {
565 self.bullet_length = Some(value);
566 self
567 }
568
569 pub fn ground_threshold(mut self, value: f64) -> Self {
570 self.ground_threshold = Some(value);
571 self
572 }
573
574 pub fn enable_weather_zones(mut self, value: bool) -> Self {
575 self.enable_weather_zones = Some(value);
576 self
577 }
578
579 pub fn enable_3d_weather(mut self, value: bool) -> Self {
580 self.enable_3d_weather = Some(value);
581 self
582 }
583
584 pub fn wind_shear_model(mut self, value: &str) -> Self {
585 self.wind_shear_model = Some(value.to_string());
586 self
587 }
588
589 pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
590 self.weather_zone_interpolation = Some(value.to_string());
591 self
592 }
593
594 pub fn sample_interval(mut self, value: f64) -> Self {
595 self.sample_interval = Some(value);
596 self
597 }
598
599 pub fn build(self) -> Result<TrajectoryRequest, String> {
605 let bc_value = self.bc_value.ok_or("bc_value is required")?;
606 let bc_type = self.bc_type.ok_or("bc_type is required")?;
607 let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
608 let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
609 let target_distance = self.target_distance.ok_or("target_distance is required")?;
610
611 Ok(TrajectoryRequest {
612 bc_value,
613 bc_type,
614 bullet_mass,
615 muzzle_velocity,
616 target_distance,
617 zero_range: self.zero_range,
618 wind_speed: self.wind_speed,
619 wind_angle: self.wind_angle,
620 temperature: self.temperature,
621 pressure: self.pressure,
622 humidity: self.humidity,
623 altitude: self.altitude,
624 latitude: self.latitude,
625 longitude: self.longitude,
626 shot_direction: self.shot_direction,
627 shooting_angle: self.shooting_angle,
628 twist_rate: self.twist_rate,
629 bullet_diameter: self.bullet_diameter,
630 bullet_length: self.bullet_length,
631 ground_threshold: self.ground_threshold,
632 enable_weather_zones: self.enable_weather_zones,
633 enable_3d_weather: self.enable_3d_weather,
634 wind_shear_model: self.wind_shear_model,
635 weather_zone_interpolation: self.weather_zone_interpolation,
636 sample_interval: self.sample_interval,
637 })
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn test_request_builder_required_fields() {
647 let result = TrajectoryRequestBuilder::new()
648 .bc_value(0.238)
649 .bc_type("G7")
650 .bullet_mass(9.07) .muzzle_velocity(860.0)
652 .target_distance(1000.0)
653 .build();
654
655 assert!(result.is_ok());
656 let request = result.unwrap();
657 assert_eq!(request.bc_value, 0.238);
658 assert_eq!(request.bc_type, "G7");
659 }
660
661 #[test]
662 fn test_request_builder_missing_fields() {
663 let result = TrajectoryRequestBuilder::new()
664 .bc_value(0.238)
665 .build();
666
667 assert!(result.is_err());
668 }
669
670 #[test]
671 fn test_request_builder_all_optional_fields() {
672 let result = TrajectoryRequestBuilder::new()
673 .bc_value(0.238)
674 .bc_type("G7")
675 .bullet_mass(9.07)
676 .muzzle_velocity(860.0)
677 .target_distance(1000.0)
678 .zero_range(100.0)
679 .wind_speed(5.0)
680 .wind_angle(90.0)
681 .temperature(15.0)
682 .pressure(1013.25)
683 .humidity(50.0)
684 .altitude(500.0)
685 .latitude(45.0)
686 .shooting_angle(0.0)
687 .twist_rate(10.0)
688 .bullet_diameter(0.00671)
689 .bullet_length(0.035)
690 .build();
691
692 assert!(result.is_ok());
693 let request = result.unwrap();
694 assert_eq!(request.zero_range, Some(100.0));
695 assert_eq!(request.wind_speed, Some(5.0));
696 assert_eq!(request.latitude, Some(45.0));
697 }
698
699 #[test]
700 fn test_api_client_url_normalization() {
701 let client1 = ApiClient::new("https://api.example.com/", 10);
702 assert_eq!(client1.base_url, "https://api.example.com");
703
704 let client2 = ApiClient::new("https://api.example.com", 10);
705 assert_eq!(client2.base_url, "https://api.example.com");
706 }
707
708 #[test]
709 fn test_api_error_display() {
710 assert_eq!(
711 format!("{}", ApiError::NetworkError("connection refused".to_string())),
712 "Network error: connection refused"
713 );
714 assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
715 assert_eq!(
716 format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
717 "Server error 500: Internal error"
718 );
719 }
720}