| |
| |
| |
| |
|
|
| use parking_lot::RwLock; |
| use rand::Rng; |
| use solverforge::prelude::*; |
| use std::collections::HashMap; |
| use std::sync::Arc; |
| use std::time::{Duration, Instant}; |
| use tokio::sync::oneshot; |
| use tracing::{debug, info}; |
|
|
| use crate::console::{self, PhaseTimer}; |
| use crate::constraints::calculate_score; |
| use crate::domain::VehicleRoutePlan; |
|
|
| |
| const DEFAULT_TIME_LIMIT_SECS: u64 = 30; |
|
|
| |
| const LATE_ACCEPTANCE_SIZE: usize = 400; |
|
|
| |
| |
| |
| #[derive(Debug, Clone, Default)] |
| pub struct SolverConfig { |
| |
| pub time_limit: Option<Duration>, |
| |
| pub unimproved_time_limit: Option<Duration>, |
| |
| pub step_limit: Option<u64>, |
| |
| pub unimproved_step_limit: Option<u64>, |
| } |
|
|
| impl SolverConfig { |
| |
| pub fn default_config() -> Self { |
| Self { |
| time_limit: Some(Duration::from_secs(DEFAULT_TIME_LIMIT_SECS)), |
| ..Default::default() |
| } |
| } |
|
|
| |
| fn should_terminate( |
| &self, |
| elapsed: Duration, |
| steps: u64, |
| time_since_improvement: Duration, |
| steps_since_improvement: u64, |
| ) -> bool { |
| if let Some(limit) = self.time_limit { |
| if elapsed >= limit { |
| return true; |
| } |
| } |
| if let Some(limit) = self.unimproved_time_limit { |
| if time_since_improvement >= limit { |
| return true; |
| } |
| } |
| if let Some(limit) = self.step_limit { |
| if steps >= limit { |
| return true; |
| } |
| } |
| if let Some(limit) = self.unimproved_step_limit { |
| if steps_since_improvement >= limit { |
| return true; |
| } |
| } |
| false |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |
| pub enum SolverStatus { |
| |
| NotSolving, |
| |
| Solving, |
| } |
|
|
| impl SolverStatus { |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn as_str(self) -> &'static str { |
| match self { |
| SolverStatus::NotSolving => "NOT_SOLVING", |
| SolverStatus::Solving => "SOLVING", |
| } |
| } |
| } |
|
|
| |
| pub struct SolveJob { |
| |
| pub id: String, |
| |
| pub status: SolverStatus, |
| |
| pub plan: VehicleRoutePlan, |
| |
| pub config: SolverConfig, |
| |
| stop_signal: Option<oneshot::Sender<()>>, |
| } |
|
|
| impl SolveJob { |
| |
| pub fn new(id: String, plan: VehicleRoutePlan) -> Self { |
| Self { |
| id, |
| status: SolverStatus::NotSolving, |
| plan, |
| config: SolverConfig::default_config(), |
| stop_signal: None, |
| } |
| } |
|
|
| |
| pub fn with_config(id: String, plan: VehicleRoutePlan, config: SolverConfig) -> Self { |
| Self { |
| id, |
| status: SolverStatus::NotSolving, |
| plan, |
| config, |
| stop_signal: None, |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub struct SolverService { |
| jobs: RwLock<HashMap<String, Arc<RwLock<SolveJob>>>>, |
| } |
|
|
| impl SolverService { |
| |
| pub fn new() -> Self { |
| Self { |
| jobs: RwLock::new(HashMap::new()), |
| } |
| } |
|
|
| |
| pub fn create_job(&self, id: String, plan: VehicleRoutePlan) -> Arc<RwLock<SolveJob>> { |
| let job = Arc::new(RwLock::new(SolveJob::new(id.clone(), plan))); |
| self.jobs.write().insert(id, job.clone()); |
| job |
| } |
|
|
| |
| pub fn create_job_with_config( |
| &self, |
| id: String, |
| plan: VehicleRoutePlan, |
| config: SolverConfig, |
| ) -> Arc<RwLock<SolveJob>> { |
| let job = Arc::new(RwLock::new(SolveJob::with_config(id.clone(), plan, config))); |
| self.jobs.write().insert(id, job.clone()); |
| job |
| } |
|
|
| |
| pub fn get_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> { |
| self.jobs.read().get(id).cloned() |
| } |
|
|
| |
| pub fn list_jobs(&self) -> Vec<String> { |
| self.jobs.read().keys().cloned().collect() |
| } |
|
|
| |
| pub fn remove_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> { |
| self.jobs.write().remove(id) |
| } |
|
|
| |
| pub fn start_solving(&self, job: Arc<RwLock<SolveJob>>) { |
| let (tx, rx) = oneshot::channel(); |
| let config = job.read().config.clone(); |
|
|
| { |
| let mut job_guard = job.write(); |
| job_guard.status = SolverStatus::Solving; |
| job_guard.stop_signal = Some(tx); |
| } |
|
|
| let job_clone = job.clone(); |
|
|
| tokio::task::spawn_blocking(move || { |
| solve_blocking(job_clone, rx, config); |
| }); |
| } |
|
|
| |
| pub fn stop_solving(&self, id: &str) -> bool { |
| if let Some(job) = self.get_job(id) { |
| let mut job_guard = job.write(); |
| if let Some(stop_signal) = job_guard.stop_signal.take() { |
| let _ = stop_signal.send(()); |
| job_guard.status = SolverStatus::NotSolving; |
| return true; |
| } |
| } |
| false |
| } |
| } |
|
|
| impl Default for SolverService { |
| fn default() -> Self { |
| Self::new() |
| } |
| } |
|
|
| |
| fn solve_blocking( |
| job: Arc<RwLock<SolveJob>>, |
| mut stop_rx: oneshot::Receiver<()>, |
| config: SolverConfig, |
| ) { |
| let mut solution = job.read().plan.clone(); |
| let job_id = job.read().id.clone(); |
| let solve_start = Instant::now(); |
|
|
| |
| console::print_config( |
| solution.vehicles.len(), |
| solution.visits.len(), |
| solution.locations.len(), |
| ); |
|
|
| info!( |
| job_id = %job_id, |
| visits = solution.visits.len(), |
| vehicles = solution.vehicles.len(), |
| "Starting VRP solver" |
| ); |
|
|
| |
| let mut ch_timer = PhaseTimer::start("ConstructionHeuristic", 0); |
| let mut current_score = construction_heuristic(&mut solution, &mut ch_timer); |
| ch_timer.finish(); |
|
|
| |
| console::print_solving_started( |
| solve_start.elapsed().as_millis() as u64, |
| ¤t_score.to_string(), |
| solution.visits.len(), |
| solution.visits.len(), |
| solution.vehicles.len(), |
| ); |
|
|
| |
| update_job(&job, &solution, current_score); |
|
|
| |
| let n_vehicles = solution.vehicles.len(); |
| if n_vehicles == 0 { |
| info!("No vehicles to optimize"); |
| console::print_solving_ended( |
| solve_start.elapsed(), |
| 0, |
| 1, |
| ¤t_score.to_string(), |
| current_score.is_feasible(), |
| ); |
| finish_job(&job, &solution, current_score); |
| return; |
| } |
|
|
| let mut ls_timer = PhaseTimer::start("LateAcceptance", 1); |
| let mut late_scores = vec![current_score; LATE_ACCEPTANCE_SIZE]; |
| let mut step: u64 = 0; |
| let mut rng = rand::thread_rng(); |
|
|
| |
| let mut best_score = current_score; |
| let mut last_improvement_time = solve_start; |
| let mut last_improvement_step: u64 = 0; |
|
|
| loop { |
| |
| let elapsed = solve_start.elapsed(); |
| let time_since_improvement = last_improvement_time.elapsed(); |
| let steps_since_improvement = step - last_improvement_step; |
|
|
| if config.should_terminate(elapsed, step, time_since_improvement, steps_since_improvement) { |
| debug!("Termination condition met"); |
| break; |
| } |
|
|
| |
| if stop_rx.try_recv().is_ok() { |
| info!("Solving terminated early by user"); |
| break; |
| } |
|
|
| |
| let accepted = if step % 3 == 0 { |
| |
| try_two_opt_move(&mut solution, &mut current_score, &late_scores, step, &mut rng, &mut ls_timer) |
| } else { |
| |
| try_list_change_move(&mut solution, &mut current_score, &late_scores, step, &mut rng, &mut ls_timer) |
| }; |
|
|
| if accepted { |
| |
| let late_idx = (step as usize) % LATE_ACCEPTANCE_SIZE; |
| late_scores[late_idx] = current_score; |
|
|
| |
| if current_score > best_score { |
| best_score = current_score; |
| last_improvement_time = Instant::now(); |
| last_improvement_step = step; |
| } |
|
|
| |
| if ls_timer.steps_accepted() % 1000 == 0 { |
| update_job(&job, &solution, current_score); |
| debug!( |
| step, |
| moves_accepted = ls_timer.steps_accepted(), |
| score = %current_score, |
| elapsed_secs = solve_start.elapsed().as_secs(), |
| "Progress update" |
| ); |
| } |
|
|
| |
| if ls_timer.moves_evaluated() % 10000 == 0 { |
| console::print_step_progress( |
| ls_timer.steps_accepted(), |
| ls_timer.elapsed(), |
| ls_timer.moves_evaluated(), |
| ¤t_score.to_string(), |
| ); |
| } |
| } |
|
|
| step += 1; |
| } |
|
|
| ls_timer.finish(); |
|
|
| let total_duration = solve_start.elapsed(); |
| let total_moves = step; |
|
|
| info!( |
| job_id = %job_id, |
| duration_secs = total_duration.as_secs_f64(), |
| steps = step, |
| score = %current_score, |
| feasible = current_score.is_feasible(), |
| "Solving complete" |
| ); |
|
|
| console::print_solving_ended( |
| total_duration, |
| total_moves, |
| 2, |
| ¤t_score.to_string(), |
| current_score.is_feasible(), |
| ); |
|
|
| finish_job(&job, &solution, current_score); |
| } |
|
|
| |
| |
| |
| fn construction_heuristic(solution: &mut VehicleRoutePlan, timer: &mut PhaseTimer) -> HardSoftScore { |
| let n_visits = solution.visits.len(); |
| let n_vehicles = solution.vehicles.len(); |
|
|
| if n_vehicles == 0 || n_visits == 0 { |
| return calculate_score(solution); |
| } |
|
|
| |
| let assigned_count: usize = solution.vehicles.iter().map(|v| v.visits.len()).sum(); |
|
|
| |
| if assigned_count == n_visits { |
| info!("All visits already assigned, skipping construction heuristic"); |
| return calculate_score(solution); |
| } |
|
|
| |
| let assigned: std::collections::HashSet<usize> = solution |
| .vehicles |
| .iter() |
| .flat_map(|v| v.visits.iter().copied()) |
| .collect(); |
|
|
| |
| let mut vehicle_idx = 0; |
| for visit_idx in 0..n_visits { |
| if assigned.contains(&visit_idx) { |
| continue; |
| } |
|
|
| timer.record_move(); |
| solution.vehicles[vehicle_idx].visits.push(visit_idx); |
|
|
| let score = calculate_score(solution); |
| timer.record_accepted(&score.to_string()); |
|
|
| vehicle_idx = (vehicle_idx + 1) % n_vehicles; |
| } |
|
|
| calculate_score(solution) |
| } |
|
|
| |
| |
| fn try_list_change_move<R: Rng>( |
| solution: &mut VehicleRoutePlan, |
| current_score: &mut HardSoftScore, |
| late_scores: &[HardSoftScore], |
| step: u64, |
| rng: &mut R, |
| timer: &mut PhaseTimer, |
| ) -> bool { |
| let n_vehicles = solution.vehicles.len(); |
|
|
| |
| let non_empty: Vec<usize> = solution |
| .vehicles |
| .iter() |
| .enumerate() |
| .filter(|(_, v)| !v.visits.is_empty()) |
| .map(|(i, _)| i) |
| .collect(); |
|
|
| if non_empty.is_empty() { |
| return false; |
| } |
|
|
| let src_vehicle = non_empty[rng.gen_range(0..non_empty.len())]; |
| let src_len = solution.vehicles[src_vehicle].visits.len(); |
| let src_pos = rng.gen_range(0..src_len); |
|
|
| |
| let dst_vehicle = rng.gen_range(0..n_vehicles); |
| let dst_len = solution.vehicles[dst_vehicle].visits.len(); |
|
|
| |
| let max_pos = if src_vehicle == dst_vehicle { |
| src_len |
| } else { |
| dst_len + 1 |
| }; |
|
|
| if max_pos == 0 { |
| return false; |
| } |
|
|
| let dst_pos = rng.gen_range(0..max_pos); |
|
|
| |
| if src_vehicle == dst_vehicle { |
| let effective_dst = if dst_pos > src_pos { dst_pos - 1 } else { dst_pos }; |
| if src_pos == effective_dst { |
| return false; |
| } |
| } |
|
|
| timer.record_move(); |
|
|
| |
| let visit_idx = solution.vehicles[src_vehicle].visits.remove(src_pos); |
| let adjusted_dst = if src_vehicle == dst_vehicle && dst_pos > src_pos { |
| dst_pos - 1 |
| } else { |
| dst_pos |
| }; |
| solution.vehicles[dst_vehicle].visits.insert(adjusted_dst, visit_idx); |
|
|
| |
| let new_score = calculate_score(solution); |
| let late_idx = (step as usize) % late_scores.len(); |
| let late_score = late_scores[late_idx]; |
|
|
| if new_score >= *current_score || new_score >= late_score { |
| |
| timer.record_accepted(&new_score.to_string()); |
| *current_score = new_score; |
| true |
| } else { |
| |
| solution.vehicles[dst_vehicle].visits.remove(adjusted_dst); |
| solution.vehicles[src_vehicle].visits.insert(src_pos, visit_idx); |
| false |
| } |
| } |
|
|
| |
| |
| fn try_two_opt_move<R: Rng>( |
| solution: &mut VehicleRoutePlan, |
| current_score: &mut HardSoftScore, |
| late_scores: &[HardSoftScore], |
| step: u64, |
| rng: &mut R, |
| timer: &mut PhaseTimer, |
| ) -> bool { |
| |
| let eligible: Vec<usize> = solution |
| .vehicles |
| .iter() |
| .enumerate() |
| .filter(|(_, v)| v.visits.len() >= 2) |
| .map(|(i, _)| i) |
| .collect(); |
|
|
| if eligible.is_empty() { |
| return false; |
| } |
|
|
| let vehicle_idx = eligible[rng.gen_range(0..eligible.len())]; |
| let route_len = solution.vehicles[vehicle_idx].visits.len(); |
|
|
| |
| let i = rng.gen_range(0..route_len); |
| let j = rng.gen_range(0..route_len); |
|
|
| if i == j { |
| return false; |
| } |
|
|
| let (start, end) = if i < j { (i, j) } else { (j, i) }; |
|
|
| |
| if end - start < 1 { |
| return false; |
| } |
|
|
| timer.record_move(); |
|
|
| |
| solution.vehicles[vehicle_idx].visits[start..=end].reverse(); |
|
|
| |
| let new_score = calculate_score(solution); |
| let late_idx = (step as usize) % late_scores.len(); |
| let late_score = late_scores[late_idx]; |
|
|
| if new_score >= *current_score || new_score >= late_score { |
| |
| timer.record_accepted(&new_score.to_string()); |
| *current_score = new_score; |
| true |
| } else { |
| |
| solution.vehicles[vehicle_idx].visits[start..=end].reverse(); |
| false |
| } |
| } |
|
|
| |
| fn update_job(job: &Arc<RwLock<SolveJob>>, solution: &VehicleRoutePlan, score: HardSoftScore) { |
| let mut job_guard = job.write(); |
| job_guard.plan = solution.clone(); |
| job_guard.plan.score = Some(score); |
| } |
|
|
| |
| fn finish_job(job: &Arc<RwLock<SolveJob>>, solution: &VehicleRoutePlan, score: HardSoftScore) { |
| let mut job_guard = job.write(); |
| job_guard.plan = solution.clone(); |
| job_guard.plan.score = Some(score); |
| job_guard.status = SolverStatus::NotSolving; |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use crate::demo_data::generate_philadelphia; |
|
|
| #[test] |
| fn test_construction_heuristic() { |
| let mut plan = generate_philadelphia(); |
|
|
| |
| let mut timer = PhaseTimer::start("ConstructionHeuristic", 0); |
| let score = construction_heuristic(&mut plan, &mut timer); |
|
|
| |
| let total_visits: usize = plan.vehicles.iter().map(|v| v.visits.len()).sum(); |
| assert_eq!(total_visits, 49); |
| assert!(score.hard() <= 0); |
| } |
| } |
|
|