1use crate::clarke_wright::{SolveError, clarke_wright};
14use crate::matrix::CostMatrix;
15use crate::model::{Problem, Solution};
16use crate::objective::Objective;
17use crate::search::State;
18use rand::{Rng, SeedableRng};
19use rand_chacha::ChaCha8Rng;
20use std::time::{Duration, Instant};
21
22#[derive(Debug, Clone, Copy)]
25pub struct Budget {
26 pub max_iterations: Option<u64>,
27 pub max_time: Option<Duration>,
28}
29
30impl Budget {
31 #[must_use]
33 pub fn iterations(n: u64) -> Self {
34 Self {
35 max_iterations: Some(n),
36 max_time: None,
37 }
38 }
39
40 #[must_use]
42 pub fn time(d: Duration) -> Self {
43 Self {
44 max_iterations: None,
45 max_time: Some(d),
46 }
47 }
48}
49
50impl Budget {
51 fn split(&self, n: usize) -> Self {
54 let n = n.max(1) as u64;
55 Self {
56 max_iterations: self.max_iterations.map(|m| (m / n).max(1)),
57 max_time: self.max_time.map(|d| d / n as u32),
58 }
59 }
60}
61
62impl Default for Budget {
63 fn default() -> Self {
64 Self::iterations(500_000)
65 }
66}
67
68#[derive(Debug, Clone, Copy)]
71pub struct SolverConfig {
72 pub seed: u64,
74 pub objective: Objective,
76 pub budget: Budget,
78 pub restarts: usize,
81 pub initial_temperature: Option<f64>,
83 pub final_temperature: Option<f64>,
85}
86
87impl Default for SolverConfig {
88 fn default() -> Self {
89 Self {
90 seed: 0x5EED,
91 objective: Objective::default(),
92 budget: Budget::default(),
93 restarts: 10,
94 initial_temperature: None,
95 final_temperature: None,
96 }
97 }
98}
99
100const TIME_CHECK_STRIDE: u64 = 2048;
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct SolveStats {
107 pub iterations: u64,
109 pub restarts: usize,
111}
112
113pub fn solve<M: CostMatrix>(
122 problem: &Problem,
123 matrix: &M,
124 config: &SolverConfig,
125) -> Result<Solution, SolveError> {
126 solve_with_stats(problem, matrix, config).map(|(solution, _)| solution)
127}
128
129pub fn solve_with_stats<M: CostMatrix>(
135 problem: &Problem,
136 matrix: &M,
137 config: &SolverConfig,
138) -> Result<(Solution, SolveStats), SolveError> {
139 let initial = clarke_wright(problem, matrix)?;
140
141 let unassigned = initial.unassigned.clone();
145
146 if problem.stops.len() <= 1 {
148 return Ok((
149 initial,
150 SolveStats {
151 iterations: 0,
152 restarts: 0,
153 },
154 ));
155 }
156
157 let capacity = problem
158 .vehicles
159 .iter()
160 .map(|v| v.capacity)
161 .min()
162 .unwrap_or(0);
163 let obj = &config.objective;
164 let restarts = config.restarts.max(1);
165 let per_start = config.budget.split(restarts);
166
167 let mut best_routes: Option<Vec<Vec<usize>>> = None;
172 let mut best_cost = f64::INFINITY;
173 let mut iterations: u64 = 0;
174
175 for s in 0..restarts {
176 let seed = config
177 .seed
178 .wrapping_add((s as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
179 let mut rng = ChaCha8Rng::seed_from_u64(seed);
180 let mut state = State::from_solution(problem, matrix, capacity, &initial);
181 iterations += anneal_once(&mut state, &mut rng, config, &per_start, obj);
182 let cost = state.cost(obj);
183 if cost < best_cost {
184 best_cost = cost;
185 best_routes = Some(state.snapshot());
186 }
187 }
188
189 let mut state = State::from_solution(problem, matrix, capacity, &initial);
190 if let Some(routes) = best_routes {
191 state.restore(routes);
192 }
193 let stats = SolveStats {
194 iterations,
195 restarts,
196 };
197 let mut solution = state.to_solution();
198 solution.feasible = solution.feasible && unassigned.is_empty();
199 solution.unassigned = unassigned;
200 Ok((solution, stats))
201}
202
203fn anneal_once<M: CostMatrix>(
207 state: &mut State<M>,
208 rng: &mut ChaCha8Rng,
209 config: &SolverConfig,
210 budget: &Budget,
211 obj: &Objective,
212) -> u64 {
213 let t0 = config
214 .initial_temperature
215 .unwrap_or_else(|| auto_initial_temperature(state, obj, rng));
216 let t_end = config.final_temperature.unwrap_or(t0 / 1000.0).max(1e-9);
217 let alpha = cooling_rate(t0, t_end, budget.max_iterations);
218
219 let mut temperature = t0;
220 let mut best_routes = state.snapshot();
221 let mut best_cost = state.cost(obj);
222
223 let start = Instant::now();
224 let mut iter: u64 = 0;
225 loop {
226 if budget.max_iterations.is_some_and(|m| iter >= m) {
227 break;
228 }
229 if iter.is_multiple_of(TIME_CHECK_STRIDE)
230 && budget
231 .max_time
232 .is_some_and(|limit| start.elapsed() >= limit)
233 {
234 break;
235 }
236
237 if let Some(mv) = state.random_move(rng) {
238 let delta = state.cost_delta(&mv, obj);
239 let accept = delta <= 0.0 || rng.random::<f64>() < (-delta / temperature).exp();
240 if accept && state.try_apply(&mv) {
241 let cost = state.cost(obj);
242 if cost < best_cost {
243 best_cost = cost;
244 best_routes = state.snapshot();
245 }
246 }
247 }
248
249 temperature = (temperature * alpha).max(t_end);
250 iter += 1;
251 }
252
253 state.restore(best_routes);
254 iter
255}
256
257fn auto_initial_temperature<M: CostMatrix>(
260 state: &State<M>,
261 obj: &Objective,
262 rng: &mut ChaCha8Rng,
263) -> f64 {
264 const SAMPLES: usize = 200;
265 let mut sum = 0.0;
266 let mut count = 0u32;
267 for _ in 0..SAMPLES {
268 if let Some(mv) = state.random_move(rng) {
269 sum += state.cost_delta(&mv, obj).abs();
270 count += 1;
271 }
272 }
273 if count == 0 {
274 return 1.0;
275 }
276 let mean = sum / f64::from(count);
277 (mean / std::f64::consts::LN_2).max(1e-6)
278}
279
280fn cooling_rate(t0: f64, t_end: f64, max_iterations: Option<u64>) -> f64 {
283 match max_iterations {
284 Some(iters) if iters > 1 => (t_end / t0).powf(1.0 / iters as f64),
285 _ => 0.99997,
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::matrix::EuclideanMatrix;
293 use crate::model::{Coord, Stop, StopId, TimeWindow, Vehicle, VehicleId};
294
295 fn clustered_problem() -> Problem {
298 let centers = [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)];
299 let mut stops = Vec::new();
300 let mut id = 1u32;
301 for (cx, cy) in centers {
302 for k in 0..5 {
303 let off = f64::from(k) * 2.0;
304 stops.push(Stop {
305 id: StopId(id),
306 coord: Coord::new(cy + off, cx + off), demand: 5,
308 time_window: Some(TimeWindow {
309 start: 0.0,
310 end: 100_000.0,
311 }),
312 service_time: 1.0,
313 });
314 id += 1;
315 }
316 }
317 let vehicles = (1..=8u32)
318 .map(|i| Vehicle {
319 id: VehicleId(i),
320 capacity: 30,
321 })
322 .collect();
323 Problem {
324 depot: Coord::new(50.0, 50.0),
325 stops,
326 vehicles,
327 depot_window: Some(TimeWindow {
328 start: 0.0,
329 end: 1_000_000.0,
330 }),
331 }
332 }
333
334 fn scattered_problem() -> Problem {
338 let pts = [
339 (20.0, 20.0),
340 (80.0, 25.0),
341 (15.0, 70.0),
342 (70.0, 80.0),
343 (45.0, 10.0),
344 (90.0, 55.0),
345 (30.0, 45.0),
346 (60.0, 30.0),
347 (10.0, 40.0),
348 (75.0, 65.0),
349 (40.0, 85.0),
350 (55.0, 60.0),
351 ];
352 let stops = pts
353 .iter()
354 .enumerate()
355 .map(|(i, &(x, y))| Stop {
356 id: StopId((i + 1) as u32),
357 coord: Coord::new(y, x),
358 demand: 1,
359 time_window: None,
360 service_time: 0.0,
361 })
362 .collect();
363 Problem {
364 depot: Coord::new(50.0, 50.0),
365 stops,
366 vehicles: vec![Vehicle {
367 id: VehicleId(1),
368 capacity: 1000,
369 }],
370 depot_window: None,
371 }
372 }
373
374 fn all_feasible(problem: &Problem, sol: &Solution) -> bool {
375 let served: usize = sol.routes.iter().map(|r| r.stop_ids.len()).sum();
376 sol.feasible && served == problem.stops.len()
377 }
378
379 #[test]
380 fn same_seed_is_deterministic() {
381 let p = clustered_problem();
382 let m = EuclideanMatrix::from_problem(&p);
383 let cfg = SolverConfig {
384 seed: 123,
385 budget: Budget::iterations(20_000),
386 ..Default::default()
387 };
388 let a = solve(&p, &m, &cfg).expect("feasible");
389 let b = solve(&p, &m, &cfg).expect("feasible");
390 assert_eq!(a.total_distance, b.total_distance);
391 let route_a: Vec<_> = a.routes.iter().map(|r| &r.stop_ids).collect();
392 let route_b: Vec<_> = b.routes.iter().map(|r| &r.stop_ids).collect();
393 assert_eq!(route_a, route_b);
394 }
395
396 #[test]
397 fn different_seeds_can_diverge_but_stay_feasible() {
398 let p = clustered_problem();
399 let m = EuclideanMatrix::from_problem(&p);
400 for seed in [1u64, 2, 99] {
401 let cfg = SolverConfig {
402 seed,
403 budget: Budget::iterations(20_000),
404 ..Default::default()
405 };
406 let sol = solve(&p, &m, &cfg).expect("feasible");
407 assert!(all_feasible(&p, &sol), "seed {seed} produced infeasible");
408 }
409 }
410
411 #[test]
412 fn improves_on_clarke_wright() {
413 let p = scattered_problem();
414 let m = EuclideanMatrix::from_problem(&p);
415 let cw = clarke_wright(&p, &m).expect("feasible");
416 let cfg = SolverConfig {
417 seed: 7,
418 budget: Budget::iterations(40_000),
419 ..Default::default()
420 };
421 let sol = solve(&p, &m, &cfg).expect("feasible");
422 assert!(all_feasible(&p, &sol));
423 assert!(
424 sol.total_distance < cw.total_distance,
425 "SA {} did not improve on CW {}",
426 sol.total_distance,
427 cw.total_distance
428 );
429 }
430
431 #[test]
432 fn respects_tight_time_windows() {
433 let stops = (1..=6u32)
436 .map(|i| Stop {
437 id: StopId(i),
438 coord: Coord::new(0.0, f64::from(i) * 10.0),
439 demand: 1,
440 time_window: Some(TimeWindow {
441 start: f64::from(i) * 10.0 - 2.0,
442 end: f64::from(i) * 10.0 + 2.0,
443 }),
444 service_time: 0.5,
445 })
446 .collect();
447 let p = Problem {
448 depot: Coord::new(0.0, 0.0),
449 stops,
450 vehicles: (1..=3u32)
451 .map(|i| Vehicle {
452 id: VehicleId(i),
453 capacity: 100,
454 })
455 .collect(),
456 depot_window: Some(TimeWindow {
457 start: 0.0,
458 end: 1000.0,
459 }),
460 };
461 let m = EuclideanMatrix::from_problem(&p);
462 let cfg = SolverConfig {
463 seed: 5,
464 budget: Budget::iterations(20_000),
465 ..Default::default()
466 };
467 let sol = solve(&p, &m, &cfg).expect("feasible");
468 assert!(all_feasible(&p, &sol));
469 }
470}