Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ are already published. So, I stick to it for now.
* add naive implementation of LKH local search
* add `minimize-depot-travel-time` objective which prefers assigning jobs close (by travel time) to the vehicle's depot (shift start)
* add `prefer-early-tours` objective which schedules work in the earliest shifts of the planning period without biasing job order within a day
* add an optional `id` property to vehicle breaks (both optional and required) which is propagated back to the
solution as `breakId` on break activities and on break violations


## [1.25.0] 2024-11-10
Expand Down
10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ members = [
]

[workspace.package]
version = "1.25.6"
version = "1.25.7"
authors = ["Ilya Builuk <ilya.builuk@gmail.com>"]
license = "Apache-2.0"
keywords = ["vrp", "optimization"]
Expand All @@ -26,10 +26,10 @@ edition = "2024"
[workspace.dependencies]
# internal dependencies
rosomaxa = { path = "rosomaxa", version = "0.9.3" }
vrp-core = { path = "vrp-core", version = "1.25.6" }
vrp-scientific = { path = "vrp-scientific", version = "1.25.6" }
vrp-pragmatic = { path = "vrp-pragmatic", version = "1.25.6" }
vrp-cli = { path = "vrp-cli", version = "1.25.6" }
vrp-core = { path = "vrp-core", version = "1.25.7" }
vrp-scientific = { path = "vrp-scientific", version = "1.25.7" }
vrp-pragmatic = { path = "vrp-pragmatic", version = "1.25.7" }
vrp-cli = { path = "vrp-cli", version = "1.25.7" }

# external dependencies
serde = { version = "1.0.219", features = ["derive"] }
Expand Down
6 changes: 5 additions & 1 deletion vrp-core/src/construction/enablers/reserved_time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ pub struct ReservedTimeSpan {
pub time: TimeSpan,
/// An extra duration to be applied at given time.
pub duration: Duration,
/// An optional id which can be used to trace the reserved time back to its origin.
pub id: Option<String>,
}

impl ReservedTimeSpan {
/// Converts `ReservedTimeSpan` to `ReservedTimeWindow`.
pub fn to_reserved_time_window(&self, offset: Timestamp) -> ReservedTimeWindow {
ReservedTimeWindow { time: self.time.to_time_window(offset), duration: self.duration }
ReservedTimeWindow { time: self.time.to_time_window(offset), duration: self.duration, id: self.id.clone() }
}
}

Expand All @@ -33,6 +35,8 @@ pub struct ReservedTimeWindow {
pub time: TimeWindow,
/// An extra duration to be applied at given time.
pub duration: Duration,
/// An optional id which can be used to trace the reserved time back to its origin.
pub id: Option<String>,
}

/// Specifies reserved time index type.
Expand Down
3 changes: 1 addition & 2 deletions vrp-core/src/construction/enablers/schedule_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ fn apply_first_job_arrival_floor(route_ctx: &mut RouteContext, transport: &dyn T
return;
}
let first_location = first_stop.place.location;
let travel =
transport.duration(route_ctx.route(), start_location, first_location, TravelTime::Arrival(*floor));
let travel = transport.duration(route_ctx.route(), start_location, first_location, TravelTime::Arrival(*floor));
let target_departure = *floor - travel;
if target_departure > start_departure {
route_ctx.route_mut().tour.get_mut(0).unwrap().schedule.departure = target_departure;
Expand Down
4 changes: 2 additions & 2 deletions vrp-core/src/construction/features/tour_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ impl FeatureConstraint for TravelLimitConstraint {
activity_ctx.target.place.location,
TravelTime::Departure(activity_ctx.prev.schedule.departure),
);
let arrival = (activity_ctx.prev.schedule.departure + travel)
.max(activity_ctx.target.place.time.start);
let arrival =
(activity_ctx.prev.schedule.departure + travel).max(activity_ctx.target.place.time.start);
if arrival > start_latest {
return ConstraintViolation::skip(self.duration_code);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ fn can_search_for_reserved_time_impl(
.map(|((start, end), duration)| ReservedTimeSpan {
time: TimeSpan::Window(TimeWindow::new(start, end)),
duration,
id: None,
})
.collect::<Vec<_>>(),
)]
Expand Down Expand Up @@ -126,6 +127,7 @@ parameterized_test! {can_update_state_for_reserved_time, (vehicle_detail_data, r
let reserved_time = ReservedTimeSpan {
time: TimeSpan::Window(TimeWindow::new(reserved_time.0, reserved_time.0)),
duration: reserved_time.1 - reserved_time.0,
id: None,
};
can_update_state_for_reserved_time_impl(vehicle_detail_data, reserved_time, activities, late_arrival_expected, expected_schedules);
}}
Expand Down Expand Up @@ -174,6 +176,7 @@ parameterized_test! {can_evaluate_activity, (vehicle_detail_data, reserved_time,
let reserved_time = ReservedTimeSpan {
time: TimeSpan::Window(TimeWindow::new(reserved_time.0, reserved_time.0)),
duration: reserved_time.1 - reserved_time.0,
id: None,
};
can_evaluate_activity_impl(vehicle_detail_data, reserved_time, target, activities, expected_schedules);
}}
Expand Down Expand Up @@ -287,6 +290,7 @@ fn can_avoid_reserved_time_when_driving_impl(
let reserved_time = ReservedTimeSpan {
time: TimeSpan::Offset(TimeOffset::new(reserved_time.0, reserved_time.1)),
duration: reserved_time.2,
id: None,
};
let (reserved_times_fn, _, mut route_ctx) =
create_feature_and_route(vehicle_detail_data, activities, reserved_time);
Expand Down
15 changes: 13 additions & 2 deletions vrp-pragmatic/src/checker/breaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ fn check_break_assignment(context: &CheckerContext) -> GenericResult<()> {
.map(|info| &info.location)
.cloned();

let has_match = match vehicle_break {
let has_match = match &vehicle_break {
// TODO check tag and duration
VehicleBreak::Optional { places, .. } => places.iter().any(|place| match &place.location {
Some(location) => actual_loc.as_ref() == Some(location),
Expand All @@ -62,6 +62,17 @@ fn check_break_assignment(context: &CheckerContext) -> GenericResult<()> {
)
.into());
}

// check id
if break_activity.break_id.as_ref() != vehicle_break.id() {
return Err(format!(
"break id '{:?}' is invalid: expected is '{:?}'",
break_activity.break_id,
vehicle_break.id()
)
.into());
}

Ok(acc + 1)
},
)
Expand Down Expand Up @@ -177,7 +188,7 @@ pub(crate) fn get_break_time_window(tour: &Tour, vehicle_break: &VehicleBreak) -

Ok(TimeWindow::new(departure + *offset.first().unwrap(), departure + *offset.last().unwrap()))
}
VehicleBreak::Required { time, duration } => {
VehicleBreak::Required { time, duration, .. } => {
let (start, end) = match time {
VehicleRequiredBreakTime::OffsetTime { earliest, latest } => {
(departure + *earliest, departure + *latest)
Expand Down
2 changes: 2 additions & 0 deletions vrp-pragmatic/src/checker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ impl CheckerContext {
.and_then(|breaks| {
breaks
.iter()
// NOTE: when break id is known, it identifies the break unambiguously
.filter(|b| activity.break_id.is_none() || activity.break_id.as_ref() == b.id())
// TODO: would be nice to propagate the error
.find(|b| get_break_time_window(tour, b).map(|tw| tw.intersects(&time)).unwrap_or(false))
})
Expand Down
2 changes: 2 additions & 0 deletions vrp-pragmatic/src/format/dimensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ custom_dimension!(pub JobType typeof String);

custom_dimension!(pub BreakPolicy typeof BreakPolicy);

custom_dimension!(pub BreakId typeof String);

custom_dimension!(pub ShiftStartLatest typeof Float);
6 changes: 2 additions & 4 deletions vrp-pragmatic/src/format/problem/fleet_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,8 @@ pub(super) fn read_fleet(api_problem: &ApiProblem, props: &ProblemProperties, co
VehiclePlace { location, time }
});

let details = vec![VehicleDetail {
start: Some(VehiclePlace { location: start_location, time: start_time }),
end,
}];
let details =
vec![VehicleDetail { start: Some(VehiclePlace { location: start_location, time: start_time }), end }];

vehicle.vehicle_ids.iter().for_each(|vehicle_id| {
let mut dimens: Dimensions = Default::default();
Expand Down
10 changes: 7 additions & 3 deletions vrp-pragmatic/src/format/problem/job_reader.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::format::coord_index::CoordIndex;
use crate::format::problem::JobSkills as ApiJobSkills;
use crate::format::problem::*;
use crate::format::{JobIndex, Location};
use crate::format::{BreakIdDimension, JobIndex, Location};
use crate::utils::VariableJobPermutation;
use std::collections::HashMap;
use std::sync::Arc;
Expand Down Expand Up @@ -209,10 +209,10 @@ fn read_optional_breaks(
) {
(1..)
.zip(breaks.iter().filter_map(|vehicle_break| match vehicle_break {
VehicleBreak::Optional { time, places, policy } => Some((time, places, policy)),
VehicleBreak::Optional { id, time, places, policy } => Some((id, time, places, policy)),
VehicleBreak::Required { .. } => None,
}))
.flat_map(|(break_idx, (break_time, break_places, policy))| {
.flat_map(|(break_idx, (break_id, break_time, break_places, policy))| {
vehicle
.vehicle_ids
.iter()
Expand Down Expand Up @@ -248,6 +248,10 @@ fn read_optional_breaks(
job.dimens.set_break_policy(policy);
}

if let Some(break_id) = break_id {
job.dimens.set_break_id(break_id.clone());
}

(job_id, job)
})
.collect::<Vec<_>>()
Expand Down
17 changes: 17 additions & 0 deletions vrp-pragmatic/src/format/problem/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,10 @@ pub enum VehicleOptionalBreakPolicy {
pub enum VehicleBreak {
/// An optional break which is more flexible, but might be not assigned.
Optional {
/// An optional break id which is propagated back to the corresponding solution activity.
/// Has to be unique within a shift.
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
/// Break time.
time: VehicleOptionalBreakTime,
/// Vehicle break places.
Expand All @@ -434,13 +438,26 @@ pub enum VehicleBreak {
/// A break which has to be assigned. It is less flexible than optional break, but has strong
/// assignment guarantee.
Required {
/// An optional break id which is propagated back to the corresponding solution activity.
/// Has to be unique within a shift.
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
/// Break time.
time: VehicleRequiredBreakTime,
/// Break duration.
duration: Float,
},
}

impl VehicleBreak {
/// Returns an id of the break if it is specified.
pub fn id(&self) -> Option<&String> {
match self {
Self::Optional { id, .. } | Self::Required { id, .. } => id.as_ref(),
}
}
}

/// Specifies a vehicle type.
#[derive(Clone, Deserialize, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down
10 changes: 5 additions & 5 deletions vrp-pragmatic/src/format/problem/problem_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ fn read_reserved_times_index(api_problem: &ApiProblem, fleet: &CoreFleet) -> Res
.flat_map(|vehicle| {
vehicle.shifts.iter().enumerate().flat_map(move |(shift_idx, shift)| {
shift.breaks.iter().flat_map(|br| br.iter()).filter_map(move |br| match br {
VehicleBreak::Required { time, duration } => {
Some((vehicle.type_id.clone(), shift_idx, time.clone(), *duration))
VehicleBreak::Required { id, time, duration } => {
Some((vehicle.type_id.clone(), shift_idx, time.clone(), *duration, id.clone()))
}
VehicleBreak::Optional { .. } => None,
})
})
})
.collect_group_by_key(|(type_id, shift_idx, _, _)| (type_id.clone(), *shift_idx));
.collect_group_by_key(|(type_id, shift_idx, _, _, _)| (type_id.clone(), *shift_idx));

fleet
.actors
Expand All @@ -89,7 +89,7 @@ fn read_reserved_times_index(api_problem: &ApiProblem, fleet: &CoreFleet) -> Res
.get(&(type_id, shift_idx))
.iter()
.flat_map(|data| data.iter())
.map(|(_, _, time, duration)| {
.map(|(_, _, time, duration, id)| {
let time = match &time {
VehicleRequiredBreakTime::ExactTime { earliest, latest } => {
TimeSpan::Window(TimeWindow::new(parse_time(earliest), parse_time(latest)))
Expand All @@ -100,7 +100,7 @@ fn read_reserved_times_index(api_problem: &ApiProblem, fleet: &CoreFleet) -> Res
};
let duration = *duration;

ReservedTimeSpan { time, duration }
ReservedTimeSpan { time, duration, id: id.clone() }
})
.collect::<Vec<_>>();

Expand Down
16 changes: 10 additions & 6 deletions vrp-pragmatic/src/format/solution/activity_matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,17 @@ pub(crate) fn try_match_break_activity(
.flat_map(|vehicle| vehicle.shifts.iter())
.flat_map(|shift| shift.breaks.iter())
.flat_map(|brs| brs.iter())
// NOTE: when activity has a break id, use it to narrow down the search
.filter(|br| activity.break_id.is_none() || activity.break_id.as_ref() == br.id())
.filter_map(|br| match br {
VehicleBreak::Required { time: VehicleRequiredBreakTime::ExactTime { earliest, latest }, duration } => {
Some(TimeWindow::new(parse_time(earliest), parse_time(latest) + *duration))
}
VehicleBreak::Required { time: VehicleRequiredBreakTime::OffsetTime { earliest, latest }, duration } => {
Some(TimeWindow::new(route_start_time + *earliest, route_start_time + *latest + *duration))
}
VehicleBreak::Required {
time: VehicleRequiredBreakTime::ExactTime { earliest, latest }, duration, ..
} => Some(TimeWindow::new(parse_time(earliest), parse_time(latest) + *duration)),
VehicleBreak::Required {
time: VehicleRequiredBreakTime::OffsetTime { earliest, latest },
duration,
..
} => Some(TimeWindow::new(route_start_time + *earliest, route_start_time + *latest + *duration)),
VehicleBreak::Optional { .. } => None,
})
.find(|time| activity_time.intersects(time))
Expand Down
4 changes: 4 additions & 0 deletions vrp-pragmatic/src/format/solution/break_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub(super) fn insert_reserved_times_as_breaks(

let break_time = reserved_time.duration as i64;
let break_cost = break_time as Float * route.actor.vehicle.costs.per_service_time;
let break_id = reserved_time.id.clone();

for (stop_idx, stop) in tour.stops.iter_mut().enumerate() {
let stop_tw =
Expand All @@ -71,6 +72,7 @@ pub(super) fn insert_reserved_times_as_breaks(
insert_break(
(stop, stop_tw, stop_idx),
(break_time, break_cost, break_info.clone()),
break_id.clone(),
&reserved_tw,
&mut tour.statistic,
)
Expand All @@ -85,6 +87,7 @@ pub(super) fn insert_reserved_times_as_breaks(
fn insert_break(
stop_data: (&mut Stop, TimeWindow, usize),
break_data: (i64, Cost, Option<BreakInsertion>),
break_id: Option<String>,
reserved_tw: &TimeWindow,
statistic: &mut Statistic,
) {
Expand Down Expand Up @@ -132,6 +135,7 @@ fn insert_break(
location: None,
time: Some(Interval { start: format_time(activity_time.start), end: format_time(activity_time.end) }),
job_tag: None,
break_id,
commute: None,
},
);
Expand Down
7 changes: 7 additions & 0 deletions vrp-pragmatic/src/format/solution/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ pub struct Activity {
/// Job tag.
#[serde(skip_serializing_if = "Option::is_none")]
pub job_tag: Option<String>,
/// An id of the vehicle break which corresponds to this activity. Set only for break activities
/// when the break has an id specified in the problem definition.
#[serde(skip_serializing_if = "Option::is_none")]
pub break_id: Option<String>,
/// Commute information.
#[serde(skip_serializing_if = "Option::is_none")]
pub commute: Option<Commute>,
Expand Down Expand Up @@ -278,6 +282,9 @@ pub enum Violation {
vehicle_id: String,
/// Index of the shift.
shift_index: usize,
/// An id of the break as specified in the problem definition, if any.
#[serde(skip_serializing_if = "Option::is_none")]
break_id: Option<String>,
},
}

Expand Down
8 changes: 8 additions & 0 deletions vrp-pragmatic/src/format/solution/solution_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fn create_tour(
None
},
job_tag: None,
break_id: None,
commute: None,
}],
parking: None,
Expand Down Expand Up @@ -151,6 +152,11 @@ fn create_tour(
get_job_tag(single, (act.place.location, (act.place.time.clone(), start.schedule.departure)))
.cloned()
});
let break_id = if is_break {
act.job.as_ref().and_then(|single| single.dimens.get_break_id().cloned())
} else {
None
};
let job_id = match activity_type.as_str() {
"pickup" | "delivery" | "replacement" | "service" => {
let single = act.job.as_ref().unwrap();
Expand Down Expand Up @@ -240,6 +246,7 @@ fn create_tour(
end: format_time(activity_departure),
}),
job_tag,
break_id,
commute: act
.commute
.as_ref()
Expand Down Expand Up @@ -389,6 +396,7 @@ fn create_violations(solution: &DomainSolution) -> Option<Vec<Violation>> {
.map(|(job, _)| Violation::Break {
vehicle_id: job.dimens().get_vehicle_id().expect("vehicle id").clone(),
shift_index: job.dimens().get_shift_index().copied().expect("shift index"),
break_id: job.dimens().get_break_id().cloned(),
})
.collect::<Vec<_>>();

Expand Down
Loading
Loading