Skip to content
61 changes: 61 additions & 0 deletions vrp-core/src/construction/features/fleet_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#[path = "../../../tests/unit/construction/features/fleet_usage_test.rs"]
mod fleet_usage_test;

use std::collections::HashMap;

use super::*;

/// Creates a feature to minimize used fleet size (affects amount of tours in solution).
Expand Down Expand Up @@ -52,6 +54,65 @@ pub fn create_minimize_arrival_time_feature(name: &str) -> GenericResult<Feature
.build()
}

/// Creates a feature to distribute shifts evenly across vehicles.
/// This encourages using different shifts from different vehicles rather than
/// exhausting all shifts from one vehicle before using another.
pub fn create_balance_shifts_feature(name: &str) -> GenericResult<Feature> {
FeatureBuilder::default()
.with_name(name)
.with_objective(FleetUsageObjective {
route_estimate_fn: Box::new(|_| 0.),
solution_estimate_fn: Box::new(|solution_ctx| {
if solution_ctx.routes.is_empty() {
return 0.;
}

// Group routes by vehicle ID
let mut vehicle_shift_counts: HashMap<String, usize> = HashMap::new();
let mut total_available_shifts: HashMap<String, usize> = HashMap::new();

// Count how many shifts each vehicle is using
for route_ctx in solution_ctx.routes.iter() {
let actor = &route_ctx.route().actor;
if let Some(vehicle_id) = actor.vehicle.dimens.get_vehicle_id() {
*vehicle_shift_counts.entry(vehicle_id.clone()).or_insert(0) += 1;

// Track total available shifts for this vehicle
total_available_shifts.entry(vehicle_id.clone()).or_insert(actor.vehicle.details.len());
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

Using or_insert here means that if the same vehicle appears in multiple routes (which is expected - one vehicle can have multiple shifts/routes), only the first occurrence's details.len() will be stored. While this might work correctly if all routes from the same vehicle have the same details.len(), it makes the logic less clear.

Consider either:

  1. Moving this outside the loop: collect all unique vehicle IDs first, then populate total_available_shifts in a separate pass
  2. Using or_insert_with to make the intent clearer
  3. Adding a comment explaining that all routes from the same vehicle share the same details.len()
Suggested change
// Track total available shifts for this vehicle
total_available_shifts.entry(vehicle_id.clone()).or_insert(actor.vehicle.details.len());
// All routes for a given vehicle share the same details.len(), so it's safe to use or_insert_with here.
total_available_shifts.entry(vehicle_id.clone()).or_insert_with(|| actor.vehicle.details.len());

Copilot uses AI. Check for mistakes.
}
}

// Calculate the imbalance score
// We want to minimize the variance of (used_shifts / available_shifts) ratio
if vehicle_shift_counts.is_empty() {
return 0.;
}

let ratios: Vec<f64> = vehicle_shift_counts
.iter()
.map(|(vehicle_id, &used_count)| {
let available = *total_available_shifts.get(vehicle_id).unwrap_or(&1) as f64;
used_count as f64 / available
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

The variance calculation only includes vehicles that have assigned routes. Vehicles with 0 assigned routes (0/available_shifts ratio) are excluded from the calculation, which defeats the purpose of fair workload distribution.

For example, if vehicle A has 2/10 routes and vehicle B has 0/10 routes, only vehicle A's ratio (0.2) will be used for variance calculation, resulting in a variance of 0 (since there's only one value). This will incorrectly suggest perfect balance.

To fix this, you should collect ratios for ALL vehicles in the fleet, not just those with active routes. Consider iterating over solution_ctx.routes first to get all unique vehicles, but then also check if there are vehicles in the fleet that haven't been used at all.

Suggested change
let ratios: Vec<f64> = vehicle_shift_counts
.iter()
.map(|(vehicle_id, &used_count)| {
let available = *total_available_shifts.get(vehicle_id).unwrap_or(&1) as f64;
used_count as f64 / available
// Build ratios for all vehicles in the fleet, including those with zero assigned routes
let ratios: Vec<f64> = solution_ctx.problem.fleet.vehicles.iter()
.filter_map(|vehicle| vehicle.dimens.get_vehicle_id().map(|vehicle_id| (vehicle_id, vehicle.details.len())))
.map(|(vehicle_id, available_shifts)| {
let used_count = *vehicle_shift_counts.get(&vehicle_id).unwrap_or(&0) as f64;
let available = available_shifts as f64;
used_count / available

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

The variance calculation only includes vehicles that have assigned routes. Vehicles with 0 assigned routes (0/available_shifts ratio) are excluded from the calculation, which defeats the purpose of fair workload distribution.

For example, if vehicle A has 2/10 routes and vehicle B has 0/10 routes, only vehicle A's ratio (0.2) will be used for variance calculation, resulting in a variance of 0 (since there's only one value). This will incorrectly suggest perfect balance.

To fix this, you should collect ratios for ALL vehicles in the fleet, not just those with active routes. Consider iterating over solution_ctx.routes first to get all unique vehicles, but then also check if there are vehicles in the fleet that haven't been used at all.

No, this is intended as mentioned, in the PR description, we only want to distribute the workload between the vehicles already needed. It won't make sense to distribute it also across vehicles, which are not even needed in order to full-fill all the services

})
.collect();

// Calculate variance of ratios
let mean: f64 = ratios.iter().sum::<f64>() / ratios.len() as f64;
let variance: f64 = ratios
.iter()
.map(|&ratio| {
let diff = ratio - mean;
diff * diff
})
.sum::<f64>()
/ ratios.len() as f64;
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

This implementation uses raw variance, but other balance features in the codebase use coefficient of variation (CV) via get_cv_safe(). The coefficient of variation (standard deviation / mean) is generally more appropriate for comparing distributions with different scales and is consistent with existing balance objectives like BalanceMaxLoad, BalanceActivities, etc.

Consider using get_cv_safe(&ratios) from rosomaxa::algorithms::math instead of manually calculating variance. This would also handle edge cases like NaN values more robustly.

Copilot uses AI. Check for mistakes.

variance
}),
})
.build()
}

struct FleetUsageObjective {
route_estimate_fn: Box<dyn Fn(&RouteContext) -> Cost + Send + Sync>,
solution_estimate_fn: Box<dyn Fn(&SolutionContext) -> Cost + Send + Sync>,
Expand Down
1 change: 1 addition & 0 deletions vrp-pragmatic/src/format/problem/goal_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ fn get_objective_feature_layer(
}),
ViolationCode::unknown(),
),
Objective::BalanceShifts => create_balance_shifts_feature("balance_shifts"),
Objective::MinimizeUnassigned { breaks } => MinimizeUnassignedBuilder::new("min_unassigned")
.set_job_estimator({
let break_value = *breaks;
Expand Down
3 changes: 3 additions & 0 deletions vrp-pragmatic/src/format/problem/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,9 @@ pub enum Objective {
/// An objective to balance duration across all tours.
BalanceDuration,

/// An objective to balance shifts across all vehicles.
BalanceShifts,

/// An objective to control how tours are built.
CompactTour {
/// Specifies radius of neighbourhood. Min is 1.
Expand Down