diff --git a/vrp-pragmatic/src/checker/assignment.rs b/vrp-pragmatic/src/checker/assignment.rs index 7efbe1311..c8bdf492d 100644 --- a/vrp-pragmatic/src/checker/assignment.rs +++ b/vrp-pragmatic/src/checker/assignment.rs @@ -10,7 +10,7 @@ use std::cmp::Ordering; use std::collections::HashSet; use vrp_core::construction::clustering::vicinity::ServingPolicy; use vrp_core::models::solution::Place; -use vrp_core::prelude::compare_floats; +use vrp_core::prelude::{compare_floats, GenericResult}; use vrp_core::utils::GenericError; /// Checks assignment of jobs and vehicles. @@ -19,7 +19,7 @@ pub fn check_assignment(ctx: &CheckerContext) -> Result<(), Vec> { } /// Checks that vehicles in each tour are used once per shift and they are known in problem. -fn check_vehicles(ctx: &CheckerContext) -> Result<(), GenericError> { +fn check_vehicles(ctx: &CheckerContext) -> GenericResult<()> { let all_vehicles: HashSet<_> = ctx.problem.fleet.vehicles.iter().flat_map(|v| v.vehicle_ids.iter()).collect(); let mut used_vehicles = HashSet::<(String, usize)>::new(); @@ -39,7 +39,7 @@ fn check_vehicles(ctx: &CheckerContext) -> Result<(), GenericError> { } /// Checks job task rules. -fn check_jobs_presence(ctx: &CheckerContext) -> Result<(), GenericError> { +fn check_jobs_presence(ctx: &CheckerContext) -> GenericResult<()> { struct JobAssignment { pub tour_info: (String, usize), pub pickups: Vec, @@ -122,7 +122,7 @@ fn check_jobs_presence(ctx: &CheckerContext) -> Result<(), GenericError> { return Err("duplicated job ids in the list of unassigned jobs".into()); } - unique_unassigned_jobs.iter().try_for_each::<_, Result<_, GenericError>>(|job_id| { + unique_unassigned_jobs.iter().try_for_each::<_, GenericResult<_>>(|job_id| { if !all_jobs.contains_key(job_id) { return Err(format!("unknown job id in the list of unassigned jobs: '{job_id}'").into()); } @@ -149,7 +149,7 @@ fn check_jobs_presence(ctx: &CheckerContext) -> Result<(), GenericError> { } /// Checks job constraint violations. -fn check_jobs_match(ctx: &CheckerContext) -> Result<(), GenericError> { +fn check_jobs_match(ctx: &CheckerContext) -> GenericResult<()> { let (job_index, coord_index) = get_indices(&ctx.core_problem.extras)?; let (job_index, coord_index) = (job_index.as_ref(), coord_index.as_ref()); @@ -256,7 +256,7 @@ fn is_valid_job_info( } } -fn check_groups(ctx: &CheckerContext) -> Result<(), GenericError> { +fn check_groups(ctx: &CheckerContext) -> GenericResult<()> { let violations = ctx .solution .tours diff --git a/vrp-pragmatic/src/checker/breaks.rs b/vrp-pragmatic/src/checker/breaks.rs index 5a456c766..a2c13e063 100644 --- a/vrp-pragmatic/src/checker/breaks.rs +++ b/vrp-pragmatic/src/checker/breaks.rs @@ -5,6 +5,7 @@ mod breaks_test; use super::*; use crate::utils::combine_error_results; use std::iter::once; +use vrp_core::prelude::GenericResult; use vrp_core::utils::GenericError; /// Checks that breaks are properly assigned. @@ -12,7 +13,7 @@ pub fn check_breaks(context: &CheckerContext) -> Result<(), Vec> { combine_error_results(&[check_break_assignment(context)]) } -fn check_break_assignment(context: &CheckerContext) -> Result<(), GenericError> { +fn check_break_assignment(context: &CheckerContext) -> GenericResult<()> { context.solution.tours.iter().try_for_each(|tour| { let vehicle_shift = context.get_vehicle_shift(tour)?; let actual_break_count = tour @@ -25,7 +26,7 @@ fn check_break_assignment(context: &CheckerContext) -> Result<(), GenericError> stop.activities() .windows(stop.activities().len().min(2)) .flat_map(|leg| as_leg_info_with_break(context, tour, stop, leg)) - .try_fold::<_, _, Result<_, GenericError>>( + .try_fold::<_, _, GenericResult<_>>( acc, |acc, (from_loc, (from, to), (break_activity, vehicle_break))| { // check time @@ -167,7 +168,7 @@ fn as_leg_info_with_break<'a>( } /// Gets break time window. -pub(crate) fn get_break_time_window(tour: &Tour, vehicle_break: &VehicleBreak) -> Result { +pub(crate) fn get_break_time_window(tour: &Tour, vehicle_break: &VehicleBreak) -> GenericResult { let departure = tour .stops .first() diff --git a/vrp-pragmatic/src/checker/capacity.rs b/vrp-pragmatic/src/checker/capacity.rs index 6e581c738..cdb792dbe 100644 --- a/vrp-pragmatic/src/checker/capacity.rs +++ b/vrp-pragmatic/src/checker/capacity.rs @@ -6,6 +6,7 @@ use super::*; use crate::utils::combine_error_results; use std::iter::once; use vrp_core::models::common::{Load, MultiDimLoad}; +use vrp_core::prelude::GenericResult; /// Checks that vehicle load is assigned correctly. The following rules are checked: /// * max vehicle's capacity is not violated @@ -14,16 +15,16 @@ pub fn check_vehicle_load(context: &CheckerContext) -> Result<(), Vec Result<(), GenericError> { - context.solution.tours.iter().try_for_each::<_, Result<_, GenericError>>(|tour| { +fn check_vehicle_load_assignment(context: &CheckerContext) -> GenericResult<()> { + context.solution.tours.iter().try_for_each::<_, GenericResult<_>>(|tour| { let capacity = MultiDimLoad::new(context.get_vehicle(&tour.vehicle_id)?.capacity.clone()); let intervals = get_intervals(context, tour); intervals .iter() - .try_fold::<_, _, Result<_, GenericError>>(MultiDimLoad::default(), |acc, interval| { + .try_fold::<_, _, GenericResult<_>>(MultiDimLoad::default(), |acc, interval| { let (start_delivery, end_pickup) = get_activities_from_interval(context, tour, interval.as_slice()) - .try_fold::<_, _, Result<_, GenericError>>( + .try_fold::<_, _, GenericResult<_>>( (acc, MultiDimLoad::default()), |acc, (activity, activity_type)| { let activity_type = activity_type?; @@ -37,9 +38,8 @@ fn check_vehicle_load_assignment(context: &CheckerContext) -> Result<(), Generic }, )?; - let end_capacity = interval.iter().try_fold::<_, _, Result<_, GenericError>>( - start_delivery, - |acc, (idx, (from, to))| { + let end_capacity = + interval.iter().try_fold::<_, _, GenericResult<_>>(start_delivery, |acc, (idx, (from, to))| { let from_load = MultiDimLoad::new(from.load().clone()); let to_load = MultiDimLoad::new(to.load().clone()); @@ -47,7 +47,7 @@ fn check_vehicle_load_assignment(context: &CheckerContext) -> Result<(), Generic return Err(format!("load exceeds capacity in tour '{}'", tour.vehicle_id).into()); } - let change = to.activities().iter().try_fold::<_, _, Result<_, GenericError>>( + let change = to.activities().iter().try_fold::<_, _, GenericResult<_>>( MultiDimLoad::default(), |acc, activity| { let activity_type = context.get_activity_type(tour, to, activity)?; @@ -80,8 +80,7 @@ fn check_vehicle_load_assignment(context: &CheckerContext) -> Result<(), Generic Err(format!("load mismatch {} in tour '{}'", message, tour.vehicle_id).into()) } - }, - )?; + })?; Ok(end_capacity - end_pickup) }) @@ -89,7 +88,7 @@ fn check_vehicle_load_assignment(context: &CheckerContext) -> Result<(), Generic }) } -fn check_resource_consumption(context: &CheckerContext) -> Result<(), GenericError> { +fn check_resource_consumption(context: &CheckerContext) -> GenericResult<()> { let resources = context .problem .fleet @@ -170,7 +169,7 @@ fn get_demand( context: &CheckerContext, activity: &Activity, activity_type: &ActivityType, -) -> Result<(DemandType, MultiDimLoad), GenericError> { +) -> GenericResult<(DemandType, MultiDimLoad)> { let (is_dynamic, demand) = context.visit_job( activity, activity_type, @@ -235,7 +234,7 @@ fn get_activities_from_interval<'a>( context: &'a CheckerContext, tour: &'a Tour, interval: &'a [(usize, (&Stop, &Stop))], -) -> impl Iterator)> + 'a { +) -> impl Iterator)> + 'a { interval .iter() .flat_map(|(_, (from, to))| once(from).chain(once(to))) diff --git a/vrp-pragmatic/src/checker/limits.rs b/vrp-pragmatic/src/checker/limits.rs index 9a34c900d..d305592e0 100644 --- a/vrp-pragmatic/src/checker/limits.rs +++ b/vrp-pragmatic/src/checker/limits.rs @@ -4,17 +4,19 @@ mod limits_test; use super::*; use crate::utils::combine_error_results; +use vrp_core::models::common::Distance; +use vrp_core::prelude::GenericResult; /// NOTE to ensure distance/duration correctness, routing check should be performed first. pub fn check_limits(context: &CheckerContext) -> Result<(), Vec> { - combine_error_results(&[check_shift_limits(context), check_shift_time(context)]) + combine_error_results(&[check_shift_limits(context), check_shift_time(context), check_recharge_limits(context)]) } /// Check that shift limits are not violated: /// * max shift time /// * max distance -fn check_shift_limits(context: &CheckerContext) -> Result<(), GenericError> { - context.solution.tours.iter().try_for_each::<_, Result<_, GenericError>>(|tour| { +fn check_shift_limits(context: &CheckerContext) -> GenericResult<()> { + context.solution.tours.iter().try_for_each::<_, GenericResult<_>>(|tour| { let vehicle = context.get_vehicle(&tour.vehicle_id)?; if let Some(ref limits) = vehicle.limits { @@ -56,8 +58,8 @@ fn check_shift_limits(context: &CheckerContext) -> Result<(), GenericError> { }) } -fn check_shift_time(context: &CheckerContext) -> Result<(), GenericError> { - context.solution.tours.iter().try_for_each::<_, Result<_, GenericError>>(|tour| { +fn check_shift_time(context: &CheckerContext) -> GenericResult<()> { + context.solution.tours.iter().try_for_each::<_, GenericResult<_>>(|tour| { let vehicle = context.get_vehicle(&tour.vehicle_id)?; let (start, end) = tour.stops.first().zip(tour.stops.last()).ok_or("empty tour")?; @@ -87,3 +89,41 @@ fn check_shift_time(context: &CheckerContext) -> Result<(), GenericError> { } }) } + +fn check_recharge_limits(context: &CheckerContext) -> GenericResult<()> { + context.solution.tours.iter().filter(|tour| tour.stops.len() > 1).try_for_each::<_, GenericResult<_>>(|tour| { + let shift = context.get_vehicle_shift(tour)?; + + let Some(recharge) = shift.recharges.as_ref() else { return Ok(()) }; + + let stops = tour.stops.iter().filter_map(|stop| stop.as_point()).collect::>(); + if stops.len() < 2 { + return Ok(()); + } + + stops + .windows(2) + .try_fold(Distance::default(), |acc, stops| { + let (prev, next) = match stops { + [prev, next] => (prev, next), + _ => unreachable!(), + }; + + let delta = (next.distance - prev.distance) as Distance; + let total_distance = acc + delta; + + if total_distance > recharge.max_distance { + return Err(format!( + "recharge distance violation: expected limit is {}, got {}, vehicle id '{}', shift index: {}", + recharge.max_distance, total_distance, tour.vehicle_id, tour.shift_index + ) + .into()); + } + + let has_recharge = next.activities.iter().any(|activity| activity.activity_type == "recharge"); + + Ok(if has_recharge { Distance::default() } else { total_distance }) + }) + .map(|_| ()) + }) +} diff --git a/vrp-pragmatic/src/checker/mod.rs b/vrp-pragmatic/src/checker/mod.rs index fa09e70f2..66a1cdc47 100644 --- a/vrp-pragmatic/src/checker/mod.rs +++ b/vrp-pragmatic/src/checker/mod.rs @@ -16,7 +16,7 @@ use vrp_core::construction::clustering::vicinity::VisitPolicy; use vrp_core::models::common::{Duration, Profile, TimeWindow}; use vrp_core::models::solution::{Commute as DomainCommute, CommuteInfo as DomainCommuteInfo}; use vrp_core::models::Problem as CoreProblem; -use vrp_core::prelude::GenericError; +use vrp_core::prelude::{GenericError, GenericResult}; use vrp_core::solver::processing::ClusterConfigExtraProperty; /// Stores problem and solution together and provides some helper methods. @@ -96,7 +96,7 @@ impl CheckerContext { } /// Gets vehicle by its id. - fn get_vehicle(&self, vehicle_id: &str) -> Result<&VehicleType, GenericError> { + fn get_vehicle(&self, vehicle_id: &str) -> GenericResult<&VehicleType> { self.problem .fleet .vehicles @@ -105,7 +105,7 @@ impl CheckerContext { .ok_or_else(|| format!("cannot find vehicle with id '{vehicle_id}'").into()) } - fn get_vehicle_profile(&self, vehicle_id: &str) -> Result { + fn get_vehicle_profile(&self, vehicle_id: &str) -> GenericResult { let profile = &self.get_vehicle(vehicle_id)?.profile; let index = self .profile_index @@ -137,7 +137,7 @@ impl CheckerContext { } /// Gets vehicle shift where activity is used. - fn get_vehicle_shift(&self, tour: &Tour) -> Result { + fn get_vehicle_shift(&self, tour: &Tour) -> GenericResult { let tour_time = TimeWindow::new( parse_time( &tour.stops.first().as_ref().ok_or_else(|| "cannot get first activity".to_string())?.schedule().arrival, @@ -167,7 +167,7 @@ impl CheckerContext { } /// Gets wrapped activity type. - fn get_activity_type(&self, tour: &Tour, stop: &Stop, activity: &Activity) -> Result { + fn get_activity_type(&self, tour: &Tour, stop: &Stop, activity: &Activity) -> GenericResult { let shift = self.get_vehicle_shift(tour)?; let time = self.get_activity_time(stop, activity); let location = self.get_activity_location(stop, activity); @@ -231,7 +231,7 @@ impl CheckerContext { parking: Duration, stop: &PointStop, activity_idx: usize, - ) -> Result, GenericError> { + ) -> GenericResult> { let get_activity_location_by_idx = |idx: usize| { stop.activities .get(idx) @@ -314,7 +314,7 @@ impl CheckerContext { activity_type: &ActivityType, job_visitor: F1, other_visitor: F2, - ) -> Result + ) -> GenericResult where F1: Fn(&Job, &JobTask) -> R, F2: Fn() -> R, @@ -346,13 +346,13 @@ impl CheckerContext { } } - fn get_location_index(&self, location: &Location) -> Result { + fn get_location_index(&self, location: &Location) -> GenericResult { self.coord_index .get_by_loc(location) .ok_or_else(|| format!("cannot find coordinate in coord index: {location:?}").into()) } - fn get_matrix_data(&self, profile: &Profile, from_idx: usize, to_idx: usize) -> Result<(i64, i64), GenericError> { + fn get_matrix_data(&self, profile: &Profile, from_idx: usize, to_idx: usize) -> GenericResult<(i64, i64)> { let matrices = get_matrices(&self.matrices)?; let matrix = matrices.get(profile.index).ok_or_else(|| format!("cannot find matrix with index {}", profile.index))?; @@ -414,14 +414,14 @@ fn get_matrix_size(matrices: &[Matrix]) -> usize { (matrices.first().unwrap().travel_times.len() as f64).sqrt().round() as usize } -fn get_matrix_value(idx: usize, matrix_values: &[i64]) -> Result { +fn get_matrix_value(idx: usize, matrix_values: &[i64]) -> GenericResult { matrix_values .get(idx) .cloned() .ok_or_else(|| format!("attempt to get value out of bounds: {} vs {}", idx, matrix_values.len()).into()) } -fn get_matrices(matrices: &Option>) -> Result<&Vec, GenericError> { +fn get_matrices(matrices: &Option>) -> GenericResult<&Vec> { let matrices = matrices.as_ref().unwrap(); if matrices.iter().any(|matrix| matrix.timestamp.is_some()) { @@ -431,7 +431,7 @@ fn get_matrices(matrices: &Option>) -> Result<&Vec, GenericE Ok(matrices) } -fn get_profile_index(problem: &Problem, matrices: &[Matrix]) -> Result, GenericError> { +fn get_profile_index(problem: &Problem, matrices: &[Matrix]) -> GenericResult> { let profiles = problem.fleet.profiles.len(); if profiles != matrices.len() { return Err(format!( diff --git a/vrp-pragmatic/src/checker/relations.rs b/vrp-pragmatic/src/checker/relations.rs index 3a4677280..985bd4dde 100644 --- a/vrp-pragmatic/src/checker/relations.rs +++ b/vrp-pragmatic/src/checker/relations.rs @@ -5,13 +5,14 @@ mod relations_test; use super::*; use crate::utils::combine_error_results; use std::collections::HashSet; +use vrp_core::prelude::GenericResult; /// Checks relation rules. pub fn check_relations(context: &CheckerContext) -> Result<(), Vec> { combine_error_results(&[check_relations_assignment(context)]) } -fn check_relations_assignment(context: &CheckerContext) -> Result<(), GenericError> { +fn check_relations_assignment(context: &CheckerContext) -> GenericResult<()> { let reserved_ids = vec!["departure", "arrival", "break", "reload"].into_iter().collect::>(); (0_usize..) @@ -94,11 +95,7 @@ fn check_relations_assignment(context: &CheckerContext) -> Result<(), GenericErr Ok(()) } -fn get_tour_by_vehicle_id( - vehicle_id: &str, - shift_index: Option, - solution: &Solution, -) -> Result { +fn get_tour_by_vehicle_id(vehicle_id: &str, shift_index: Option, solution: &Solution) -> GenericResult { solution .tours .iter() diff --git a/vrp-pragmatic/src/checker/routing.rs b/vrp-pragmatic/src/checker/routing.rs index 0f9a490c6..8b60ce064 100644 --- a/vrp-pragmatic/src/checker/routing.rs +++ b/vrp-pragmatic/src/checker/routing.rs @@ -5,22 +5,23 @@ mod routing_test; use super::*; use crate::format_time; use crate::utils::combine_error_results; +use vrp_core::prelude::GenericResult; /// Checks that matrix routing information is used properly. pub fn check_routing(context: &CheckerContext) -> Result<(), Vec> { combine_error_results(&[check_routing_rules(context)]) } -fn check_routing_rules(context: &CheckerContext) -> Result<(), GenericError> { +fn check_routing_rules(context: &CheckerContext) -> GenericResult<()> { if context.matrices.as_ref().map_or(true, |m| m.is_empty()) { return Ok(()); } let skip_distance_check = skip_distance_check(&context.solution); - context.solution.tours.iter().try_for_each::<_, Result<_, GenericError>>(|tour| { + context.solution.tours.iter().try_for_each::<_, GenericResult<_>>(|tour| { let profile = context.get_vehicle_profile(&tour.vehicle_id)?; - let get_matrix_data = |from: &PointStop, to: &PointStop| -> Result<(i64, i64), GenericError> { + let get_matrix_data = |from: &PointStop, to: &PointStop| -> GenericResult<(i64, i64)> { let from_idx = context.get_location_index(&from.location)?; let to_idx = context.get_location_index(&to.location)?; context.get_matrix_data(&profile, from_idx, to_idx) @@ -37,60 +38,59 @@ fn check_routing_rules(context: &CheckerContext) -> Result<(), GenericError> { .unwrap_or_else(|| &first_stop.schedule().departure), ) as i64; - let (departure_time, total_distance) = - tour.stops.windows(2).enumerate().try_fold::<_, _, Result<_, GenericError>>( - (parse_time(&first_stop.schedule().departure) as i64, 0), - |(arrival_time, total_distance), (leg_idx, stops)| { - let (from, to) = match stops { - [from, to] => (from, to), - _ => unreachable!(), - }; - - let (distance, duration, to_distance) = match (from, to) { - (Stop::Point(from), Stop::Point(to)) => { - let (distance, duration) = get_matrix_data(from, to)?; - (distance, duration, to.distance) - } - (prev, Stop::Transit(transit)) => { - let prev_departure = parse_time(&prev.schedule().departure); - let next_arrival = parse_time(&transit.time.arrival); - // NOTE an edge case: duration of break will be counted in transit stop - let duration = if next_arrival == prev_departure { - 0. - } else { - parse_time(&transit.time.departure) - next_arrival - }; - (0_i64, duration as i64, total_distance) - } - (Stop::Transit(_), Stop::Point(to)) => { - assert!(leg_idx > 0); - let from = tour - .stops - .get(leg_idx - 1) - .unwrap() - .as_point() - .expect("two consistent transit stops are not supported"); - let (distance, duration) = get_matrix_data(from, to)?; - (distance, duration, to.distance) - } - }; - - let arrival_time = arrival_time + duration; - let total_distance = total_distance + distance; - - check_stop_statistic( - arrival_time, - total_distance, - to.schedule(), - to_distance, - leg_idx + 1, - tour, - skip_distance_check, - )?; - - Ok((parse_time(&to.schedule().departure) as i64, to_distance)) - }, - )?; + let (departure_time, total_distance) = tour.stops.windows(2).enumerate().try_fold::<_, _, GenericResult<_>>( + (parse_time(&first_stop.schedule().departure) as i64, 0), + |(arrival_time, total_distance), (leg_idx, stops)| { + let (from, to) = match stops { + [from, to] => (from, to), + _ => unreachable!(), + }; + + let (distance, duration, to_distance) = match (from, to) { + (Stop::Point(from), Stop::Point(to)) => { + let (distance, duration) = get_matrix_data(from, to)?; + (distance, duration, to.distance) + } + (prev, Stop::Transit(transit)) => { + let prev_departure = parse_time(&prev.schedule().departure); + let next_arrival = parse_time(&transit.time.arrival); + // NOTE an edge case: duration of break will be counted in transit stop + let duration = if next_arrival == prev_departure { + 0. + } else { + parse_time(&transit.time.departure) - next_arrival + }; + (0_i64, duration as i64, total_distance) + } + (Stop::Transit(_), Stop::Point(to)) => { + assert!(leg_idx > 0); + let from = tour + .stops + .get(leg_idx - 1) + .unwrap() + .as_point() + .expect("two consistent transit stops are not supported"); + let (distance, duration) = get_matrix_data(from, to)?; + (distance, duration, to.distance) + } + }; + + let arrival_time = arrival_time + duration; + let total_distance = total_distance + distance; + + check_stop_statistic( + arrival_time, + total_distance, + to.schedule(), + to_distance, + leg_idx + 1, + tour, + skip_distance_check, + )?; + + Ok((parse_time(&to.schedule().departure) as i64, to_distance)) + }, + )?; check_tour_statistic(departure_time, total_distance, time_offset, tour, skip_distance_check) })?; @@ -106,7 +106,7 @@ fn check_stop_statistic( stop_idx: usize, tour: &Tour, skip_distance_check: bool, -) -> Result<(), GenericError> { +) -> GenericResult<()> { if (arrival_time - parse_time(&schedule.arrival) as i64).abs() > 1 { return Err(format!( "arrival time mismatch for {stop_idx} stop in the tour: {}, expected: '{}', got: '{}'", @@ -134,7 +134,7 @@ fn check_tour_statistic( time_offset: i64, tour: &Tour, skip_distance_check: bool, -) -> Result<(), GenericError> { +) -> GenericResult<()> { if !skip_distance_check && (total_distance - tour.statistic.distance).abs() > 1 { return Err(format!( "distance mismatch for tour statistic: {}, expected: '{}', got: '{}'", @@ -155,7 +155,7 @@ fn check_tour_statistic( Ok(()) } -fn check_solution_statistic(solution: &Solution) -> Result<(), GenericError> { +fn check_solution_statistic(solution: &Solution) -> GenericResult<()> { let statistic = solution.tours.iter().fold(Statistic::default(), |acc, tour| acc + tour.statistic.clone()); // NOTE cost should be ignored due to floating point issues diff --git a/vrp-pragmatic/tests/discovery/property/generated_with_recharge.rs b/vrp-pragmatic/tests/discovery/property/generated_with_recharge.rs new file mode 100644 index 000000000..697d2829f --- /dev/null +++ b/vrp-pragmatic/tests/discovery/property/generated_with_recharge.rs @@ -0,0 +1,91 @@ +use crate::format::problem::*; +use crate::generator::*; +use crate::helpers::solve_with_metaheuristic_and_iterations; +use proptest::prelude::*; +use std::ops::Range; +use vrp_core::models::common::Distance; + +prop_compose! { + pub fn get_max_distances(range: Range)(distance in range) -> Distance { + distance as Distance + } +} + +fn get_recharge_stations() -> impl Strategy> { + prop::collection::vec( + generate_recharge_station( + generate_location(&DEFAULT_BOUNDING_BOX), + generate_durations(300..3600), + generate_no_tags(), + generate_no_time_windows(), + ), + 5..20, + ) +} + +prop_compose! { + fn get_vehicle_type_with_recharges() + ( + vehicle in default_vehicle_type_prototype(), + max_distance in get_max_distances(3000..30000), + stations in get_recharge_stations() + ) -> VehicleType { + assert_eq!(vehicle.shifts.len(), 1); + + let mut vehicle = vehicle; + + // set capacity to high and have only one vehicle of such type to have a higher probability + // for recharge to be kicked in + vehicle.capacity = vec![10000]; + vehicle.vehicle_ids = vec![format!("{}_1", vehicle.type_id)]; + + vehicle.shifts.first_mut().unwrap().end = None; + vehicle.shifts.first_mut().unwrap().recharges = Some(VehicleRecharges { max_distance, stations }); + + vehicle + } +} + +pub fn get_delivery_prototype() -> impl Strategy { + delivery_job_prototype( + job_task_prototype( + job_place_prototype( + generate_location(&DEFAULT_BOUNDING_BOX), + generate_durations(1..10), + generate_no_time_windows(), + generate_no_tags(), + ), + generate_simple_demand(1..2), + generate_no_order(), + ), + generate_no_jobs_skills(), + generate_no_jobs_value(), + generate_no_jobs_group(), + generate_no_jobs_compatibility(), + ) +} + +prop_compose! { + fn create_problem_with_recharges() + ( + plan in generate_plan(generate_jobs(get_delivery_prototype(), 1..512)), + fleet in generate_fleet( + generate_vehicles(get_vehicle_type_with_recharges(), 1..2), + default_matrix_profiles()) + ) -> Problem { + Problem { + plan, + fleet, + objectives: None, + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + #[test] + #[ignore] + fn can_solve_problem_with_recharge(problem in create_problem_with_recharges()) { + solve_with_metaheuristic_and_iterations(problem, None, 10); + } +} diff --git a/vrp-pragmatic/tests/discovery/property/mod.rs b/vrp-pragmatic/tests/discovery/property/mod.rs index a3f689d8c..db63bda9c 100644 --- a/vrp-pragmatic/tests/discovery/property/mod.rs +++ b/vrp-pragmatic/tests/discovery/property/mod.rs @@ -3,5 +3,6 @@ mod generated_with_breaks; mod generated_with_clustering; mod generated_with_groups; +mod generated_with_recharge; mod generated_with_relations; mod generated_with_reload; diff --git a/vrp-pragmatic/tests/features/recharge/basic_recharge.rs b/vrp-pragmatic/tests/features/recharge/basic_recharge.rs index fac3bd8f3..d003d469c 100644 --- a/vrp-pragmatic/tests/features/recharge/basic_recharge.rs +++ b/vrp-pragmatic/tests/features/recharge/basic_recharge.rs @@ -82,3 +82,54 @@ fn can_still_skip_jobs_with_recharge() { assert!(!solution.tours.is_empty()); assert_eq!(solution.unassigned.iter().flatten().count(), 2); } + +#[test] +fn can_use_recharge_with_ten_jobs() { + let problem = ApiProblem { + plan: Plan { + jobs: (1..=10).map(|idx| create_delivery_job(&format!("job{idx}"), ((idx as f64) * 10., 0.))).collect(), + ..create_empty_plan() + }, + fleet: Fleet { + vehicles: vec![VehicleType { + shifts: vec![VehicleShift { + recharges: Some(VehicleRecharges { + max_distance: 55., + stations: vec![VehicleRechargeStation { + location: (50., 0.).to_loc(), + duration: 0.0, + times: None, + tag: None, + }], + }), + ..create_default_open_vehicle_shift() + }], + ..create_default_vehicle_type() + }], + profiles: create_default_matrix_profiles(), + resources: None, + }, + ..create_empty_problem() + }; + let matrix = create_matrix_from_problem(&problem); + + let solution = solve_with_metaheuristic(problem, Some(vec![matrix])); + + assert!(solution.unassigned.is_none()); + assert_eq!( + get_ids_from_tour(&solution.tours[0]), + vec![ + vec!["departure"], + vec!["job1"], + vec!["job2"], + vec!["job3"], + vec!["job4"], + vec!["recharge", "job5"], + vec!["job6"], + vec!["job7"], + vec!["job8"], + vec!["job9"], + vec!["job10"] + ] + ); +} diff --git a/vrp-pragmatic/tests/generator/defaults.rs b/vrp-pragmatic/tests/generator/defaults.rs index 742c0184a..8b4dadd7e 100644 --- a/vrp-pragmatic/tests/generator/defaults.rs +++ b/vrp-pragmatic/tests/generator/defaults.rs @@ -9,6 +9,7 @@ use crate::{format_time, parse_time}; pub const START_DAY: &str = "2020-07-04T00:00:00Z"; +/// Approximately, 32km by Haversine formula pub const DEFAULT_BOUNDING_BOX: ((f64, f64), (f64, f64)) = ((52.4240, 13.2148), (52.5937, 13.5970)); pub fn get_default_bounding_box_radius() -> f64 { diff --git a/vrp-pragmatic/tests/generator/vehicles.rs b/vrp-pragmatic/tests/generator/vehicles.rs index 6e7591910..dffd2561e 100644 --- a/vrp-pragmatic/tests/generator/vehicles.rs +++ b/vrp-pragmatic/tests/generator/vehicles.rs @@ -60,6 +60,28 @@ prop_compose! { } } +prop_compose! { + pub fn generate_recharge_station( + locations: impl Strategy, + durations: impl Strategy, + tags: impl Strategy>, + time_windows: impl Strategy>>>, + ) + ( + location in locations, + duration in durations, + tag in tags, + times in time_windows + ) -> VehicleRechargeStation { + VehicleRechargeStation { + times, + location, + duration, + tag, + } + } +} + /// Generates shifts. pub fn generate_shifts( shift_proto: impl Strategy, diff --git a/vrp-pragmatic/tests/unit/checker/limits_test.rs b/vrp-pragmatic/tests/unit/checker/limits_test.rs index 73413a6ee..76cb44246 100644 --- a/vrp-pragmatic/tests/unit/checker/limits_test.rs +++ b/vrp-pragmatic/tests/unit/checker/limits_test.rs @@ -164,3 +164,67 @@ fn can_check_shift_time() { assert_eq!(result, Err("tour time is outside shift time, vehicle id 'my_vehicle_1', shift index: 0".into())); } + +#[test] +fn can_check_recharge_distance() { + let problem = Problem { + plan: Plan { + jobs: vec![create_delivery_job("job1", (1., 0.)), create_delivery_job("job2", (10., 0.))], + ..create_empty_plan() + }, + fleet: Fleet { + vehicles: vec![VehicleType { + shifts: vec![VehicleShift { + start: ShiftStart { earliest: format_time(0.), latest: None, location: (0., 0.).to_loc() }, + end: None, + recharges: Some(VehicleRecharges { + max_distance: 8., + stations: vec![VehicleRechargeStation { + location: (8., 0.).to_loc(), + duration: 0., + times: None, + tag: None, + }], + }), + ..create_default_vehicle_shift() + }], + ..create_default_vehicle_type() + }], + ..create_default_fleet() + }, + ..create_empty_problem() + }; + + let solution = SolutionBuilder::default() + .tour( + TourBuilder::default() + .stops(vec![ + StopBuilder::default().coordinate((0., 0.)).schedule_stamp(0., 0.).load(vec![2]).build_departure(), + StopBuilder::default() + .coordinate((1., 0.)) + .schedule_stamp(1., 2.) + .load(vec![1]) + .distance(1) + .build_single("job1", "delivery"), + StopBuilder::default() + .coordinate((10., 0.)) + .schedule_stamp(11., 12.) + .load(vec![0]) + .distance(10) + .build_single("job2", "delivery"), + ]) + .statistic(StatisticBuilder::default().driving(10).serving(2).waiting(0).build()) + .build(), + ) + .build(); + let core_problem = Arc::new(problem.clone().read_pragmatic().unwrap()); + let ctx = CheckerContext::new(core_problem, problem, None, solution).unwrap(); + + let result = check_recharge_limits(&ctx); + + assert_eq!( + result, + Err("recharge distance violation: expected limit is 8, got 10, vehicle id 'my_vehicle_1', shift index: 0" + .into()) + ); +}