vrp_pragmatic/format/solution/
solution_writer.rs

1#[cfg(test)]
2#[path = "../../../tests/unit/format/solution/writer_test.rs"]
3mod writer_test;
4
5use crate::format::solution::activity_matcher::get_job_tag;
6use crate::format::solution::model::Timing;
7use crate::format::solution::*;
8use crate::format::CoordIndex;
9use vrp_core::construction::enablers::{get_route_intervals, ReservedTimesIndex};
10use vrp_core::construction::features::JobDemandDimension;
11use vrp_core::construction::heuristics::UnassignmentInfo;
12use vrp_core::models::common::*;
13use vrp_core::models::problem::{JobIdDimension, Multi, TravelTime, VehicleIdDimension};
14use vrp_core::models::solution::{Activity, Route};
15use vrp_core::prelude::Float;
16use vrp_core::rosomaxa::evolution::TelemetryMetrics;
17use vrp_core::solver::processing::{ClusterConfigExtraProperty, ReservedTimesExtraProperty};
18use vrp_core::utils::CollectGroupBy;
19
20struct Leg {
21    pub last_detail: Option<(DomainLocation, Timestamp)>,
22    pub load: Option<MultiDimLoad>,
23    pub statistic: Statistic,
24}
25
26impl Leg {
27    fn new(last_detail: Option<(DomainLocation, Timestamp)>, load: Option<MultiDimLoad>, statistic: Statistic) -> Self {
28        Self { last_detail, load, statistic }
29    }
30
31    fn empty() -> Self {
32        Self { last_detail: None, load: None, statistic: Statistic::default() }
33    }
34}
35
36/// Creates solution.
37pub(crate) fn create_solution(
38    problem: &DomainProblem,
39    solution: &DomainSolution,
40    output_type: &PragmaticOutputType,
41) -> ApiSolution {
42    let coord_index = problem.extras.get_coord_index().expect("no coord index");
43
44    let empty_reserved_times = Default::default();
45    let reserved_times_index = problem.extras.get_reserved_times();
46    let reserved_times_index = reserved_times_index.as_ref().unwrap_or(&empty_reserved_times);
47
48    let tours = solution
49        .routes
50        .iter()
51        .map(|r| create_tour(problem, r, &coord_index, reserved_times_index))
52        .collect::<Vec<Tour>>();
53
54    let statistic = tours.iter().fold(Statistic::default(), |acc, tour| acc + tour.statistic.clone());
55
56    let unassigned = create_unassigned(solution);
57    let violations = create_violations(solution);
58
59    let api_solution = ApiSolution { statistic, tours, unassigned, violations, extras: None };
60
61    let extras = create_extras(problem, &api_solution, solution.telemetry.as_ref(), output_type);
62
63    ApiSolution { extras, ..api_solution }
64}
65
66fn create_tour(
67    problem: &DomainProblem,
68    route: &Route,
69    coord_index: &CoordIndex,
70    reserved_times_index: &ReservedTimesIndex,
71) -> Tour {
72    // TODO reduce complexity
73    let parking = get_parking_time(problem.extras.as_ref());
74
75    let actor = route.actor.as_ref();
76    let vehicle = actor.vehicle.as_ref();
77    let transport = problem.transport.as_ref();
78
79    let mut tour = Tour {
80        vehicle_id: vehicle.dimens.get_vehicle_id().unwrap().clone(),
81        type_id: vehicle.dimens.get_vehicle_type().unwrap().clone(),
82        shift_index: vehicle.dimens.get_shift_index().copied().unwrap(),
83        stops: vec![],
84        statistic: Statistic::default(),
85    };
86
87    let intervals = get_route_intervals(route, |a| get_activity_type(a).map_or(false, |t| t == "reload"));
88
89    let mut leg = intervals.into_iter().fold(Leg::empty(), |leg, (start_idx, end_idx)| {
90        let (start_delivery, end_pickup) = route.tour.activities_slice(start_idx, end_idx).iter().fold(
91            (leg.load.unwrap_or_default(), MultiDimLoad::default()),
92            |acc, activity| {
93                let (delivery, pickup) = activity
94                    .job
95                    .as_ref()
96                    .and_then(|job| get_capacity(&job.dimens).map(|d| (d.delivery.0, d.pickup.0)))
97                    .unwrap_or((MultiDimLoad::default(), MultiDimLoad::default()));
98                (acc.0 + delivery, acc.1 + pickup)
99            },
100        );
101
102        let (start_idx, start) = if start_idx == 0 {
103            let start = route.tour.start().unwrap();
104            let is_same_location =
105                route.tour.get(1).map_or(false, |activity| start.place.location == activity.place.location);
106
107            tour.stops.push(Stop::Point(PointStop {
108                location: coord_index.get_by_idx(start.place.location).unwrap(),
109                time: format_schedule(&start.schedule),
110                load: start_delivery.as_vec(),
111                distance: 0,
112                activities: vec![ApiActivity {
113                    job_id: "departure".to_string(),
114                    activity_type: "departure".to_string(),
115                    location: None,
116                    time: if is_same_location {
117                        Some(Interval {
118                            start: format_time(start.schedule.arrival),
119                            end: format_time(start.schedule.departure),
120                        })
121                    } else {
122                        None
123                    },
124                    job_tag: None,
125                    commute: None,
126                }],
127                parking: None,
128            }));
129            (start_idx + 1, start)
130        } else {
131            (start_idx, route.tour.get(start_idx - 1).unwrap())
132        };
133
134        let mut leg = route.tour.activities_slice(start_idx, end_idx).iter().fold(
135            Leg::new(Some((start.place.location, start.schedule.departure)), Some(start_delivery), leg.statistic),
136            |leg, act| {
137                let activity_type = get_activity_type(act).cloned();
138                let (prev_location, prev_departure) = leg.last_detail.unwrap();
139                let prev_load = if activity_type.is_some() {
140                    leg.load.unwrap()
141                } else {
142                    // NOTE arrival must have zero load
143                    let dimen_size = leg.load.unwrap().size;
144                    MultiDimLoad::new(vec![0; dimen_size])
145                };
146
147                let activity_type = activity_type.unwrap_or_else(|| "arrival".to_string());
148                let is_break = activity_type == "break";
149
150                let job_tag = act.job.as_ref().and_then(|single| {
151                    get_job_tag(single, (act.place.location, (act.place.time.clone(), start.schedule.departure)))
152                        .cloned()
153                });
154                let job_id = match activity_type.as_str() {
155                    "pickup" | "delivery" | "replacement" | "service" => {
156                        let single = act.job.as_ref().unwrap();
157                        let id = single.dimens.get_job_id().cloned();
158                        id.unwrap_or_else(|| Multi::roots(single).unwrap().dimens.get_job_id().unwrap().clone())
159                    }
160                    _ => activity_type.clone(),
161                };
162
163                let commute = act.commute.clone().unwrap_or_default();
164                let commuting = commute.duration();
165
166                let (driving, transport_cost) = if commute.is_zero_distance() {
167                    // NOTE: use original cost traits to adapt time-based costs (except waiting/commuting)
168                    let prev_departure = TravelTime::Departure(prev_departure);
169                    let duration = transport.duration(route, prev_location, act.place.location, prev_departure);
170                    let transport_cost = transport.cost(route, prev_location, act.place.location, prev_departure);
171                    (duration, transport_cost)
172                } else {
173                    // NOTE: no need to drive in case of non-zero commute, this goes to commuting time
174                    (0., commuting * vehicle.costs.per_service_time)
175                };
176
177                // NOTE two clusters at the same stop location
178                let parking =
179                    match (prev_location == act.place.location, act.commute.is_some(), commute.is_zero_distance()) {
180                        (false, true, true) => parking,
181                        _ => 0.,
182                    };
183
184                let activity_arrival = parking + act.schedule.arrival + commute.forward.duration;
185                let service_start = activity_arrival.max(act.place.time.start);
186                let waiting = service_start - activity_arrival;
187                let serving = act.place.duration - parking;
188                let service_end = service_start + serving;
189                let activity_departure = service_end;
190
191                // TODO: add better support of time based activity costs
192                let serving_cost = problem.activity.cost(route, act, service_start);
193                let total_cost = serving_cost + transport_cost + waiting * vehicle.costs.per_waiting_time;
194
195                let location_distance =
196                    transport.distance(route, prev_location, act.place.location, TravelTime::Departure(prev_departure))
197                        as i64;
198                let distance = leg.statistic.distance + location_distance - commute.forward.distance as i64;
199
200                let is_new_stop = match (act.commute.as_ref(), prev_location == act.place.location) {
201                    (Some(commute), false) if commute.is_zero_distance() => true,
202                    (Some(_), _) => false,
203                    (None, is_same_location) => !is_same_location,
204                };
205
206                if is_new_stop {
207                    tour.stops.push(Stop::Point(PointStop {
208                        location: coord_index.get_by_idx(act.place.location).unwrap(),
209                        time: format_schedule(&act.schedule),
210                        load: prev_load.as_vec(),
211                        distance,
212                        parking: if parking > 0. {
213                            Some(Interval {
214                                start: format_time(act.schedule.arrival),
215                                end: format_time(act.schedule.arrival + parking),
216                            })
217                        } else {
218                            None
219                        },
220                        activities: vec![],
221                    }));
222                }
223
224                let load = calculate_load(prev_load, act);
225
226                let last = tour.stops.len() - 1;
227                let last = match tour.stops.get_mut(last).unwrap() {
228                    Stop::Point(point) => point,
229                    Stop::Transit(_) => unreachable!(),
230                };
231
232                last.time.departure = format_time(act.schedule.departure);
233                last.load = load.as_vec();
234                last.activities.push(ApiActivity {
235                    job_id,
236                    activity_type: activity_type.clone(),
237                    location: Some(coord_index.get_by_idx(act.place.location).unwrap()),
238                    time: Some(Interval {
239                        start: format_time(activity_arrival.max(act.place.time.start)),
240                        end: format_time(activity_departure),
241                    }),
242                    job_tag,
243                    commute: act
244                        .commute
245                        .as_ref()
246                        .map(|commute| Commute::new(commute, act.schedule.arrival, activity_departure, coord_index)),
247                });
248
249                // NOTE detect when vehicle returns after activity to stop point
250                let end_location = if commute.backward.is_zero_distance() {
251                    act.place.location
252                } else {
253                    tour.stops
254                        .last()
255                        .and_then(|stop| stop.as_point())
256                        .and_then(|stop| coord_index.get_by_loc(&stop.location))
257                        .expect("expect to have at least one stop")
258                };
259
260                Leg {
261                    last_detail: Some((end_location, act.schedule.departure)),
262                    statistic: Statistic {
263                        cost: leg.statistic.cost + total_cost,
264                        distance,
265                        duration: leg.statistic.duration + act.schedule.departure as i64 - prev_departure as i64,
266                        times: Timing {
267                            driving: leg.statistic.times.driving + driving as i64,
268                            serving: leg.statistic.times.serving + (if is_break { 0 } else { serving as i64 }),
269                            waiting: leg.statistic.times.waiting + waiting as i64,
270                            break_time: leg.statistic.times.break_time + (if is_break { serving as i64 } else { 0 }),
271                            commuting: leg.statistic.times.commuting + commuting as i64,
272                            parking: leg.statistic.times.parking + parking as i64,
273                        },
274                    },
275                    load: Some(load),
276                }
277            },
278        );
279
280        leg.load = Some(leg.load.unwrap() - end_pickup);
281
282        leg
283    });
284
285    leg.statistic.cost += vehicle.costs.fixed;
286    tour.statistic = leg.statistic;
287
288    insert_reserved_times_as_breaks(route, &mut tour, reserved_times_index);
289
290    // NOTE remove redundant info from single activity on the stop
291    tour.stops
292        .iter_mut()
293        .filter(|stop| stop.activities().len() == 1)
294        .flat_map(|stop| {
295            let schedule = stop.schedule().clone();
296            let location = stop.location().cloned();
297            stop.activities_mut().first_mut().map(|activity| (location, schedule, activity))
298        })
299        .for_each(|(location, schedule, activity)| {
300            let is_same_schedule = activity.time.as_ref().map_or(true, |time| schedule.arrival == time.start);
301            let is_same_location = activity.location.clone().zip(location).map_or(true, |(lhs, rhs)| lhs == rhs);
302
303            if is_same_schedule {
304                activity.time = None;
305            }
306
307            if is_same_location {
308                activity.location = None;
309            }
310        });
311
312    tour.vehicle_id.clone_from(vehicle.dimens.get_vehicle_id().unwrap());
313    tour.type_id.clone_from(vehicle.dimens.get_vehicle_type().unwrap());
314
315    tour
316}
317
318fn format_schedule(schedule: &DomainSchedule) -> ApiSchedule {
319    ApiSchedule { arrival: format_time(schedule.arrival), departure: format_time(schedule.departure) }
320}
321
322fn calculate_load(current: MultiDimLoad, act: &Activity) -> MultiDimLoad {
323    let job = act.job.as_ref();
324    let demand = job.and_then(|job| get_capacity(&job.dimens)).unwrap_or_default();
325    current - demand.delivery.0 - demand.delivery.1 + demand.pickup.0 + demand.pickup.1
326}
327
328fn create_unassigned(solution: &DomainSolution) -> Option<Vec<UnassignedJob>> {
329    let create_simple_reasons = |code: ViolationCode| {
330        let (code, reason) = map_code_reason(code);
331        vec![UnassignedJobReason { code: code.to_string(), description: reason.to_string(), details: None }]
332    };
333
334    let unassigned = solution
335        .unassigned
336        .iter()
337        .filter(|(job, _)| job.dimens().get_vehicle_id().is_none())
338        .map(|(job, code)| {
339            let job_id = job.dimens().get_job_id().expect("job id expected").clone();
340
341            let reasons = match code {
342                UnassignmentInfo::Simple(code) => create_simple_reasons(*code),
343                UnassignmentInfo::Detailed(details) if !details.is_empty() => details
344                    .iter()
345                    .collect_group_by_key(|(_, code)| *code)
346                    .into_iter()
347                    .map(|(code, group)| {
348                        let (code, reason) = map_code_reason(code);
349                        let mut vehicle_details = group
350                            .iter()
351                            .map(|(actor, _)| {
352                                let dimens = &actor.vehicle.dimens;
353                                let vehicle_id = dimens.get_vehicle_id().cloned().unwrap();
354                                let shift_index = dimens.get_shift_index().copied().unwrap();
355                                (vehicle_id, shift_index)
356                            })
357                            .collect::<Vec<_>>();
358                        // NOTE sort to have consistent order
359                        vehicle_details.sort();
360
361                        UnassignedJobReason {
362                            details: Some(
363                                vehicle_details
364                                    .into_iter()
365                                    .map(|(vehicle_id, shift_index)| UnassignedJobDetail { vehicle_id, shift_index })
366                                    .collect(),
367                            ),
368                            code: code.to_string(),
369                            description: reason.to_string(),
370                        }
371                    })
372                    .collect(),
373                _ => create_simple_reasons(ViolationCode(0)),
374            };
375
376            UnassignedJob { job_id, reasons }
377        })
378        .collect::<Vec<_>>();
379
380    if unassigned.is_empty() {
381        None
382    } else {
383        Some(unassigned)
384    }
385}
386
387fn create_violations(solution: &DomainSolution) -> Option<Vec<Violation>> {
388    // NOTE at the moment only break violation is mapped
389    let violations = solution
390        .unassigned
391        .iter()
392        .filter(|(job, _)| job.dimens().get_job_type().map_or(false, |t| t == "break"))
393        .map(|(job, _)| Violation::Break {
394            vehicle_id: job.dimens().get_vehicle_id().expect("vehicle id").clone(),
395            shift_index: job.dimens().get_shift_index().copied().expect("shift index"),
396        })
397        .collect::<Vec<_>>();
398
399    if violations.is_empty() {
400        None
401    } else {
402        Some(violations)
403    }
404}
405
406fn get_activity_type(activity: &Activity) -> Option<&String> {
407    activity.job.as_ref().and_then(|single| single.dimens.get_job_type())
408}
409
410fn get_capacity(dimens: &Dimensions) -> Option<Demand<MultiDimLoad>> {
411    // NOTE: try to detect whether dimensions stores multidimensional demand
412    let demand: Option<Demand<MultiDimLoad>> = dimens.get_job_demand().cloned();
413    if let Some(demand) = demand {
414        return Some(demand);
415    }
416
417    let create_capacity = |capacity: SingleDimLoad| {
418        if capacity.value == 0 {
419            MultiDimLoad::default()
420        } else {
421            MultiDimLoad::new(vec![capacity.value])
422        }
423    };
424    dimens.get_job_demand().map(|demand: &Demand<SingleDimLoad>| Demand {
425        pickup: (create_capacity(demand.pickup.0), create_capacity(demand.pickup.1)),
426        delivery: (create_capacity(demand.delivery.0), create_capacity(demand.delivery.1)),
427    })
428}
429
430fn get_parking_time(extras: &DomainExtras) -> Float {
431    extras.get_cluster_config().map_or(0., |config| config.serving.get_parking())
432}
433
434fn create_extras(
435    problem: &DomainProblem,
436    solution: &ApiSolution,
437    metrics: Option<&TelemetryMetrics>,
438    output_type: &PragmaticOutputType,
439) -> Option<Extras> {
440    match output_type {
441        PragmaticOutputType::OnlyPragmatic => {
442            get_api_metrics(metrics).map(|metrics| Extras { metrics: Some(metrics), features: None })
443        }
444        PragmaticOutputType::OnlyGeoJson => None,
445        PragmaticOutputType::Combined => {
446            Some(Extras {
447                metrics: get_api_metrics(metrics),
448                // TODO do not hide error here, propagate it to the caller
449                features: create_feature_collection(problem, solution).ok(),
450            })
451        }
452    }
453}
454
455fn get_api_metrics(metrics: Option<&TelemetryMetrics>) -> Option<ApiMetrics> {
456    metrics.as_ref().map(|metrics| ApiMetrics {
457        duration: metrics.duration,
458        generations: metrics.generations,
459        speed: metrics.speed,
460        evolution: metrics
461            .evolution
462            .iter()
463            .map(|g| ApiGeneration {
464                number: g.number,
465                timestamp: g.timestamp,
466                i_all_ratio: g.i_all_ratio,
467                i_1000_ratio: g.i_1000_ratio,
468                is_improvement: g.is_improvement,
469                population: AppPopulation {
470                    individuals: g
471                        .population
472                        .individuals
473                        .iter()
474                        .map(|i| ApiIndividual { difference: i.difference, fitness: i.fitness.clone() })
475                        .collect(),
476                },
477            })
478            .collect(),
479    })
480}