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
15pub 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
20fn 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
40fn 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 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
150fn 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 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 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 (not_equal(actual_departure, expected_departure)
248 && not_equal(actual_departure, expected_departure - parking))
249 || 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}