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", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
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. --ignore-ground-impact sets f64::NEG_INFINITY, which
321            // format!("{:.1}") would render as the literal "-inf" (rejected by most server-side
322            // numeric validators, silently dropping the ignore-ground intent); send a large finite
323            // negative sentinel for THAT case only. Finite values pass through. Any other
324            // non-finite value (NaN or +Inf) is invalid input, not an ignore-ground request, so
325            // omit the parameter rather than silently mapping it to the sentinel.
326            if threshold.is_finite() {
327                req = req.query("ground_threshold", &format!("{:.1}", threshold));
328            } else if threshold == f64::NEG_INFINITY {
329                req = req.query("ground_threshold", "-1000000000.0");
330            }
331        }
332        if let Some(enable) = request.enable_weather_zones {
333            req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
334        }
335        if let Some(enable) = request.enable_3d_weather {
336            req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
337        }
338        if let Some(ref model) = request.wind_shear_model {
339            req = req.query("wind_shear_model", model);
340        }
341        if let Some(ref method) = request.weather_zone_interpolation {
342            req = req.query("weather_zone_interpolation", method);
343        }
344        if let Some(sample_interval) = request.sample_interval {
345            // Convert from meters to yards for the Flask API (trajectory_step is in yards)
346            let step_yards = sample_interval / 0.9144;
347            req = req.query("trajectory_step", &format!("{:.4}", step_yards));
348        }
349
350        let response = req.call().map_err(|e| match e {
351            ureq::Error::Status(code, response) => {
352                let body = response.into_string().unwrap_or_default();
353                ApiError::ServerError(code, body)
354            }
355            ureq::Error::Transport(transport) => {
356                // Check for timeout by looking at the error message
357                let msg = transport.to_string();
358                if msg.contains("timed out") || msg.contains("timeout") {
359                    ApiError::Timeout
360                } else {
361                    ApiError::NetworkError(msg)
362                }
363            }
364        })?;
365
366        let body = response
367            .into_string()
368            .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
369
370        // Parse the Flask API response and convert to our format
371        let api_response: serde_json::Value = serde_json::from_str(&body)
372            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
373
374        // Convert Flask API response to our TrajectoryResponse format
375        self.convert_api_response(&api_response)
376    }
377
378    /// Helper to extract a value from a nested {value: x, unit: y} structure or plain number
379    #[cfg(feature = "online")]
380    fn extract_value(val: &serde_json::Value) -> Option<f64> {
381        // Try nested {value: x} first, then plain number
382        val.get("value")
383            .and_then(|v| v.as_f64())
384            .or_else(|| val.as_f64())
385    }
386
387    #[cfg(feature = "online")]
388    fn convert_api_response(&self, api_response: &serde_json::Value) -> Result<TrajectoryResponse, ApiError> {
389        // Get results object
390        let results = api_response.get("results");
391
392        // Extract trajectory points from Flask API response
393        // The Flask API returns trajectory in "trajectory" array with nested value objects
394        let trajectory_array = api_response.get("trajectory")
395            .and_then(|t| t.as_array())
396            .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
397
398        let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
399            .iter()
400            .filter_map(|point| {
401                // Flask API returns nested {value: x, unit: y} objects in imperial units
402                let range_yards = point.get("distance")
403                    .and_then(Self::extract_value)?;
404                let drop_inches = point.get("drop")
405                    .and_then(Self::extract_value)
406                    .unwrap_or(0.0);
407                let drift_inches = point.get("wind_drift")
408                    .and_then(Self::extract_value)
409                    .unwrap_or(0.0);
410                let velocity_fps = point.get("velocity")
411                    .and_then(Self::extract_value)?;
412                let energy_ftlbs = point.get("energy")
413                    .and_then(Self::extract_value)
414                    .unwrap_or(0.0);
415                let time = point.get("time")
416                    .and_then(Self::extract_value)
417                    .unwrap_or(0.0);
418
419                Some(ApiTrajectoryPoint {
420                    range: range_yards * 0.9144,        // yards to meters
421                    drop: drop_inches * 0.0254,          // inches to meters
422                    drift: drift_inches * 0.0254,        // inches to meters
423                    velocity: velocity_fps * 0.3048,     // fps to m/s
424                    energy: energy_ftlbs * 1.35582,      // ft-lbs to Joules
425                    time,
426                })
427            })
428            .collect();
429
430        // Extract summary values from results object
431        let zero_angle = results
432            .and_then(|r| r.get("barrel_angle"))
433            .and_then(Self::extract_value)
434            .unwrap_or(0.0)
435            .to_radians();
436
437        let time_of_flight = results
438            .and_then(|r| r.get("time_of_flight"))
439            .and_then(Self::extract_value)
440            .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
441
442        let bc_confidence = api_response.get("bc_confidence")
443            .and_then(|v| v.as_f64());
444
445        let ml_corrections = api_response.get("ml_corrections_applied")
446            .or_else(|| api_response.get("corrections_applied"))
447            .and_then(|v| v.as_array())
448            .map(|arr| {
449                arr.iter()
450                    .filter_map(|v| v.as_str().map(String::from))
451                    .collect()
452            });
453
454        // max_height is directly a number in results
455        let max_ordinate = results
456            .and_then(|r| r.get("max_height"))
457            .and_then(|v| v.as_f64())
458            .map(|h| h * 0.0254); // inches to meters
459
460        let impact_velocity = results
461            .and_then(|r| r.get("final_velocity"))
462            .and_then(Self::extract_value)
463            .map(|v| v * 0.3048); // fps to m/s
464
465        let impact_energy = results
466            .and_then(|r| r.get("final_energy"))
467            .and_then(Self::extract_value)
468            .map(|e| e * 1.35582); // ft-lbs to Joules
469
470        Ok(TrajectoryResponse {
471            trajectory,
472            zero_angle,
473            time_of_flight,
474            bc_confidence,
475            ml_corrections_applied: ml_corrections,
476            max_ordinate,
477            impact_velocity,
478            impact_energy,
479        })
480    }
481
482    /// Check API health
483    #[cfg(feature = "online")]
484    pub fn health_check(&self) -> Result<bool, ApiError> {
485        let url = format!("{}/health", self.base_url);
486
487        let response = ureq::get(&url)
488            .timeout(Duration::from_secs(5))
489            .call()
490            .map_err(|e| match e {
491                ureq::Error::Status(code, response) => {
492                    let body = response.into_string().unwrap_or_default();
493                    ApiError::ServerError(code, body)
494                }
495                ureq::Error::Transport(transport) => {
496                    let msg = transport.to_string();
497                    if msg.contains("timed out") || msg.contains("timeout") {
498                        ApiError::Timeout
499                    } else {
500                        ApiError::NetworkError(msg)
501                    }
502                }
503            })?;
504
505        Ok(response.status() == 200)
506    }
507
508    /// Calculate true/effective muzzle velocity via Flask API
509    ///
510    /// # Arguments
511    /// * `request` - Velocity truing request parameters
512    ///
513    /// # Returns
514    /// * `Ok(TrueVelocityResponse)` - Successful calculation with effective velocity
515    /// * `Err(ApiError)` - Error during API communication
516    #[cfg(feature = "online")]
517    pub fn true_velocity(
518        &self,
519        request: &TrueVelocityRequest,
520    ) -> Result<TrueVelocityResponse, ApiError> {
521        let url = format!("{}/v1/true-velocity", self.base_url);
522
523        let body = serde_json::to_string(request)
524            .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
525
526        let response = ureq::post(&url)
527            .set("Content-Type", "application/json")
528            .set("Accept", "application/json")
529            .set("User-Agent", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
530            .timeout(self.timeout)
531            .send_string(&body)
532            .map_err(|e| match e {
533                ureq::Error::Status(code, response) => {
534                    let body = response.into_string().unwrap_or_default();
535                    ApiError::ServerError(code, body)
536                }
537                ureq::Error::Transport(transport) => {
538                    let msg = transport.to_string();
539                    if msg.contains("timed out") || msg.contains("timeout") {
540                        ApiError::Timeout
541                    } else {
542                        ApiError::NetworkError(msg)
543                    }
544                }
545            })?;
546
547        let response_body = response
548            .into_string()
549            .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
550
551        serde_json::from_str(&response_body)
552            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
553    }
554}
555
556/// Builder for TrajectoryRequest
557#[derive(Default)]
558pub struct TrajectoryRequestBuilder {
559    bc_value: Option<f64>,
560    bc_type: Option<String>,
561    bullet_mass: Option<f64>,
562    muzzle_velocity: Option<f64>,
563    target_distance: Option<f64>,
564    zero_range: Option<f64>,
565    wind_speed: Option<f64>,
566    wind_angle: Option<f64>,
567    temperature: Option<f64>,
568    pressure: Option<f64>,
569    humidity: Option<f64>,
570    altitude: Option<f64>,
571    latitude: Option<f64>,
572    longitude: Option<f64>,
573    shot_direction: Option<f64>,
574    shooting_angle: Option<f64>,
575    twist_rate: Option<f64>,
576    bullet_diameter: Option<f64>,
577    bullet_length: Option<f64>,
578    ground_threshold: Option<f64>,
579    enable_weather_zones: Option<bool>,
580    enable_3d_weather: Option<bool>,
581    wind_shear_model: Option<String>,
582    weather_zone_interpolation: Option<String>,
583    sample_interval: Option<f64>,
584}
585
586impl TrajectoryRequestBuilder {
587    pub fn new() -> Self {
588        Self::default()
589    }
590
591    pub fn bc_value(mut self, value: f64) -> Self {
592        self.bc_value = Some(value);
593        self
594    }
595
596    pub fn bc_type(mut self, value: &str) -> Self {
597        self.bc_type = Some(value.to_string());
598        self
599    }
600
601    pub fn bullet_mass(mut self, value: f64) -> Self {
602        self.bullet_mass = Some(value);
603        self
604    }
605
606    pub fn muzzle_velocity(mut self, value: f64) -> Self {
607        self.muzzle_velocity = Some(value);
608        self
609    }
610
611    pub fn target_distance(mut self, value: f64) -> Self {
612        self.target_distance = Some(value);
613        self
614    }
615
616    pub fn zero_range(mut self, value: f64) -> Self {
617        self.zero_range = Some(value);
618        self
619    }
620
621    pub fn wind_speed(mut self, value: f64) -> Self {
622        self.wind_speed = Some(value);
623        self
624    }
625
626    pub fn wind_angle(mut self, value: f64) -> Self {
627        self.wind_angle = Some(value);
628        self
629    }
630
631    pub fn temperature(mut self, value: f64) -> Self {
632        self.temperature = Some(value);
633        self
634    }
635
636    pub fn pressure(mut self, value: f64) -> Self {
637        self.pressure = Some(value);
638        self
639    }
640
641    pub fn humidity(mut self, value: f64) -> Self {
642        self.humidity = Some(value);
643        self
644    }
645
646    pub fn altitude(mut self, value: f64) -> Self {
647        self.altitude = Some(value);
648        self
649    }
650
651    pub fn latitude(mut self, value: f64) -> Self {
652        self.latitude = Some(value);
653        self
654    }
655
656    pub fn longitude(mut self, value: f64) -> Self {
657        self.longitude = Some(value);
658        self
659    }
660
661    pub fn shot_direction(mut self, value: f64) -> Self {
662        self.shot_direction = Some(value);
663        self
664    }
665
666    pub fn shooting_angle(mut self, value: f64) -> Self {
667        self.shooting_angle = Some(value);
668        self
669    }
670
671    pub fn twist_rate(mut self, value: f64) -> Self {
672        self.twist_rate = Some(value);
673        self
674    }
675
676    pub fn bullet_diameter(mut self, value: f64) -> Self {
677        self.bullet_diameter = Some(value);
678        self
679    }
680
681    pub fn bullet_length(mut self, value: f64) -> Self {
682        self.bullet_length = Some(value);
683        self
684    }
685
686    pub fn ground_threshold(mut self, value: f64) -> Self {
687        self.ground_threshold = Some(value);
688        self
689    }
690
691    pub fn enable_weather_zones(mut self, value: bool) -> Self {
692        self.enable_weather_zones = Some(value);
693        self
694    }
695
696    pub fn enable_3d_weather(mut self, value: bool) -> Self {
697        self.enable_3d_weather = Some(value);
698        self
699    }
700
701    pub fn wind_shear_model(mut self, value: &str) -> Self {
702        self.wind_shear_model = Some(value.to_string());
703        self
704    }
705
706    pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
707        self.weather_zone_interpolation = Some(value.to_string());
708        self
709    }
710
711    pub fn sample_interval(mut self, value: f64) -> Self {
712        self.sample_interval = Some(value);
713        self
714    }
715
716    /// Build the TrajectoryRequest
717    ///
718    /// # Returns
719    /// * `Ok(TrajectoryRequest)` - Valid request
720    /// * `Err(String)` - Missing required fields
721    pub fn build(self) -> Result<TrajectoryRequest, String> {
722        let bc_value = self.bc_value.ok_or("bc_value is required")?;
723        let bc_type = self.bc_type.ok_or("bc_type is required")?;
724        let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
725        let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
726        let target_distance = self.target_distance.ok_or("target_distance is required")?;
727
728        Ok(TrajectoryRequest {
729            bc_value,
730            bc_type,
731            bullet_mass,
732            muzzle_velocity,
733            target_distance,
734            zero_range: self.zero_range,
735            wind_speed: self.wind_speed,
736            wind_angle: self.wind_angle,
737            temperature: self.temperature,
738            pressure: self.pressure,
739            humidity: self.humidity,
740            altitude: self.altitude,
741            latitude: self.latitude,
742            longitude: self.longitude,
743            shot_direction: self.shot_direction,
744            shooting_angle: self.shooting_angle,
745            twist_rate: self.twist_rate,
746            bullet_diameter: self.bullet_diameter,
747            bullet_length: self.bullet_length,
748            ground_threshold: self.ground_threshold,
749            enable_weather_zones: self.enable_weather_zones,
750            enable_3d_weather: self.enable_3d_weather,
751            wind_shear_model: self.wind_shear_model,
752            weather_zone_interpolation: self.weather_zone_interpolation,
753            sample_interval: self.sample_interval,
754        })
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn test_request_builder_required_fields() {
764        let result = TrajectoryRequestBuilder::new()
765            .bc_value(0.238)
766            .bc_type("G7")
767            .bullet_mass(9.07) // 140gr in grams
768            .muzzle_velocity(860.0)
769            .target_distance(1000.0)
770            .build();
771
772        assert!(result.is_ok());
773        let request = result.unwrap();
774        assert_eq!(request.bc_value, 0.238);
775        assert_eq!(request.bc_type, "G7");
776    }
777
778    #[test]
779    fn test_request_builder_missing_fields() {
780        let result = TrajectoryRequestBuilder::new()
781            .bc_value(0.238)
782            .build();
783
784        assert!(result.is_err());
785    }
786
787    #[test]
788    fn test_request_builder_all_optional_fields() {
789        let result = TrajectoryRequestBuilder::new()
790            .bc_value(0.238)
791            .bc_type("G7")
792            .bullet_mass(9.07)
793            .muzzle_velocity(860.0)
794            .target_distance(1000.0)
795            .zero_range(100.0)
796            .wind_speed(5.0)
797            .wind_angle(90.0)
798            .temperature(15.0)
799            .pressure(1013.25)
800            .humidity(50.0)
801            .altitude(500.0)
802            .latitude(45.0)
803            .shooting_angle(0.0)
804            .twist_rate(10.0)
805            .bullet_diameter(0.00671)
806            .bullet_length(0.035)
807            .build();
808
809        assert!(result.is_ok());
810        let request = result.unwrap();
811        assert_eq!(request.zero_range, Some(100.0));
812        assert_eq!(request.wind_speed, Some(5.0));
813        assert_eq!(request.latitude, Some(45.0));
814    }
815
816    #[test]
817    fn test_api_client_url_normalization() {
818        let client1 = ApiClient::new("https://api.example.com/", 10);
819        assert_eq!(client1.base_url, "https://api.example.com");
820
821        let client2 = ApiClient::new("https://api.example.com", 10);
822        assert_eq!(client2.base_url, "https://api.example.com");
823    }
824
825    #[test]
826    fn test_api_error_display() {
827        assert_eq!(
828            format!("{}", ApiError::NetworkError("connection refused".to_string())),
829            "Network error: connection refused"
830        );
831        assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
832        assert_eq!(
833            format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
834            "Server error 500: Internal error"
835        );
836    }
837}