diff --git a/CHANGELOG.md b/CHANGELOG.md index 7020d457f..47f32bdda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ are already published. So, I stick to it for now. * add an experimental objective * 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) ## [1.25.0] 2024-11-10 diff --git a/Cargo.toml b/Cargo.toml index 8a8d7732d..700002dca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "1.25.2" +version = "1.25.3" 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.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" } +rosomaxa = { path = "rosomaxa", version = "0.9.3" } +vrp-core = { path = "vrp-core", version = "1.25.3" } +vrp-scientific = { path = "vrp-scientific", version = "1.25.3" } +vrp-pragmatic = { path = "vrp-pragmatic", version = "1.25.3" } +vrp-cli = { path = "vrp-cli", version = "1.25.3" } # external dependencies serde = { version = "1.0.219", features = ["derive"] } diff --git a/rosomaxa/Cargo.toml b/rosomaxa/Cargo.toml index be004f81d..1208f7372 100644 --- a/rosomaxa/Cargo.toml +++ b/rosomaxa/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rosomaxa" -version = "0.9.2" +version = "0.9.3" description = "A rosomaxa algorithm and other building blocks for creating a solver for optimization problems" authors.workspace = true license.workspace = true diff --git a/vrp-core/src/construction/features/depot_proximity.rs b/vrp-core/src/construction/features/depot_proximity.rs new file mode 100644 index 000000000..dd145bbbb --- /dev/null +++ b/vrp-core/src/construction/features/depot_proximity.rs @@ -0,0 +1,129 @@ +//! Prefers assigning jobs whose location is close (by travel time) to the vehicle's depot. +//! +//! The depot is the vehicle shift's start location (`route.actor.detail.start`). This objective +//! scores each served job by the travel duration from its route's depot to the job, preferring +//! assignments that keep a vehicle working near its own depot even when that is not the globally +//! cheapest route. +//! +//! It has two modes, selected by the optional `cap`: +//! +//! * **Soft (`cap` = `None`)** — purely a penalty over *assigned* jobs (unassigned jobs cost +//! nothing). Intended as a member of a `weighted-sum` tier where it trades off against other +//! objectives. On its own as a dominant tier it is degenerate: the cheapest solution assigns +//! nothing (zero travel), so it must not be used standalone in this mode. +//! +//! * **Capped / "depot radius" (`cap` = `Some(c)`)** — leaving a job unassigned costs `c`, while +//! assigning it costs its (penalised) depot→job travel. Minimising this assigns every job whose +//! depot travel is below `c` (nearest first) and drops the rest, so it drives *proximity-gated +//! assignment* with a tunable radius and is safe to use as a dominant lexicographic tier (the +//! empty solution costs `N * c`, never wins). +//! +//! Objective only — no constraint or state. + +#[cfg(test)] +#[path = "../../../tests/unit/construction/features/depot_proximity_test.rs"] +mod depot_proximity_test; + +use super::*; +use crate::models::problem::TransportCost; +use crate::models::solution::Route; +use crate::utils::Either; +use std::iter::empty; + +/// Maps a raw depot→job travel duration to a penalty cost. Linear (identity) today, but this +/// signature is the single extension point for a future non-linear penalty shape (e.g. quadratic +/// or thresholded) without touching the objective plumbing. +pub type DepotPenaltyFn = Arc Cost + Send + Sync>; + +/// Creates a feature which prefers assigning jobs close (by travel time) to the vehicle's depot. +/// +/// `penalty_fn` converts the raw depot→job travel duration into a penalty cost; the default caller +/// passes the identity closure (`Arc::new(|duration| duration)`) for a linear penalty. +/// +/// `cap` selects the mode: `None` for a soft weighted-sum penalty (assigned jobs only), or +/// `Some(c)` for a "depot radius" where leaving a job unassigned costs `c` — driving proximity-gated +/// assignment (assign jobs within `c` of the depot, nearest first; drop the rest). `c` is expressed +/// in the same (penalised) units the `penalty_fn` returns. +pub fn create_minimize_depot_travel_time_feature( + name: &str, + transport: Arc, + penalty_fn: DepotPenaltyFn, + cap: Option, +) -> Result { + FeatureBuilder::default() + .with_name(name) + .with_objective(MinimizeDepotTravelTimeObjective { transport, penalty_fn, cap }) + .build() +} + +struct MinimizeDepotTravelTimeObjective { + transport: Arc, + penalty_fn: DepotPenaltyFn, + cap: Option, +} + +impl MinimizeDepotTravelTimeObjective { + /// Returns the penalised travel duration from the route's depot (shift start) to the nearest + /// of the job's candidate locations. Returns `0` when the route has no depot or the job has no + /// located place. + fn depot_to_job_duration(&self, route: &Route, job: &Job) -> Cost { + let Some(depot) = route.actor.detail.start.as_ref().map(|place| place.location) else { + return 0.; + }; + let profile = &route.actor.vehicle.profile; + + // Take the closest candidate location across the job's singles (handles Single and Multi). + let nearest = job + .places() + .filter_map(|place| place.location) + .map(|location| self.transport.duration_approx(profile, depot, location)) + .min_by(|left, right| left.total_cmp(right)); + + match nearest { + Some(duration) => (self.penalty_fn)(duration), + None => 0., + } + } +} + +impl FeatureObjective for MinimizeDepotTravelTimeObjective { + fn fitness(&self, solution: &InsertionContext) -> Cost { + let assigned = solution.solution.routes.iter().fold(0., |acc, route_ctx| { + route_ctx.route().tour.jobs().fold(acc, |acc, job| acc + self.depot_to_job_duration(route_ctx.route(), job)) + }); + + match self.cap { + // Capped mode: each unassigned job costs `cap`, so dropping near jobs is worse than + // assigning them and the empty solution does not win. Mirrors `minimize_unassigned`'s + // handling of `ignored` jobs when no routes exist yet. + Some(cap) => { + let unassigned = if solution.solution.routes.is_empty() { + Either::Left(solution.solution.ignored.iter()) + } else { + Either::Right(empty()) + } + .chain(solution.solution.unassigned.keys()) + .count(); + + assigned + unassigned as Cost * cap + } + None => assigned, + } + } + + fn estimate(&self, move_ctx: &MoveContext<'_>) -> Cost { + match move_ctx { + // Inserting a job removes its unassigned `cap` penalty and adds its depot travel, so the + // marginal cost is `travel - cap` (negative — rewarded — for jobs within the radius). + MoveContext::Route { route_ctx, job, .. } => { + let travel = self.depot_to_job_duration(route_ctx.route(), job); + match self.cap { + Some(cap) => travel - cap, + None => travel, + } + } + // Proximity is per-job, not per-activity; scoring here would double-count. + MoveContext::Activity { .. } => Cost::default(), + } + } +} diff --git a/vrp-core/src/construction/features/mod.rs b/vrp-core/src/construction/features/mod.rs index f54ca429f..49bc10829 100644 --- a/vrp-core/src/construction/features/mod.rs +++ b/vrp-core/src/construction/features/mod.rs @@ -17,6 +17,9 @@ pub use self::capacity::{CapacityFeatureBuilder, JobDemandDimension, VehicleCapa mod compatibility; pub use self::compatibility::{JobCompatibilityDimension, create_compatibility_feature}; +mod depot_proximity; +pub use self::depot_proximity::*; + mod fast_service; pub use self::fast_service::FastServiceFeatureBuilder; diff --git a/vrp-core/tests/unit/construction/features/depot_proximity_test.rs b/vrp-core/tests/unit/construction/features/depot_proximity_test.rs new file mode 100644 index 000000000..0bc9d5fe6 --- /dev/null +++ b/vrp-core/tests/unit/construction/features/depot_proximity_test.rs @@ -0,0 +1,153 @@ +use super::*; +use crate::helpers::construction::heuristics::TestInsertionContextBuilder; +use crate::helpers::models::problem::{TestSingleBuilder, TestTransportCost, test_multi_job_with_locations}; +use crate::helpers::models::solution::*; + +// The default actor's depot (shift start) is at location 0 and `TestTransportCost` reports the +// travel duration between two locations as their absolute difference, so a job at location `L` is +// scored `L` from the depot. + +fn create_objective() -> Arc { + create_objective_with_cap(None) +} + +fn create_objective_with_cap(cap: Option) -> Arc { + create_minimize_depot_travel_time_feature("min_depot_travel", TestTransportCost::new_shared(), Arc::new(|d| d), cap) + .unwrap() + .objective + .unwrap() +} + +parameterized_test! {can_estimate_depot_to_job_duration, (location, expected), { + can_estimate_depot_to_job_duration_impl(location, expected); +}} + +can_estimate_depot_to_job_duration! { + case_01_near: (Some(3), 3.), + case_02_far: (Some(30), 30.), + case_03_at_depot: (Some(0), 0.), + case_04_no_location: (None, 0.), +} + +fn can_estimate_depot_to_job_duration_impl(location: Option, expected: Cost) { + let objective = create_objective(); + let route_ctx = RouteContextBuilder::default().build(); + let solution_ctx = TestInsertionContextBuilder::default().build().solution; + let job = TestSingleBuilder::default().location(location).build_as_job_ref(); + + let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job)); + + assert_eq!(result, expected); +} + +#[test] +fn can_estimate_nearest_of_multiple_places_for_single_job() { + let objective = create_objective(); + let route_ctx = RouteContextBuilder::default().build(); + let solution_ctx = TestInsertionContextBuilder::default().build().solution; + let job = TestSingleBuilder::default() + .places(vec![(Some(30), 0., vec![]), (Some(7), 0., vec![]), (Some(15), 0., vec![])]) + .build_as_job_ref(); + + let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job)); + + assert_eq!(result, 7.); +} + +#[test] +fn can_estimate_nearest_of_multiple_singles_for_multi_job() { + let objective = create_objective(); + let route_ctx = RouteContextBuilder::default().build(); + let solution_ctx = TestInsertionContextBuilder::default().build().solution; + let job = Job::Multi(test_multi_job_with_locations(vec![vec![Some(30)], vec![Some(9)]])); + + let result = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &job)); + + assert_eq!(result, 9.); +} + +#[test] +fn can_score_far_job_higher_than_near_job() { + let objective = create_objective(); + let route_ctx = RouteContextBuilder::default().build(); + let solution_ctx = TestInsertionContextBuilder::default().build().solution; + let near = TestSingleBuilder::default().location(Some(5)).build_as_job_ref(); + let far = TestSingleBuilder::default().location(Some(50)).build_as_job_ref(); + + let near_cost = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &near)); + let far_cost = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &far)); + + assert!(far_cost > near_cost, "expected far job ({far_cost}) to be scored higher than near job ({near_cost})"); +} + +#[test] +fn can_ignore_activity_context() { + let objective = create_objective(); + // Use a route with a start activity (the depot) so the activity context has a valid `prev`. + let route_ctx = RouteContextBuilder::default().with_route(RouteBuilder::default().build()).build(); + let solution_ctx = TestInsertionContextBuilder::default().build().solution; + let target = ActivityBuilder::with_location(40).build(); + let activity_ctx = + ActivityContext { index: 1, prev: route_ctx.route().tour.get(0).unwrap(), target: &target, next: None }; + + let result = objective.estimate(&MoveContext::activity(&solution_ctx, &route_ctx, &activity_ctx)); + + assert_eq!(result, Cost::default()); +} + +#[test] +fn can_sum_fitness_over_routes() { + let objective = create_objective(); + let route_ctx = RouteContextBuilder::default() + .with_route( + RouteBuilder::default() + .add_activity(ActivityBuilder::with_location(4).build()) + .add_activity(ActivityBuilder::with_location(6).build()) + .build(), + ) + .build(); + let insertion_ctx = TestInsertionContextBuilder::default().with_routes(vec![route_ctx]).build(); + + let result = objective.fitness(&insertion_ctx); + + assert_eq!(result, 10.); +} + +#[test] +fn capped_estimate_rewards_near_job_and_penalises_far_job() { + // cap = 10: a job within the radius gets a negative (rewarded) insertion cost, a job beyond it + // a positive (discouraged) one. The break-even is exactly at the cap distance. + let objective = create_objective_with_cap(Some(10.)); + let route_ctx = RouteContextBuilder::default().build(); + let solution_ctx = TestInsertionContextBuilder::default().build().solution; + + let near = TestSingleBuilder::default().location(Some(3)).build_as_job_ref(); + let far = TestSingleBuilder::default().location(Some(30)).build_as_job_ref(); + + let near_cost = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &near)); + let far_cost = objective.estimate(&MoveContext::route(&solution_ctx, &route_ctx, &far)); + + assert_eq!(near_cost, 3. - 10.); // -7: within radius, rewarded + assert_eq!(far_cost, 30. - 10.); // +20: beyond radius, discouraged +} + +#[test] +fn capped_fitness_penalises_unassigned_jobs() { + // One assigned job at location 4 (travel 4) plus two unassigned jobs, cap = 10. + // fitness = 4 (assigned travel) + 2 * 10 (unassigned penalty) = 24. + let objective = create_objective_with_cap(Some(10.)); + let route_ctx = RouteContextBuilder::default() + .with_route(RouteBuilder::default().add_activity(ActivityBuilder::with_location(4).build()).build()) + .build(); + let insertion_ctx = TestInsertionContextBuilder::default() + .with_routes(vec![route_ctx]) + .with_unassigned(vec![ + (TestSingleBuilder::default().id("u1").build_as_job_ref(), UnassignmentInfo::Unknown), + (TestSingleBuilder::default().id("u2").build_as_job_ref(), UnassignmentInfo::Unknown), + ]) + .build(); + + let result = objective.fitness(&insertion_ctx); + + assert_eq!(result, 24.); +} diff --git a/vrp-pragmatic/src/format/problem/goal_reader.rs b/vrp-pragmatic/src/format/problem/goal_reader.rs index 968633d25..e0fd3c8ef 100644 --- a/vrp-pragmatic/src/format/problem/goal_reader.rs +++ b/vrp-pragmatic/src/format/problem/goal_reader.rs @@ -199,6 +199,12 @@ fn get_objective_feature_layer( } Objective::TourOrder => create_tour_order_soft_feature("tour_order", get_tour_order_fn()), Objective::FastService => get_fast_service_feature("fast_service", blocks), + Objective::MinimizeDepotTravelTime { cap } => create_minimize_depot_travel_time_feature( + "min_depot_travel", + blocks.transport.clone(), + Arc::new(|duration| duration), // linear penalty; see DepotPenaltyFn for the non-linear seam + *cap, + ), Objective::HierarchicalAreas { levels } => get_hierarchical_areas_feature(blocks, *levels), Objective::MultiObjective { objectives, strategy: composition_type } => { let features = objectives diff --git a/vrp-pragmatic/src/format/problem/model.rs b/vrp-pragmatic/src/format/problem/model.rs index 608c1a510..293b51db5 100644 --- a/vrp-pragmatic/src/format/problem/model.rs +++ b/vrp-pragmatic/src/format/problem/model.rs @@ -589,6 +589,18 @@ pub enum Objective { /// An objective to prefer jobs to be served as soon as possible. FastService, + /// An objective to prefer assigning jobs close (by travel time) to the vehicle's depot + /// (shift start). + MinimizeDepotTravelTime { + /// Optional "depot radius": the cost of leaving a job unassigned, in travel-duration + /// units. When set, the objective drives proximity-gated assignment — jobs within `cap` + /// travel of a depot are assigned (nearest first) and farther jobs are dropped — and is + /// safe to use as a dominant tier. When omitted, it is a soft penalty over assigned jobs + /// only, intended for use inside a `weighted-sum` tier. + #[serde(skip_serializing_if = "Option::is_none")] + cap: Option, + }, + /// An objective to consider hierarchy of areas while serving jobs. HierarchicalAreas { /// Number of levels in area hierarchy. diff --git a/vrp-pragmatic/tests/features/mod.rs b/vrp-pragmatic/tests/features/mod.rs index f7d032198..b3368ae40 100644 --- a/vrp-pragmatic/tests/features/mod.rs +++ b/vrp-pragmatic/tests/features/mod.rs @@ -10,6 +10,7 @@ mod format; mod group; mod limits; mod multjob; +mod objectives; mod pickdev; mod priorities; mod recharge; diff --git a/vrp-pragmatic/tests/features/objectives/minimize_depot_travel_time.rs b/vrp-pragmatic/tests/features/objectives/minimize_depot_travel_time.rs new file mode 100644 index 000000000..e31cf4b2f --- /dev/null +++ b/vrp-pragmatic/tests/features/objectives/minimize_depot_travel_time.rs @@ -0,0 +1,53 @@ +use crate::format::problem::Objective::*; +use crate::format::problem::*; +use crate::helpers::*; + +#[test] +fn can_round_trip_minimize_depot_travel_time_objective() { + let json = r#"{"type":"minimize-depot-travel-time"}"#; + + let objective: Objective = serde_json::from_str(json).expect("cannot deserialize objective"); + assert!(matches!(objective, MinimizeDepotTravelTime { cap: None })); + + let serialized = serde_json::to_string(&objective).expect("cannot serialize objective"); + assert_eq!(serialized, json); +} + +#[test] +fn can_round_trip_minimize_depot_travel_time_objective_with_cap() { + let json = r#"{"type":"minimize-depot-travel-time","cap":1800.0}"#; + + let objective: Objective = serde_json::from_str(json).expect("cannot deserialize objective"); + assert!(matches!(objective, MinimizeDepotTravelTime { cap: Some(c) } if c == 1800.0)); + + let serialized = serde_json::to_string(&objective).expect("cannot serialize objective"); + assert_eq!(serialized, json); +} + +#[test] +fn can_build_goal_with_minimize_depot_travel_time_objective() { + let problem = Problem { + plan: Plan { + jobs: vec![create_delivery_job("job1", (1., 0.)), create_delivery_job("job2", (2., 0.))], + ..create_empty_plan() + }, + fleet: Fleet { + vehicles: vec![VehicleType { + shifts: vec![create_default_open_vehicle_shift()], + ..create_default_vehicle_type() + }], + ..create_default_fleet() + }, + objectives: Some(vec![ + MinimizeUnassigned { breaks: None }, + MinimizeDepotTravelTime { cap: Some(1800.0) }, + MinimizeCost, + ]), + ..create_empty_problem() + }; + let matrix = create_matrix_from_problem(&problem); + + let core_problem = (problem, vec![matrix]).read_pragmatic(); + + assert!(core_problem.is_ok(), "failed to build goal: {:?}", core_problem.err()); +} diff --git a/vrp-pragmatic/tests/features/objectives/mod.rs b/vrp-pragmatic/tests/features/objectives/mod.rs new file mode 100644 index 000000000..0b8a00e0c --- /dev/null +++ b/vrp-pragmatic/tests/features/objectives/mod.rs @@ -0,0 +1 @@ +mod minimize_depot_travel_time;