| //! Score calculator for Vehicle Routing Problem. | |
| //! | |
| //! # Constraints | |
| //! | |
| //! - **Vehicle capacity** (hard): Total demand must not exceed vehicle capacity | |
| //! - **Time windows** (hard): Service must complete before max end time | |
| //! - **Minimize travel time** (soft): Reduce total driving time | |
| //! | |
| //! # Design | |
| //! | |
| //! Uses a simple score calculator function with full solution access. | |
| //! No global state or RwLock overhead - direct array indexing into the plan's | |
| //! travel time matrix and visits. | |
| use solverforge::prelude::*; | |
| use crate::domain::{Vehicle, VehicleRoutePlan}; | |
| /// Calculates the score for a vehicle routing solution. | |
| /// | |
| /// # Hard constraints | |
| /// - Vehicle capacity: penalize excess demand | |
| /// - Time windows: penalize late arrivals | |
| /// | |
| /// # Soft constraints | |
| /// - Minimize total travel time (in minutes) | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::constraints::calculate_score; | |
| /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan}; | |
| /// use solverforge::prelude::Score; // For is_feasible() | |
| /// | |
| /// let depot = Location::new(0, 0.0, 0.0); | |
| /// let locations = vec![depot.clone()]; | |
| /// let visits = vec![Visit::new(0, "A", depot.clone()).with_demand(5)]; | |
| /// let mut vehicle = Vehicle::new(0, "V1", 10, depot); | |
| /// vehicle.visits = vec![0]; | |
| /// | |
| /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]); | |
| /// plan.finalize(); | |
| /// | |
| /// let score = calculate_score(&plan); | |
| /// assert!(score.is_feasible()); // Demand 5 <= capacity 10 | |
| /// ``` | |
| pub fn calculate_score(plan: &VehicleRoutePlan) -> HardSoftScore { | |
| let mut hard = 0i64; | |
| let mut soft = 0i64; | |
| for vehicle in &plan.vehicles { | |
| // ===================================================================== | |
| // HARD: Vehicle Capacity | |
| // ===================================================================== | |
| let total_demand: i32 = vehicle | |
| .visits | |
| .iter() | |
| .filter_map(|&idx| plan.visits.get(idx)) | |
| .map(|v| v.demand) | |
| .sum(); | |
| if total_demand > vehicle.capacity { | |
| hard -= (total_demand - vehicle.capacity) as i64; | |
| } | |
| // ===================================================================== | |
| // HARD: Time Windows | |
| // ===================================================================== | |
| let late_minutes = calculate_late_minutes_for_vehicle(plan, vehicle); | |
| if late_minutes > 0 { | |
| hard -= late_minutes; | |
| } | |
| // ===================================================================== | |
| // SOFT: Minimize Travel Time | |
| // ===================================================================== | |
| let driving_seconds = plan.total_driving_time(vehicle); | |
| soft -= driving_seconds / 60; // Convert to minutes | |
| } | |
| HardSoftScore::of(hard, soft) | |
| } | |
| /// Calculates total late minutes for a vehicle's route. | |
| /// | |
| /// A visit is late if service finishes after `max_end_time`. | |
| fn calculate_late_minutes_for_vehicle(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i64 { | |
| if vehicle.visits.is_empty() { | |
| return 0; | |
| } | |
| let mut total_late = 0i64; | |
| let mut current_time = vehicle.departure_time; | |
| let mut current_loc_idx = vehicle.home_location.index; | |
| for &visit_idx in &vehicle.visits { | |
| let Some(visit) = plan.visits.get(visit_idx) else { | |
| continue; | |
| }; | |
| // Travel to this visit | |
| let travel = plan.travel_time(current_loc_idx, 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 service_end = service_start + visit.service_duration; | |
| // Check if late (service finishes after max_end_time) | |
| if service_end > visit.max_end_time { | |
| let late_seconds = service_end - visit.max_end_time; | |
| // Round up to minutes | |
| total_late += (late_seconds + 59) / 60; | |
| } | |
| current_time = service_end; | |
| current_loc_idx = visit.location.index; | |
| } | |
| total_late | |
| } | |
| // ============================================================================ | |
| // Helper functions (for analyze endpoint) | |
| // ============================================================================ | |
| /// Calculates total late minutes for a vehicle's route (public API). | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::constraints::calculate_late_minutes; | |
| /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan}; | |
| /// | |
| /// let depot = Location::new(0, 0.0, 0.0); | |
| /// let customer = Location::new(1, 0.0, 1.0); // ~111 km away, ~2.2 hours at 50 km/h | |
| /// | |
| /// let locations = vec![depot.clone(), customer.clone()]; | |
| /// let visits = vec![ | |
| /// Visit::new(0, "A", customer) | |
| /// .with_time_window(0, 8 * 3600 + 30 * 60) // Must finish by 8:30am | |
| /// .with_service_duration(300), // 5 min service | |
| /// ]; | |
| /// let mut vehicle = Vehicle::new(0, "V1", 100, depot); | |
| /// vehicle.departure_time = 8 * 3600; // Depart at 8am | |
| /// vehicle.visits = vec![0]; | |
| /// | |
| /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle.clone()]); | |
| /// plan.finalize(); | |
| /// | |
| /// // Vehicle departs 8am, travels ~2.2 hours, arrives ~10:13am | |
| /// // Service ends ~10:18am, but max_end is 8:30am | |
| /// // Late by ~108 minutes | |
| /// let late = calculate_late_minutes(&plan, &vehicle); | |
| /// assert!(late > 100); | |
| /// ``` | |
| pub fn calculate_late_minutes(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i64 { | |
| calculate_late_minutes_for_vehicle(plan, vehicle) | |
| } | |
| /// Calculates excess demand for a vehicle (0 if under capacity). | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use vehicle_routing::constraints::calculate_excess_capacity; | |
| /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan}; | |
| /// | |
| /// let depot = Location::new(0, 0.0, 0.0); | |
| /// let locations = vec![depot.clone()]; | |
| /// let visits = vec![ | |
| /// Visit::new(0, "A", depot.clone()).with_demand(60), | |
| /// Visit::new(1, "B", depot.clone()).with_demand(50), | |
| /// ]; | |
| /// let mut vehicle = Vehicle::new(0, "V1", 100, depot); | |
| /// vehicle.visits = vec![0, 1]; // Total demand = 110 | |
| /// | |
| /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle.clone()]); | |
| /// plan.finalize(); | |
| /// | |
| /// // Excess = 110 - 100 = 10 | |
| /// assert_eq!(calculate_excess_capacity(&plan, &vehicle), 10); | |
| /// ``` | |
| pub fn calculate_excess_capacity(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i32 { | |
| let total_demand: i32 = vehicle | |
| .visits | |
| .iter() | |
| .filter_map(|&idx| plan.visits.get(idx)) | |
| .map(|v| v.demand) | |
| .sum(); | |
| (total_demand - vehicle.capacity).max(0) | |
| } | |