Skip to content

Commit 9539e05

Browse files
authored
Merge pull request #12 from uptick/feat/add-shift-time-from-first-job-param
feat(shifts): add shift time from first job param
2 parents 69756b6 + 4bf8933 commit 9539e05

18 files changed

Lines changed: 252 additions & 44 deletions

File tree

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(git stash *)"
5+
]
6+
}
7+
}

Cargo.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ members = [
1313
]
1414

1515
[workspace.package]
16-
version = "1.25.1"
16+
version = "1.25.2"
1717
authors = ["Ilya Builuk <ilya.builuk@gmail.com>"]
1818
license = "Apache-2.0"
1919
keywords = ["vrp", "optimization"]
@@ -25,11 +25,11 @@ edition = "2024"
2525

2626
[workspace.dependencies]
2727
# internal dependencies
28-
rosomaxa = { path = "rosomaxa", version = "0.9.1" }
29-
vrp-core = { path = "vrp-core", version = "1.25.1" }
30-
vrp-scientific = { path = "vrp-scientific", version = "1.25.1" }
31-
vrp-pragmatic = { path = "vrp-pragmatic", version = "1.25.1" }
32-
vrp-cli = { path = "vrp-cli", version = "1.25.1" }
28+
rosomaxa = { path = "rosomaxa", version = "0.9.2" }
29+
vrp-core = { path = "vrp-core", version = "1.25.2" }
30+
vrp-scientific = { path = "vrp-scientific", version = "1.25.2" }
31+
vrp-pragmatic = { path = "vrp-pragmatic", version = "1.25.2" }
32+
vrp-cli = { path = "vrp-cli", version = "1.25.2" }
3333

3434
# external dependencies
3535
serde = { version = "1.0.219", features = ["derive"] }

rosomaxa/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rosomaxa"
3-
version = "0.9.1"
3+
version = "0.9.2"
44
description = "A rosomaxa algorithm and other building blocks for creating a solver for optimization problems"
55
authors.workspace = true
66
license.workspace = true

vrp-core/src/construction/enablers/schedule_update.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::construction::heuristics::{RouteContext, RouteState};
22
use crate::models::OP_START_MSG;
3-
use crate::models::common::{Distance, Duration, Schedule, Timestamp};
3+
use crate::models::common::{Dimensions, Distance, Duration, Schedule, Timestamp};
44
use crate::models::problem::{ActivityCost, TransportCost, TravelTime};
55
use rosomaxa::prelude::Float;
66
use rosomaxa::utils::UnwrapValue;
@@ -11,6 +11,8 @@ custom_tour_state!(pub TotalDistance typeof Distance);
1111
custom_tour_state!(pub TotalDuration typeof Duration);
1212
custom_tour_state!(pub(crate) LimitDuration typeof Duration);
1313

14+
custom_dimension!(pub FirstJobArrivalFloor typeof Timestamp);
15+
1416
/// Updates route schedule data.
1517
pub fn update_route_schedule(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, transport: &dyn TransportCost) {
1618
update_schedules(route_ctx, activity, transport);
@@ -32,6 +34,8 @@ pub fn update_route_departure(
3234
}
3335

3436
fn update_schedules(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, transport: &dyn TransportCost) {
37+
apply_first_job_arrival_floor(route_ctx, transport);
38+
3539
let init = {
3640
let start = route_ctx.route().tour.start().unwrap();
3741
(start.place.location, start.schedule.departure)
@@ -53,6 +57,30 @@ fn update_schedules(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, t
5357
});
5458
}
5559

60+
/// When an actor has `FirstJobArrivalFloor` set, the shift bounds apply to the first job's
61+
/// arrival rather than to the depot departure. Pre-adjust the depot departure so that the
62+
/// first job is reached at the floor (or later, if travel from the depot makes that impossible).
63+
fn apply_first_job_arrival_floor(route_ctx: &mut RouteContext, transport: &dyn TransportCost) {
64+
let Some(floor) = route_ctx.route().actor.vehicle.dimens.get_first_job_arrival_floor() else {
65+
return;
66+
};
67+
let (start_location, start_departure) = {
68+
let start = route_ctx.route().tour.start().unwrap();
69+
(start.place.location, start.schedule.departure)
70+
};
71+
let Some(first_stop) = route_ctx.route().tour.get(1) else { return };
72+
if first_stop.job.is_none() {
73+
return;
74+
}
75+
let first_location = first_stop.place.location;
76+
let travel =
77+
transport.duration(route_ctx.route(), start_location, first_location, TravelTime::Arrival(*floor));
78+
let target_departure = *floor - travel;
79+
if target_departure > start_departure {
80+
route_ctx.route_mut().tour.get_mut(0).unwrap().schedule.departure = target_departure;
81+
}
82+
}
83+
5684
fn update_states(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, transport: &dyn TransportCost) {
5785
// update latest arrival and waiting states of non-terminate (jobs) activities
5886
let actor = route_ctx.route().actor.clone();

vrp-core/src/construction/features/tour_limits.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ pub fn create_activity_limit_feature(
3131

3232
/// Creates a travel limits such as distance and/or duration.
3333
/// This is a hard constraint.
34+
///
35+
/// `shift_start_latest_fn` returns `Some(latest)` when the actor uses
36+
/// `allow_out_of_hours_depot_travel` AND the shift has a `start.latest` to enforce on the
37+
/// first job's arrival. Returns `None` to skip this check entirely.
3438
pub fn create_travel_limit_feature(
3539
name: &str,
3640
transport: Arc<dyn TransportCost>,
@@ -39,13 +43,15 @@ pub fn create_travel_limit_feature(
3943
duration_code: ViolationCode,
4044
tour_distance_limit_fn: TravelLimitFn<Distance>,
4145
tour_duration_limit_fn: TravelLimitFn<Duration>,
46+
shift_start_latest_fn: TravelLimitFn<Duration>,
4247
) -> Result<Feature, GenericError> {
4348
FeatureBuilder::default()
4449
.with_name(name)
4550
.with_constraint(TravelLimitConstraint {
4651
transport: transport.clone(),
4752
tour_distance_limit_fn,
4853
tour_duration_limit_fn: tour_duration_limit_fn.clone(),
54+
shift_start_latest_fn,
4955
distance_code,
5056
duration_code,
5157
})
@@ -90,6 +96,7 @@ struct TravelLimitConstraint {
9096
transport: Arc<dyn TransportCost>,
9197
tour_distance_limit_fn: TravelLimitFn<Distance>,
9298
tour_duration_limit_fn: TravelLimitFn<Duration>,
99+
shift_start_latest_fn: TravelLimitFn<Duration>,
93100
distance_code: ViolationCode,
94101
duration_code: ViolationCode,
95102
}
@@ -105,8 +112,29 @@ impl FeatureConstraint for TravelLimitConstraint {
105112
match move_ctx {
106113
MoveContext::Route { .. } => None,
107114
MoveContext::Activity { route_ctx, activity_ctx, .. } => {
108-
let tour_distance_limit = (self.tour_distance_limit_fn)(route_ctx.route().actor.as_ref());
109-
let tour_duration_limit = (self.tour_duration_limit_fn)(route_ctx.route().actor.as_ref());
115+
let actor = route_ctx.route().actor.as_ref();
116+
117+
// When inserting the first job (prev is the depot), enforce the shift's
118+
// `start.latest` against the first-job arrival. The depot-start time window is
119+
// relaxed under `allow_out_of_hours_depot_travel`, so nothing else enforces it.
120+
if activity_ctx.prev.job.is_none()
121+
&& let Some(start_latest) = (self.shift_start_latest_fn)(actor)
122+
{
123+
let travel = self.transport.duration(
124+
route_ctx.route(),
125+
activity_ctx.prev.place.location,
126+
activity_ctx.target.place.location,
127+
TravelTime::Departure(activity_ctx.prev.schedule.departure),
128+
);
129+
let arrival = (activity_ctx.prev.schedule.departure + travel)
130+
.max(activity_ctx.target.place.time.start);
131+
if arrival > start_latest {
132+
return ConstraintViolation::skip(self.duration_code);
133+
}
134+
}
135+
136+
let tour_distance_limit = (self.tour_distance_limit_fn)(actor);
137+
let tour_duration_limit = (self.tour_duration_limit_fn)(actor);
110138

111139
if tour_distance_limit.is_some() || tour_duration_limit.is_some() {
112140
let (change_distance, change_duration) = self.calculate_travel(route_ctx, activity_ctx);

vrp-core/tests/unit/construction/features/tour_limits_test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ mod traveling {
101101
DURATION_CODE,
102102
tour_distance_limit,
103103
tour_duration_limit,
104+
Arc::new(|_| None),
104105
)
105106
.unwrap();
106107

vrp-pragmatic/src/checker/limits.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,20 @@ fn check_shift_limits(context: &CheckerContext) -> GenericResult<()> {
5959
fn check_shift_time(context: &CheckerContext) -> GenericResult<()> {
6060
context.solution.tours.iter().try_for_each::<_, GenericResult<_>>(|tour| {
6161
let vehicle = context.get_vehicle(&tour.vehicle_id)?;
62+
let allow_out_of_hours_depot_travel =
63+
vehicle.limits.as_ref().and_then(|limits| limits.allow_out_of_hours_depot_travel).unwrap_or(false);
6264

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

65-
let departure = parse_time(&start.schedule().departure);
66-
let arrival = parse_time(&end.schedule().arrival);
67+
// With `allow_out_of_hours_depot_travel`, the shift start applies to the first-job arrival
68+
// rather than to the depot departure. The end side is NOT relaxed (depot return must
69+
// still sit within the shift end).
70+
let effective_start = if allow_out_of_hours_depot_travel && tour.stops.len() > 2 {
71+
parse_time(&tour.stops[1].schedule().arrival)
72+
} else {
73+
parse_time(&start.schedule().departure)
74+
};
75+
let effective_end = parse_time(&end.schedule().arrival);
6776

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

7584
(start, end)
7685
})
77-
.any(|(start, end)| departure >= start && arrival <= end);
86+
.any(|(start, end)| effective_start >= start && effective_end <= end);
7887

7988
if !has_match {
8089
Err(format!(

vrp-pragmatic/src/format/dimensions.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ custom_dimension!(pub JobValue typeof Float);
2020
custom_dimension!(pub JobType typeof String);
2121

2222
custom_dimension!(pub BreakPolicy typeof BreakPolicy);
23+
24+
custom_dimension!(pub ShiftStartLatest typeof Float);

vrp-pragmatic/src/format/problem/fleet_reader.rs

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::format::UnknownLocationFallback;
88
use crate::get_unique_locations;
99
use crate::utils::get_approx_transportation;
1010
use std::collections::HashSet;
11-
use vrp_core::construction::enablers::create_typed_actor_groups;
11+
use vrp_core::construction::enablers::{FirstJobArrivalFloorDimension, create_typed_actor_groups};
1212
use vrp_core::construction::features::{VehicleCapacityDimension, VehicleSkillsDimension};
1313
use vrp_core::models::common::*;
1414
use vrp_core::models::problem::*;
@@ -111,31 +111,36 @@ pub(super) fn read_fleet(api_problem: &ApiProblem, props: &ProblemProperties, co
111111
let index = *profile_indices.get(&vehicle.profile.matrix).unwrap();
112112
let profile = Profile::new(index, vehicle.profile.scale);
113113

114-
let tour_size = vehicle.limits.as_ref().and_then(|l| l.tour_size);
114+
let tour_size = vehicle.limits.as_ref().and_then(|limits| limits.tour_size);
115+
116+
let allow_out_of_hours_depot_travel =
117+
vehicle.limits.as_ref().and_then(|limits| limits.allow_out_of_hours_depot_travel).unwrap_or(false);
115118

116119
for (shift_index, shift) in vehicle.shifts.iter().enumerate() {
117-
let start = {
118-
let location = coord_index.get_by_loc(&shift.start.location).unwrap();
119-
let earliest = parse_time(&shift.start.earliest);
120-
let latest = shift.start.latest.as_ref().map(|time| parse_time(time));
121-
(location, earliest, latest)
120+
let shift_start_earliest = parse_time(&shift.start.earliest);
121+
let shift_start_latest = shift.start.latest.as_ref().map(|time| parse_time(time));
122+
123+
// When `allow_out_of_hours_depot_travel` is set, the shift start applies to the first
124+
// job's arrival rather than to the depot departure. Relax the depot start window;
125+
// the floor is re-applied in `apply_first_job_arrival_floor` (via `FirstJobArrivalFloor`
126+
// dim) and `start.latest` is enforced in the travel-limit constraint. The end side
127+
// is NOT relaxed — the back-propagated `latest_arrival` bounds the last-job departure.
128+
let start_time = if allow_out_of_hours_depot_travel {
129+
TimeInterval { earliest: None, latest: None }
130+
} else {
131+
TimeInterval { earliest: Some(shift_start_earliest), latest: shift_start_latest }
122132
};
133+
let start_location = coord_index.get_by_loc(&shift.start.location).unwrap();
123134

124135
let end = shift.end.as_ref().map(|end| {
125136
let location = coord_index.get_by_loc(&end.location).unwrap();
126-
let time = parse_time(&end.latest);
127-
(location, time)
137+
let time = TimeInterval { earliest: None, latest: Some(parse_time(&end.latest)) };
138+
VehiclePlace { location, time }
128139
});
129140

130141
let details = vec![VehicleDetail {
131-
start: Some(VehiclePlace {
132-
location: start.0,
133-
time: TimeInterval { earliest: Some(start.1), latest: start.2 },
134-
}),
135-
end: end.map(|(location, time)| VehiclePlace {
136-
location,
137-
time: TimeInterval { earliest: None, latest: Some(time) },
138-
}),
142+
start: Some(VehiclePlace { location: start_location, time: start_time }),
143+
end,
139144
}];
140145

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

158+
if allow_out_of_hours_depot_travel {
159+
dimens.set_first_job_arrival_floor(shift_start_earliest);
160+
if let Some(latest) = shift_start_latest {
161+
dimens.set_shift_start_latest(latest);
162+
}
163+
}
164+
153165
if props.has_multi_dimen_capacity {
154166
dimens.set_vehicle_capacity(MultiDimLoad::new(vehicle.capacity.clone()));
155167
} else {

vrp-pragmatic/src/format/problem/goal_reader.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use vrp_core::algorithms::clustering::kmedoids::create_hierarchical_kmedoids;
44
use vrp_core::construction::clustering::vicinity::ClusterInfoDimension;
55
use vrp_core::construction::enablers::FeatureCombinator;
66
use vrp_core::construction::features::*;
7-
use vrp_core::models::common::{Demand, LoadOps, MultiDimLoad, SingleDimLoad};
7+
use vrp_core::models::common::{Demand, Duration, LoadOps, MultiDimLoad, SingleDimLoad};
88
use vrp_core::models::problem::{Actor, Single, TransportCost};
99
use vrp_core::models::solution::Route;
1010
use vrp_core::models::{Feature, FeatureObjective, GoalBuilder, GoalContext, GoalContextBuilder};
@@ -439,6 +439,9 @@ fn get_tour_limit_feature(
439439
})
440440
};
441441

442+
let shift_start_latest_fn: TravelLimitFn<Duration> =
443+
Arc::new(|actor: &Actor| actor.vehicle.dimens.get_shift_start_latest().copied());
444+
442445
create_travel_limit_feature(
443446
name,
444447
transport,
@@ -447,6 +450,7 @@ fn get_tour_limit_feature(
447450
DURATION_LIMIT_CONSTRAINT_CODE,
448451
get_limit(distances),
449452
get_limit(durations),
453+
shift_start_latest_fn,
450454
)
451455
}
452456

0 commit comments

Comments
 (0)