|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
|