Skip to main content

vrp_pragmatic/checker/
assignment.rs

1#[cfg(test)]
2#[path = "../../tests/unit/checker/assignment_test.rs"]
3mod assignment_test;
4
5use super::*;
6use crate::format::get_indices;
7use crate::format::solution::activity_matcher::*;
8use crate::utils::combine_error_results;
9use std::collections::HashSet;
10use vrp_core::construction::clustering::vicinity::ServingPolicy;
11use vrp_core::models::solution::Place;
12use vrp_core::prelude::GenericResult;
13use vrp_core::utils::GenericError;
14
15/// Checks assignment of jobs and vehicles.
16pub fn check_assignment(ctx: &CheckerContext) -> Result<(), Vec<GenericError>> {
17    combine_error_results(&[check_vehicles(ctx), check_jobs_presence(ctx), check_jobs_match(ctx), check_groups(ctx)])
18}
19
20/// Checks that vehicles in each tour are used once per shift and they are known in problem.
21fn check_vehicles(ctx: &CheckerContext) -> GenericResult<()> {
22    let all_vehicles: HashSet<_> = ctx.problem.fleet.vehicles.iter().flat_map(|v| v.vehicle_ids.iter()).collect();
23    let mut used_vehicles = HashSet::<(String, usize)>::new();
24
25    ctx.solution.tours.iter().try_for_each(|tour| {
26        if !all_vehicles.contains(&tour.vehicle_id) {
27            return Err(format!("used vehicle with unknown id: '{}'", tour.vehicle_id));
28        }
29
30        if !(used_vehicles.insert((tour.vehicle_id.to_string(), tour.shift_index))) {
31            Err(format!("vehicle with '{}' id used more than once for shift {}", tour.vehicle_id, tour.shift_index))
32        } else {
33            Ok(())
34        }
35    })?;
36
37    Ok(())
38}
39
40/// Checks job task rules.
41fn check_jobs_presence(ctx: &CheckerContext) -> GenericResult<()> {
42    struct JobAssignment {
43        pub tour_info: (String, usize),
44        pub pickups: Vec<usize>,
45        pub deliveries: Vec<usize>,
46        pub replacements: Vec<usize>,
47        pub services: Vec<usize>,
48    }
49    let new_assignment = |tour_info: (String, usize)| JobAssignment {
50        tour_info,
51        pickups: vec![],
52        deliveries: vec![],
53        replacements: vec![],
54        services: vec![],
55    };
56    let activity_types: HashSet<_> = vec!["pickup", "delivery", "service", "replacement"].into_iter().collect();
57
58    let all_jobs = ctx.problem.plan.jobs.iter().map(|job| (job.id.clone(), job.clone())).collect::<HashMap<_, _>>();
59    let mut used_jobs = HashMap::<String, JobAssignment>::new();
60
61    ctx.solution.tours.iter().try_for_each(|tour| {
62        tour.stops
63            .iter()
64            .flat_map(|stop| stop.activities())
65            .enumerate()
66            .filter(|(_, activity)| activity_types.contains(&activity.activity_type.as_str()))
67            .try_for_each(|(idx, activity)| {
68                let tour_info = (tour.vehicle_id.clone(), tour.shift_index);
69                let asgn =
70                    used_jobs.entry(activity.job_id.clone()).or_insert_with(|| new_assignment(tour_info.clone()));
71
72                if asgn.tour_info != tour_info {
73                    return Err(GenericError::from(format!("job served in multiple tours: '{}'", activity.job_id)));
74                }
75
76                match activity.activity_type.as_str() {
77                    "pickup" => asgn.pickups.push(idx),
78                    "delivery" => asgn.deliveries.push(idx),
79                    "service" => asgn.services.push(idx),
80                    "replacement" => asgn.replacements.push(idx),
81                    _ => {}
82                }
83
84                Ok(())
85            })
86    })?;
87
88    used_jobs.iter().try_for_each(|(id, asgn)| {
89        // TODO validate whether each job task is served once
90        let job = all_jobs.get(id).ok_or_else(|| format!("cannot find job with id {id}"))?;
91        let expected_tasks = job.pickups.as_ref().map_or(0, |p| p.len())
92            + job.deliveries.as_ref().map_or(0, |d| d.len())
93            + job.services.as_ref().map_or(0, |s| s.len())
94            + job.replacements.as_ref().map_or(0, |r| r.len());
95        let assigned_tasks = asgn.pickups.len() + asgn.deliveries.len() + asgn.services.len() + asgn.replacements.len();
96
97        if expected_tasks != assigned_tasks {
98            return Err(GenericError::from(format!(
99                "not all tasks served for '{id}', expected: {expected_tasks}, assigned: {assigned_tasks}"
100            )));
101        }
102
103        if !asgn.deliveries.is_empty() && asgn.pickups.iter().max() > asgn.deliveries.iter().min() {
104            return Err(GenericError::from(format!("found pickup after delivery for '{id}'")));
105        }
106
107        Ok(())
108    })?;
109
110    let all_unassigned_jobs = ctx
111        .solution
112        .unassigned
113        .iter()
114        .flat_map(|jobs| jobs.iter().filter(|job| !job.job_id.ends_with("_break")))
115        .map(|job| job.job_id.clone())
116        .collect::<Vec<_>>();
117
118    let unique_unassigned_jobs = all_unassigned_jobs.iter().cloned().collect::<HashSet<_>>();
119
120    if unique_unassigned_jobs.len() != all_unassigned_jobs.len() {
121        return Err("duplicated job ids in the list of unassigned jobs".into());
122    }
123
124    unique_unassigned_jobs.iter().try_for_each::<_, GenericResult<_>>(|job_id| {
125        if !all_jobs.contains_key(job_id) {
126            return Err(format!("unknown job id in the list of unassigned jobs: '{job_id}'").into());
127        }
128
129        if used_jobs.contains_key(job_id) {
130            return Err(format!("job present as assigned and unassigned: '{job_id}'").into());
131        }
132
133        Ok(())
134    })?;
135
136    let all_used_job = unique_unassigned_jobs.into_iter().chain(used_jobs.into_keys()).collect::<Vec<_>>();
137
138    if all_used_job.len() != all_jobs.len() {
139        return Err(format!(
140            "amount of jobs present in problem and solution doesn't match: {} vs {}",
141            all_jobs.len(),
142            all_used_job.len()
143        )
144        .into());
145    }
146
147    Ok(())
148}
149
150/// Checks job constraint violations.
151fn check_jobs_match(ctx: &CheckerContext) -> GenericResult<()> {
152    let (job_index, coord_index) = get_indices(&ctx.core_problem.extras)?;
153    let (job_index, coord_index) = (job_index.as_ref(), coord_index.as_ref());
154
155    let job_ids = ctx
156        .solution
157        .tours
158        .iter()
159        .flat_map(move |tour| {
160            tour.stops.iter().flat_map(move |stop| {
161                stop.activities()
162                    .iter()
163                    .enumerate()
164                    .filter({
165                        move |(idx, activity)| {
166                            match stop {
167                                Stop::Point(stop) => {
168                                    let result = try_match_point_job(tour, stop, activity, job_index, coord_index);
169                                    match result {
170                                        Err(_) => {
171                                            // NOTE required break is not a job
172                                            if activity.activity_type == "break" {
173                                                try_match_break_activity(&ctx.problem, tour, &stop.time, activity)
174                                                    .is_err()
175                                            } else {
176                                                true
177                                            }
178                                        }
179                                        Ok(Some(JobInfo(_, _, place, time))) => {
180                                            is_valid_job_info(ctx, stop, activity, *idx, place, time)
181                                        }
182                                        _ => false,
183                                    }
184                                }
185                                Stop::Transit(stop) => {
186                                    try_match_transit_activity(&ctx.problem, tour, stop, activity).is_err()
187                                }
188                            }
189                        }
190                    })
191                    .map(|(_, activity)| {
192                        format!(
193                            "{}:{}",
194                            activity.job_id.clone(),
195                            activity.job_tag.as_ref().unwrap_or(&"<no tag>".to_string())
196                        )
197                    })
198            })
199        })
200        .collect::<Vec<_>>();
201
202    if !job_ids.is_empty() {
203        return Err(format!("cannot match activities to jobs: {}", job_ids.join(", ")).into());
204    }
205
206    Ok(())
207}
208
209fn is_valid_job_info(
210    ctx: &CheckerContext,
211    stop: &PointStop,
212    activity: &Activity,
213    activity_idx: usize,
214    place: Place,
215    time: TimeWindow,
216) -> bool {
217    let not_equal = |left: Float, right: Float| left != right;
218    let parking = ctx.clustering.as_ref().map(|config| config.serving.get_parking()).unwrap_or(0.);
219    let commute_profile = ctx.clustering.as_ref().map(|config| config.profile.clone());
220    let domain_commute = ctx.get_commute_info(commute_profile, parking, stop, activity_idx);
221    let extra_time = get_extra_time(stop, activity, &place).unwrap_or(0.);
222
223    match (&ctx.clustering, &activity.commute, domain_commute) {
224        (_, _, Err(_)) | (_, None, Ok(Some(_))) | (_, Some(_), Ok(None)) | (&None, &Some(_), Ok(Some(_))) => true,
225        (_, None, Ok(None)) => {
226            let expected_departure = time.start.max(place.time.start) + place.duration + extra_time;
227            not_equal(time.end, expected_departure)
228        }
229        (Some(config), Some(commute), Ok(Some(d_commute))) => {
230            let (service_time, parking) = match config.serving {
231                ServingPolicy::Original { parking } => (place.duration, parking),
232                ServingPolicy::Multiplier { multiplier, parking } => (place.duration * multiplier, parking),
233                ServingPolicy::Fixed { value, parking } => (value, parking),
234            };
235
236            let a_commute = commute.to_domain(&ctx.coord_index);
237
238            // NOTE: we keep parking in service time of a first activity of the non-first cluster
239            let service_time =
240                service_time + if a_commute.is_zero_distance() && activity_idx > 0 { parking } else { 0. };
241
242            let expected_departure =
243                time.start.max(place.time.start) + service_time + d_commute.backward.duration + extra_time;
244            let actual_departure = time.end + d_commute.backward.duration;
245
246            // NOTE: a "workaroundish" approach for two clusters in the same stop
247            (not_equal(actual_departure, expected_departure)
248                && not_equal(actual_departure, expected_departure - parking))
249                // compare commute
250                || not_equal(a_commute.forward.distance, d_commute.forward.distance)
251                || not_equal(a_commute.forward.duration, d_commute.forward.duration)
252                || not_equal(a_commute.backward.distance, d_commute.backward.distance)
253                || not_equal(a_commute.backward.duration, d_commute.backward.duration)
254        }
255    }
256}
257
258fn check_groups(ctx: &CheckerContext) -> GenericResult<()> {
259    let violations = ctx
260        .solution
261        .tours
262        .iter()
263        .fold(HashMap::<String, HashSet<_>>::default(), |mut acc, tour| {
264            tour.stops
265                .iter()
266                .flat_map(|stop| stop.activities().iter())
267                .flat_map(|activity| ctx.get_job_by_id(&activity.job_id))
268                .flat_map(|job| job.group.as_ref())
269                .for_each(|group| {
270                    acc.entry(group.clone()).or_default().insert((
271                        tour.type_id.clone(),
272                        tour.vehicle_id.clone(),
273                        tour.shift_index,
274                    ));
275                });
276
277            acc
278        })
279        .into_iter()
280        .filter(|(_, usage)| usage.len() > 1)
281        .collect::<Vec<_>>();
282
283    if violations.is_empty() {
284        Ok(())
285    } else {
286        let err_info = violations.into_iter().map(|(group, _)| group).collect::<Vec<_>>().join(",");
287        Err(format!("job groups are not respected: '{err_info}'").into())
288    }
289}