From eb40b254db86d6d43edd2f8b68339c56074d0412 Mon Sep 17 00:00:00 2001 From: Paul Hutchins Date: Wed, 29 Apr 2026 14:38:37 +1000 Subject: [PATCH 1/3] feat(shifts): add allow_out_of_hours_depot_travel param Adds a boolean `allow_out_of_hours_depot_travel` flag on `VehicleLimits`. When enabled, the shift's start window applies to the first job's arrival rather than to depot departure, allowing the truck to leave the depot before the shift starts so it arrives on-site at the shift start. The end side is unchanged: `shift.end.latest` still bounds depot return. Pins the rust toolchain to nightly-2026-02-10 for build stability. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../construction/enablers/schedule_update.rs | 30 ++++++++- .../src/construction/features/tour_limits.rs | 32 ++++++++- .../construction/features/tour_limits_test.rs | 1 + vrp-pragmatic/src/checker/limits.rs | 15 ++++- vrp-pragmatic/src/format/dimensions.rs | 2 + .../src/format/problem/fleet_reader.rs | 46 ++++++++----- .../src/format/problem/goal_reader.rs | 6 +- vrp-pragmatic/src/format/problem/model.rs | 5 ++ .../tests/features/limits/max_distance.rs | 4 +- .../tests/features/limits/max_duration.rs | 66 ++++++++++++++++++- .../tests/features/limits/tour_size.rs | 2 +- .../tests/features/priorities/basic_order.rs | 2 +- .../tests/unit/checker/limits_test.rs | 4 +- .../tests/unit/format/problem/reader_test.rs | 2 +- 14 files changed, 185 insertions(+), 32 deletions(-) diff --git a/vrp-core/src/construction/enablers/schedule_update.rs b/vrp-core/src/construction/enablers/schedule_update.rs index a64770795..d85c868cb 100644 --- a/vrp-core/src/construction/enablers/schedule_update.rs +++ b/vrp-core/src/construction/enablers/schedule_update.rs @@ -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; @@ -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); @@ -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) @@ -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(); diff --git a/vrp-core/src/construction/features/tour_limits.rs b/vrp-core/src/construction/features/tour_limits.rs index 732b87e79..e185435e2 100644 --- a/vrp-core/src/construction/features/tour_limits.rs +++ b/vrp-core/src/construction/features/tour_limits.rs @@ -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, @@ -39,6 +43,7 @@ pub fn create_travel_limit_feature( duration_code: ViolationCode, tour_distance_limit_fn: TravelLimitFn, tour_duration_limit_fn: TravelLimitFn, + shift_start_latest_fn: TravelLimitFn, ) -> Result { FeatureBuilder::default() .with_name(name) @@ -46,6 +51,7 @@ pub fn create_travel_limit_feature( transport: transport.clone(), tour_distance_limit_fn, tour_duration_limit_fn: tour_duration_limit_fn.clone(), + shift_start_latest_fn, distance_code, duration_code, }) @@ -90,6 +96,7 @@ struct TravelLimitConstraint { transport: Arc, tour_distance_limit_fn: TravelLimitFn, tour_duration_limit_fn: TravelLimitFn, + shift_start_latest_fn: TravelLimitFn, distance_code: ViolationCode, duration_code: ViolationCode, } @@ -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); + } + } + + 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); diff --git a/vrp-core/tests/unit/construction/features/tour_limits_test.rs b/vrp-core/tests/unit/construction/features/tour_limits_test.rs index 52d549e0c..0ebaf3a95 100644 --- a/vrp-core/tests/unit/construction/features/tour_limits_test.rs +++ b/vrp-core/tests/unit/construction/features/tour_limits_test.rs @@ -101,6 +101,7 @@ mod traveling { DURATION_CODE, tour_distance_limit, tour_duration_limit, + Arc::new(|_| None), ) .unwrap(); diff --git a/vrp-pragmatic/src/checker/limits.rs b/vrp-pragmatic/src/checker/limits.rs index 13b34e92a..2348ecc30 100644 --- a/vrp-pragmatic/src/checker/limits.rs +++ b/vrp-pragmatic/src/checker/limits.rs @@ -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) + }; + let effective_end = parse_time(&end.schedule().arrival); let has_match = vehicle .shifts @@ -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!( diff --git a/vrp-pragmatic/src/format/dimensions.rs b/vrp-pragmatic/src/format/dimensions.rs index a842da055..c3ab71254 100644 --- a/vrp-pragmatic/src/format/dimensions.rs +++ b/vrp-pragmatic/src/format/dimensions.rs @@ -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); diff --git a/vrp-pragmatic/src/format/problem/fleet_reader.rs b/vrp-pragmatic/src/format/problem/fleet_reader.rs index f9468f14b..9cabc7c64 100644 --- a/vrp-pragmatic/src/format/problem/fleet_reader.rs +++ b/vrp-pragmatic/src/format/problem/fleet_reader.rs @@ -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::*; @@ -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 } }; + 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| { @@ -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 { diff --git a/vrp-pragmatic/src/format/problem/goal_reader.rs b/vrp-pragmatic/src/format/problem/goal_reader.rs index 1724b7efe..968633d25 100644 --- a/vrp-pragmatic/src/format/problem/goal_reader.rs +++ b/vrp-pragmatic/src/format/problem/goal_reader.rs @@ -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}; @@ -439,6 +439,9 @@ fn get_tour_limit_feature( }) }; + let shift_start_latest_fn: TravelLimitFn = + Arc::new(|actor: &Actor| actor.vehicle.dimens.get_shift_start_latest().copied()); + create_travel_limit_feature( name, transport, @@ -447,6 +450,7 @@ fn get_tour_limit_feature( DURATION_LIMIT_CONSTRAINT_CODE, get_limit(distances), get_limit(durations), + shift_start_latest_fn, ) } diff --git a/vrp-pragmatic/src/format/problem/model.rs b/vrp-pragmatic/src/format/problem/model.rs index 341c09a87..608c1a510 100644 --- a/vrp-pragmatic/src/format/problem/model.rs +++ b/vrp-pragmatic/src/format/problem/model.rs @@ -356,6 +356,11 @@ pub struct VehicleLimits { /// No job activities restrictions when omitted. #[serde(skip_serializing_if = "Option::is_none")] pub tour_size: Option, + + /// 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, } /// Vehicle optional break time variant. diff --git a/vrp-pragmatic/tests/features/limits/max_distance.rs b/vrp-pragmatic/tests/features/limits/max_distance.rs index 261a8cd91..e71ec3e96 100644 --- a/vrp-pragmatic/tests/features/limits/max_distance.rs +++ b/vrp-pragmatic/tests/features/limits/max_distance.rs @@ -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() @@ -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() diff --git a/vrp-pragmatic/tests/features/limits/max_duration.rs b/vrp-pragmatic/tests/features/limits/max_duration.rs index 9ae73fe39..1519a166e 100644 --- a/vrp-pragmatic/tests/features/limits/max_duration.rs +++ b/vrp-pragmatic/tests/features/limits/max_duration.rs @@ -1,11 +1,12 @@ use crate::format::problem::*; use crate::format::solution::*; +use crate::format_time; use crate::helpers::*; use vrp_core::prelude::Float; fn create_vehicle_type_with_max_duration_limit(max_duration: Float) -> VehicleType { VehicleType { - limits: Some(VehicleLimits { max_distance: None, max_duration: Some(max_duration), tour_size: None }), + limits: Some(VehicleLimits { max_distance: None, max_duration: Some(max_duration), tour_size: None, allow_out_of_hours_depot_travel: None }), ..create_default_vehicle_type() } } @@ -117,6 +118,69 @@ fn can_skip_job_from_multiple_because_of_max_duration() { ); } +#[test] +fn allow_out_of_hours_depot_travel_moves_shift_bounds_onto_first_and_last_jobs() { + // Depot at (0,0), first job at (10,0) → 10 units of travel. + // Shift bounds are [100, 200]. With the flag, the vehicle should be able to leave the + // depot before t=100 so it arrives at the first job at t>=100. The last job departure + // must be <= 200, but the depot return may happen later. + let problem = Problem { + plan: Plan { + jobs: vec![create_delivery_job_with_duration("job1", (10., 0.), 5.)], + ..create_empty_plan() + }, + fleet: Fleet { + vehicles: vec![VehicleType { + shifts: vec![VehicleShift { + start: ShiftStart { + earliest: format_time(100.), + latest: None, + location: (0., 0.).to_loc(), + }, + end: Some(ShiftEnd { + earliest: None, + latest: format_time(200.), + location: (0., 0.).to_loc(), + }), + breaks: None, + reloads: None, + recharges: None, + }], + limits: Some(VehicleLimits { + max_distance: None, + max_duration: None, + tour_size: None, + allow_out_of_hours_depot_travel: Some(true), + }), + ..create_default_vehicle_type() + }], + ..create_default_fleet() + }, + ..create_empty_problem() + }; + let matrix = create_matrix_from_problem(&problem); + + let solution = solve_with_metaheuristic(problem, Some(vec![matrix])); + + assert!(solution.unassigned.is_none() || solution.unassigned.unwrap().is_empty()); + assert_eq!(solution.tours.len(), 1); + + let tour = &solution.tours[0]; + let depot_departure = crate::parse_time(tour.stops[0].schedule().departure.as_str()); + let first_job_arrival = crate::parse_time(tour.stops[1].schedule().arrival.as_str()); + + // The depot was left before the shift started (at t<100) so that the first job is + // reached at the shift start. Without the flag, depot departure would be pinned at >=100. + assert!( + depot_departure < 100., + "expected depot to depart before shift start (t<100), got t={depot_departure}" + ); + assert!( + first_job_arrival >= 100., + "expected first-job arrival at/after shift start (t>=100), got t={first_job_arrival}" + ); +} + #[test] fn can_serve_job_when_it_starts_late() { let problem = Problem { diff --git a/vrp-pragmatic/tests/features/limits/tour_size.rs b/vrp-pragmatic/tests/features/limits/tour_size.rs index 3c4a9a6c6..636ce39ba 100644 --- a/vrp-pragmatic/tests/features/limits/tour_size.rs +++ b/vrp-pragmatic/tests/features/limits/tour_size.rs @@ -16,7 +16,7 @@ fn can_skip_job_from_multiple_because_of_tour_size() { fleet: Fleet { vehicles: vec![VehicleType { shifts: vec![create_default_open_vehicle_shift()], - limits: Some(VehicleLimits { max_distance: None, max_duration: None, tour_size: Some(2) }), + limits: Some(VehicleLimits { max_distance: None, max_duration: None, tour_size: Some(2), allow_out_of_hours_depot_travel: None }), ..create_default_vehicle_type() }], ..create_default_fleet() diff --git a/vrp-pragmatic/tests/features/priorities/basic_order.rs b/vrp-pragmatic/tests/features/priorities/basic_order.rs index a4cfcbe75..d16cab282 100644 --- a/vrp-pragmatic/tests/features/priorities/basic_order.rs +++ b/vrp-pragmatic/tests/features/priorities/basic_order.rs @@ -15,7 +15,7 @@ fn create_test_plan_with_three_jobs() -> Plan { } fn create_test_limit() -> Option { - Some(VehicleLimits { max_distance: Some(15.), max_duration: None, tour_size: None }) + Some(VehicleLimits { max_distance: Some(15.), max_duration: None, tour_size: None, allow_out_of_hours_depot_travel: None }) } fn create_order_objective(is_constrained: bool) -> Vec { diff --git a/vrp-pragmatic/tests/unit/checker/limits_test.rs b/vrp-pragmatic/tests/unit/checker/limits_test.rs index a0700c87d..b6aaea4b6 100644 --- a/vrp-pragmatic/tests/unit/checker/limits_test.rs +++ b/vrp-pragmatic/tests/unit/checker/limits_test.rs @@ -59,7 +59,7 @@ pub fn can_check_shift_and_distance_limit_impl( actual: i64, expected: Result<(), GenericError>, ) { - let problem = create_test_problem(Some(VehicleLimits { max_distance, max_duration, tour_size: None })); + let problem = create_test_problem(Some(VehicleLimits { max_distance, max_duration, tour_size: None, allow_out_of_hours_depot_travel: None })); let solution = create_test_solution(Statistic { distance: actual, duration: actual, ..Statistic::default() }, vec![]); let ctx = CheckerContext::new(create_example_problem(), problem, None, solution).unwrap(); @@ -72,7 +72,7 @@ pub fn can_check_shift_and_distance_limit_impl( #[test] pub fn can_check_tour_size_limit() { let problem = - create_test_problem(Some(VehicleLimits { max_distance: None, max_duration: None, tour_size: Some(2) })); + create_test_problem(Some(VehicleLimits { max_distance: None, max_duration: None, tour_size: Some(2), allow_out_of_hours_depot_travel: None })); let solution = create_test_solution( Statistic::default(), vec![ diff --git a/vrp-pragmatic/tests/unit/format/problem/reader_test.rs b/vrp-pragmatic/tests/unit/format/problem/reader_test.rs index e4fbb50a5..507e6acf6 100644 --- a/vrp-pragmatic/tests/unit/format/problem/reader_test.rs +++ b/vrp-pragmatic/tests/unit/format/problem/reader_test.rs @@ -167,7 +167,7 @@ fn can_read_complex_problem() { }], capacity: vec![10, 1], skills: Some(vec!["unique1".to_string(), "unique2".to_string()]), - limits: Some(VehicleLimits { max_distance: Some(123.1), max_duration: Some(100.), tour_size: Some(3) }), + limits: Some(VehicleLimits { max_distance: Some(123.1), max_duration: Some(100.), tour_size: Some(3), allow_out_of_hours_depot_travel: None }), }], ..create_default_fleet() }, From ae3137cfedf41fe84756c6616f25a75c5d1a0857 Mon Sep 17 00:00:00 2001 From: Paul Hutchins Date: Thu, 7 May 2026 14:58:40 +1000 Subject: [PATCH 2/3] test(shifts): test out of hours travel enforces shift start latest --- .../src/format/problem/problem_reader.rs | 10 ++-- .../tests/features/limits/max_duration.rs | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/vrp-pragmatic/src/format/problem/problem_reader.rs b/vrp-pragmatic/src/format/problem/problem_reader.rs index b1aaf2628..5d85a0cf8 100644 --- a/vrp-pragmatic/src/format/problem/problem_reader.rs +++ b/vrp-pragmatic/src/format/problem/problem_reader.rs @@ -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, diff --git a/vrp-pragmatic/tests/features/limits/max_duration.rs b/vrp-pragmatic/tests/features/limits/max_duration.rs index 1519a166e..816766092 100644 --- a/vrp-pragmatic/tests/features/limits/max_duration.rs +++ b/vrp-pragmatic/tests/features/limits/max_duration.rs @@ -181,6 +181,54 @@ fn allow_out_of_hours_depot_travel_moves_shift_bounds_onto_first_and_last_jobs() ); } +#[test] +fn allow_out_of_hours_depot_travel_still_enforces_shift_start_latest() { + // Depot at (0,0), job at (200,0) → 200 units of travel. + // Shift start window [100, 150]. Even leaving the depot at t=0, first-job arrival + // would be t=200, which exceeds start.latest=150. The flag relaxes the depot-start + // window but the travel-limit constraint must still reject this insertion. + let problem = Problem { + plan: Plan { + jobs: vec![create_delivery_job_with_duration("job1", (200., 0.), 5.)], + ..create_empty_plan() + }, + fleet: Fleet { + vehicles: vec![VehicleType { + shifts: vec![VehicleShift { + start: ShiftStart { + earliest: format_time(100.), + latest: Some(format_time(150.)), + location: (0., 0.).to_loc(), + }, + end: Some(ShiftEnd { + earliest: None, + latest: format_time(1000.), + location: (0., 0.).to_loc(), + }), + breaks: None, + reloads: None, + recharges: None, + }], + limits: Some(VehicleLimits { + max_distance: None, + max_duration: None, + tour_size: None, + allow_out_of_hours_depot_travel: Some(true), + }), + ..create_default_vehicle_type() + }], + ..create_default_fleet() + }, + ..create_empty_problem() + }; + let matrix = create_matrix_from_problem(&problem); + + let solution = solve_with_metaheuristic(problem, Some(vec![matrix])); + + assert_eq!(solution.unassigned.iter().flatten().count(), 1); + assert!(solution.tours.is_empty(), "expected no tours since the only job cannot be assigned"); +} + #[test] fn can_serve_job_when_it_starts_late() { let problem = Problem { From 4bf8933d5b6e9c0b4379b3653745669146824525 Mon Sep 17 00:00:00 2001 From: Paul Hutchins Date: Mon, 11 May 2026 17:12:24 +1000 Subject: [PATCH 3/3] chore: bump patch version --- .claude/settings.local.json | 7 +++++++ Cargo.toml | 12 ++++++------ rosomaxa/Cargo.toml | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..84f5b48ae --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git stash *)" + ] + } +} diff --git a/Cargo.toml b/Cargo.toml index 65b8a61b9..8a8d7732d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "1.25.1" +version = "1.25.2" authors = ["Ilya Builuk "] license = "Apache-2.0" keywords = ["vrp", "optimization"] @@ -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"] } diff --git a/rosomaxa/Cargo.toml b/rosomaxa/Cargo.toml index 3ca92739f..be004f81d 100644 --- a/rosomaxa/Cargo.toml +++ b/rosomaxa/Cargo.toml @@ -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