-
Notifications
You must be signed in to change notification settings - Fork 88
Add feature to balance shifts across vehicles #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
8307c52
db6c4e5
a1b9d47
985757e
3ab58c5
c568830
d64cdf2
8c613c5
82d1bb9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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). | ||||||||||||||||||||||||||
|
|
@@ -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()); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 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 | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| 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 |
There was a problem hiding this comment.
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.routesfirst 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
Outdated
Copilot
AI
Nov 15, 2025
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
or_inserthere means that if the same vehicle appears in multiple routes (which is expected - one vehicle can have multiple shifts/routes), only the first occurrence'sdetails.len()will be stored. While this might work correctly if all routes from the same vehicle have the samedetails.len(), it makes the logic less clear.Consider either:
total_available_shiftsin a separate passor_insert_withto make the intent clearerdetails.len()