vrp_pragmatic/checker/
mod.rs

1//! This module provides functionality to automatically check that given solution is feasible
2//! which means that there is no constraint violations.
3
4#[cfg(test)]
5#[path = "../../tests/unit/checker/checker_test.rs"]
6mod checker_test;
7
8use crate::format::problem::*;
9use crate::format::solution::*;
10use crate::format::{CoordIndex, Location};
11use crate::parse_time;
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14use vrp_core::construction::clustering::vicinity::ClusterConfig;
15use vrp_core::construction::clustering::vicinity::VisitPolicy;
16use vrp_core::models::common::{Duration, Profile, TimeWindow};
17use vrp_core::models::solution::{Commute as DomainCommute, CommuteInfo as DomainCommuteInfo};
18use vrp_core::models::Problem as CoreProblem;
19use vrp_core::prelude::{GenericError, GenericResult};
20use vrp_core::solver::processing::ClusterConfigExtraProperty;
21use vrp_core::utils::Float;
22
23/// Stores problem and solution together and provides some helper methods.
24pub struct CheckerContext {
25    /// An original problem definition.
26    pub problem: Problem,
27    /// Routing matrices.
28    pub matrices: Option<Vec<Matrix>>,
29    /// Solution to be checked
30    pub solution: Solution,
31
32    job_map: HashMap<String, Job>,
33    coord_index: CoordIndex,
34    profile_index: HashMap<String, usize>,
35    core_problem: Arc<CoreProblem>,
36    clustering: Option<ClusterConfig>,
37}
38
39/// Represents all possible activity types.
40#[allow(dead_code)] // NOTE: keep data in each variant for future use
41enum ActivityType {
42    Terminal,
43    Job(Job),
44    Break(VehicleBreak),
45    Reload(VehicleReload),
46    Recharge(VehicleRechargeStation),
47}
48
49impl CheckerContext {
50    /// Creates an instance of `CheckerContext`
51    pub fn new(
52        core_problem: Arc<CoreProblem>,
53        problem: Problem,
54        matrices: Option<Vec<Matrix>>,
55        solution: Solution,
56    ) -> Result<Self, Vec<GenericError>> {
57        let job_map = problem.plan.jobs.iter().map(|job| (job.id.clone(), job.clone())).collect();
58        let clustering = core_problem.extras.get_cluster_config().map(|config| config.as_ref().clone());
59        let coord_index = CoordIndex::new(&problem);
60        let profile_index = if matrices.is_none() {
61            HashMap::new()
62        } else {
63            get_matrices(&matrices)
64                .and_then(|matrices| get_profile_index(&problem, matrices.as_slice()))
65                .map_err(|err| vec![err])?
66        };
67
68        Ok(Self { problem, matrices, solution, job_map, coord_index, profile_index, core_problem, clustering })
69    }
70
71    /// Performs solution check.
72    pub fn check(&self) -> Result<(), Vec<GenericError>> {
73        // avoid duplicates keeping original order
74        let (_, errors) = check_vehicle_load(self)
75            .err()
76            .into_iter()
77            .chain(check_relations(self).err())
78            .chain(check_breaks(self).err())
79            .chain(check_assignment(self).err())
80            .chain(check_routing(self).err())
81            .chain(check_limits(self).err())
82            .flatten()
83            .fold((HashSet::new(), Vec::default()), |(mut used, mut errors), error| {
84                if !used.contains(&error) {
85                    errors.push(error.clone());
86                    used.insert(error);
87                }
88
89                (used, errors)
90            });
91
92        if errors.is_empty() {
93            Ok(())
94        } else {
95            Err(errors)
96        }
97    }
98
99    /// Gets vehicle by its id.
100    fn get_vehicle(&self, vehicle_id: &str) -> GenericResult<&VehicleType> {
101        self.problem
102            .fleet
103            .vehicles
104            .iter()
105            .find(|v| v.vehicle_ids.contains(&vehicle_id.to_string()))
106            .ok_or_else(|| format!("cannot find vehicle with id '{vehicle_id}'").into())
107    }
108
109    fn get_vehicle_profile(&self, vehicle_id: &str) -> GenericResult<Profile> {
110        let profile = &self.get_vehicle(vehicle_id)?.profile;
111        let index = self
112            .profile_index
113            .get(profile.matrix.as_str())
114            .cloned()
115            .ok_or(format!("cannot get matrix for '{}' profile", profile.matrix))?;
116
117        Ok(Profile { index, scale: profile.scale.unwrap_or(1.) })
118    }
119
120    /// Gets activity operation time range in seconds since Unix epoch.
121    fn get_activity_time(&self, stop: &Stop, activity: &Activity) -> TimeWindow {
122        let schedule = stop.schedule();
123
124        let time = activity
125            .time
126            .clone()
127            .unwrap_or_else(|| Interval { start: schedule.arrival.clone(), end: schedule.departure.clone() });
128
129        TimeWindow::new(parse_time(&time.start), parse_time(&time.end))
130    }
131
132    /// Gets activity location.
133    fn get_activity_location(&self, stop: &Stop, activity: &Activity) -> Option<Location> {
134        activity.location.clone().or_else(|| match stop {
135            Stop::Point(stop) => Some(stop.location.clone()),
136            Stop::Transit(_) => None,
137        })
138    }
139
140    /// Gets vehicle shift where activity is used.
141    fn get_vehicle_shift(&self, tour: &Tour) -> GenericResult<VehicleShift> {
142        let tour_time = TimeWindow::new(
143            parse_time(
144                &tour.stops.first().as_ref().ok_or_else(|| "cannot get first activity".to_string())?.schedule().arrival,
145            ),
146            parse_time(
147                &tour.stops.last().as_ref().ok_or_else(|| "cannot get last activity".to_string())?.schedule().arrival,
148            ),
149        );
150
151        self.get_vehicle(&tour.vehicle_id)?
152            .shifts
153            .iter()
154            .find(|shift| {
155                let shift_time = TimeWindow::new(
156                    parse_time(&shift.start.earliest),
157                    shift.end.as_ref().map_or_else(|| Float::MAX, |place| parse_time(&place.latest)),
158                );
159                shift_time.intersects(&tour_time)
160            })
161            .cloned()
162            .ok_or_else(|| format!("cannot find shift for tour with vehicle if: '{}'", tour.vehicle_id).into())
163    }
164
165    /// Returns stop's activity type names.
166    fn get_stop_activity_types(&self, stop: &Stop) -> Vec<String> {
167        stop.activities().iter().map(|a| a.activity_type.clone()).collect()
168    }
169
170    /// Gets wrapped activity type.
171    fn get_activity_type(&self, tour: &Tour, stop: &Stop, activity: &Activity) -> GenericResult<ActivityType> {
172        let shift = self.get_vehicle_shift(tour)?;
173        let time = self.get_activity_time(stop, activity);
174        let location = self.get_activity_location(stop, activity);
175
176        match activity.activity_type.as_str() {
177            "departure" | "arrival" => Ok(ActivityType::Terminal),
178
179            "pickup" | "delivery" | "service" | "replacement" => {
180                self.job_map.get(activity.job_id.as_str()).map_or_else(
181                    || Err(format!("cannot find job with id '{}'", activity.job_id).into()),
182                    |job| Ok(ActivityType::Job(job.clone())),
183                )
184            }
185
186            "break" => shift
187                .breaks
188                .as_ref()
189                .and_then(|breaks| {
190                    breaks
191                        .iter()
192                        // TODO: would be nice to propagate the error
193                        .find(|b| get_break_time_window(tour, b).map(|tw| tw.intersects(&time)).unwrap_or(false))
194                })
195                .map(|b| ActivityType::Break(b.clone()))
196                .ok_or_else(|| format!("cannot find break for tour '{}'", tour.vehicle_id).into()),
197
198            "reload" => shift
199                .reloads
200                .as_ref()
201                // TODO match reload's time windows
202                .and_then(|reload| {
203                    reload.iter().find(|r| {
204                        location.as_ref().map_or(false, |location| r.location == *location) && r.tag == activity.job_tag
205                    })
206                })
207                .map(|r| ActivityType::Reload(r.clone()))
208                .ok_or_else(|| format!("cannot find reload for tour '{}'", tour.vehicle_id).into()),
209
210            "recharge" => shift
211                .recharges
212                .as_ref()
213                .and_then(|recharges| {
214                    recharges.stations.iter().find(|r| {
215                        location.as_ref().map_or(false, |location| r.location == *location) && r.tag == activity.job_tag
216                    })
217                })
218                .map(|r| ActivityType::Recharge(r.clone()))
219                .ok_or_else(|| format!("cannot find recharge for tour '{}'", tour.vehicle_id).into()),
220
221            _ => Err(format!("unknown activity type: '{}'", activity.activity_type).into()),
222        }
223    }
224
225    fn get_job_by_id(&self, job_id: &str) -> Option<&Job> {
226        self.problem.plan.jobs.iter().find(|job| job.id == job_id)
227    }
228
229    fn get_commute_info(
230        &self,
231        profile: Option<Profile>,
232        parking: Duration,
233        stop: &PointStop,
234        activity_idx: usize,
235    ) -> GenericResult<Option<DomainCommute>> {
236        let get_activity_location_by_idx = |idx: usize| {
237            stop.activities
238                .get(idx)
239                .and_then(|activity| activity.location.as_ref())
240                .and_then(|location| self.get_location_index(location).ok())
241        };
242
243        let get_activity_commute_by_idx = |idx: usize| -> Option<DomainCommute> {
244            stop.activities
245                .get(idx)
246                .and_then(|activity| activity.commute.as_ref())
247                .map(|commute| commute.to_domain(&self.coord_index))
248        };
249
250        match (&self.clustering, &profile, get_activity_commute_by_idx(activity_idx)) {
251            (Some(config), Some(profile), Some(commute)) => {
252                let stop_location = self.get_location_index(&stop.location).ok();
253                // NOTE we don't check whether zero time commute is correct here
254                match (commute.is_zero_distance(), activity_idx) {
255                    (true, _) => Ok(Some(commute)),
256                    // NOTE that's unreachable
257                    (false, 0) => Err("cannot have commute at first activity in the stop".into()),
258                    (false, idx) => {
259                        let prev_location = if matches!(config.visiting, VisitPolicy::Return) {
260                            stop_location
261                        } else {
262                            get_activity_location_by_idx(idx - 1)
263                        };
264                        let curr_location = get_activity_location_by_idx(idx);
265
266                        match (curr_location, prev_location) {
267                            (Some(curr_location), Some(prev_location)) => {
268                                let (f_distance, f_duration) =
269                                    self.get_matrix_data(profile, prev_location, curr_location)?;
270
271                                let has_next_commute = get_activity_location_by_idx(idx + 1)
272                                    .zip(get_activity_commute_by_idx(idx + 1))
273                                    .is_some();
274                                let (b_location, b_distance, b_duration) = match (&config.visiting, has_next_commute) {
275                                    (VisitPolicy::Return, _) | (VisitPolicy::ClosedContinuation, false) => {
276                                        let stop_location = stop_location.ok_or("no location for clustered stop")?;
277                                        let (b_distance, b_duration) =
278                                            self.get_matrix_data(profile, curr_location, stop_location)?;
279
280                                        (stop_location, b_distance, b_duration)
281                                    }
282                                    (VisitPolicy::OpenContinuation, _) | (VisitPolicy::ClosedContinuation, true) => {
283                                        (curr_location, 0_i64, 0_i64)
284                                    }
285                                };
286
287                                // NOTE parking correction
288                                let f_duration = if f_duration == 0 { parking } else { f_duration as Float };
289
290                                Ok(Some(DomainCommute {
291                                    forward: DomainCommuteInfo {
292                                        location: prev_location,
293                                        distance: f_distance as Float,
294                                        duration: f_duration,
295                                    },
296                                    backward: DomainCommuteInfo {
297                                        location: b_location,
298                                        distance: b_distance as Float,
299                                        duration: b_duration as Float,
300                                    },
301                                }))
302                            }
303                            _ => Err("cannot find next commute info".into()),
304                        }
305                    }
306                }
307            }
308            _ => Ok(None),
309        }
310    }
311
312    fn visit_job<F1, F2, R>(
313        &self,
314        activity: &Activity,
315        activity_type: &ActivityType,
316        job_visitor: F1,
317        other_visitor: F2,
318    ) -> GenericResult<R>
319    where
320        F1: Fn(&Job, &JobTask) -> R,
321        F2: Fn() -> R,
322    {
323        match activity_type {
324            ActivityType::Job(job) => {
325                let pickups = job_task_size(&job.pickups);
326                let deliveries = job_task_size(&job.deliveries);
327                let tasks = pickups + deliveries + job_task_size(&job.services) + job_task_size(&job.replacements);
328
329                if tasks < 2 || (tasks == 2 && pickups == 1 && deliveries == 1) {
330                    match_job_task(activity.activity_type.as_str(), job, |tasks| tasks.first())
331                } else {
332                    activity.job_tag.as_ref().ok_or_else(|| {
333                        GenericError::from(format!(
334                            "checker requires that multi job activity must have tag: '{}'",
335                            activity.job_id
336                        ))
337                    })?;
338
339                    match_job_task(activity.activity_type.as_str(), job, |tasks| {
340                        tasks.iter().find(|task| task.places.iter().any(|place| place.tag == activity.job_tag))
341                    })
342                }
343                .map(|task| job_visitor(job, task))
344            }
345            .ok_or_else(|| "cannot match activity to job place".into()),
346            _ => Ok(other_visitor()),
347        }
348    }
349
350    fn get_location_index(&self, location: &Location) -> GenericResult<usize> {
351        self.coord_index
352            .get_by_loc(location)
353            .ok_or_else(|| format!("cannot find coordinate in coord index: {location:?}").into())
354    }
355
356    fn get_matrix_data(&self, profile: &Profile, from_idx: usize, to_idx: usize) -> GenericResult<(i64, i64)> {
357        let matrices = get_matrices(&self.matrices)?;
358        let matrix =
359            matrices.get(profile.index).ok_or_else(|| format!("cannot find matrix with index {}", profile.index))?;
360
361        let is_special_from_idx = self.coord_index.is_special_index(from_idx);
362        let is_special_to_idx = self.coord_index.is_special_index(to_idx);
363
364        if is_special_from_idx || is_special_to_idx {
365            // TODO handle other types of custom locations
366            return Ok((0, 0));
367        }
368
369        let matrix_size = get_matrix_size(matrices.as_slice());
370        let matrix_idx = from_idx * matrix_size + to_idx;
371
372        let distance = get_matrix_value(matrix_idx, &matrix.distances)?;
373        let duration = get_matrix_value(matrix_idx, &matrix.travel_times)?;
374        let duration = (duration as Float * profile.scale) as i64;
375
376        Ok((distance, duration))
377    }
378}
379
380fn job_task_size(tasks: &Option<Vec<JobTask>>) -> usize {
381    tasks.as_ref().map_or(0, |p| p.len())
382}
383
384fn match_job_task<'a>(
385    activity_type: &str,
386    job: &'a Job,
387    tasks_fn: impl Fn(&'a Vec<JobTask>) -> Option<&'a JobTask>,
388) -> Option<&'a JobTask> {
389    let tasks = match activity_type {
390        "pickup" => job.pickups.as_ref(),
391        "delivery" => job.deliveries.as_ref(),
392        "service" => job.services.as_ref(),
393        "replacement" => job.replacements.as_ref(),
394        _ => None,
395    };
396
397    tasks.and_then(tasks_fn)
398}
399
400fn parse_time_window(tw: &[String]) -> TimeWindow {
401    TimeWindow::new(parse_time(tw.first().unwrap()), parse_time(tw.last().unwrap()))
402}
403
404fn get_time_window(stop: &Stop, activity: &Activity) -> TimeWindow {
405    let schedule = stop.schedule();
406    let (start, end) = activity
407        .time
408        .as_ref()
409        .map_or_else(|| (&schedule.arrival, &schedule.departure), |interval| (&interval.start, &interval.end));
410
411    TimeWindow::new(parse_time(start), parse_time(end))
412}
413
414fn get_matrix_size(matrices: &[Matrix]) -> usize {
415    (matrices.first().unwrap().travel_times.len() as Float).sqrt().round() as usize
416}
417
418fn get_matrix_value(idx: usize, matrix_values: &[i64]) -> GenericResult<i64> {
419    matrix_values
420        .get(idx)
421        .cloned()
422        .ok_or_else(|| format!("attempt to get value out of bounds: {} vs {}", idx, matrix_values.len()).into())
423}
424
425fn get_matrices(matrices: &Option<Vec<Matrix>>) -> GenericResult<&Vec<Matrix>> {
426    let matrices = matrices.as_ref().unwrap();
427
428    if matrices.iter().any(|matrix| matrix.timestamp.is_some()) {
429        return Err("not implemented: time aware routing check".into());
430    }
431
432    Ok(matrices)
433}
434
435fn get_profile_index(problem: &Problem, matrices: &[Matrix]) -> GenericResult<HashMap<String, usize>> {
436    let profiles = problem.fleet.profiles.len();
437    if profiles != matrices.len() {
438        return Err(format!(
439            "precondition failed: amount of matrices supplied ({}) does not match profile specified ({})",
440            matrices.len(),
441            profiles,
442        )
443        .into());
444    }
445
446    Ok(problem
447        .fleet
448        .profiles
449        .iter()
450        .enumerate()
451        .map(|(idx, profile)| (profile.name.to_string(), idx))
452        .collect::<HashMap<_, _>>())
453}
454
455mod assignment;
456use crate::checker::assignment::check_assignment;
457
458mod capacity;
459use crate::checker::capacity::check_vehicle_load;
460
461mod limits;
462use crate::checker::limits::check_limits;
463
464mod breaks;
465use crate::checker::breaks::{check_breaks, get_break_time_window};
466
467mod relations;
468use crate::checker::relations::check_relations;
469
470mod routing;
471use crate::checker::routing::check_routing;