|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
use parking_lot::RwLock; |
|
|
use rand::Rng; |
|
|
use solverforge::prelude::*; |
|
|
use solverforge::TypedScoreDirector; |
|
|
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::create_fluent_constraints; |
|
|
use crate::domain::EmployeeSchedule; |
|
|
|
|
|
|
|
|
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 schedule: EmployeeSchedule, |
|
|
|
|
|
pub config: SolverConfig, |
|
|
|
|
|
stop_signal: Option<oneshot::Sender<()>>, |
|
|
} |
|
|
|
|
|
impl SolveJob { |
|
|
|
|
|
pub fn new(id: String, schedule: EmployeeSchedule) -> Self { |
|
|
Self { |
|
|
id, |
|
|
status: SolverStatus::NotSolving, |
|
|
schedule, |
|
|
config: SolverConfig::default_config(), |
|
|
stop_signal: None, |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
pub fn with_config(id: String, schedule: EmployeeSchedule, config: SolverConfig) -> Self { |
|
|
Self { |
|
|
id, |
|
|
status: SolverStatus::NotSolving, |
|
|
schedule, |
|
|
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, schedule: EmployeeSchedule) -> Arc<RwLock<SolveJob>> { |
|
|
let job = Arc::new(RwLock::new(SolveJob::new(id.clone(), schedule))); |
|
|
self.jobs.write().insert(id, job.clone()); |
|
|
job |
|
|
} |
|
|
|
|
|
|
|
|
pub fn create_job_with_config( |
|
|
&self, |
|
|
id: String, |
|
|
schedule: EmployeeSchedule, |
|
|
config: SolverConfig, |
|
|
) -> Arc<RwLock<SolveJob>> { |
|
|
let job = Arc::new(RwLock::new(SolveJob::with_config(id.clone(), schedule, 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 initial_schedule = job.read().schedule.clone(); |
|
|
let job_id = job.read().id.clone(); |
|
|
let solve_start = Instant::now(); |
|
|
|
|
|
|
|
|
console::print_config( |
|
|
initial_schedule.shifts.len(), |
|
|
initial_schedule.employees.len(), |
|
|
); |
|
|
|
|
|
info!( |
|
|
job_id = %job_id, |
|
|
shifts = initial_schedule.shifts.len(), |
|
|
employees = initial_schedule.employees.len(), |
|
|
"Starting Employee Scheduling solver" |
|
|
); |
|
|
|
|
|
|
|
|
let constraints = create_fluent_constraints(); |
|
|
let mut director = TypedScoreDirector::new(initial_schedule.clone(), constraints); |
|
|
|
|
|
|
|
|
let mut ch_timer = PhaseTimer::start("ConstructionHeuristic", 0); |
|
|
let mut current_score = construction_heuristic(&mut director, &mut ch_timer); |
|
|
ch_timer.finish(); |
|
|
|
|
|
|
|
|
console::print_solving_started( |
|
|
solve_start.elapsed().as_millis() as u64, |
|
|
¤t_score.to_string(), |
|
|
initial_schedule.shifts.len(), |
|
|
initial_schedule.shifts.len(), |
|
|
initial_schedule.employees.len(), |
|
|
); |
|
|
|
|
|
|
|
|
update_job(&job, &director, current_score); |
|
|
|
|
|
|
|
|
let n_employees = director.working_solution().employees.len(); |
|
|
if n_employees == 0 { |
|
|
info!("No employees to optimize"); |
|
|
console::print_solving_ended( |
|
|
solve_start.elapsed(), |
|
|
0, |
|
|
1, |
|
|
¤t_score.to_string(), |
|
|
current_score.is_feasible(), |
|
|
); |
|
|
finish_job(&job, &director, 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; |
|
|
} |
|
|
|
|
|
|
|
|
if let Some((shift_idx, new_employee_idx)) = generate_move(&director, &mut rng) { |
|
|
ls_timer.record_move(); |
|
|
|
|
|
|
|
|
let old_score = current_score; |
|
|
let old_employee_idx = apply_move(&mut director, shift_idx, new_employee_idx); |
|
|
let new_score = director.get_score(); |
|
|
|
|
|
|
|
|
let late_idx = (step as usize) % LATE_ACCEPTANCE_SIZE; |
|
|
let late_score = late_scores[late_idx]; |
|
|
|
|
|
if new_score >= old_score || new_score >= late_score { |
|
|
|
|
|
ls_timer.record_accepted(¤t_score.to_string()); |
|
|
current_score = new_score; |
|
|
late_scores[late_idx] = new_score; |
|
|
|
|
|
|
|
|
if new_score > best_score { |
|
|
best_score = new_score; |
|
|
last_improvement_time = Instant::now(); |
|
|
last_improvement_step = step; |
|
|
} |
|
|
|
|
|
|
|
|
if ls_timer.steps_accepted().is_multiple_of(1000) { |
|
|
update_job(&job, &director, 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().is_multiple_of(10000) { |
|
|
console::print_step_progress( |
|
|
ls_timer.steps_accepted(), |
|
|
ls_timer.elapsed(), |
|
|
ls_timer.moves_evaluated(), |
|
|
¤t_score.to_string(), |
|
|
); |
|
|
} |
|
|
} else { |
|
|
|
|
|
undo_move(&mut director, shift_idx, old_employee_idx); |
|
|
} |
|
|
|
|
|
step += 1; |
|
|
} |
|
|
} |
|
|
|
|
|
ls_timer.finish(); |
|
|
|
|
|
let total_duration = solve_start.elapsed(); |
|
|
|
|
|
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, |
|
|
step, |
|
|
2, |
|
|
¤t_score.to_string(), |
|
|
current_score.is_feasible(), |
|
|
); |
|
|
|
|
|
finish_job(&job, &director, current_score); |
|
|
} |
|
|
|
|
|
|
|
|
fn construction_heuristic( |
|
|
director: &mut TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
|
|
timer: &mut PhaseTimer, |
|
|
) -> HardSoftDecimalScore { |
|
|
|
|
|
let _ = director.calculate_score(); |
|
|
|
|
|
let n_shifts = director.working_solution().shifts.len(); |
|
|
let n_employees = director.working_solution().employees.len(); |
|
|
|
|
|
if n_employees == 0 || n_shifts == 0 { |
|
|
return director.get_score(); |
|
|
} |
|
|
|
|
|
|
|
|
let assigned_count = director |
|
|
.working_solution() |
|
|
.shifts |
|
|
.iter() |
|
|
.filter(|s| s.employee_idx.is_some()) |
|
|
.count(); |
|
|
|
|
|
|
|
|
if assigned_count == n_shifts { |
|
|
info!("All shifts already assigned, skipping construction heuristic"); |
|
|
return director.get_score(); |
|
|
} |
|
|
|
|
|
|
|
|
let mut employee_idx = 0; |
|
|
for shift_idx in 0..n_shifts { |
|
|
if director.working_solution().shifts[shift_idx].employee_idx.is_some() { |
|
|
continue; |
|
|
} |
|
|
|
|
|
timer.record_move(); |
|
|
director.before_variable_changed(shift_idx); |
|
|
director.working_solution_mut().shifts[shift_idx].employee_idx = Some(employee_idx); |
|
|
director.after_variable_changed(shift_idx); |
|
|
|
|
|
let score = director.get_score(); |
|
|
timer.record_accepted(&score.to_string()); |
|
|
|
|
|
employee_idx = (employee_idx + 1) % n_employees; |
|
|
} |
|
|
|
|
|
director.get_score() |
|
|
} |
|
|
|
|
|
|
|
|
fn generate_move<R: Rng>( |
|
|
director: &TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
|
|
rng: &mut R, |
|
|
) -> Option<(usize, Option<usize>)> { |
|
|
let solution = director.working_solution(); |
|
|
let n_shifts = solution.shifts.len(); |
|
|
let n_employees = solution.employees.len(); |
|
|
|
|
|
if n_shifts == 0 || n_employees == 0 { |
|
|
return None; |
|
|
} |
|
|
|
|
|
|
|
|
let shift_idx = rng.gen_range(0..n_shifts); |
|
|
let current_employee = solution.shifts[shift_idx].employee_idx; |
|
|
|
|
|
|
|
|
let new_employee_idx = rng.gen_range(0..n_employees); |
|
|
|
|
|
|
|
|
if current_employee == Some(new_employee_idx) { |
|
|
return None; |
|
|
} |
|
|
|
|
|
Some((shift_idx, Some(new_employee_idx))) |
|
|
} |
|
|
|
|
|
|
|
|
fn apply_move( |
|
|
director: &mut TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
|
|
shift_idx: usize, |
|
|
new_employee_idx: Option<usize>, |
|
|
) -> Option<usize> { |
|
|
let old_employee_idx = director.working_solution().shifts[shift_idx].employee_idx; |
|
|
|
|
|
director.before_variable_changed(shift_idx); |
|
|
director.working_solution_mut().shifts[shift_idx].employee_idx = new_employee_idx; |
|
|
director.after_variable_changed(shift_idx); |
|
|
|
|
|
old_employee_idx |
|
|
} |
|
|
|
|
|
|
|
|
fn undo_move( |
|
|
director: &mut TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
|
|
shift_idx: usize, |
|
|
old_employee_idx: Option<usize>, |
|
|
) { |
|
|
director.before_variable_changed(shift_idx); |
|
|
director.working_solution_mut().shifts[shift_idx].employee_idx = old_employee_idx; |
|
|
director.after_variable_changed(shift_idx); |
|
|
} |
|
|
|
|
|
|
|
|
fn update_job( |
|
|
job: &Arc<RwLock<SolveJob>>, |
|
|
director: &TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
|
|
score: HardSoftDecimalScore, |
|
|
) { |
|
|
let mut job_guard = job.write(); |
|
|
job_guard.schedule = director.clone_working_solution(); |
|
|
job_guard.schedule.score = Some(score); |
|
|
} |
|
|
|
|
|
|
|
|
fn finish_job( |
|
|
job: &Arc<RwLock<SolveJob>>, |
|
|
director: &TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
|
|
score: HardSoftDecimalScore, |
|
|
) { |
|
|
let mut job_guard = job.write(); |
|
|
job_guard.schedule = director.clone_working_solution(); |
|
|
job_guard.schedule.score = Some(score); |
|
|
job_guard.status = SolverStatus::NotSolving; |
|
|
} |
|
|
|
|
|
#[cfg(test)] |
|
|
mod tests { |
|
|
use super::*; |
|
|
use crate::demo_data::{generate, DemoData}; |
|
|
|
|
|
#[test] |
|
|
fn test_construction_heuristic() { |
|
|
let schedule = generate(DemoData::Small); |
|
|
let constraints = create_fluent_constraints(); |
|
|
let mut director = TypedScoreDirector::new(schedule, constraints); |
|
|
|
|
|
let mut timer = PhaseTimer::start("ConstructionHeuristic", 0); |
|
|
let score = construction_heuristic(&mut director, &mut timer); |
|
|
|
|
|
|
|
|
let assigned_count = director |
|
|
.working_solution() |
|
|
.shifts |
|
|
.iter() |
|
|
.filter(|s| s.employee_idx.is_some()) |
|
|
.count(); |
|
|
let total_shifts = director.working_solution().shifts.len(); |
|
|
assert_eq!(assigned_count, total_shifts); |
|
|
assert!(score.hard_scaled() <= 0); |
|
|
} |
|
|
} |
|
|
|