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
7 changes: 7 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git stash *)"
]
}
}
12 changes: 6 additions & 6 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.1"
version = "1.25.2"
authors = ["Ilya Builuk <ilya.builuk@gmail.com>"]
license = "Apache-2.0"
keywords = ["vrp", "optimization"]
Expand All @@ -25,11 +25,11 @@ edition = "2024"

[workspace.dependencies]
# internal dependencies
rosomaxa = { path = "rosomaxa", version = "0.9.1" }
vrp-core = { path = "vrp-core", version = "1.25.1" }
vrp-scientific = { path = "vrp-scientific", version = "1.25.1" }
vrp-pragmatic = { path = "vrp-pragmatic", version = "1.25.1" }
vrp-cli = { path = "vrp-cli", version = "1.25.1" }
rosomaxa = { path = "rosomaxa", version = "0.9.2" }
vrp-core = { path = "vrp-core", version = "1.25.2" }
vrp-scientific = { path = "vrp-scientific", version = "1.25.2" }
vrp-pragmatic = { path = "vrp-pragmatic", version = "1.25.2" }
vrp-cli = { path = "vrp-cli", version = "1.25.2" }

# external dependencies
serde = { version = "1.0.219", features = ["derive"] }
Expand Down
2 changes: 1 addition & 1 deletion rosomaxa/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rosomaxa"
version = "0.9.1"
version = "0.9.2"
description = "A rosomaxa algorithm and other building blocks for creating a solver for optimization problems"
authors.workspace = true
license.workspace = true
Expand Down
30 changes: 29 additions & 1 deletion vrp-core/src/construction/enablers/schedule_update.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::construction::heuristics::{RouteContext, RouteState};
use crate::models::OP_START_MSG;
use crate::models::common::{Distance, Duration, Schedule, Timestamp};
use crate::models::common::{Dimensions, Distance, Duration, Schedule, Timestamp};
use crate::models::problem::{ActivityCost, TransportCost, TravelTime};
use rosomaxa::prelude::Float;
use rosomaxa::utils::UnwrapValue;
Expand All @@ -11,6 +11,8 @@ custom_tour_state!(pub TotalDistance typeof Distance);
custom_tour_state!(pub TotalDuration typeof Duration);
custom_tour_state!(pub(crate) LimitDuration typeof Duration);

custom_dimension!(pub FirstJobArrivalFloor typeof Timestamp);

/// Updates route schedule data.
pub fn update_route_schedule(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, transport: &dyn TransportCost) {
update_schedules(route_ctx, activity, transport);
Expand All @@ -32,6 +34,8 @@ pub fn update_route_departure(
}

fn update_schedules(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, transport: &dyn TransportCost) {
apply_first_job_arrival_floor(route_ctx, transport);

let init = {
let start = route_ctx.route().tour.start().unwrap();
(start.place.location, start.schedule.departure)
Expand All @@ -53,6 +57,30 @@ fn update_schedules(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, t
});
}

/// When an actor has `FirstJobArrivalFloor` set, the shift bounds apply to the first job's
/// arrival rather than to the depot departure. Pre-adjust the depot departure so that the
/// first job is reached at the floor (or later, if travel from the depot makes that impossible).
fn apply_first_job_arrival_floor(route_ctx: &mut RouteContext, transport: &dyn TransportCost) {
let Some(floor) = route_ctx.route().actor.vehicle.dimens.get_first_job_arrival_floor() else {
return;
};
let (start_location, start_departure) = {
let start = route_ctx.route().tour.start().unwrap();
(start.place.location, start.schedule.departure)
};
let Some(first_stop) = route_ctx.route().tour.get(1) else { return };
if first_stop.job.is_none() {
return;
}
let first_location = first_stop.place.location;
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;
}
}

fn update_states(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, transport: &dyn TransportCost) {
// update latest arrival and waiting states of non-terminate (jobs) activities
let actor = route_ctx.route().actor.clone();
Expand Down
32 changes: 30 additions & 2 deletions vrp-core/src/construction/features/tour_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub fn create_activity_limit_feature(

/// Creates a travel limits such as distance and/or duration.
/// This is a hard constraint.
///
/// `shift_start_latest_fn` returns `Some(latest)` when the actor uses
/// `allow_out_of_hours_depot_travel` AND the shift has a `start.latest` to enforce on the
/// first job's arrival. Returns `None` to skip this check entirely.
pub fn create_travel_limit_feature(
name: &str,
transport: Arc<dyn TransportCost>,
Expand All @@ -39,13 +43,15 @@ pub fn create_travel_limit_feature(
duration_code: ViolationCode,
tour_distance_limit_fn: TravelLimitFn<Distance>,
tour_duration_limit_fn: TravelLimitFn<Duration>,
shift_start_latest_fn: TravelLimitFn<Duration>,
) -> Result<Feature, GenericError> {
FeatureBuilder::default()
.with_name(name)
.with_constraint(TravelLimitConstraint {
transport: transport.clone(),
tour_distance_limit_fn,
tour_duration_limit_fn: tour_duration_limit_fn.clone(),
shift_start_latest_fn,
distance_code,
duration_code,
})
Expand Down Expand Up @@ -90,6 +96,7 @@ struct TravelLimitConstraint {
transport: Arc<dyn TransportCost>,
tour_distance_limit_fn: TravelLimitFn<Distance>,
tour_duration_limit_fn: TravelLimitFn<Duration>,
shift_start_latest_fn: TravelLimitFn<Duration>,
distance_code: ViolationCode,
duration_code: ViolationCode,
}
Expand All @@ -105,8 +112,29 @@ impl FeatureConstraint for TravelLimitConstraint {
match move_ctx {
MoveContext::Route { .. } => None,
MoveContext::Activity { route_ctx, activity_ctx, .. } => {
let tour_distance_limit = (self.tour_distance_limit_fn)(route_ctx.route().actor.as_ref());
let tour_duration_limit = (self.tour_duration_limit_fn)(route_ctx.route().actor.as_ref());
let actor = route_ctx.route().actor.as_ref();

// When inserting the first job (prev is the depot), enforce the shift's
// `start.latest` against the first-job arrival. The depot-start time window is
// relaxed under `allow_out_of_hours_depot_travel`, so nothing else enforces it.
if activity_ctx.prev.job.is_none()
&& let Some(start_latest) = (self.shift_start_latest_fn)(actor)
{
let travel = self.transport.duration(
route_ctx.route(),
activity_ctx.prev.place.location,
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);
if arrival > start_latest {
return ConstraintViolation::skip(self.duration_code);
}
Comment on lines +131 to +133

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ensures we can't insert jobs that would cause us to start too late

}

let tour_distance_limit = (self.tour_distance_limit_fn)(actor);
let tour_duration_limit = (self.tour_duration_limit_fn)(actor);

if tour_distance_limit.is_some() || tour_duration_limit.is_some() {
let (change_distance, change_duration) = self.calculate_travel(route_ctx, activity_ctx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ mod traveling {
DURATION_CODE,
tour_distance_limit,
tour_duration_limit,
Arc::new(|_| None),
)
.unwrap();

Expand Down
15 changes: 12 additions & 3 deletions vrp-pragmatic/src/checker/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,20 @@ fn check_shift_limits(context: &CheckerContext) -> GenericResult<()> {
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 allow_out_of_hours_depot_travel =
vehicle.limits.as_ref().and_then(|limits| limits.allow_out_of_hours_depot_travel).unwrap_or(false);

let (start, end) = tour.stops.first().zip(tour.stops.last()).ok_or("empty tour")?;

let departure = parse_time(&start.schedule().departure);
let arrival = parse_time(&end.schedule().arrival);
// With `allow_out_of_hours_depot_travel`, the shift start applies to the first-job arrival
// rather than to the depot departure. The end side is NOT relaxed (depot return must
// still sit within the shift end).
let effective_start = if allow_out_of_hours_depot_travel && tour.stops.len() > 2 {
parse_time(&tour.stops[1].schedule().arrival)
} else {
parse_time(&start.schedule().departure)
};
Comment on lines +70 to +74

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validates the solution after we have solved. Check effective start on either the 1st or 2nd stop depending on if the flag is enabled

let effective_end = parse_time(&end.schedule().arrival);

let has_match = vehicle
.shifts
Expand All @@ -74,7 +83,7 @@ fn check_shift_time(context: &CheckerContext) -> GenericResult<()> {

(start, end)
})
.any(|(start, end)| departure >= start && arrival <= end);
.any(|(start, end)| effective_start >= start && effective_end <= end);

if !has_match {
Err(format!(
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 @@ -20,3 +20,5 @@ custom_dimension!(pub JobValue typeof Float);
custom_dimension!(pub JobType typeof String);

custom_dimension!(pub BreakPolicy typeof BreakPolicy);

custom_dimension!(pub ShiftStartLatest typeof Float);
46 changes: 29 additions & 17 deletions vrp-pragmatic/src/format/problem/fleet_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::format::UnknownLocationFallback;
use crate::get_unique_locations;
use crate::utils::get_approx_transportation;
use std::collections::HashSet;
use vrp_core::construction::enablers::create_typed_actor_groups;
use vrp_core::construction::enablers::{FirstJobArrivalFloorDimension, create_typed_actor_groups};
use vrp_core::construction::features::{VehicleCapacityDimension, VehicleSkillsDimension};
use vrp_core::models::common::*;
use vrp_core::models::problem::*;
Expand Down Expand Up @@ -111,31 +111,36 @@ pub(super) fn read_fleet(api_problem: &ApiProblem, props: &ProblemProperties, co
let index = *profile_indices.get(&vehicle.profile.matrix).unwrap();
let profile = Profile::new(index, vehicle.profile.scale);

let tour_size = vehicle.limits.as_ref().and_then(|l| l.tour_size);
let tour_size = vehicle.limits.as_ref().and_then(|limits| limits.tour_size);

let allow_out_of_hours_depot_travel =
vehicle.limits.as_ref().and_then(|limits| limits.allow_out_of_hours_depot_travel).unwrap_or(false);

for (shift_index, shift) in vehicle.shifts.iter().enumerate() {
let start = {
let location = coord_index.get_by_loc(&shift.start.location).unwrap();
let earliest = parse_time(&shift.start.earliest);
let latest = shift.start.latest.as_ref().map(|time| parse_time(time));
(location, earliest, latest)
let shift_start_earliest = parse_time(&shift.start.earliest);
let shift_start_latest = shift.start.latest.as_ref().map(|time| parse_time(time));

// When `allow_out_of_hours_depot_travel` is set, the shift start applies to the first
// job's arrival rather than to the depot departure. Relax the depot start window;
// the floor is re-applied in `apply_first_job_arrival_floor` (via `FirstJobArrivalFloor`
// dim) and `start.latest` is enforced in the travel-limit constraint. The end side
// is NOT relaxed — the back-propagated `latest_arrival` bounds the last-job departure.
let start_time = if allow_out_of_hours_depot_travel {
TimeInterval { earliest: None, latest: None }
} else {
TimeInterval { earliest: Some(shift_start_earliest), latest: shift_start_latest }
};
Comment on lines +128 to 132

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removes the depots time window if we allow_out_of_hours_travel as the solver no longer enforces the shift window at the depo stop.

The insertions and departure calculation handle this instead now

let start_location = coord_index.get_by_loc(&shift.start.location).unwrap();

let end = shift.end.as_ref().map(|end| {
let location = coord_index.get_by_loc(&end.location).unwrap();
let time = parse_time(&end.latest);
(location, time)
let time = TimeInterval { earliest: None, latest: Some(parse_time(&end.latest)) };
VehiclePlace { location, time }
});

let details = vec![VehicleDetail {
start: Some(VehiclePlace {
location: start.0,
time: TimeInterval { earliest: Some(start.1), latest: start.2 },
}),
end: end.map(|(location, time)| VehiclePlace {
location,
time: TimeInterval { earliest: None, latest: Some(time) },
}),
start: Some(VehiclePlace { location: start_location, time: start_time }),
end,
}];

vehicle.vehicle_ids.iter().for_each(|vehicle_id| {
Expand All @@ -150,6 +155,13 @@ pub(super) fn read_fleet(api_problem: &ApiProblem, props: &ProblemProperties, co
dimens.set_tour_size(tour_size);
}

if allow_out_of_hours_depot_travel {
dimens.set_first_job_arrival_floor(shift_start_earliest);
if let Some(latest) = shift_start_latest {
dimens.set_shift_start_latest(latest);
}
}

if props.has_multi_dimen_capacity {
dimens.set_vehicle_capacity(MultiDimLoad::new(vehicle.capacity.clone()));
} else {
Expand Down
6 changes: 5 additions & 1 deletion vrp-pragmatic/src/format/problem/goal_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use vrp_core::algorithms::clustering::kmedoids::create_hierarchical_kmedoids;
use vrp_core::construction::clustering::vicinity::ClusterInfoDimension;
use vrp_core::construction::enablers::FeatureCombinator;
use vrp_core::construction::features::*;
use vrp_core::models::common::{Demand, LoadOps, MultiDimLoad, SingleDimLoad};
use vrp_core::models::common::{Demand, Duration, LoadOps, MultiDimLoad, SingleDimLoad};
use vrp_core::models::problem::{Actor, Single, TransportCost};
use vrp_core::models::solution::Route;
use vrp_core::models::{Feature, FeatureObjective, GoalBuilder, GoalContext, GoalContextBuilder};
Expand Down Expand Up @@ -439,6 +439,9 @@ fn get_tour_limit_feature(
})
};

let shift_start_latest_fn: TravelLimitFn<Duration> =
Arc::new(|actor: &Actor| actor.vehicle.dimens.get_shift_start_latest().copied());

create_travel_limit_feature(
name,
transport,
Expand All @@ -447,6 +450,7 @@ fn get_tour_limit_feature(
DURATION_LIMIT_CONSTRAINT_CODE,
get_limit(distances),
get_limit(durations),
shift_start_latest_fn,
)
}

Expand Down
5 changes: 5 additions & 0 deletions vrp-pragmatic/src/format/problem/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ pub struct VehicleLimits {
/// No job activities restrictions when omitted.
#[serde(skip_serializing_if = "Option::is_none")]
pub tour_size: Option<usize>,

/// When true, the shift duration starts at the first job arrival rather than
/// at depot departure. Travel from depot to first job does not count against maxDuration.
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_out_of_hours_depot_travel: Option<bool>,
}

/// Vehicle optional break time variant.
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 @@ -150,11 +150,11 @@ fn get_problem_properties(api_problem: &ApiProblem, matrices: &[Matrix]) -> Prob
let has_tour_size_limits =
api_problem.fleet.vehicles.iter().any(|v| v.limits.as_ref().is_some_and(|l| l.tour_size.is_some()));

let has_tour_travel_limits = api_problem
.fleet
.vehicles
.iter()
.any(|v| v.limits.as_ref().is_some_and(|l| l.max_duration.or(l.max_distance).is_some()));
let has_tour_travel_limits = api_problem.fleet.vehicles.iter().any(|v| {
v.limits.as_ref().is_some_and(|l| {
l.max_duration.or(l.max_distance).is_some() || l.allow_out_of_hours_depot_travel.unwrap_or(false)
})
});

ProblemProperties {
has_multi_dimen_capacity,
Expand Down
4 changes: 2 additions & 2 deletions vrp-pragmatic/tests/features/limits/max_distance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ fn can_limit_by_max_distance() {
plan: Plan { jobs: vec![create_delivery_job("job1", (100., 0.))], ..create_empty_plan() },
fleet: Fleet {
vehicles: vec![VehicleType {
limits: Some(VehicleLimits { max_distance: Some(99.), max_duration: None, tour_size: None }),
limits: Some(VehicleLimits { max_distance: Some(99.), max_duration: None, tour_size: None, allow_out_of_hours_depot_travel: None }),
..create_default_vehicle_type()
}],
..create_default_fleet()
Expand Down Expand Up @@ -52,7 +52,7 @@ fn can_handle_empty_route() {
end: Some(ShiftEnd { earliest: None, latest: format_time(100.), location: (10., 0.).to_loc() }),
..create_default_open_vehicle_shift()
}],
limits: Some(VehicleLimits { max_distance: Some(9.), max_duration: None, tour_size: None }),
limits: Some(VehicleLimits { max_distance: Some(9.), max_duration: None, tour_size: None, allow_out_of_hours_depot_travel: None }),
..create_default_vehicle_type()
}],
..create_default_fleet()
Expand Down
Loading
Loading