Skip to main content

ballistics_engine/
api_client.rs

1//! HTTP client for Flask API communication
2//!
3//! This module provides an HTTP client for routing trajectory calculations
4//! through the Flask API instead of local computation, giving CLI users
5//! access to ML-enhanced predictions.
6
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10/// Request structure for trajectory calculation via Flask API
11#[derive(Debug, Clone, Serialize)]
12pub struct TrajectoryRequest {
13    /// Ballistic coefficient value
14    pub bc_value: f64,
15    /// BC type: "G1" or "G7"
16    pub bc_type: String,
17    /// Bullet mass in grams
18    pub bullet_mass: f64,
19    /// Muzzle velocity in m/s
20    pub muzzle_velocity: f64,
21    /// Target distance in meters
22    pub target_distance: f64,
23    /// Zero range in meters (optional)
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub zero_range: Option<f64>,
26    /// Wind speed in m/s (optional)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub wind_speed: Option<f64>,
29    /// Wind angle in degrees (optional)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub wind_angle: Option<f64>,
32    /// Temperature in Celsius (optional)
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub temperature: Option<f64>,
35    /// Pressure in hPa/mbar (optional)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub pressure: Option<f64>,
38    /// Humidity percentage 0-100 (optional)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub humidity: Option<f64>,
41    /// Altitude in meters (optional)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub altitude: Option<f64>,
44    /// Latitude for Coriolis calculations (optional)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub latitude: Option<f64>,
47    /// Longitude for weather zones (optional)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub longitude: Option<f64>,
50    /// Shot direction/azimuth in degrees (optional, 0=North, 90=East)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub shot_direction: Option<f64>,
53    /// Shooting angle in degrees (optional)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub shooting_angle: Option<f64>,
56    /// Barrel twist rate in inches per turn (optional)
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub twist_rate: Option<f64>,
59    /// Bullet diameter in meters (optional)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub bullet_diameter: Option<f64>,
62    /// Bullet length in meters (optional)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub bullet_length: Option<f64>,
65    /// Ground threshold in meters (optional, negative = ignore ground impact)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub ground_threshold: Option<f64>,
68    /// Enable weather zones (optional)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub enable_weather_zones: Option<bool>,
71    /// Enable 3D weather corrections (optional)
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub enable_3d_weather: Option<bool>,
74    /// Wind shear model (optional: none, logarithmic, power_law, ekman_spiral)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub wind_shear_model: Option<String>,
77    /// Weather zone interpolation method (optional: linear, cubic, step)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub weather_zone_interpolation: Option<String>,
80    /// Sample/trajectory step interval in meters (optional)
81    /// Will be converted to yards for API as trajectory_step
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub sample_interval: Option<f64>,
84}
85
86/// Response structure from Flask API trajectory calculation
87#[derive(Debug, Clone, Deserialize)]
88pub struct TrajectoryResponse {
89    /// Array of trajectory points
90    pub trajectory: Vec<ApiTrajectoryPoint>,
91    /// Zero angle in radians
92    pub zero_angle: f64,
93    /// Total time of flight in seconds
94    pub time_of_flight: f64,
95    /// BC confidence score (0-1) if available
96    #[serde(default)]
97    pub bc_confidence: Option<f64>,
98    /// List of ML corrections applied
99    #[serde(default)]
100    pub ml_corrections_applied: Option<Vec<String>>,
101    /// Maximum ordinate (height) in meters
102    #[serde(default)]
103    pub max_ordinate: Option<f64>,
104    /// Impact velocity in m/s
105    #[serde(default)]
106    pub impact_velocity: Option<f64>,
107    /// Impact energy in Joules
108    #[serde(default)]
109    pub impact_energy: Option<f64>,
110}
111
112/// A single point in the trajectory from API response
113#[derive(Debug, Clone, Deserialize)]
114pub struct ApiTrajectoryPoint {
115    /// Range/distance in meters
116    pub range: f64,
117    /// Drop below line of sight in meters (negative = below)
118    pub drop: f64,
119    /// Wind drift in meters
120    pub drift: f64,
121    /// Velocity at this point in m/s
122    pub velocity: f64,
123    /// Kinetic energy at this point in Joules
124    pub energy: f64,
125    /// Time of flight to this point in seconds
126    pub time: f64,
127}
128
129/// Request structure for velocity truing via Flask API
130#[derive(Debug, Clone, Serialize)]
131pub struct TrueVelocityRequest {
132    /// Measured drop in MILs at the target range
133    pub measured_drop_mil: f64,
134    /// Range at which drop was measured (yards)
135    pub range_yd: f64,
136    /// Ballistic coefficient
137    pub bc: f64,
138    /// Drag model ("G1" or "G7")
139    pub drag_model: String,
140    /// Bullet weight in grains
141    pub weight_gr: f64,
142    /// Bullet caliber/diameter in inches
143    pub caliber: f64,
144    /// Zero range in yards (default: 100)
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub zero_range_yd: Option<f64>,
147    /// Chronograph velocity in fps (optional, for comparison)
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub chrono_velocity_fps: Option<f64>,
150    /// Altitude in feet (default: 0)
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub altitude_ft: Option<f64>,
153    /// Temperature in Fahrenheit (default: 59)
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub temperature_f: Option<f64>,
156    /// Barometric pressure in inHg (default: 29.92)
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub pressure_inhg: Option<f64>,
159    /// Humidity percentage (default: 50)
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub humidity: Option<f64>,
162    /// Sight height in inches (default: 2.0)
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub sight_height_in: Option<f64>,
165    /// Enable BC enhancement (default: true)
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub use_bc_enhancement: Option<bool>,
168}
169
170/// Response structure from Flask API velocity truing
171#[derive(Debug, Clone, Deserialize)]
172pub struct TrueVelocityResponse {
173    /// Effective muzzle velocity in fps
174    pub effective_velocity_fps: f64,
175    /// Velocity adjustment from chrono (if chrono provided)
176    #[serde(default)]
177    pub velocity_adjustment_fps: Option<f64>,
178    /// Adjustment as percentage (if chrono provided)
179    #[serde(default)]
180    pub adjustment_percent: Option<f64>,
181    /// Confidence in the result ("high", "medium", "low")
182    pub confidence: String,
183    /// Number of iterations to converge
184    pub iterations: i32,
185    /// Final error in MILs after convergence
186    pub final_error_mil: f64,
187    /// Calculated drop at the effective velocity
188    pub calculated_drop_mil: f64,
189}
190
191/// Error types for API communication
192#[derive(Debug)]
193pub enum ApiError {
194    /// Network connectivity error
195    NetworkError(String),
196    /// Request timed out
197    Timeout,
198    /// Invalid or unparseable response
199    InvalidResponse(String),
200    /// HTTP error from server (status code, message)
201    ServerError(u16, String),
202    /// Request building error
203    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
220/// HTTP client for Flask API communication
221pub struct ApiClient {
222    base_url: String,
223    timeout: Duration,
224}
225
226impl ApiClient {
227    /// Create a new API client
228    ///
229    /// # Arguments
230    /// * `base_url` - Base URL of the Flask API (e.g., "https://api.ballistics.7.62x51mm.sh")
231    /// * `timeout_secs` - Request timeout in seconds
232    pub fn new(base_url: &str, timeout_secs: u64) -> Self {
233        // Normalize URL by removing trailing slash
234        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    /// Calculate trajectory via Flask API
243    ///
244    /// # Arguments
245    /// * `request` - Trajectory calculation request parameters
246    ///
247    /// # Returns
248    /// * `Ok(TrajectoryResponse)` - Successful calculation with trajectory data
249    /// * `Err(ApiError)` - Error during API communication
250    #[cfg(feature = "online")]
251    pub fn calculate_trajectory(
252        &self,
253        request: &TrajectoryRequest,
254    ) -> Result<TrajectoryResponse, ApiError> {
255        // Flask API uses GET /v1/calculate with query parameters (imperial units)
256        let url = format!("{}/v1/calculate", self.base_url);
257
258        // Convert metric values to imperial for API
259        let velocity_fps = request.muzzle_velocity / 0.3048; // m/s to fps
260        let mass_grains = request.bullet_mass / 0.0647989; // grams to grains
261        let distance_yards = request.target_distance / 0.9144; // meters to yards
262
263        let mut req = ureq::get(&url)
264            .set("Accept", "application/json")
265            .set("User-Agent", "ballistics-cli/0.13.31")
266            .timeout(self.timeout)
267            .query("bc_value", &request.bc_value.to_string())
268            .query("bc_type", &request.bc_type)
269            .query("bullet_mass", &format!("{:.1}", mass_grains))
270            .query("muzzle_velocity", &format!("{:.1}", velocity_fps))
271            .query("target_distance", &format!("{:.1}", distance_yards));
272
273        // Add optional parameters
274        if let Some(zero_range) = request.zero_range {
275            let zero_yards = zero_range / 0.9144;
276            req = req.query("zero_distance", &format!("{:.1}", zero_yards));
277        }
278        if let Some(wind_speed) = request.wind_speed {
279            let wind_mph = wind_speed * 2.23694; // m/s to mph
280            req = req.query("wind_speed", &format!("{:.1}", wind_mph));
281        }
282        if let Some(wind_angle) = request.wind_angle {
283            req = req.query("wind_angle", &format!("{:.1}", wind_angle));
284        }
285        if let Some(temp) = request.temperature {
286            let temp_f = temp * 9.0 / 5.0 + 32.0; // Celsius to Fahrenheit
287            req = req.query("temperature", &format!("{:.1}", temp_f));
288        }
289        if let Some(pressure) = request.pressure {
290            let pressure_inhg = pressure / 33.8639; // hPa to inHg
291            req = req.query("pressure", &format!("{:.2}", pressure_inhg));
292        }
293        if let Some(humidity) = request.humidity {
294            req = req.query("humidity", &format!("{:.1}", humidity));
295        }
296        if let Some(altitude) = request.altitude {
297            let altitude_ft = altitude / 0.3048; // meters to feet
298            req = req.query("altitude", &format!("{:.1}", altitude_ft));
299        }
300        if let Some(shooting_angle) = request.shooting_angle {
301            req = req.query("shooting_angle", &format!("{:.1}", shooting_angle));
302        }
303        if let Some(latitude) = request.latitude {
304            req = req.query("latitude", &format!("{:.2}", latitude));
305        }
306        if let Some(longitude) = request.longitude {
307            req = req.query("longitude", &format!("{:.2}", longitude));
308        }
309        if let Some(shot_direction) = request.shot_direction {
310            req = req.query("shot_direction", &format!("{:.1}", shot_direction));
311        }
312        if let Some(twist_rate) = request.twist_rate {
313            req = req.query("twist_rate", &format!("{:.1}", twist_rate));
314        }
315        if let Some(diameter) = request.bullet_diameter {
316            let diameter_in = diameter / 0.0254; // meters to inches
317            req = req.query("bullet_diameter", &format!("{:.3}", diameter_in));
318        }
319        if let Some(threshold) = request.ground_threshold {
320            // Ground threshold is in meters - API expects meters (will be converted by parse_ballistic_inputs)
321            // Use a very large negative value when ignoring ground impact
322            req = req.query("ground_threshold", &format!("{:.1}", threshold));
323        }
324        if let Some(enable) = request.enable_weather_zones {
325            req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
326        }
327        if let Some(enable) = request.enable_3d_weather {
328            req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
329        }
330        if let Some(ref model) = request.wind_shear_model {
331            req = req.query("wind_shear_model", model);
332        }
333        if let Some(ref method) = request.weather_zone_interpolation {
334            req = req.query("weather_zone_interpolation", method);
335        }
336        if let Some(sample_interval) = request.sample_interval {
337            // Convert from meters to yards for the Flask API (trajectory_step is in yards)
338            let step_yards = sample_interval / 0.9144;
339            req = req.query("trajectory_step", &format!("{:.4}", step_yards));
340        }
341
342        let response = req.call().map_err(|e| match e {
343            ureq::Error::Status(code, response) => {
344                let body = response.into_string().unwrap_or_default();
345                ApiError::ServerError(code, body)
346            }
347            ureq::Error::Transport(transport) => {
348                // Check for timeout by looking at the error message
349                let msg = transport.to_string();
350                if msg.contains("timed out") || msg.contains("timeout") {
351                    ApiError::Timeout
352                } else {
353                    ApiError::NetworkError(msg)
354                }
355            }
356        })?;
357
358        let body = response
359            .into_string()
360            .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
361
362        // Parse the Flask API response and convert to our format
363        let api_response: serde_json::Value = serde_json::from_str(&body)
364            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
365
366        // Convert Flask API response to our TrajectoryResponse format
367        self.convert_api_response(&api_response)
368    }
369
370    /// Helper to extract a value from a nested {value: x, unit: y} structure or plain number
371    #[cfg(feature = "online")]
372    fn extract_value(val: &serde_json::Value) -> Option<f64> {
373        // Try nested {value: x} first, then plain number
374        val.get("value")
375            .and_then(|v| v.as_f64())
376            .or_else(|| val.as_f64())
377    }
378
379    #[cfg(feature = "online")]
380    fn convert_api_response(&self, api_response: &serde_json::Value) -> Result<TrajectoryResponse, ApiError> {
381        // Get results object
382        let results = api_response.get("results");
383
384        // Extract trajectory points from Flask API response
385        // The Flask API returns trajectory in "trajectory" array with nested value objects
386        let trajectory_array = api_response.get("trajectory")
387            .and_then(|t| t.as_array())
388            .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
389
390        let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
391            .iter()
392            .filter_map(|point| {
393                // Flask API returns nested {value: x, unit: y} objects in imperial units
394                let range_yards = point.get("distance")
395                    .and_then(Self::extract_value)?;
396                let drop_inches = point.get("drop")
397                    .and_then(Self::extract_value)
398                    .unwrap_or(0.0);
399                let drift_inches = point.get("wind_drift")
400                    .and_then(Self::extract_value)
401                    .unwrap_or(0.0);
402                let velocity_fps = point.get("velocity")
403                    .and_then(Self::extract_value)?;
404                let energy_ftlbs = point.get("energy")
405                    .and_then(Self::extract_value)
406                    .unwrap_or(0.0);
407                let time = point.get("time")
408                    .and_then(Self::extract_value)
409                    .unwrap_or(0.0);
410
411                Some(ApiTrajectoryPoint {
412                    range: range_yards * 0.9144,        // yards to meters
413                    drop: drop_inches * 0.0254,          // inches to meters
414                    drift: drift_inches * 0.0254,        // inches to meters
415                    velocity: velocity_fps * 0.3048,     // fps to m/s
416                    energy: energy_ftlbs * 1.35582,      // ft-lbs to Joules
417                    time,
418                })
419            })
420            .collect();
421
422        // Extract summary values from results object
423        let zero_angle = results
424            .and_then(|r| r.get("barrel_angle"))
425            .and_then(Self::extract_value)
426            .unwrap_or(0.0)
427            .to_radians();
428
429        let time_of_flight = results
430            .and_then(|r| r.get("time_of_flight"))
431            .and_then(Self::extract_value)
432            .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
433
434        let bc_confidence = api_response.get("bc_confidence")
435            .and_then(|v| v.as_f64());
436
437        let ml_corrections = api_response.get("ml_corrections_applied")
438            .or_else(|| api_response.get("corrections_applied"))
439            .and_then(|v| v.as_array())
440            .map(|arr| {
441                arr.iter()
442                    .filter_map(|v| v.as_str().map(String::from))
443                    .collect()
444            });
445
446        // max_height is directly a number in results
447        let max_ordinate = results
448            .and_then(|r| r.get("max_height"))
449            .and_then(|v| v.as_f64())
450            .map(|h| h * 0.0254); // inches to meters
451
452        let impact_velocity = results
453            .and_then(|r| r.get("final_velocity"))
454            .and_then(Self::extract_value)
455            .map(|v| v * 0.3048); // fps to m/s
456
457        let impact_energy = results
458            .and_then(|r| r.get("final_energy"))
459            .and_then(Self::extract_value)
460            .map(|e| e * 1.35582); // ft-lbs to Joules
461
462        Ok(TrajectoryResponse {
463            trajectory,
464            zero_angle,
465            time_of_flight,
466            bc_confidence,
467            ml_corrections_applied: ml_corrections,
468            max_ordinate,
469            impact_velocity,
470            impact_energy,
471        })
472    }
473
474    /// Check API health
475    #[cfg(feature = "online")]
476    pub fn health_check(&self) -> Result<bool, ApiError> {
477        let url = format!("{}/health", self.base_url);
478
479        let response = ureq::get(&url)
480            .timeout(Duration::from_secs(5))
481            .call()
482            .map_err(|e| match e {
483                ureq::Error::Status(code, response) => {
484                    let body = response.into_string().unwrap_or_default();
485                    ApiError::ServerError(code, body)
486                }
487                ureq::Error::Transport(transport) => {
488                    let msg = transport.to_string();
489                    if msg.contains("timed out") || msg.contains("timeout") {
490                        ApiError::Timeout
491                    } else {
492                        ApiError::NetworkError(msg)
493                    }
494                }
495            })?;
496
497        Ok(response.status() == 200)
498    }
499
500    /// Calculate true/effective muzzle velocity via Flask API
501    ///
502    /// # Arguments
503    /// * `request` - Velocity truing request parameters
504    ///
505    /// # Returns
506    /// * `Ok(TrueVelocityResponse)` - Successful calculation with effective velocity
507    /// * `Err(ApiError)` - Error during API communication
508    #[cfg(feature = "online")]
509    pub fn true_velocity(
510        &self,
511        request: &TrueVelocityRequest,
512    ) -> Result<TrueVelocityResponse, ApiError> {
513        let url = format!("{}/v1/true-velocity", self.base_url);
514
515        let body = serde_json::to_string(request)
516            .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
517
518        let response = ureq::post(&url)
519            .set("Content-Type", "application/json")
520            .set("Accept", "application/json")
521            .set("User-Agent", "ballistics-cli/0.13.31")
522            .timeout(self.timeout)
523            .send_string(&body)
524            .map_err(|e| match e {
525                ureq::Error::Status(code, response) => {
526                    let body = response.into_string().unwrap_or_default();
527                    ApiError::ServerError(code, body)
528                }
529                ureq::Error::Transport(transport) => {
530                    let msg = transport.to_string();
531                    if msg.contains("timed out") || msg.contains("timeout") {
532                        ApiError::Timeout
533                    } else {
534                        ApiError::NetworkError(msg)
535                    }
536                }
537            })?;
538
539        let response_body = response
540            .into_string()
541            .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
542
543        serde_json::from_str(&response_body)
544            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
545    }
546}
547
548/// Builder for TrajectoryRequest
549#[derive(Default)]
550pub struct TrajectoryRequestBuilder {
551    bc_value: Option<f64>,
552    bc_type: Option<String>,
553    bullet_mass: Option<f64>,
554    muzzle_velocity: Option<f64>,
555    target_distance: Option<f64>,
556    zero_range: Option<f64>,
557    wind_speed: Option<f64>,
558    wind_angle: Option<f64>,
559    temperature: Option<f64>,
560    pressure: Option<f64>,
561    humidity: Option<f64>,
562    altitude: Option<f64>,
563    latitude: Option<f64>,
564    longitude: Option<f64>,
565    shot_direction: Option<f64>,
566    shooting_angle: Option<f64>,
567    twist_rate: Option<f64>,
568    bullet_diameter: Option<f64>,
569    bullet_length: Option<f64>,
570    ground_threshold: Option<f64>,
571    enable_weather_zones: Option<bool>,
572    enable_3d_weather: Option<bool>,
573    wind_shear_model: Option<String>,
574    weather_zone_interpolation: Option<String>,
575    sample_interval: Option<f64>,
576}
577
578impl TrajectoryRequestBuilder {
579    pub fn new() -> Self {
580        Self::default()
581    }
582
583    pub fn bc_value(mut self, value: f64) -> Self {
584        self.bc_value = Some(value);
585        self
586    }
587
588    pub fn bc_type(mut self, value: &str) -> Self {
589        self.bc_type = Some(value.to_string());
590        self
591    }
592
593    pub fn bullet_mass(mut self, value: f64) -> Self {
594        self.bullet_mass = Some(value);
595        self
596    }
597
598    pub fn muzzle_velocity(mut self, value: f64) -> Self {
599        self.muzzle_velocity = Some(value);
600        self
601    }
602
603    pub fn target_distance(mut self, value: f64) -> Self {
604        self.target_distance = Some(value);
605        self
606    }
607
608    pub fn zero_range(mut self, value: f64) -> Self {
609        self.zero_range = Some(value);
610        self
611    }
612
613    pub fn wind_speed(mut self, value: f64) -> Self {
614        self.wind_speed = Some(value);
615        self
616    }
617
618    pub fn wind_angle(mut self, value: f64) -> Self {
619        self.wind_angle = Some(value);
620        self
621    }
622
623    pub fn temperature(mut self, value: f64) -> Self {
624        self.temperature = Some(value);
625        self
626    }
627
628    pub fn pressure(mut self, value: f64) -> Self {
629        self.pressure = Some(value);
630        self
631    }
632
633    pub fn humidity(mut self, value: f64) -> Self {
634        self.humidity = Some(value);
635        self
636    }
637
638    pub fn altitude(mut self, value: f64) -> Self {
639        self.altitude = Some(value);
640        self
641    }
642
643    pub fn latitude(mut self, value: f64) -> Self {
644        self.latitude = Some(value);
645        self
646    }
647
648    pub fn longitude(mut self, value: f64) -> Self {
649        self.longitude = Some(value);
650        self
651    }
652
653    pub fn shot_direction(mut self, value: f64) -> Self {
654        self.shot_direction = Some(value);
655        self
656    }
657
658    pub fn shooting_angle(mut self, value: f64) -> Self {
659        self.shooting_angle = Some(value);
660        self
661    }
662
663    pub fn twist_rate(mut self, value: f64) -> Self {
664        self.twist_rate = Some(value);
665        self
666    }
667
668    pub fn bullet_diameter(mut self, value: f64) -> Self {
669        self.bullet_diameter = Some(value);
670        self
671    }
672
673    pub fn bullet_length(mut self, value: f64) -> Self {
674        self.bullet_length = Some(value);
675        self
676    }
677
678    pub fn ground_threshold(mut self, value: f64) -> Self {
679        self.ground_threshold = Some(value);
680        self
681    }
682
683    pub fn enable_weather_zones(mut self, value: bool) -> Self {
684        self.enable_weather_zones = Some(value);
685        self
686    }
687
688    pub fn enable_3d_weather(mut self, value: bool) -> Self {
689        self.enable_3d_weather = Some(value);
690        self
691    }
692
693    pub fn wind_shear_model(mut self, value: &str) -> Self {
694        self.wind_shear_model = Some(value.to_string());
695        self
696    }
697
698    pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
699        self.weather_zone_interpolation = Some(value.to_string());
700        self
701    }
702
703    pub fn sample_interval(mut self, value: f64) -> Self {
704        self.sample_interval = Some(value);
705        self
706    }
707
708    /// Build the TrajectoryRequest
709    ///
710    /// # Returns
711    /// * `Ok(TrajectoryRequest)` - Valid request
712    /// * `Err(String)` - Missing required fields
713    pub fn build(self) -> Result<TrajectoryRequest, String> {
714        let bc_value = self.bc_value.ok_or("bc_value is required")?;
715        let bc_type = self.bc_type.ok_or("bc_type is required")?;
716        let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
717        let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
718        let target_distance = self.target_distance.ok_or("target_distance is required")?;
719
720        Ok(TrajectoryRequest {
721            bc_value,
722            bc_type,
723            bullet_mass,
724            muzzle_velocity,
725            target_distance,
726            zero_range: self.zero_range,
727            wind_speed: self.wind_speed,
728            wind_angle: self.wind_angle,
729            temperature: self.temperature,
730            pressure: self.pressure,
731            humidity: self.humidity,
732            altitude: self.altitude,
733            latitude: self.latitude,
734            longitude: self.longitude,
735            shot_direction: self.shot_direction,
736            shooting_angle: self.shooting_angle,
737            twist_rate: self.twist_rate,
738            bullet_diameter: self.bullet_diameter,
739            bullet_length: self.bullet_length,
740            ground_threshold: self.ground_threshold,
741            enable_weather_zones: self.enable_weather_zones,
742            enable_3d_weather: self.enable_3d_weather,
743            wind_shear_model: self.wind_shear_model,
744            weather_zone_interpolation: self.weather_zone_interpolation,
745            sample_interval: self.sample_interval,
746        })
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    #[test]
755    fn test_request_builder_required_fields() {
756        let result = TrajectoryRequestBuilder::new()
757            .bc_value(0.238)
758            .bc_type("G7")
759            .bullet_mass(9.07) // 140gr in grams
760            .muzzle_velocity(860.0)
761            .target_distance(1000.0)
762            .build();
763
764        assert!(result.is_ok());
765        let request = result.unwrap();
766        assert_eq!(request.bc_value, 0.238);
767        assert_eq!(request.bc_type, "G7");
768    }
769
770    #[test]
771    fn test_request_builder_missing_fields() {
772        let result = TrajectoryRequestBuilder::new()
773            .bc_value(0.238)
774            .build();
775
776        assert!(result.is_err());
777    }
778
779    #[test]
780    fn test_request_builder_all_optional_fields() {
781        let result = TrajectoryRequestBuilder::new()
782            .bc_value(0.238)
783            .bc_type("G7")
784            .bullet_mass(9.07)
785            .muzzle_velocity(860.0)
786            .target_distance(1000.0)
787            .zero_range(100.0)
788            .wind_speed(5.0)
789            .wind_angle(90.0)
790            .temperature(15.0)
791            .pressure(1013.25)
792            .humidity(50.0)
793            .altitude(500.0)
794            .latitude(45.0)
795            .shooting_angle(0.0)
796            .twist_rate(10.0)
797            .bullet_diameter(0.00671)
798            .bullet_length(0.035)
799            .build();
800
801        assert!(result.is_ok());
802        let request = result.unwrap();
803        assert_eq!(request.zero_range, Some(100.0));
804        assert_eq!(request.wind_speed, Some(5.0));
805        assert_eq!(request.latitude, Some(45.0));
806    }
807
808    #[test]
809    fn test_api_client_url_normalization() {
810        let client1 = ApiClient::new("https://api.example.com/", 10);
811        assert_eq!(client1.base_url, "https://api.example.com");
812
813        let client2 = ApiClient::new("https://api.example.com", 10);
814        assert_eq!(client2.base_url, "https://api.example.com");
815    }
816
817    #[test]
818    fn test_api_error_display() {
819        assert_eq!(
820            format!("{}", ApiError::NetworkError("connection refused".to_string())),
821            "Network error: connection refused"
822        );
823        assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
824        assert_eq!(
825            format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
826            "Server error 500: Internal error"
827        );
828    }
829}