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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.2"
version = "1.25.3"
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.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"] }
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.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
Expand Down
129 changes: 129 additions & 0 deletions vrp-core/src/construction/features/depot_proximity.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Fn(Duration) -> 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<dyn TransportCost>,
penalty_fn: DepotPenaltyFn,
cap: Option<Cost>,
) -> Result<Feature, GenericError> {
FeatureBuilder::default()
.with_name(name)
.with_objective(MinimizeDepotTravelTimeObjective { transport, penalty_fn, cap })
.build()
}

struct MinimizeDepotTravelTimeObjective {
transport: Arc<dyn TransportCost>,
penalty_fn: DepotPenaltyFn,
cap: Option<Cost>,
}

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(),
}
}
}
3 changes: 3 additions & 0 deletions vrp-core/src/construction/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
153 changes: 153 additions & 0 deletions vrp-core/tests/unit/construction/features/depot_proximity_test.rs
Original file line number Diff line number Diff line change
@@ -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<dyn FeatureObjective> {
create_objective_with_cap(None)
}

fn create_objective_with_cap(cap: Option<Cost>) -> Arc<dyn FeatureObjective> {
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<Location>, 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.);
}
6 changes: 6 additions & 0 deletions vrp-pragmatic/src/format/problem/goal_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions vrp-pragmatic/src/format/problem/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Float>,
},

/// An objective to consider hierarchy of areas while serving jobs.
HierarchicalAreas {
/// Number of levels in area hierarchy.
Expand Down
1 change: 1 addition & 0 deletions vrp-pragmatic/tests/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod format;
mod group;
mod limits;
mod multjob;
mod objectives;
mod pickdev;
mod priorities;
mod recharge;
Expand Down
Loading
Loading