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
7#[cfg(feature = "online")]
8use crate::constants::GRAMS_PER_GRAIN;
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12/// Request structure for trajectory calculation via Flask API
13#[derive(Debug, Clone, Serialize)]
14pub struct TrajectoryRequest {
15    /// Ballistic coefficient value
16    pub bc_value: f64,
17    /// BC type: "G1" or "G7"
18    pub bc_type: String,
19    /// Bullet mass in grams
20    pub bullet_mass: f64,
21    /// Muzzle velocity in m/s
22    pub muzzle_velocity: f64,
23    /// Target distance in meters
24    pub target_distance: f64,
25    /// Zero range in meters (optional)
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub zero_range: Option<f64>,
28    /// Wind speed in m/s (optional)
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub wind_speed: Option<f64>,
31    /// Wind angle in degrees (optional)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub wind_angle: Option<f64>,
34    /// Temperature in Celsius (optional)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub temperature: Option<f64>,
37    /// Pressure in hPa/mbar (optional)
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub pressure: Option<f64>,
40    /// Humidity percentage 0-100 (optional)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub humidity: Option<f64>,
43    /// Altitude in meters (optional)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub altitude: Option<f64>,
46    /// Latitude for Coriolis calculations (optional)
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub latitude: Option<f64>,
49    /// Longitude for weather zones (optional)
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub longitude: Option<f64>,
52    /// Shot direction/azimuth in degrees (optional, 0=North, 90=East)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub shot_direction: Option<f64>,
55    /// Shooting angle in degrees (optional)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub shooting_angle: Option<f64>,
58    /// Barrel twist rate in inches per turn (optional)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub twist_rate: Option<f64>,
61    /// Bullet diameter in meters (optional)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub bullet_diameter: Option<f64>,
64    /// Bullet length in meters (optional)
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub bullet_length: Option<f64>,
67    /// Ground threshold in meters (optional, negative = ignore ground impact)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub ground_threshold: Option<f64>,
70    /// Enable weather zones (optional)
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub enable_weather_zones: Option<bool>,
73    /// Enable 3D weather corrections (optional)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub enable_3d_weather: Option<bool>,
76    /// Wind shear model (optional: none, logarithmic, power_law, ekman_spiral)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub wind_shear_model: Option<String>,
79    /// Weather zone interpolation method (optional: linear, cubic, step)
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub weather_zone_interpolation: Option<String>,
82    /// Sample/trajectory step interval in meters (optional)
83    /// Will be converted to yards for API as trajectory_step
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub sample_interval: Option<f64>,
86}
87
88/// Response structure from Flask API trajectory calculation
89#[derive(Debug, Clone, Deserialize)]
90pub struct TrajectoryResponse {
91    /// Array of trajectory points
92    pub trajectory: Vec<ApiTrajectoryPoint>,
93    /// Zero angle in radians
94    pub zero_angle: f64,
95    /// Total time of flight in seconds
96    pub time_of_flight: f64,
97    /// BC confidence score (0-1) if available
98    #[serde(default)]
99    pub bc_confidence: Option<f64>,
100    /// List of ML corrections applied
101    #[serde(default)]
102    pub ml_corrections_applied: Option<Vec<String>>,
103    /// Maximum ordinate (height) in meters
104    #[serde(default)]
105    pub max_ordinate: Option<f64>,
106    /// Impact velocity in m/s
107    #[serde(default)]
108    pub impact_velocity: Option<f64>,
109    /// Impact energy in Joules
110    #[serde(default)]
111    pub impact_energy: Option<f64>,
112}
113
114/// A single point in the trajectory from API response
115#[derive(Debug, Clone, Deserialize)]
116pub struct ApiTrajectoryPoint {
117    /// Range/distance in meters
118    pub range: f64,
119    /// Drop below line of sight in meters (negative = below)
120    pub drop: f64,
121    /// Wind drift in meters
122    pub drift: f64,
123    /// Velocity at this point in m/s
124    pub velocity: f64,
125    /// Kinetic energy at this point in Joules
126    pub energy: f64,
127    /// Time of flight to this point in seconds
128    pub time: f64,
129}
130
131/// Request structure for velocity truing via Flask API
132#[derive(Debug, Clone, Serialize)]
133pub struct TrueVelocityRequest {
134    /// Measured drop in MILs at the target range
135    pub measured_drop_mil: f64,
136    /// Range at which drop was measured (yards)
137    pub range_yd: f64,
138    /// Ballistic coefficient
139    pub bc: f64,
140    /// Drag model ("G1" or "G7")
141    pub drag_model: String,
142    /// Bullet weight in grains
143    pub weight_gr: f64,
144    /// Bullet caliber/diameter in inches
145    pub caliber: f64,
146    /// Zero range in yards (default: 100)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub zero_range_yd: Option<f64>,
149    /// Chronograph velocity in fps (optional, for comparison)
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub chrono_velocity_fps: Option<f64>,
152    /// Altitude in feet (default: 0)
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub altitude_ft: Option<f64>,
155    /// Temperature in Fahrenheit (default: 59)
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub temperature_f: Option<f64>,
158    /// Barometric pressure in inHg (default: 29.92)
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub pressure_inhg: Option<f64>,
161    /// Humidity percentage (default: 50)
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub humidity: Option<f64>,
164    /// Sight height in inches (default: 2.0)
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub sight_height_in: Option<f64>,
167    /// Enable BC enhancement (default: true)
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub use_bc_enhancement: Option<bool>,
170}
171
172/// Response structure from Flask API velocity truing
173#[derive(Debug, Clone, Deserialize)]
174pub struct TrueVelocityResponse {
175    /// Effective muzzle velocity in fps
176    pub effective_velocity_fps: f64,
177    /// Velocity adjustment from chrono (if chrono provided)
178    #[serde(default)]
179    pub velocity_adjustment_fps: Option<f64>,
180    /// Adjustment as percentage (if chrono provided)
181    #[serde(default)]
182    pub adjustment_percent: Option<f64>,
183    /// Confidence in the result ("high", "medium", "low")
184    pub confidence: String,
185    /// Number of iterations to converge
186    pub iterations: i32,
187    /// Final error in MILs after convergence
188    pub final_error_mil: f64,
189    /// Calculated drop at the effective velocity
190    pub calculated_drop_mil: f64,
191}
192
193/// Error types for API communication
194#[derive(Debug)]
195pub enum ApiError {
196    /// Network connectivity error
197    NetworkError(String),
198    /// Request timed out
199    Timeout,
200    /// Invalid or unparseable response
201    InvalidResponse(String),
202    /// HTTP error from server (status code, message)
203    ServerError(u16, String),
204    /// Request building error
205    RequestError(String),
206}
207
208impl std::fmt::Display for ApiError {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
212            ApiError::Timeout => write!(f, "Request timed out"),
213            ApiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
214            ApiError::ServerError(code, msg) => write!(f, "Server error {}: {}", code, msg),
215            ApiError::RequestError(msg) => write!(f, "Request error: {}", msg),
216        }
217    }
218}
219
220impl std::error::Error for ApiError {}
221
222/// HTTP client for Flask API communication
223pub struct ApiClient {
224    base_url: String,
225    timeout: Duration,
226}
227
228impl ApiClient {
229    /// Create a new API client
230    ///
231    /// # Arguments
232    /// * `base_url` - Base URL of the Flask API (e.g., <https://api.ballistics.7.62x51mm.sh>)
233    /// * `timeout_secs` - Request timeout in seconds
234    pub fn new(base_url: &str, timeout_secs: u64) -> Self {
235        // Normalize URL by removing trailing slash
236        let base_url = base_url.trim_end_matches('/').to_string();
237
238        Self {
239            base_url,
240            timeout: Duration::from_secs(timeout_secs),
241        }
242    }
243
244    /// Calculate trajectory via Flask API
245    ///
246    /// # Arguments
247    /// * `request` - Trajectory calculation request parameters
248    ///
249    /// # Returns
250    /// * `Ok(TrajectoryResponse)` - Successful calculation with trajectory data
251    /// * `Err(ApiError)` - Error during API communication
252    #[cfg(feature = "online")]
253    pub fn calculate_trajectory(
254        &self,
255        request: &TrajectoryRequest,
256    ) -> Result<TrajectoryResponse, ApiError> {
257        // Flask API uses GET /v1/calculate with query parameters (imperial units)
258        let url = format!("{}/v1/calculate", self.base_url);
259
260        // Convert metric values to imperial for API
261        let velocity_fps = request.muzzle_velocity / 0.3048; // m/s to fps
262        let mass_grains = request.bullet_mass / GRAMS_PER_GRAIN; // grams to grains
263        let distance_yards = request.target_distance / 0.9144; // meters to yards
264
265        let mut req = ureq::get(&url)
266            .config()
267            .timeout_global(Some(self.timeout))
268            .build()
269            .header("Accept", "application/json")
270            .header("User-Agent", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
271            .query("bc_value", request.bc_value.to_string())
272            .query("bc_type", &request.bc_type)
273            .query("bullet_mass", format!("{:.1}", mass_grains))
274            .query("muzzle_velocity", format!("{:.1}", velocity_fps))
275            .query("target_distance", format!("{:.1}", distance_yards));
276
277        // Add optional parameters
278        if let Some(zero_range) = request.zero_range {
279            let zero_yards = zero_range / 0.9144;
280            req = req.query("zero_distance", format!("{:.1}", zero_yards));
281        }
282        if let Some(wind_speed) = request.wind_speed {
283            let wind_mph = wind_speed * 2.23694; // m/s to mph
284            req = req.query("wind_speed", format!("{:.1}", wind_mph));
285        }
286        if let Some(wind_angle) = request.wind_angle {
287            req = req.query("wind_angle", format!("{:.1}", wind_angle));
288        }
289        if let Some(temp) = request.temperature {
290            let temp_f = temp * 9.0 / 5.0 + 32.0; // Celsius to Fahrenheit
291            req = req.query("temperature", format!("{:.1}", temp_f));
292        }
293        if let Some(pressure) = request.pressure {
294            let pressure_inhg = pressure / 33.8639; // hPa to inHg
295            req = req.query("pressure", format!("{:.2}", pressure_inhg));
296        }
297        if let Some(humidity) = request.humidity {
298            req = req.query("humidity", format!("{:.1}", humidity));
299        }
300        if let Some(altitude) = request.altitude {
301            let altitude_ft = altitude / 0.3048; // meters to feet
302            req = req.query("altitude", format!("{:.1}", altitude_ft));
303        }
304        if let Some(shooting_angle) = request.shooting_angle {
305            req = req.query("shooting_angle", format!("{:.1}", shooting_angle));
306        }
307        if let Some(latitude) = request.latitude {
308            req = req.query("latitude", format!("{:.2}", latitude));
309        }
310        if let Some(longitude) = request.longitude {
311            req = req.query("longitude", format!("{:.2}", longitude));
312        }
313        if let Some(shot_direction) = request.shot_direction {
314            req = req.query("shot_direction", format!("{:.1}", shot_direction));
315        }
316        if let Some(twist_rate) = request.twist_rate {
317            req = req.query("twist_rate", format!("{:.1}", twist_rate));
318        }
319        if let Some(diameter) = request.bullet_diameter {
320            let diameter_in = diameter / 0.0254; // meters to inches
321            req = req.query("bullet_diameter", format!("{:.3}", diameter_in));
322        }
323        if let Some(threshold) = request.ground_threshold {
324            // Ground threshold is in meters. --ignore-ground-impact sets f64::NEG_INFINITY, which
325            // format!("{:.1}") would render as the literal "-inf" (rejected by most server-side
326            // numeric validators, silently dropping the ignore-ground intent); send a large finite
327            // negative sentinel for THAT case only. Finite values pass through. Any other
328            // non-finite value (NaN or +Inf) is invalid input, not an ignore-ground request, so
329            // omit the parameter rather than silently mapping it to the sentinel.
330            if threshold.is_finite() {
331                req = req.query("ground_threshold", format!("{:.1}", threshold));
332            } else if threshold == f64::NEG_INFINITY {
333                req = req.query("ground_threshold", "-1000000000.0");
334            }
335        }
336        if let Some(enable) = request.enable_weather_zones {
337            req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
338        }
339        if let Some(enable) = request.enable_3d_weather {
340            req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
341        }
342        if let Some(ref model) = request.wind_shear_model {
343            req = req.query("wind_shear_model", model);
344        }
345        if let Some(ref method) = request.weather_zone_interpolation {
346            req = req.query("weather_zone_interpolation", method);
347        }
348        if let Some(sample_interval) = request.sample_interval {
349            // Convert from meters to yards for the Flask API (trajectory_step is in yards)
350            let step_yards = sample_interval / 0.9144;
351            req = req.query("trajectory_step", format!("{:.4}", step_yards));
352        }
353
354        let mut response = req.call().map_err(map_ureq_error)?;
355
356        // In ureq 3.x, 4xx/5xx responses are no longer returned as Err by default;
357        // .call() yields Ok(response) for any status. Inspect the status ourselves and
358        // map non-2xx to ServerError(code, body) to preserve the previous behavior.
359        let status = response.status();
360        if !status.is_success() {
361            let body = response.body_mut().read_to_string().unwrap_or_default();
362            return Err(ApiError::ServerError(status.as_u16(), body));
363        }
364
365        let body = response
366            .body_mut()
367            .read_to_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            .config()
489            .timeout_global(Some(Duration::from_secs(5)))
490            .build()
491            .call()
492            .map_err(map_ureq_error)?;
493
494        Ok(response.status().as_u16() == 200)
495    }
496
497    /// Calculate true/effective muzzle velocity via Flask API
498    ///
499    /// # Arguments
500    /// * `request` - Velocity truing request parameters
501    ///
502    /// # Returns
503    /// * `Ok(TrueVelocityResponse)` - Successful calculation with effective velocity
504    /// * `Err(ApiError)` - Error during API communication
505    #[cfg(feature = "online")]
506    pub fn true_velocity(
507        &self,
508        request: &TrueVelocityRequest,
509    ) -> Result<TrueVelocityResponse, ApiError> {
510        let url = format!("{}/v1/true-velocity", self.base_url);
511
512        let body = serde_json::to_string(request)
513            .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
514
515        let mut response = ureq::post(&url)
516            .config()
517            .timeout_global(Some(self.timeout))
518            .build()
519            .header("Content-Type", "application/json")
520            .header("Accept", "application/json")
521            .header("User-Agent", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
522            .send(&body)
523            .map_err(map_ureq_error)?;
524
525        // In ureq 3.x, 4xx/5xx responses are returned as Ok; check status explicitly.
526        let status = response.status();
527        if !status.is_success() {
528            let body = response.body_mut().read_to_string().unwrap_or_default();
529            return Err(ApiError::ServerError(status.as_u16(), body));
530        }
531
532        let response_body = response
533            .body_mut()
534            .read_to_string()
535            .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
536
537        serde_json::from_str(&response_body)
538            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
539    }
540}
541
542/// Map a ureq 3.x transport/protocol error to an `ApiError`.
543///
544/// Note: in ureq 3.x, HTTP 4xx/5xx responses are NOT returned as errors by default
545/// (`.call()`/`.send()` yield `Ok(response)` for any status), so the status is checked
546/// on the returned response rather than here. This helper only sees genuine
547/// transport-level failures.
548#[cfg(feature = "online")]
549fn map_ureq_error(e: ureq::Error) -> ApiError {
550    match e {
551        // Any configured timeout (global/connect/etc.) maps to Timeout.
552        ureq::Error::Timeout(_) => ApiError::Timeout,
553        // Socket/IO errors: distinguish timeouts surfaced as IO errors, otherwise network.
554        ureq::Error::Io(io_err) => {
555            if io_err.kind() == std::io::ErrorKind::TimedOut {
556                ApiError::Timeout
557            } else {
558                ApiError::NetworkError(io_err.to_string())
559            }
560        }
561        // Other transport-level failures (DNS, connect, proxy, redirects, TLS, etc.).
562        other => ApiError::NetworkError(other.to_string()),
563    }
564}
565
566/// Builder for TrajectoryRequest
567#[derive(Default)]
568pub struct TrajectoryRequestBuilder {
569    bc_value: Option<f64>,
570    bc_type: Option<String>,
571    bullet_mass: Option<f64>,
572    muzzle_velocity: Option<f64>,
573    target_distance: Option<f64>,
574    zero_range: Option<f64>,
575    wind_speed: Option<f64>,
576    wind_angle: Option<f64>,
577    temperature: Option<f64>,
578    pressure: Option<f64>,
579    humidity: Option<f64>,
580    altitude: Option<f64>,
581    latitude: Option<f64>,
582    longitude: Option<f64>,
583    shot_direction: Option<f64>,
584    shooting_angle: Option<f64>,
585    twist_rate: Option<f64>,
586    bullet_diameter: Option<f64>,
587    bullet_length: Option<f64>,
588    ground_threshold: Option<f64>,
589    enable_weather_zones: Option<bool>,
590    enable_3d_weather: Option<bool>,
591    wind_shear_model: Option<String>,
592    weather_zone_interpolation: Option<String>,
593    sample_interval: Option<f64>,
594}
595
596impl TrajectoryRequestBuilder {
597    pub fn new() -> Self {
598        Self::default()
599    }
600
601    pub fn bc_value(mut self, value: f64) -> Self {
602        self.bc_value = Some(value);
603        self
604    }
605
606    pub fn bc_type(mut self, value: &str) -> Self {
607        self.bc_type = Some(value.to_string());
608        self
609    }
610
611    pub fn bullet_mass(mut self, value: f64) -> Self {
612        self.bullet_mass = Some(value);
613        self
614    }
615
616    pub fn muzzle_velocity(mut self, value: f64) -> Self {
617        self.muzzle_velocity = Some(value);
618        self
619    }
620
621    pub fn target_distance(mut self, value: f64) -> Self {
622        self.target_distance = Some(value);
623        self
624    }
625
626    pub fn zero_range(mut self, value: f64) -> Self {
627        self.zero_range = Some(value);
628        self
629    }
630
631    pub fn wind_speed(mut self, value: f64) -> Self {
632        self.wind_speed = Some(value);
633        self
634    }
635
636    pub fn wind_angle(mut self, value: f64) -> Self {
637        self.wind_angle = Some(value);
638        self
639    }
640
641    pub fn temperature(mut self, value: f64) -> Self {
642        self.temperature = Some(value);
643        self
644    }
645
646    pub fn pressure(mut self, value: f64) -> Self {
647        self.pressure = Some(value);
648        self
649    }
650
651    pub fn humidity(mut self, value: f64) -> Self {
652        self.humidity = Some(value);
653        self
654    }
655
656    pub fn altitude(mut self, value: f64) -> Self {
657        self.altitude = Some(value);
658        self
659    }
660
661    pub fn latitude(mut self, value: f64) -> Self {
662        self.latitude = Some(value);
663        self
664    }
665
666    pub fn longitude(mut self, value: f64) -> Self {
667        self.longitude = Some(value);
668        self
669    }
670
671    pub fn shot_direction(mut self, value: f64) -> Self {
672        self.shot_direction = Some(value);
673        self
674    }
675
676    pub fn shooting_angle(mut self, value: f64) -> Self {
677        self.shooting_angle = Some(value);
678        self
679    }
680
681    pub fn twist_rate(mut self, value: f64) -> Self {
682        self.twist_rate = Some(value);
683        self
684    }
685
686    pub fn bullet_diameter(mut self, value: f64) -> Self {
687        self.bullet_diameter = Some(value);
688        self
689    }
690
691    pub fn bullet_length(mut self, value: f64) -> Self {
692        self.bullet_length = Some(value);
693        self
694    }
695
696    pub fn ground_threshold(mut self, value: f64) -> Self {
697        self.ground_threshold = Some(value);
698        self
699    }
700
701    pub fn enable_weather_zones(mut self, value: bool) -> Self {
702        self.enable_weather_zones = Some(value);
703        self
704    }
705
706    pub fn enable_3d_weather(mut self, value: bool) -> Self {
707        self.enable_3d_weather = Some(value);
708        self
709    }
710
711    pub fn wind_shear_model(mut self, value: &str) -> Self {
712        self.wind_shear_model = Some(value.to_string());
713        self
714    }
715
716    pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
717        self.weather_zone_interpolation = Some(value.to_string());
718        self
719    }
720
721    pub fn sample_interval(mut self, value: f64) -> Self {
722        self.sample_interval = Some(value);
723        self
724    }
725
726    /// Build the TrajectoryRequest
727    ///
728    /// # Returns
729    /// * `Ok(TrajectoryRequest)` - Valid request
730    /// * `Err(String)` - Missing required fields
731    pub fn build(self) -> Result<TrajectoryRequest, String> {
732        let bc_value = self.bc_value.ok_or("bc_value is required")?;
733        let bc_type = self.bc_type.ok_or("bc_type is required")?;
734        let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
735        let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
736        let target_distance = self.target_distance.ok_or("target_distance is required")?;
737
738        Ok(TrajectoryRequest {
739            bc_value,
740            bc_type,
741            bullet_mass,
742            muzzle_velocity,
743            target_distance,
744            zero_range: self.zero_range,
745            wind_speed: self.wind_speed,
746            wind_angle: self.wind_angle,
747            temperature: self.temperature,
748            pressure: self.pressure,
749            humidity: self.humidity,
750            altitude: self.altitude,
751            latitude: self.latitude,
752            longitude: self.longitude,
753            shot_direction: self.shot_direction,
754            shooting_angle: self.shooting_angle,
755            twist_rate: self.twist_rate,
756            bullet_diameter: self.bullet_diameter,
757            bullet_length: self.bullet_length,
758            ground_threshold: self.ground_threshold,
759            enable_weather_zones: self.enable_weather_zones,
760            enable_3d_weather: self.enable_3d_weather,
761            wind_shear_model: self.wind_shear_model,
762            weather_zone_interpolation: self.weather_zone_interpolation,
763            sample_interval: self.sample_interval,
764        })
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771
772    #[test]
773    fn test_request_builder_required_fields() {
774        let result = TrajectoryRequestBuilder::new()
775            .bc_value(0.238)
776            .bc_type("G7")
777            .bullet_mass(9.07) // 140gr in grams
778            .muzzle_velocity(860.0)
779            .target_distance(1000.0)
780            .build();
781
782        assert!(result.is_ok());
783        let request = result.unwrap();
784        assert_eq!(request.bc_value, 0.238);
785        assert_eq!(request.bc_type, "G7");
786    }
787
788    #[test]
789    fn test_request_builder_missing_fields() {
790        let result = TrajectoryRequestBuilder::new()
791            .bc_value(0.238)
792            .build();
793
794        assert!(result.is_err());
795    }
796
797    #[test]
798    fn test_request_builder_all_optional_fields() {
799        let result = TrajectoryRequestBuilder::new()
800            .bc_value(0.238)
801            .bc_type("G7")
802            .bullet_mass(9.07)
803            .muzzle_velocity(860.0)
804            .target_distance(1000.0)
805            .zero_range(100.0)
806            .wind_speed(5.0)
807            .wind_angle(90.0)
808            .temperature(15.0)
809            .pressure(1013.25)
810            .humidity(50.0)
811            .altitude(500.0)
812            .latitude(45.0)
813            .shooting_angle(0.0)
814            .twist_rate(10.0)
815            .bullet_diameter(0.00671)
816            .bullet_length(0.035)
817            .build();
818
819        assert!(result.is_ok());
820        let request = result.unwrap();
821        assert_eq!(request.zero_range, Some(100.0));
822        assert_eq!(request.wind_speed, Some(5.0));
823        assert_eq!(request.latitude, Some(45.0));
824    }
825
826    #[test]
827    fn test_api_client_url_normalization() {
828        let client1 = ApiClient::new("https://api.example.com/", 10);
829        assert_eq!(client1.base_url, "https://api.example.com");
830
831        let client2 = ApiClient::new("https://api.example.com", 10);
832        assert_eq!(client2.base_url, "https://api.example.com");
833    }
834
835    #[test]
836    fn test_api_error_display() {
837        assert_eq!(
838            format!("{}", ApiError::NetworkError("connection refused".to_string())),
839            "Network error: connection refused"
840        );
841        assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
842        assert_eq!(
843            format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
844            "Server error 500: Internal error"
845        );
846    }
847}