| //! Domain model for Vehicle Routing Problem. | |
| //! | |
| //! # Overview | |
| //! | |
| //! Models a vehicle routing problem with: | |
| //! - Geographic [`Location`]s with haversine distance calculation | |
| //! - Customer [`Visit`]s with time windows, demand, and service duration | |
| //! - [`Vehicle`]s with capacity constraints and routes | |
| //! - [`VehicleRoutePlan`] as the complete planning solution | |
| //! | |
| //! # Design | |
| //! | |
| //! All scoring uses direct access to the plan's travel time matrix. | |
| //! No global state or RwLock overhead. | |
| use serde::{Deserialize, Serialize}; | |
| use solverforge::prelude::*; | |
| use std::collections::HashMap; | |
| /// Average driving speed in km/h for travel time estimation. | |
| pub const AVERAGE_SPEED_KMPH: f64 = 50.0; | |
| /// Earth radius in meters for haversine calculation. | |
| const EARTH_RADIUS_M: f64 = 6_371_000.0; | |
| /// A geographic location with latitude and longitude. | |
| /// | |
| /// Supports haversine distance calculation for travel time estimation. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::domain::Location; | |
| /// | |
| /// let philadelphia = Location::new(0, 39.9526, -75.1652); | |
| /// let new_york = Location::new(1, 40.7128, -74.0060); | |
| /// | |
| /// // Distance is approximately 130 km | |
| /// let distance = philadelphia.distance_meters(&new_york); | |
| /// assert!(distance > 120_000.0 && distance < 140_000.0); | |
| /// | |
| /// // Travel time at 50 km/h is approximately 2.6 hours | |
| /// let travel_secs = philadelphia.travel_time_seconds(&new_york); | |
| /// assert!(travel_secs > 8000 && travel_secs < 10000); | |
| /// ``` | |
| pub struct Location { | |
| /// Index in `VehicleRoutePlan.locations`. | |
| pub index: usize, | |
| /// Latitude in degrees (-90 to 90). | |
| pub latitude: f64, | |
| /// Longitude in degrees (-180 to 180). | |
| pub longitude: f64, | |
| } | |
| impl PartialEq for Location { | |
| fn eq(&self, other: &Self) -> bool { | |
| self.index == other.index | |
| } | |
| } | |
| impl Eq for Location {} | |
| impl std::hash::Hash for Location { | |
| fn hash<H: std::hash::Hasher>(&self, state: &mut H) { | |
| self.index.hash(state); | |
| } | |
| } | |
| impl Location { | |
| /// Creates a new location. | |
| pub fn new(index: usize, latitude: f64, longitude: f64) -> Self { | |
| Self { | |
| index, | |
| latitude, | |
| longitude, | |
| } | |
| } | |
| /// Calculates the great-circle distance in meters using the haversine formula. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::domain::Location; | |
| /// | |
| /// let a = Location::new(0, 0.0, 0.0); | |
| /// let b = Location::new(1, 0.0, 1.0); | |
| /// | |
| /// // 1 degree of longitude at equator is about 111 km | |
| /// let dist = a.distance_meters(&b); | |
| /// assert!(dist > 110_000.0 && dist < 112_000.0); | |
| /// ``` | |
| pub fn distance_meters(&self, other: &Location) -> f64 { | |
| if self.latitude == other.latitude && self.longitude == other.longitude { | |
| return 0.0; | |
| } | |
| let lat1 = self.latitude.to_radians(); | |
| let lat2 = other.latitude.to_radians(); | |
| let lon1 = self.longitude.to_radians(); | |
| let lon2 = other.longitude.to_radians(); | |
| // Haversine formula | |
| let dlat = lat2 - lat1; | |
| let dlon = lon2 - lon1; | |
| let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2); | |
| let c = 2.0 * a.sqrt().asin(); | |
| EARTH_RADIUS_M * c | |
| } | |
| /// Calculates travel time in seconds assuming average driving speed. | |
| /// | |
| /// Uses [`AVERAGE_SPEED_KMPH`] (50 km/h) for conversion. | |
| pub fn travel_time_seconds(&self, other: &Location) -> i64 { | |
| let meters = self.distance_meters(other); | |
| // seconds = meters / (km/h * 1000 / 3600) = meters * 3.6 / km/h | |
| (meters * 3.6 / AVERAGE_SPEED_KMPH).round() as i64 | |
| } | |
| } | |
| /// A customer visit with time window and demand constraints. | |
| /// | |
| /// # Time Window | |
| /// | |
| /// - `min_start_time`: Earliest time service can begin (vehicle may wait) | |
| /// - `max_end_time`: Latest time service must finish (hard constraint) | |
| /// - `service_duration`: Time required to complete the visit | |
| /// | |
| /// All times are in seconds from midnight. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::domain::{Visit, Location}; | |
| /// | |
| /// let location = Location::new(0, 39.95, -75.17); | |
| /// | |
| /// // A restaurant delivery: 6am-10am window, 5-minute service | |
| /// let visit = Visit::new(0, "Restaurant A", location) | |
| /// .with_demand(8) | |
| /// .with_time_window(6 * 3600, 10 * 3600) | |
| /// .with_service_duration(300); | |
| /// | |
| /// assert_eq!(visit.demand, 8); | |
| /// assert_eq!(visit.min_start_time, 21600); // 6 * 3600 | |
| /// ``` | |
| pub struct Visit { | |
| /// Index in `VehicleRoutePlan.visits`. | |
| pub index: usize, | |
| /// Customer name. | |
| pub name: String, | |
| /// The geographic location of this visit. | |
| pub location: Location, | |
| /// Quantity demanded (must fit in vehicle capacity). | |
| pub demand: i32, | |
| /// Earliest service start time (seconds from midnight). | |
| pub min_start_time: i64, | |
| /// Latest service end time (seconds from midnight). | |
| pub max_end_time: i64, | |
| /// Service duration in seconds. | |
| pub service_duration: i64, | |
| } | |
| impl Visit { | |
| /// Creates a new visit with default time window (all day). | |
| pub fn new(index: usize, name: impl Into<String>, location: Location) -> Self { | |
| Self { | |
| index, | |
| name: name.into(), | |
| location, | |
| demand: 1, | |
| min_start_time: 0, | |
| max_end_time: 24 * 3600, | |
| service_duration: 0, | |
| } | |
| } | |
| /// Sets the demand. | |
| pub fn with_demand(mut self, demand: i32) -> Self { | |
| self.demand = demand; | |
| self | |
| } | |
| /// Sets the time window (min_start_time, max_end_time) in seconds from midnight. | |
| pub fn with_time_window(mut self, min_start: i64, max_end: i64) -> Self { | |
| self.min_start_time = min_start; | |
| self.max_end_time = max_end; | |
| self | |
| } | |
| /// Sets the service duration in seconds. | |
| pub fn with_service_duration(mut self, duration: i64) -> Self { | |
| self.service_duration = duration; | |
| self | |
| } | |
| } | |
| /// A delivery vehicle with capacity and assigned route. | |
| /// | |
| /// The route is stored as a list of visit indices in order. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::domain::{Vehicle, Location}; | |
| /// | |
| /// let depot = Location::new(0, 39.95, -75.17); | |
| /// let vehicle = Vehicle::new(0, "Truck 1", 100, depot) | |
| /// .with_departure_time(8 * 3600); // Departs at 8am | |
| /// | |
| /// assert_eq!(vehicle.capacity, 100); | |
| /// assert!(vehicle.visits.is_empty()); | |
| /// ``` | |
| pub struct Vehicle { | |
| /// Unique vehicle ID. | |
| pub id: usize, | |
| /// Vehicle name for display. | |
| pub name: String, | |
| /// Maximum capacity (sum of visit demands must not exceed). | |
| pub capacity: i32, | |
| /// Home depot location. | |
| pub home_location: Location, | |
| /// Departure time from depot (seconds from midnight). | |
| pub departure_time: i64, | |
| /// Ordered list of visit indices (the route). | |
| pub visits: Vec<usize>, | |
| } | |
| impl Vehicle { | |
| /// Creates a new vehicle with empty route. | |
| pub fn new(id: usize, name: impl Into<String>, capacity: i32, home_location: Location) -> Self { | |
| Self { | |
| id, | |
| name: name.into(), | |
| capacity, | |
| home_location, | |
| departure_time: 8 * 3600, // Default 8am | |
| visits: Vec::new(), | |
| } | |
| } | |
| /// Sets the departure time in seconds from midnight. | |
| pub fn with_departure_time(mut self, time: i64) -> Self { | |
| self.departure_time = time; | |
| self | |
| } | |
| } | |
| /// Arrival and departure times for a visit in a route. | |
| pub struct VisitTiming { | |
| /// Visit index. | |
| pub visit_idx: usize, | |
| /// Arrival time at the visit (seconds from midnight). | |
| pub arrival: i64, | |
| /// Departure time from the visit (seconds from midnight). | |
| pub departure: i64, | |
| } | |
| /// The complete vehicle routing solution. | |
| /// | |
| /// Contains all problem facts (locations, visits) and planning entities (vehicles). | |
| /// Call `finalize()` after construction to populate the travel time matrix. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan}; | |
| /// | |
| /// let depot = Location::new(0, 39.95, -75.17); // Philadelphia | |
| /// let customer_loc = Location::new(1, 40.00, -75.10); | |
| /// | |
| /// let locations = vec![depot.clone(), customer_loc.clone()]; | |
| /// let visits = vec![ | |
| /// Visit::new(0, "Customer 1", customer_loc).with_demand(5), | |
| /// ]; | |
| /// let vehicles = vec![ | |
| /// Vehicle::new(0, "Truck 1", 100, depot), | |
| /// ]; | |
| /// | |
| /// let mut plan = VehicleRoutePlan::new("Philadelphia", locations, visits, vehicles); | |
| /// plan.finalize(); | |
| /// | |
| /// // Travel time matrix is now populated | |
| /// assert!(plan.travel_time(0, 1) > 0); | |
| /// ``` | |
| pub struct VehicleRoutePlan { | |
| /// Problem name. | |
| pub name: String, | |
| /// South-west corner of bounding box (for map display). | |
| pub south_west_corner: [f64; 2], | |
| /// North-east corner of bounding box (for map display). | |
| pub north_east_corner: [f64; 2], | |
| /// All locations (depot and customer locations). | |
| pub locations: Vec<Location>, | |
| /// All customer visits. | |
| pub visits: Vec<Visit>, | |
| /// All vehicles. | |
| pub vehicles: Vec<Vehicle>, | |
| /// Current score. | |
| pub score: Option<HardSoftScore>, | |
| /// Solver status for REST API. | |
| pub solver_status: Option<String>, | |
| /// Precomputed travel times: `travel_time_matrix[from][to]` in seconds. | |
| pub travel_time_matrix: Vec<Vec<i64>>, | |
| /// Route geometries: `(from_loc, to_loc)` -> list of (lat, lng) waypoints. | |
| pub route_geometries: HashMap<(usize, usize), Vec<(f64, f64)>>, | |
| } | |
| impl VehicleRoutePlan { | |
| /// Creates a new vehicle route plan. | |
| pub fn new( | |
| name: impl Into<String>, | |
| locations: Vec<Location>, | |
| visits: Vec<Visit>, | |
| vehicles: Vec<Vehicle>, | |
| ) -> Self { | |
| // Compute bounding box from locations | |
| let (sw, ne) = Self::compute_bounds(&locations); | |
| Self { | |
| name: name.into(), | |
| south_west_corner: sw, | |
| north_east_corner: ne, | |
| locations, | |
| visits, | |
| vehicles, | |
| score: None, | |
| solver_status: None, | |
| travel_time_matrix: Vec::new(), | |
| route_geometries: HashMap::new(), | |
| } | |
| } | |
| /// Computes bounding box from locations. | |
| fn compute_bounds(locations: &[Location]) -> ([f64; 2], [f64; 2]) { | |
| if locations.is_empty() { | |
| return ([0.0, 0.0], [0.0, 0.0]); | |
| } | |
| let mut min_lat = f64::MAX; | |
| let mut max_lat = f64::MIN; | |
| let mut min_lon = f64::MAX; | |
| let mut max_lon = f64::MIN; | |
| for loc in locations { | |
| min_lat = min_lat.min(loc.latitude); | |
| max_lat = max_lat.max(loc.latitude); | |
| min_lon = min_lon.min(loc.longitude); | |
| max_lon = max_lon.max(loc.longitude); | |
| } | |
| // No padding here - init_routing() adds expansion | |
| ([min_lat, min_lon], [max_lat, max_lon]) | |
| } | |
| /// Populates travel time matrix using haversine distances. | |
| /// | |
| /// Must be called after construction and before solving. | |
| /// For real road routing, use `init_routing()` instead. | |
| pub fn finalize(&mut self) { | |
| let n = self.locations.len(); | |
| self.travel_time_matrix = vec![vec![0; n]; n]; | |
| for i in 0..n { | |
| for j in 0..n { | |
| if i != j { | |
| self.travel_time_matrix[i][j] = | |
| self.locations[i].travel_time_seconds(&self.locations[j]); | |
| } | |
| } | |
| } | |
| } | |
| /// Initializes with real road routing from OSM data. | |
| /// | |
| /// Downloads road network via Overpass API (cached), builds graph, | |
| /// and computes travel times using Dijkstra shortest paths. | |
| /// Also stores route geometries for visualization. | |
| pub async fn init_routing(&mut self) -> Result<(), crate::routing::RoutingError> { | |
| use crate::routing::{BoundingBox, RoadNetwork}; | |
| // Build bounding box from plan bounds (with expansion) | |
| let bbox = BoundingBox::new( | |
| self.south_west_corner[0], | |
| self.south_west_corner[1], | |
| self.north_east_corner[0], | |
| self.north_east_corner[1], | |
| ) | |
| .expand(0.05); // 5% expansion to catch nearby roads | |
| // Load or fetch road network | |
| let network = RoadNetwork::load_or_fetch(&bbox).await?; | |
| // Extract coordinates | |
| let coords: Vec<(f64, f64)> = self | |
| .locations | |
| .iter() | |
| .map(|l| (l.latitude, l.longitude)) | |
| .collect(); | |
| // Compute travel time matrix | |
| self.travel_time_matrix = network.compute_matrix(&coords); | |
| // Compute route geometries for visualization | |
| self.route_geometries = network.compute_all_geometries(&coords); | |
| Ok(()) | |
| } | |
| /// Returns the bounding box for this plan. | |
| pub fn bounding_box(&self) -> crate::routing::BoundingBox { | |
| crate::routing::BoundingBox::new( | |
| self.south_west_corner[0], | |
| self.south_west_corner[1], | |
| self.north_east_corner[0], | |
| self.north_east_corner[1], | |
| ) | |
| } | |
| /// Gets travel time between two locations in seconds. | |
| /// | |
| /// Returns 0 if indices are out of bounds or matrix not initialized. | |
| pub fn travel_time(&self, from_idx: usize, to_idx: usize) -> i64 { | |
| self.travel_time_matrix | |
| .get(from_idx) | |
| .and_then(|row| row.get(to_idx)) | |
| .copied() | |
| .unwrap_or(0) | |
| } | |
| /// Gets route geometry between two locations. | |
| /// | |
| /// Returns the waypoints if real road routing was initialized, | |
| /// or `None` if using haversine fallback. | |
| pub fn route_geometry(&self, from_idx: usize, to_idx: usize) -> Option<&[(f64, f64)]> { | |
| self.route_geometries.get(&(from_idx, to_idx)).map(|v| v.as_slice()) | |
| } | |
| /// Gets a location by index. | |
| pub fn get_location(&self, idx: usize) -> Option<&Location> { | |
| self.locations.get(idx) | |
| } | |
| /// Gets a visit by index. | |
| pub fn get_visit(&self, idx: usize) -> Option<&Visit> { | |
| self.visits.get(idx) | |
| } | |
| /// Calculates arrival and departure times for each visit in a vehicle's route. | |
| /// | |
| /// Returns a vector of [`VisitTiming`] in route order. | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan}; | |
| /// | |
| /// let depot = Location::new(0, 0.0, 0.0); | |
| /// let customer_loc = Location::new(1, 0.0, 0.01); // ~1.1 km away | |
| /// | |
| /// let locations = vec![depot.clone(), customer_loc.clone()]; | |
| /// let visits = vec![ | |
| /// Visit::new(0, "A", customer_loc) | |
| /// .with_service_duration(300) | |
| /// .with_time_window(0, 86400), | |
| /// ]; | |
| /// let mut vehicle = Vehicle::new(0, "V1", 100, depot); | |
| /// vehicle.departure_time = 8 * 3600; // 8am | |
| /// vehicle.visits = vec![0]; | |
| /// | |
| /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]); | |
| /// plan.finalize(); | |
| /// | |
| /// let timings = plan.calculate_route_times(&plan.vehicles[0]); | |
| /// assert_eq!(timings.len(), 1); | |
| /// assert!(timings[0].arrival > 8 * 3600); // Arrives after departure | |
| /// assert_eq!(timings[0].departure, timings[0].arrival + 300); // Service takes 5 min | |
| /// ``` | |
| pub fn calculate_route_times(&self, vehicle: &Vehicle) -> Vec<VisitTiming> { | |
| let mut timings = Vec::with_capacity(vehicle.visits.len()); | |
| let mut current_time = vehicle.departure_time; | |
| let mut current_loc = vehicle.home_location.index; | |
| for &visit_idx in &vehicle.visits { | |
| let Some(visit) = self.visits.get(visit_idx) else { | |
| continue; | |
| }; | |
| // Travel to this visit | |
| let travel = self.travel_time(current_loc, visit.location.index); | |
| let arrival = current_time + travel; | |
| // Service starts at max(arrival, min_start_time) | |
| let service_start = arrival.max(visit.min_start_time); | |
| let departure = service_start + visit.service_duration; | |
| timings.push(VisitTiming { | |
| visit_idx, | |
| arrival, | |
| departure, | |
| }); | |
| current_time = departure; | |
| current_loc = visit.location.index; | |
| } | |
| timings | |
| } | |
| /// Calculates total driving time for a vehicle's route in seconds. | |
| /// | |
| /// Includes travel from depot, between visits, and back to depot. | |
| pub fn total_driving_time(&self, vehicle: &Vehicle) -> i64 { | |
| if vehicle.visits.is_empty() { | |
| return 0; | |
| } | |
| let mut total = 0i64; | |
| let mut current_loc = vehicle.home_location.index; | |
| for &visit_idx in &vehicle.visits { | |
| if let Some(visit) = self.visits.get(visit_idx) { | |
| total += self.travel_time(current_loc, visit.location.index); | |
| current_loc = visit.location.index; | |
| } | |
| } | |
| // Return to depot | |
| total += self.travel_time(current_loc, vehicle.home_location.index); | |
| total | |
| } | |
| /// Calculates total driving time across all vehicles. | |
| pub fn total_driving_time_all(&self) -> i64 { | |
| self.vehicles.iter().map(|v| self.total_driving_time(v)).sum() | |
| } | |
| } | |