drone-vrp 1.0.0

Drone Vehicle Routing Problem solver with physics-based energy models for UAV delivery — supports DJI FlyCart 30, Ukrainian drones (R18, Vampire, PD-2, HeavyShot, Kazhan, Nemesis), and DJI Matrice 350 RTK
Documentation
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use drone_vrp::{DroneSpec, DroneVrpInstance, DroneSolver};

#[derive(Parser)]
#[command(name = "drone-vrp")]
#[command(about = "Drone VRP Solver and Energy Calculator — 9 drone models including Ukrainian combat-proven platforms", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Solve a multi-trip Drone VRP
    Solve {
        /// Drone model (FlyCart30, Wing, R18, PD2, Vampire, HeavyShot, Kazhan, Nemesis, Matrice350RTK)
        #[arg(long, default_value = "FlyCart30")]
        drone: String,

        /// Path to GeoJSON file with customers (point features with 'demand' property)
        #[arg(long)]
        customers: String,

        /// Depot latitude
        #[arg(long)]
        depot_lat: f64,

        /// Depot longitude
        #[arg(long)]
        depot_lon: f64,

        /// Wind speed in m/s (headwind reduces ground speed)
        #[arg(long, default_value_t = 0.0)]
        wind: f64,

        /// Output path for the solution JSON
        #[arg(short, long)]
        output: Option<String>,
    },

    /// Calculate energy for a single flight leg
    Energy {
        /// Drone model (see `specs` for full list)
        #[arg(long, default_value = "FlyCart30")]
        drone: String,

        /// Distance in meters
        #[arg(short, long)]
        distance: f64,

        /// Payload in kg
        #[arg(short, long, default_value_t = 0.0)]
        payload: f64,

        /// Wind speed in m/s (positive = headwind)
        #[arg(short, long, default_value_t = 0.0)]
        wind: f64,
    },

    /// Estimate maximum one-way range at given payload and wind
    Range {
        /// Drone model
        #[arg(long, default_value = "FlyCart30")]
        drone: String,

        /// Payload in kg
        #[arg(short, long, default_value_t = 0.0)]
        payload: f64,

        /// Wind speed in m/s
        #[arg(short, long, default_value_t = 0.0)]
        wind: f64,
    },

    /// Display drone specifications
    Specs {
        /// Drone model (omit to show all 9)
        #[arg(long)]
        drone: Option<String>,
    },
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Solve {
            drone,
            customers,
            depot_lat,
            depot_lon,
            wind,
            output,
        } => {
            let spec = DroneSpec::from_name(&drone)
                .map_err(|e| anyhow::anyhow!("{}", e))?;
            let model = spec.model.clone();

            let geojson_str =
                fs::read_to_string(&customers).context("Failed to read customers file")?;
            let geojson = geojson_str.parse::<geojson::GeoJson>()?;

            let mut customer_coords = Vec::new();
            let mut demands = Vec::new();

            if let geojson::GeoJson::FeatureCollection(collection) = geojson {
                for feature in collection.features {
                    if let Some(ref geometry) = feature.geometry {
                        if let geojson::Value::Point(ref coords) = geometry.value {
                            customer_coords.push([coords[1], coords[0]]); // lat, lon
                            let demand = feature
                                .property("demand")
                                .and_then(|v| v.as_f64())
                                .unwrap_or(1.0);
                            demands.push(demand);
                        }
                    }
                }
            }

            let instance = DroneVrpInstance {
                drone_model: model.clone(),
                depot: [depot_lat, depot_lon],
                customers: customer_coords,
                demands_kg: demands,
                wind_speed_ms: wind,
                no_fly_zones: vec![],
            };

            println!(
                "Solving VRP for {} customers using {:?} ({})...",
                instance.customers.len(),
                model,
                spec.manufacturer,
            );
            let result = DroneSolver::solve(&instance)?;

            println!("Solution found:");
            println!("  Routes: {}", result.routes.len());
            println!("  Energy used: {:.2} Wh", result.energy_used_wh);
            if !result.violations.is_empty() {
                println!("  Violations: {:?}", result.violations);
            }

            if let Some(out_path) = output {
                let json = serde_json::to_string_pretty(&result)?;
                fs::write(out_path, json)?;
                println!("Result saved.");
            }
        }
        Commands::Energy {
            drone,
            distance,
            payload,
            wind,
        } => {
            let spec = DroneSpec::from_name(&drone)
                .map_err(|e| anyhow::anyhow!("{}", e))?;
            let energy = spec.calculate_energy_wh(distance, payload, wind);
            println!("Energy consumption: {:.4} Wh", energy);

            let pct_battery = (energy / spec.battery_capacity_wh) * 100.0;
            println!("Battery used: {:.1}% of {} Wh", pct_battery, spec.battery_capacity_wh);
        }
        Commands::Range {
            drone,
            payload,
            wind,
        } => {
            let spec = DroneSpec::from_name(&drone)
                .map_err(|e| anyhow::anyhow!("{}", e))?;
            let range_m = spec.estimate_max_range_m(payload, wind);
            println!("{:?} ({}) range estimate:", spec.model, spec.manufacturer);
            println!("  Payload: {:.1} kg / {:.1} kg max", payload, spec.max_payload_kg);
            println!("  Wind: {:.1} m/s headwind", wind);
            println!("  Max one-way range: {:.1} km (with 20% reserve)", range_m / 1000.0);
            println!("  Max round-trip radius: {:.1} km", range_m / 2000.0);
        }
        Commands::Specs { drone } => {
            let models = if let Some(d) = drone {
                vec![DroneSpec::from_name(&d)
                    .map_err(|e| anyhow::anyhow!("{}", e))?]
            } else {
                DroneSpec::all()
            };

            for s in &models {
                println!("┌─────────────────────────────────────────────────────┐");
                println!("{:?} ({})", s.model, s.manufacturer);
                println!("{}", s.description);
                println!("│ Country: {}  |  Propulsion: {:?}", s.country, s.propulsion);
                println!("├─────────────────────────────────────────────────────┤");
                println!("│ Airframe mass:   {:.1} kg", s.mass_kg);
                println!("│ Max payload:     {:.1} kg", s.max_payload_kg);
                println!("│ Battery/Energy:  {:.0} Wh", s.battery_capacity_wh);
                println!("│ Cruise speed:    {:.1} m/s ({:.0} km/h)", s.cruise_speed_ms, s.cruise_speed_ms * 3.6);
                println!("│ Power (empty):   {:.0} W", s.power_no_load_w);
                println!("│ Power (loaded):  {:.0} W", s.power_max_load_w);

                let range_empty = s.estimate_max_range_m(0.0, 0.0) / 1000.0;
                let range_loaded = s.estimate_max_range_m(s.max_payload_kg, 0.0) / 1000.0;
                println!("│ Range (empty):   {:.1} km one-way (80% reserve)", range_empty);
                println!("│ Range (loaded):  {:.1} km one-way (80% reserve)", range_loaded);
                println!("└─────────────────────────────────────────────────────┘");
                println!();
            }

            if models.len() > 1 {
                println!("Aliases: fc30=FlyCart30, aerorozvidka=R18, baba_yaga=Vampire,");
                println!("         skyfall=Vampire, gurzuf=HeavyShot, reactive_drone=Kazhan,");
                println!("         uforce=Nemesis, m350=Matrice350RTK, ukrspecsystems=PD2");
            }
        }
    }

    Ok(())
}