diff --git a/docs/src/concepts/pragmatic/errors/index.md b/docs/src/concepts/pragmatic/errors/index.md index b664d8f2b..fc3eac90b 100644 --- a/docs/src/concepts/pragmatic/errors/index.md +++ b/docs/src/concepts/pragmatic/errors/index.md @@ -485,28 +485,6 @@ Additionally, reload time should be inside vehicle shift it is specified: You can fix the error by defining a small value (e.g. 0.0000001) for duration or time costs. -#### E1307 - -`time offset interval for break is used with departure rescheduling` is returned when time offset interval is specified for break, -but `start.latest` is not set equal to `start.earliest` in the shift. - -```json - { - "start": { - "earliest": "2019-07-04T09:00:00Z", - /** Error: need to set latest to "2019-07-04T09:00:00Z" explicitely **/ - "location": { "lat": 52.5316, "lng": 13.3884 } - }, - "breaks": [{ - /** Note: offset time is used here **/ - "time": [3600, 4000], - "places": [{ "duration": 1800 } ] - }] - } -``` - -Alternatively, you can switch to time window definition and keep `start.latest` property as you wish. - #### E1308 `invalid vehicle reload resource` is returned when: diff --git a/docs/src/concepts/pragmatic/problem/objectives.md b/docs/src/concepts/pragmatic/problem/objectives.md index 131eca035..63f1cf2bc 100644 --- a/docs/src/concepts/pragmatic/problem/objectives.md +++ b/docs/src/concepts/pragmatic/problem/objectives.md @@ -4,14 +4,12 @@ A classical objective function (or simply objective) for VRP is minimization of require different objective function or even more than one considered simultaneously. That's why the solver has a concept of multi objective. - ## Understanding multi objective structure A multi objective is defined by `objectives` property which has array of objectives and defines lexicographical ordered objective function. Here, priority of objectives decreases from first to the last element of the array. For the same priority (or in other words, competitive) objectives, a special `multi-objective` type can be used. - ## Available objectives The solver already provides multiple built-in objectives distinguished by their `type`. All these objectives can be @@ -21,10 +19,10 @@ split into the following groups. These objectives specify how "total" cost of job insertion is calculated: -* `minimize-cost`: minimizes total transport cost calculated for all routes. Here, total transport cost is seen as linear +- `minimize-cost`: minimizes total transport cost calculated for all routes. Here, total transport cost is seen as linear combination of total time and distance -* `minimize-distance`: minimizes total distance of all routes -* `minimize-duration`: minimizes total duration of all routes +- `minimize-distance`: minimizes total distance of all routes +- `minimize-duration`: minimizes total duration of all routes One of these objectives has to be set and only one. @@ -32,49 +30,47 @@ One of these objectives has to be set and only one. Besides cost objectives, there are other objectives which are targeting for some scalar characteristic of solution: -* `minimize-unassigned`: minimizes amount of unassigned jobs. Although, solver tries to minimize amount of -unassigned jobs all the time, it is possible that solution, discovered during refinement, has more unassigned jobs than -previously accepted. The reason of that can be conflicting objective (e.g. minimize tours) and restrictive -constraints such as time windows. The objective has the following optional parameter: - * `breaks`: a multiplicative coefficient to make breaks more preferable for assignment. Default value is 1. Setting - this parameter to a value bigger than 1 is useful when it is highly desirable to have break assigned but its - assignment leads to more jobs unassigned. -* `minimize-tours`: minimizes total amount of tours present in solution -* `maximize-tours`: maximizes total amount of tours present in solution -* `minimize-arrival-time`: prefers solutions where work is finished earlier -* `fast-service`: prefers solutions when jobs are served early in tours. Optional parameter: - * `tolerance`: an objective tolerance specifies how different objective values have to be to consider them different. - Relative distance metric is used. -* `hierarchical-areas`: an experimental objective to play with clusters of jobs. Internally uses distance minimization as +- `minimize-unassigned`: minimizes amount of unassigned jobs. Although, solver tries to minimize amount of + unassigned jobs all the time, it is possible that solution, discovered during refinement, has more unassigned jobs than + previously accepted. The reason of that can be conflicting objective (e.g. minimize tours) and restrictive + constraints such as time windows. The objective has the following optional parameter: \* `breaks`: a multiplicative coefficient to make breaks more preferable for assignment. Default value is 1. Setting + this parameter to a value bigger than 1 is useful when it is highly desirable to have break assigned but its + assignment leads to more jobs unassigned. +- `minimize-tours`: minimizes total amount of tours present in solution +- `maximize-tours`: maximizes total amount of tours present in solution +- `minimize-arrival-time`: prefers solutions where work is finished earlier +- `fast-service`: prefers solutions when jobs are served early in tours. Optional parameter: + - `tolerance`: an objective tolerance specifies how different objective values have to be to consider them different. + Relative distance metric is used. +- `hierarchical-areas`: an experimental objective to play with clusters of jobs. Internally uses distance minimization as a base penalty. - * `levels` - number of hierarchy levels + - `levels` - number of hierarchy levels ### Job distribution objectives These objectives provide some extra control on job assignment: -* `maximize-value`: maximizes total value of served jobs. It has optional parameters: - * `reductionFactor`: a factor to reduce value cost compared to max routing costs - * `breaks`: a value penalty for skipping a break. Default value is 100. -* `tour-order`: controls desired activity order in tours - * `isConstrained`: violating order is not allowed, even if it leads to less assigned jobs (default is true). -* `compact-tour`: controls how tour is shaped by limiting amount of shared jobs, assigned in different routes, - for a given job' neighbourhood. It has the following mandatory parameters: - * `options`: options to relax objective: - - `jobRadius`: a radius of neighbourhood, minimum is 1 - - `threshold`: a minimum shared jobs to count - - `distance`: a minimum relative distance between counts when comparing different solutions. - This objective is supposed to be on the same level within cost ones. - +- `maximize-value`: maximizes total value of served jobs. It has optional parameters: + - `reductionFactor`: a factor to reduce value cost compared to max routing costs + - `breaks`: a value penalty for skipping a break. Default value is 100. +- `tour-order`: controls desired activity order in tours + - `isConstrained`: violating order is not allowed, even if it leads to less assigned jobs (default is true). +- `compact-tour`: controls how tour is shaped by limiting amount of shared jobs, assigned in different routes, + for a given job' neighbourhood. It has the following mandatory parameters: + - `options`: options to relax objective: - `jobRadius`: a radius of neighbourhood, minimum is 1 - `threshold`: a minimum shared jobs to count - `distance`: a minimum relative distance between counts when comparing different solutions. + This objective is supposed to be on the same level within cost ones. ### Work balance objectives There are four work balance objectives available: -* `balance-max-load`: balances max load in tour -* `balance-activities`: balances amount of activities performed in tour -* `balance-distance`: balances travelled distance per tour -* `balance-duration`: balances tour durations +- `balance-max-load`: balances max load in tour +- `balance-activities`: balances amount of activities performed in tour +- `balance-distance`: balances travelled distance per tour +- `balance-duration`: balances tour durations +- `balance-shifts`: balances how often different vehicle shifts are used. Optional parameters: + - `saturation` (default `0.05`): controls how strongly small variance deviations are penalized. Lower values enforce nearly equal usage, while higher values allow more imbalance before additional costs are applied. + - `weight` (default `1.0`): multiplies the resulting penalty so you can emphasize or de-emphasize shift balancing relative to other objectives. This is especially important when `balance-shifts` shares a multi-objective block with cost-based objectives whose raw magnitudes are much higher. Typically, you need to use these objective with one from the cost group combined under single `multi-objective`. @@ -104,11 +100,10 @@ If at least one job has non-zero value associated, then the following objective If order on job task is specified, then it is also added to the list of objectives after `minimize-tours` objective. - ## Hints -* pay attention to the order of objectives -* if you're using balancing objective and getting high cost or non-realistic, but balanced routes, try to use multi-objective: +- pay attention to the order of objectives +- if you're using balancing objective and getting high cost or non-realistic, but balanced routes, try to use multi-objective: ```json "objectives": [ @@ -137,15 +132,14 @@ If order on job task is specified, then it is also added to the list of objectiv ## Related errors -* [E1600 an empty objective specified](../errors/index.md#e1600) -* [E1601 duplicate objective specified](../errors/index.md#e1601) -* [E1602 missing one of cost objectives](../errors/index.md#e1602) -* [E1603 redundant value objective](../errors/index.md#e1603) -* [E1604 redundant tour order objective](../errors/index.md#e1604) -* [E1605 value or order of a job should be greater than zero](../errors/index.md#e1605) -* [E1606 multiple cost objectives specified](../errors/index.md#e1606) -* [E1607 missing value objective](../errors/index.md#e1607) - +- [E1600 an empty objective specified](../errors/index.md#e1600) +- [E1601 duplicate objective specified](../errors/index.md#e1601) +- [E1602 missing one of cost objectives](../errors/index.md#e1602) +- [E1603 redundant value objective](../errors/index.md#e1603) +- [E1604 redundant tour order objective](../errors/index.md#e1604) +- [E1605 value or order of a job should be greater than zero](../errors/index.md#e1605) +- [E1606 multiple cost objectives specified](../errors/index.md#e1606) +- [E1607 missing value objective](../errors/index.md#e1607) ## Examples diff --git a/docs/src/concepts/pragmatic/problem/vehicles.md b/docs/src/concepts/pragmatic/problem/vehicles.md index ccd7ada60..f9fde7700 100644 --- a/docs/src/concepts/pragmatic/problem/vehicles.md +++ b/docs/src/concepts/pragmatic/problem/vehicles.md @@ -44,6 +44,18 @@ A vehicle types are defined by `fleet.vehicles` property and their schema has th - **tourSize** (optional): max amount of activities in the tour (without departure/arrival). Please note, that clustered activities are counted as one in case of vicinity clustering. +- **minShifts** (optional): enforces a minimum number of shifts which each `vehicleId` of this type should serve with + actual jobs assigned. It is defined as object with: + - `value`: minimum amount of shifts required for every vehicle id of this type. + - `allowZeroUsage` (optional, default `false`): when `true`, a vehicle id may stay completely unused; otherwise + zero usage counts als Verstoß, auch wenn die Mindestanzahl global erreicht wird. +```json +"minShifts": { + "value": 2, + "allowZeroUsage": false +} +``` + An example: ```json @@ -86,8 +98,6 @@ Each shift can have the following properties: as time windows. You can control its unassignment weight using specific property on `minimize-unassigned` objective. See example [here](../../../examples/pragmatic/basics/break.md) - Additionally, offset time interval requires departure time optimization to be disabled explicitly (see [E1307](../errors/index.md#e1307)). - - **reloads** (optional) a list of vehicle reloads. A reload is a place where vehicle can load new deliveries and unload pickups. It can be used to model multi trip routes. Each reload has optional and required fields: @@ -108,5 +118,4 @@ Each shift can have the following properties: * [E1303 invalid break time windows in vehicle shift](../errors/index.md#e1303) * [E1304 invalid reload time windows in vehicle shift](../errors/index.md#e1304) * [E1306 time and duration costs are zeros](../errors/index.md#e1306) -* [E1307 time offset interval for break is used with departure rescheduling](../errors/index.md#e1307) -* [E1308 invalid vehicle reload resource](../errors/index.md#e1308) \ No newline at end of file +* [E1308 invalid vehicle reload resource](../errors/index.md#e1308) diff --git a/examples/python-interop/config_types.py b/examples/python-interop/config_types.py index 6716a6166..a456e45e5 100644 --- a/examples/python-interop/config_types.py +++ b/examples/python-interop/config_types.py @@ -19,9 +19,6 @@ class Progress: dumpPopulation: bool -Telemetry.__pydantic_model__.update_forward_refs() - - @dataclass class Config: termination: Termination @@ -47,16 +44,7 @@ class Logging: enabled: bool -Logging.__pydantic_model__.update_forward_refs() - - @dataclass class Environment: logging: Logging = Logging(enabled=True) isExperimental: Optional[bool] = None - - -Config.__pydantic_model__.update_forward_refs() -Telemetry.__pydantic_model__.update_forward_refs() -Termination.__pydantic_model__.update_forward_refs() -Environment.__pydantic_model__.update_forward_refs() diff --git a/examples/python-interop/pragmatic_types.py b/examples/python-interop/pragmatic_types.py index f838f18fe..ac4b88071 100644 --- a/examples/python-interop/pragmatic_types.py +++ b/examples/python-interop/pragmatic_types.py @@ -22,7 +22,7 @@ class RoutingMatrix: class Problem: plan: Plan fleet: Fleet - objectives: Optional[List[List[Objective]]] = None + objectives: Optional[List[Objective]] = None @dataclass @@ -142,25 +142,6 @@ class Objective: class ObjectiveOptions: threshold: float - -Problem.__pydantic_model__.update_forward_refs() - -Plan.__pydantic_model__.update_forward_refs() -Job.__pydantic_model__.update_forward_refs() -JobTask.__pydantic_model__.update_forward_refs() -JobPlace.__pydantic_model__.update_forward_refs() - -Fleet.__pydantic_model__.update_forward_refs() -VehicleReload.__pydantic_model__.update_forward_refs() -VehicleType.__pydantic_model__.update_forward_refs() -VehicleShift.__pydantic_model__.update_forward_refs() -VehicleShiftStart.__pydantic_model__.update_forward_refs() -VehicleShiftEnd.__pydantic_model__.update_forward_refs() -VehicleBreak.__pydantic_model__.update_forward_refs() - -Objective.__pydantic_model__.update_forward_refs() - - # Solution @dataclass @@ -223,10 +204,3 @@ class Activity: class Time: start: datetime end: datetime - - -Solution.__pydantic_model__.update_forward_refs() -Statistic.__pydantic_model__.update_forward_refs() -Tour.__pydantic_model__.update_forward_refs() -Stop.__pydantic_model__.update_forward_refs() -Activity.__pydantic_model__.update_forward_refs() diff --git a/vrp-cli/src/extensions/generate/fleet.rs b/vrp-cli/src/extensions/generate/fleet.rs index 764202f7b..d4cdc59c5 100644 --- a/vrp-cli/src/extensions/generate/fleet.rs +++ b/vrp-cli/src/extensions/generate/fleet.rs @@ -3,7 +3,7 @@ mod fleet_test; use super::*; -use vrp_pragmatic::format::problem::{Fleet, VehicleCosts, VehicleLimits, VehicleShift, VehicleType}; +use vrp_pragmatic::format::problem::{Fleet, VehicleCosts, VehicleLimits, VehicleMinShifts, VehicleShift, VehicleType}; /// Generates fleet of vehicles. pub(crate) fn generate_fleet(problem_proto: &Problem, vehicle_types_size: usize) -> Fleet { @@ -16,6 +16,7 @@ pub(crate) fn generate_fleet(problem_proto: &Problem, vehicle_types_size: usize) let skills = get_vehicle_skills(problem_proto); let limits = get_vehicle_limits(problem_proto); let vehicles_sizes = get_vehicles_sizes(problem_proto); + let min_shifts = get_vehicle_min_shifts(problem_proto); let vehicles = (1..=vehicle_types_size) .map(|type_idx| { @@ -33,6 +34,7 @@ pub(crate) fn generate_fleet(problem_proto: &Problem, vehicle_types_size: usize) capacity: get_random_item(capacities.as_slice(), &rnd).expect("cannot find any capacity").clone(), skills: get_random_item(skills.as_slice(), &rnd).expect("cannot find any skills").clone(), limits: get_random_item(limits.as_slice(), &rnd).expect("cannot find any limits").clone(), + min_shifts: get_random_item(min_shifts.as_slice(), &rnd).expect("cannot find min shifts").clone(), } }) .collect(); @@ -70,3 +72,7 @@ fn get_vehicle_limits(problem_proto: &Problem) -> Vec> { fn get_vehicles_sizes(problem_proto: &Problem) -> Vec { get_from_vehicle(problem_proto, |vehicle| vehicle.vehicle_ids.len()) } + +fn get_vehicle_min_shifts(problem_proto: &Problem) -> Vec> { + get_from_vehicle(problem_proto, |vehicle| vehicle.min_shifts.clone()) +} diff --git a/vrp-cli/src/extensions/import/csv.rs b/vrp-cli/src/extensions/import/csv.rs index ca283f9ba..9593b9871 100644 --- a/vrp-cli/src/extensions/import/csv.rs +++ b/vrp-cli/src/extensions/import/csv.rs @@ -129,6 +129,7 @@ mod actual { capacity: vec![vehicle.capacity], skills: None, limits: None, + min_shifts: None, } }) .collect(); diff --git a/vrp-cli/tests/helpers/generate.rs b/vrp-cli/tests/helpers/generate.rs index 304987a7b..e092392b0 100644 --- a/vrp-cli/tests/helpers/generate.rs +++ b/vrp-cli/tests/helpers/generate.rs @@ -47,6 +47,7 @@ pub fn create_test_vehicle_type() -> VehicleType { capacity: vec![10], skills: None, limits: None, + min_shifts: None, } } diff --git a/vrp-core/src/construction/enablers/schedule_update.rs b/vrp-core/src/construction/enablers/schedule_update.rs index 2443840a4..bcc9e98d5 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::{Distance, Duration, Schedule, TimeSpan, Timestamp}; use crate::models::problem::{ActivityCost, TransportCost, TravelTime}; use rosomaxa::prelude::Float; @@ -24,12 +24,48 @@ pub fn update_route_departure( transport: &dyn TransportCost, new_departure_time: Timestamp, ) { - let start = route_ctx.route_mut().tour.get_mut(0).unwrap(); - start.schedule.departure = new_departure_time; + let start = route_ctx.route().tour.get(0).unwrap(); + let old_departure_time = start.schedule.departure; + + { + let start = route_ctx.route_mut().tour.get_mut(0).unwrap(); + start.schedule.departure = new_departure_time; + } + + recompute_offset_time_windows(route_ctx, old_departure_time, new_departure_time); update_route_schedule(route_ctx, activity, transport); } +/// Recomputes activity time windows derived from offset spans after departure shift. +fn recompute_offset_time_windows( + route_ctx: &mut RouteContext, + old_departure_time: Timestamp, + new_departure_time: Timestamp, +) { + if old_departure_time == new_departure_time { + return; + } + + route_ctx.route_mut().tour.all_activities_mut().for_each(|activity| { + let Some(job) = activity.job.as_ref() else { return }; + let place_idx = activity.place.idx; + + let Some(place_def) = job.places.get(place_idx) else { return }; + + // Only adjust activities whose selected time window came from an offset span. + let Some(span) = place_def + .times + .iter() + .find(|span| matches!(span, TimeSpan::Offset(_)) && span.to_time_window(old_departure_time) == activity.place.time) + else { + return; + }; + + activity.place.time = span.to_time_window(new_departure_time); + }); +} + fn update_schedules(route_ctx: &mut RouteContext, activity: &dyn ActivityCost, transport: &dyn TransportCost) { let init = { let start = route_ctx.route().tour.start().unwrap(); diff --git a/vrp-core/src/construction/features/fleet_usage.rs b/vrp-core/src/construction/features/fleet_usage.rs index 86f747fd1..575e31994 100644 --- a/vrp-core/src/construction/features/fleet_usage.rs +++ b/vrp-core/src/construction/features/fleet_usage.rs @@ -4,6 +4,9 @@ #[path = "../../../tests/unit/construction/features/fleet_usage_test.rs"] mod fleet_usage_test; +use std::collections::HashMap; +use std::sync::Arc; + use super::*; /// Creates a feature to minimize used fleet size (affects amount of tours in solution). @@ -52,6 +55,73 @@ pub fn create_minimize_arrival_time_feature(name: &str) -> GenericResult GenericResult { + create_balance_shifts_feature_with_penalty(name, Arc::new(|variance| variance)) +} + +/// Creates a balance shifts feature with a custom penalty applied to the variance value. +pub fn create_balance_shifts_feature_with_penalty( + name: &str, + penalty_fn: Arc Float + Send + Sync>, +) -> GenericResult { + let penalty_fn_cloned = penalty_fn.clone(); + + FeatureBuilder::default() + .with_name(name) + .with_objective(FleetUsageObjective { + route_estimate_fn: Box::new(|_| 0.), + solution_estimate_fn: Box::new(move |solution_ctx| { + let variance = calculate_shift_variance(solution_ctx); + (penalty_fn_cloned)(variance) + }), + }) + .build() +} + +fn calculate_shift_variance(solution_ctx: &SolutionContext) -> Float { + if solution_ctx.routes.is_empty() { + return 0.; + } + + let mut vehicle_shift_counts: HashMap = HashMap::new(); + let mut total_available_shifts: HashMap = HashMap::new(); + + 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; + total_available_shifts.entry(vehicle_id.clone()).or_insert(actor.vehicle.details.len()); + } + } + + if vehicle_shift_counts.is_empty() { + return 0.; + } + + let ratios: Vec = vehicle_shift_counts + .iter() + .map(|(vehicle_id, &used_count)| { + let available = *total_available_shifts.get(vehicle_id).unwrap_or(&1) as Float; + used_count as Float / available + }) + .collect(); + + let mean: Float = ratios.iter().sum::() / ratios.len() as Float; + let variance: Float = ratios + .iter() + .map(|&ratio| { + let diff = ratio - mean; + diff * diff + }) + .sum::() + / ratios.len() as Float; + + variance +} + struct FleetUsageObjective { route_estimate_fn: Box Cost + Send + Sync>, solution_estimate_fn: Box Cost + Send + Sync>, diff --git a/vrp-core/src/construction/features/mod.rs b/vrp-core/src/construction/features/mod.rs index f54ca429f..6066d4c10 100644 --- a/vrp-core/src/construction/features/mod.rs +++ b/vrp-core/src/construction/features/mod.rs @@ -65,6 +65,9 @@ pub use self::tour_order::*; mod transport; pub use self::transport::*; +mod vehicle_shifts; +pub use self::vehicle_shifts::*; + mod work_balance; pub use self::work_balance::{ create_activity_balanced_feature, create_distance_balanced_feature, create_duration_balanced_feature, diff --git a/vrp-core/src/construction/features/vehicle_shifts.rs b/vrp-core/src/construction/features/vehicle_shifts.rs new file mode 100644 index 000000000..8e0998046 --- /dev/null +++ b/vrp-core/src/construction/features/vehicle_shifts.rs @@ -0,0 +1,140 @@ +//! Provides a feature to enforce minimum shift usage per vehicle. + +#[cfg(test)] +#[path = "../../../tests/unit/construction/features/vehicle_shifts_test.rs"] +mod vehicle_shifts_test; + +use super::*; +use std::collections::{HashMap, HashSet}; + +custom_solution_state!(pub VehicleShiftSummary typeof VehicleShiftInfo); + +/// Provides a way to build a feature which enforces minimum shift usage per vehicle. +pub struct MinVehicleShiftsFeatureBuilder { + name: String, + violation_code: ViolationCode, + requirements: Option>, +} + +/// Represents minimum shift requirements per vehicle id. +#[derive(Clone)] +pub struct MinShiftRequirement { + /// Minimum number of shifts that must be used. + pub minimum: usize, + /// When true, usage of zero shifts is allowed without violating the minimum requirement. + pub allow_zero_usage: bool, +} + +impl MinVehicleShiftsFeatureBuilder { + /// Creates a new builder instance. + pub fn new(name: &str) -> Self { + Self { name: name.to_string(), violation_code: ViolationCode::default(), requirements: None } + } + + /// Sets a violation code which is used when constraint forbids an insertion. + pub fn with_violation_code(mut self, violation_code: ViolationCode) -> Self { + self.violation_code = violation_code; + self + } + + /// Sets a map with required shifts per vehicle id. + pub fn with_requirements(mut self, requirements: HashMap) -> Self { + self.requirements = Some(requirements); + self + } + + /// Builds a feature instance. + pub fn build(self) -> GenericResult { + let requirements = self.requirements.ok_or_else(|| "requirements map is not defined".to_string())?; + + FeatureBuilder::default() + .with_name(self.name.as_str()) + .with_constraint(MinVehicleShiftsConstraint { violation_code: self.violation_code }) + .with_state(MinVehicleShiftsState { requirements }) + .build() + } +} + +struct MinVehicleShiftsConstraint { + violation_code: ViolationCode, +} + +impl FeatureConstraint for MinVehicleShiftsConstraint { + fn evaluate(&self, move_ctx: &MoveContext<'_>) -> Option { + match move_ctx { + MoveContext::Route { solution_ctx, route_ctx, .. } => { + let summary = solution_ctx.state.get_vehicle_shift_summary()?; + + if summary.missing_vehicle_ids.is_empty() { + return None; + } + + route_ctx.route().actor.vehicle.dimens.get_vehicle_id().and_then(|vehicle_id| { + if summary.missing_vehicle_ids.contains(vehicle_id) { + None + } else { + ConstraintViolation::skip(self.violation_code) + } + }) + } + MoveContext::Activity { .. } => None, + } + } + + fn merge(&self, source: Job, _: Job) -> Result { + Ok(source) + } +} + +struct MinVehicleShiftsState { + requirements: HashMap, +} + +impl FeatureState for MinVehicleShiftsState { + fn accept_insertion(&self, solution_ctx: &mut SolutionContext, route_index: usize, _: &Job) { + self.accept_route_state(solution_ctx.routes.get_mut(route_index).unwrap()); + self.accept_solution_state(solution_ctx); + } + + fn accept_route_state(&self, _: &mut RouteContext) {} + + fn accept_solution_state(&self, solution_ctx: &mut SolutionContext) { + let summary = build_vehicle_shift_summary(solution_ctx.routes.as_slice(), &self.requirements); + + solution_ctx.state.set_vehicle_shift_summary(summary); + } +} + +fn build_vehicle_shift_summary( + routes: &[RouteContext], + requirements: &HashMap, +) -> VehicleShiftInfo { + let usage = routes.iter().fold(HashMap::new(), |mut used, route_ctx| { + if let Some(vehicle_id) = route_ctx.route().actor.vehicle.dimens.get_vehicle_id().cloned() { + if requirements.contains_key(&vehicle_id) && route_ctx.route().tour.has_jobs() { + *used.entry(vehicle_id).or_insert(0) += 1; + } + } + + used + }); + + let missing_vehicle_ids = requirements + .iter() + .filter_map(|(vehicle_id, requirement)| { + let used = usage.get(vehicle_id).copied().unwrap_or(0); + let below_minimum = used < requirement.minimum; + let zero_allowed = requirement.allow_zero_usage && used == 0; + if below_minimum && !zero_allowed { Some(vehicle_id.clone()) } else { None } + }) + .collect(); + + VehicleShiftInfo { missing_vehicle_ids } +} + +/// Provides aggregated vehicle shift usage information. +#[derive(Clone, Default)] +pub struct VehicleShiftInfo { + /// Vehicle ids that still require additional shifts. + pub missing_vehicle_ids: HashSet, +} diff --git a/vrp-core/tests/unit/construction/enablers/departure_time_test.rs b/vrp-core/tests/unit/construction/enablers/departure_time_test.rs index 88fb235b5..23a8a3f64 100644 --- a/vrp-core/tests/unit/construction/enablers/departure_time_test.rs +++ b/vrp-core/tests/unit/construction/enablers/departure_time_test.rs @@ -3,7 +3,10 @@ use crate::helpers::models::problem::*; use crate::helpers::models::solution::*; use crate::models::common::*; use crate::models::problem::*; +use crate::models::problem::Place as JobPlace; +use crate::models::solution::{Activity, Place as ActivityPlace}; use rosomaxa::prelude::Float; +use std::sync::Arc; parameterized_test! {can_advance_departure_time, (latest, optimize_whole_tour, tws, expected), { let tws = tws.into_iter().map(|(start, end)| TimeWindow::new(start, end)).collect::>(); @@ -113,3 +116,48 @@ fn can_recede_departure_time_impl( assert_eq!(departure_time, expected); } + +#[test] +fn recomputes_offset_time_windows_on_departure_shift() { + let offset = TimeOffset::new(10., 12.); + let old_departure = 0.; + let new_departure = 5.; + + let job = { + let mut dimens = Dimensions::default(); + dimens.set_job_id("break".to_string()); + + Arc::new(Single { + places: vec![JobPlace { location: Some(1), duration: 0., times: vec![TimeSpan::Offset(offset.clone())] }], + dimens, + }) + }; + + let mut route_ctx = RouteContextBuilder::default() + .with_route( + RouteBuilder::default() + .with_vehicle(&test_fleet(), "v1") + .add_activity({ + let mut activity = Activity::new_with_job(job.clone()); + activity.place = ActivityPlace { + idx: 0, + location: job.places[0].location.unwrap(), + duration: job.places[0].duration, + time: TimeSpan::Offset(offset.clone()).to_time_window(old_departure), + }; + activity + }) + .build(), + ) + .build(); + + update_route_departure( + &mut route_ctx, + &TestActivityCost::default(), + &TestTransportCost::default(), + new_departure, + ); + + let activity = route_ctx.route().tour.get(1).unwrap(); + assert_eq!(activity.place.time, TimeSpan::Offset(offset).to_time_window(new_departure)); +} diff --git a/vrp-core/tests/unit/construction/features/fleet_usage_test.rs b/vrp-core/tests/unit/construction/features/fleet_usage_test.rs index e38bf6e54..c3b8d4771 100644 --- a/vrp-core/tests/unit/construction/features/fleet_usage_test.rs +++ b/vrp-core/tests/unit/construction/features/fleet_usage_test.rs @@ -1,7 +1,14 @@ use super::*; +use crate::construction::heuristics::RouteContext; use crate::helpers::construction::heuristics::TestInsertionContextBuilder; +use crate::helpers::models::problem::{ + FleetBuilder, TestVehicleBuilder, test_driver, test_vehicle_detail, test_vehicle_with_id, +}; use crate::helpers::models::solution::*; +use crate::models::GoalContextBuilder; +use crate::models::problem::Actor; use std::cmp::Ordering; +use std::sync::Arc; fn create_test_insertion_ctx(routes: &[Float]) -> InsertionContext { let mut insertion_ctx = TestInsertionContextBuilder::default().build(); @@ -42,3 +49,83 @@ fn can_properly_estimate_solutions_impl(left: &[Float], right: &[Float], expecte assert_eq!(left.total_cmp(&right), expected); } + +#[test] +fn can_apply_shift_penalty_function() { + let mut fleet_builder = FleetBuilder::default(); + fleet_builder.add_driver(test_driver()); + fleet_builder.add_vehicle(test_vehicle_with_id("v1")); + fleet_builder.add_vehicle(test_vehicle_with_id("v2")); + let fleet = Arc::new(fleet_builder.build()); + + let build_route = |vehicle_id: &str| { + RouteContextBuilder::default() + .with_route(RouteBuilder::default().with_vehicle(fleet.as_ref(), vehicle_id).build()) + .build() + }; + + let mut insertion_ctx = TestInsertionContextBuilder::default(); + insertion_ctx.with_fleet(fleet.clone()); + insertion_ctx.with_routes(vec![build_route("v1"), build_route("v1"), build_route("v2")]); + let insertion_ctx = insertion_ctx.build(); + + let penalty = Arc::new(|variance: Float| variance * 2.); + let feature = create_balance_shifts_feature_with_penalty("balance_shifts", penalty).unwrap(); + let objective = feature.objective.unwrap(); + + let variance = super::calculate_shift_variance(&insertion_ctx.solution); + let fitness = objective.fitness(&insertion_ctx); + + assert!((fitness - variance * 2.).abs() < 1e-9); +} + +#[test] +fn balance_shifts_objective_prefers_even_distribution() { + let mut vehicle_one = test_vehicle_with_id("v1"); + vehicle_one.details = vec![test_vehicle_detail(), test_vehicle_detail()]; + + let mut vehicle_two = TestVehicleBuilder::default().id("v2").build(); + vehicle_two.details = + vec![test_vehicle_detail(), test_vehicle_detail(), test_vehicle_detail(), test_vehicle_detail()]; + + let mut fleet_builder = FleetBuilder::default(); + fleet_builder.add_driver(test_driver()); + fleet_builder.add_vehicle(vehicle_one); + fleet_builder.add_vehicle(vehicle_two); + let fleet = Arc::new(fleet_builder.build()); + + let mut actors_by_vehicle: HashMap>> = HashMap::new(); + fleet.actors.iter().cloned().for_each(|actor| { + let vehicle_id = actor.vehicle.dimens.get_vehicle_id().unwrap().clone(); + actors_by_vehicle.entry(vehicle_id).or_default().push(actor); + }); + + let make_route = |actor: Arc| RouteContext::new(actor); + + let balanced_routes = vec![ + make_route(actors_by_vehicle.get("v1").unwrap()[0].clone()), + make_route(actors_by_vehicle.get("v2").unwrap()[0].clone()), + make_route(actors_by_vehicle.get("v2").unwrap()[1].clone()), + ]; + + let unbalanced_routes = vec![ + make_route(actors_by_vehicle.get("v1").unwrap()[0].clone()), + make_route(actors_by_vehicle.get("v1").unwrap()[1].clone()), + make_route(actors_by_vehicle.get("v2").unwrap()[0].clone()), + ]; + + let mut balanced_ctx_builder = TestInsertionContextBuilder::default(); + balanced_ctx_builder.with_fleet(fleet.clone()); + balanced_ctx_builder.with_routes(balanced_routes); + let balanced_ctx = balanced_ctx_builder.build(); + + let mut unbalanced_ctx_builder = TestInsertionContextBuilder::default(); + unbalanced_ctx_builder.with_fleet(fleet); + unbalanced_ctx_builder.with_routes(unbalanced_routes); + let unbalanced_ctx = unbalanced_ctx_builder.build(); + + let feature = create_balance_shifts_feature("balance").unwrap(); + let goal = GoalContextBuilder::with_features(&[feature]).and_then(|builder| builder.build()).unwrap(); + + assert_eq!(goal.total_order(&balanced_ctx, &unbalanced_ctx), Ordering::Less); +} diff --git a/vrp-core/tests/unit/construction/features/vehicle_shifts_test.rs b/vrp-core/tests/unit/construction/features/vehicle_shifts_test.rs new file mode 100644 index 000000000..c7a3b82ed --- /dev/null +++ b/vrp-core/tests/unit/construction/features/vehicle_shifts_test.rs @@ -0,0 +1,126 @@ +use super::*; +use crate::construction::heuristics::{RegistryContext, RouteContext}; +use crate::helpers::models::domain::{TestGoalContextBuilder, test_random}; +use crate::helpers::models::problem::{FleetBuilder, TestSingleBuilder, test_driver, test_vehicle_with_id}; +use crate::helpers::models::solution::{ActivityBuilder, RouteBuilder, RouteContextBuilder}; +use crate::models::problem::Fleet; +use crate::models::solution::Registry; +use std::collections::{HashMap, HashSet}; + +const VIOLATION_CODE: ViolationCode = ViolationCode(42); + +#[test] +fn can_collect_missing_vehicle_ids() { + let fleet = create_test_fleet(&["v1", "v2"]); + let mut solution_ctx = create_solution_ctx(&fleet, vec![("v1", 1), ("v2", 0)]); + let feature = create_feature(vec![("v1", 1, false), ("v2", 1, false)]); + + feature.state.unwrap().accept_solution_state(&mut solution_ctx); + + let summary = solution_ctx.state.get_vehicle_shift_summary().unwrap(); + let expected = HashSet::from(["v2".to_string()]); + + assert_eq!(summary.missing_vehicle_ids, expected); +} + +#[test] +fn can_block_insertions_on_satisfied_routes_when_missing_exists() { + let fleet = create_test_fleet(&["v1", "v2"]); + let mut solution_ctx = create_solution_ctx(&fleet, vec![("v1", 1), ("v2", 0)]); + let feature = create_feature(vec![("v1", 1, false), ("v2", 1, false)]); + let constraint = feature.constraint.unwrap(); + feature.state.unwrap().accept_solution_state(&mut solution_ctx); + let job = Job::Single(TestSingleBuilder::default().build_shared()); + + let route_v1 = get_route_ctx(&solution_ctx, "v1"); + let violation = constraint.evaluate(&MoveContext::route(&solution_ctx, route_v1, &job)); + assert_eq!(violation, Some(ConstraintViolation { code: VIOLATION_CODE, stopped: false })); + + let route_v2 = get_route_ctx(&solution_ctx, "v2"); + let violation = constraint.evaluate(&MoveContext::route(&solution_ctx, route_v2, &job)); + assert_eq!(violation, None); +} + +#[test] +fn allows_insertions_when_all_requirements_met() { + let fleet = create_test_fleet(&["v1", "v2"]); + let mut solution_ctx = create_solution_ctx(&fleet, vec![("v1", 1), ("v2", 1)]); + let feature = create_feature(vec![("v1", 1, false), ("v2", 1, false)]); + let constraint = feature.constraint.unwrap(); + feature.state.unwrap().accept_solution_state(&mut solution_ctx); + let job = Job::Single(TestSingleBuilder::default().build_shared()); + + let route_v1 = get_route_ctx(&solution_ctx, "v1"); + let violation = constraint.evaluate(&MoveContext::route(&solution_ctx, route_v1, &job)); + assert_eq!(violation, None); +} + +#[test] +fn can_allow_zero_usage() { + let fleet = create_test_fleet(&["v1"]); + let mut solution_ctx = create_solution_ctx(&fleet, vec![("v1", 0)]); + let feature = create_feature(vec![("v1", 1, true)]); + feature.state.unwrap().accept_solution_state(&mut solution_ctx); + + let summary = solution_ctx.state.get_vehicle_shift_summary().unwrap(); + assert!(summary.missing_vehicle_ids.is_empty()); +} + +fn create_feature(requirements: Vec<(&str, usize, bool)>) -> Feature { + let requirements = requirements + .into_iter() + .map(|(id, value, allow_zero)| { + (id.to_string(), MinShiftRequirement { minimum: value, allow_zero_usage: allow_zero }) + }) + .collect::>(); + + MinVehicleShiftsFeatureBuilder::new("min_shifts") + .with_violation_code(VIOLATION_CODE) + .with_requirements(requirements) + .build() + .unwrap() +} + +fn create_solution_ctx(fleet: &Fleet, vehicle_jobs: Vec<(&str, usize)>) -> SolutionContext { + let routes = vehicle_jobs + .into_iter() + .map(|(vehicle_id, job_count)| { + let mut route_builder = RouteBuilder::default(); + route_builder.with_vehicle(fleet, vehicle_id); + if job_count > 0 { + let activities = (0..job_count).map(|_| ActivityBuilder::default().build()).collect::>(); + route_builder.add_activities(activities); + } + + RouteContextBuilder::default().with_route(route_builder.build()).build() + }) + .collect(); + + SolutionContext { + required: vec![], + ignored: vec![], + unassigned: Default::default(), + locked: Default::default(), + routes, + registry: RegistryContext::new(&TestGoalContextBuilder::default().build(), Registry::new(fleet, test_random())), + state: Default::default(), + } +} + +fn create_test_fleet(vehicle_ids: &[&str]) -> Fleet { + let mut builder = FleetBuilder::default(); + builder.add_driver(test_driver()); + vehicle_ids.iter().for_each(|vehicle_id| { + builder.add_vehicle(test_vehicle_with_id(vehicle_id)); + }); + + builder.build() +} + +fn get_route_ctx<'a>(solution_ctx: &'a SolutionContext, vehicle_id: &str) -> &'a RouteContext { + solution_ctx + .routes + .iter() + .find(|route_ctx| route_ctx.route().actor.vehicle.dimens.get_vehicle_id().unwrap() == vehicle_id) + .unwrap() +} diff --git a/vrp-pragmatic/src/format/mod.rs b/vrp-pragmatic/src/format/mod.rs index 38aa83f5d..bc603455f 100644 --- a/vrp-pragmatic/src/format/mod.rs +++ b/vrp-pragmatic/src/format/mod.rs @@ -195,6 +195,7 @@ const GROUP_CONSTRAINT_CODE: ViolationCode = ViolationCode(12); const COMPATIBILITY_CONSTRAINT_CODE: ViolationCode = ViolationCode(13); const RELOAD_RESOURCE_CONSTRAINT_CODE: ViolationCode = ViolationCode(14); const RECHARGE_CONSTRAINT_CODE: ViolationCode = ViolationCode(15); +const MIN_VEHICLE_SHIFTS_CONSTRAINT_CODE: ViolationCode = ViolationCode(16); /// An job id to job index. pub type JobIndex = HashMap; diff --git a/vrp-pragmatic/src/format/problem/goal_reader.rs b/vrp-pragmatic/src/format/problem/goal_reader.rs index 1724b7efe..57613d6ca 100644 --- a/vrp-pragmatic/src/format/problem/goal_reader.rs +++ b/vrp-pragmatic/src/format/problem/goal_reader.rs @@ -75,6 +75,12 @@ pub(super) fn create_goal_context( )?); } + if props.has_min_vehicle_shifts { + if let Some(feature) = get_min_vehicle_shifts_feature("min_vehicle_shifts", api_problem)? { + features.push(feature); + } + } + GoalContextBuilder::with_features(&features)?.set_main_goal(goal_builder.build()?).build() } @@ -149,6 +155,13 @@ fn get_objective_feature_layer( }), ViolationCode::unknown(), ), + Objective::BalanceShifts { saturation, weight } => { + const DEFAULT_VARIANCE_SATURATION: Float = 0.05; + let saturation = saturation.unwrap_or(DEFAULT_VARIANCE_SATURATION).max(1e-6); + let weight = weight.unwrap_or(1.).max(0.); + let penalty = Arc::new(move |variance: Float| weight * variance / (variance + saturation)); + create_balance_shifts_feature_with_penalty("balance_shifts", penalty) + } Objective::MinimizeUnassigned { breaks } => MinimizeUnassignedBuilder::new("min_unassigned") .set_job_estimator({ let break_value = *breaks; @@ -450,6 +463,33 @@ fn get_tour_limit_feature( ) } +fn get_min_vehicle_shifts_feature(name: &str, api_problem: &ApiProblem) -> GenericResult> { + let requirements = api_problem + .fleet + .vehicles + .iter() + .filter_map(|vehicle| vehicle.min_shifts.as_ref().map(|value| (vehicle, value.clone()))) + .flat_map(|(vehicle, min_shifts)| { + vehicle.vehicle_ids.iter().map(move |vehicle_id| { + ( + vehicle_id.clone(), + MinShiftRequirement { minimum: min_shifts.value, allow_zero_usage: min_shifts.allow_zero_usage }, + ) + }) + }) + .collect::>(); + + if requirements.is_empty() { + return Ok(None); + } + + MinVehicleShiftsFeatureBuilder::new(name) + .with_violation_code(MIN_VEHICLE_SHIFTS_CONSTRAINT_CODE) + .with_requirements(requirements) + .build() + .map(Some) +} + fn get_recharge_feature( name: &str, api_problem: &ApiProblem, @@ -583,3 +623,54 @@ fn get_tour_order_fn() -> TourOrderFn { }) })) } + +#[cfg(test)] +mod tests { + use super::*; + + fn create_problem_with_min_shifts(min_shifts: Option) -> ApiProblem { + ApiProblem { + plan: Plan { jobs: vec![], relations: None, clustering: None }, + fleet: Fleet { + vehicles: vec![VehicleType { + type_id: "vehicle_type".to_string(), + vehicle_ids: vec!["vehicle_1".to_string()], + profile: VehicleProfile { matrix: "car".to_string(), scale: None }, + costs: VehicleCosts { fixed: Some(0.), distance: 1., time: 1. }, + shifts: vec![VehicleShift { + start: ShiftStart { + earliest: "1970-01-01T00:00:00Z".to_string(), + latest: None, + location: Location::new_coordinate(0., 0.), + }, + end: None, + breaks: None, + reloads: None, + recharges: None, + }], + capacity: vec![1], + skills: None, + limits: None, + min_shifts, + }], + profiles: vec![MatrixProfile { name: "car".to_string(), speed: None }], + resources: None, + }, + objectives: None, + } + } + + #[test] + fn creates_min_vehicle_shift_feature_when_needed() { + let problem = create_problem_with_min_shifts(Some(VehicleMinShifts { value: 1, allow_zero_usage: false })); + let feature = get_min_vehicle_shifts_feature("min_vehicle_shifts", &problem).unwrap(); + + assert!(feature.is_some()); + } + + #[test] + fn returns_none_when_no_requirements() { + let problem = create_problem_with_min_shifts(None); + assert!(get_min_vehicle_shifts_feature("min_vehicle_shifts", &problem).unwrap().is_none()); + } +} diff --git a/vrp-pragmatic/src/format/problem/mod.rs b/vrp-pragmatic/src/format/problem/mod.rs index 114500a61..3d931f263 100644 --- a/vrp-pragmatic/src/format/problem/mod.rs +++ b/vrp-pragmatic/src/format/problem/mod.rs @@ -109,6 +109,7 @@ struct ProblemProperties { has_compatibility: bool, has_tour_size_limits: bool, has_tour_travel_limits: bool, + has_min_vehicle_shifts: bool, } /// Keeps track of materialized problem building blocks. diff --git a/vrp-pragmatic/src/format/problem/model.rs b/vrp-pragmatic/src/format/problem/model.rs index 341c09a87..aa0b46491 100644 --- a/vrp-pragmatic/src/format/problem/model.rs +++ b/vrp-pragmatic/src/format/problem/model.rs @@ -465,6 +465,21 @@ pub struct VehicleType { /// Vehicle limits. #[serde(skip_serializing_if = "Option::is_none")] pub limits: Option, + + /// Specifies a minimum amount of shifts each vehicle id of this type should serve. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_shifts: Option, +} + +/// Specifies minimum shift usage requirement per vehicle. +#[derive(Clone, Deserialize, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VehicleMinShifts { + /// Minimum number of shifts that should be used. + pub value: usize, + /// Whether zero usage is allowed without violating the minimum. Default false. + #[serde(default)] + pub allow_zero_usage: bool, } /// Specifies a vehicle profile. @@ -572,6 +587,17 @@ pub enum Objective { /// An objective to balance duration across all tours. BalanceDuration, + /// An objective to balance shifts across all vehicles. + BalanceShifts { + /// Controls how quickly the penalty grows as variance increases. + /// Lower values make even small imbalances costly. Default is 0.05. + #[serde(skip_serializing_if = "Option::is_none")] + saturation: Option, + /// Scales the resulting penalty (default 1.0). Allows making shift balance more/less important. + #[serde(skip_serializing_if = "Option::is_none")] + weight: Option, + }, + /// An objective to control how tours are built. CompactTour { /// Specifies radius of neighbourhood. Min is 1. diff --git a/vrp-pragmatic/src/format/problem/problem_reader.rs b/vrp-pragmatic/src/format/problem/problem_reader.rs index b1aaf2628..089e054dc 100644 --- a/vrp-pragmatic/src/format/problem/problem_reader.rs +++ b/vrp-pragmatic/src/format/problem/problem_reader.rs @@ -156,6 +156,8 @@ fn get_problem_properties(api_problem: &ApiProblem, matrices: &[Matrix]) -> Prob .iter() .any(|v| v.limits.as_ref().is_some_and(|l| l.max_duration.or(l.max_distance).is_some())); + let has_min_vehicle_shifts = api_problem.fleet.vehicles.iter().any(|vehicle| vehicle.min_shifts.is_some()); + ProblemProperties { has_multi_dimen_capacity, has_breaks, @@ -169,6 +171,7 @@ fn get_problem_properties(api_problem: &ApiProblem, matrices: &[Matrix]) -> Prob has_compatibility, has_tour_size_limits, has_tour_travel_limits, + has_min_vehicle_shifts, } } diff --git a/vrp-pragmatic/src/format/solution/mod.rs b/vrp-pragmatic/src/format/solution/mod.rs index fae7280fa..d318c3020 100644 --- a/vrp-pragmatic/src/format/solution/mod.rs +++ b/vrp-pragmatic/src/format/solution/mod.rs @@ -101,6 +101,9 @@ fn map_code_reason(code: ViolationCode) -> (&'static str, &'static str) { ("RELOAD_RESOURCE_CONSTRAINT", "cannot be assigned due to reload resource constraint") } RECHARGE_CONSTRAINT_CODE => ("RECHARGE_CONSTRAINT_CODE", "cannot be assigned due to recharge constraint"), + MIN_VEHICLE_SHIFTS_CONSTRAINT_CODE => { + ("MIN_SHIFT_CONSTRAINT", "cannot be assigned due to minimum shift requirement") + } _ => ("NO_REASON_FOUND", "unknown"), } } @@ -122,6 +125,7 @@ fn map_reason_code(reason: &str) -> ViolationCode { "COMPATIBILITY_CONSTRAINT" => COMPATIBILITY_CONSTRAINT_CODE, "RELOAD_RESOURCE_CONSTRAINT" => RELOAD_RESOURCE_CONSTRAINT_CODE, "RECHARGE_CONSTRAINT_CODE" => RECHARGE_CONSTRAINT_CODE, + "MIN_SHIFT_CONSTRAINT" => MIN_VEHICLE_SHIFTS_CONSTRAINT_CODE, _ => ViolationCode::unknown(), } } diff --git a/vrp-pragmatic/src/validation/vehicles.rs b/vrp-pragmatic/src/validation/vehicles.rs index c70458a39..159e7fe62 100644 --- a/vrp-pragmatic/src/validation/vehicles.rs +++ b/vrp-pragmatic/src/validation/vehicles.rs @@ -171,44 +171,6 @@ fn check_e1306_vehicle_has_no_zero_costs(ctx: &ValidationContext) -> Result<(), } } -fn check_e1307_vehicle_offset_break_rescheduling(ctx: &ValidationContext) -> Result<(), FormatError> { - let type_ids = get_invalid_type_ids( - ctx, - Box::new(|_, shift, _| { - shift - .breaks - .as_ref() - .map(|breaks| { - let has_time_offset = breaks.iter().any(|br| { - matches!( - br, - VehicleBreak::Required { time: VehicleRequiredBreakTime::OffsetTime { .. }, .. } - | VehicleBreak::Optional { time: VehicleOptionalBreakTime::TimeOffset { .. }, .. } - ) - }); - let has_rescheduling = - shift.start.latest.as_ref().is_none_or(|latest| *latest != shift.start.earliest); - - !(has_time_offset && has_rescheduling) - }) - .unwrap_or(true) - }), - ); - - if type_ids.is_empty() { - Ok(()) - } else { - Err(FormatError::new( - "E1307".to_string(), - "time offset interval for break is used with departure rescheduling".to_string(), - format!( - "when time offset is used, start.latest should be set equal to start.earliest in the shift, check vehicle type ids: '{}'", - type_ids.join(", ") - ), - )) - } -} - fn check_e1308_vehicle_reload_resources(ctx: &ValidationContext) -> Result<(), FormatError> { let reload_resource_ids = ctx .problem @@ -291,16 +253,15 @@ fn get_shift_time_window(shift: &VehicleShift) -> Option { } /// Validates vehicles from the fleet. -pub fn validate_vehicles(ctx: &ValidationContext) -> Result<(), MultiFormatError> { - combine_error_results(&[ - check_e1300_no_vehicle_types_with_duplicate_type_ids(ctx), - check_e1301_no_vehicle_types_with_duplicate_ids(ctx), - check_e1302_vehicle_shift_time(ctx), - check_e1303_vehicle_breaks_time_is_correct(ctx), - check_e1304_vehicle_reload_time_is_correct(ctx), - check_e1306_vehicle_has_no_zero_costs(ctx), - check_e1307_vehicle_offset_break_rescheduling(ctx), - check_e1308_vehicle_reload_resources(ctx), - ]) - .map_err(From::from) -} + pub fn validate_vehicles(ctx: &ValidationContext) -> Result<(), MultiFormatError> { + combine_error_results(&[ + check_e1300_no_vehicle_types_with_duplicate_type_ids(ctx), + check_e1301_no_vehicle_types_with_duplicate_ids(ctx), + check_e1302_vehicle_shift_time(ctx), + check_e1303_vehicle_breaks_time_is_correct(ctx), + check_e1304_vehicle_reload_time_is_correct(ctx), + check_e1306_vehicle_has_no_zero_costs(ctx), + check_e1308_vehicle_reload_resources(ctx), + ]) + .map_err(From::from) + } diff --git a/vrp-pragmatic/tests/features/breaks/interval_break_test.rs b/vrp-pragmatic/tests/features/breaks/interval_break_test.rs index f2cbd6a86..990b2302d 100644 --- a/vrp-pragmatic/tests/features/breaks/interval_break_test.rs +++ b/vrp-pragmatic/tests/features/breaks/interval_break_test.rs @@ -194,7 +194,6 @@ fn can_assign_interval_break_with_reload() { } #[test] -#[ignore] fn can_consider_departure_rescheduling() { let problem = Problem { plan: Plan { @@ -225,6 +224,9 @@ fn can_consider_departure_rescheduling() { let solution = solve_with_metaheuristic_and_iterations(problem, Some(vec![matrix]), 2000); + print!("{:#?}", solution); + + assert!(solution.tours[0].stops[0].schedule().arrival != solution.tours[0].stops[0].schedule().departure); assert!(solution.violations.is_none()); assert!(solution.unassigned.is_none()); } diff --git a/vrp-pragmatic/tests/features/fleet/balance_and_min_shifts.rs b/vrp-pragmatic/tests/features/fleet/balance_and_min_shifts.rs new file mode 100644 index 000000000..be2533497 --- /dev/null +++ b/vrp-pragmatic/tests/features/fleet/balance_and_min_shifts.rs @@ -0,0 +1,52 @@ +use crate::format::problem::*; +use crate::helpers::*; + +fn build_problem(objectives: Option>, min_shifts: Option) -> (Problem, Matrix) { + let jobs = vec![ + create_delivery_job("job1", (1., 0.)), + create_delivery_job("job2", (2., 0.)), + create_delivery_job("job3", (3., 0.)), + ]; + + let fleet = Fleet { + vehicles: vec![VehicleType { + type_id: "vehicle_type".to_string(), + vehicle_ids: vec!["vehicle_1".to_string(), "vehicle_2".to_string()], + profile: create_default_vehicle_profile(), + costs: VehicleCosts { fixed: Some(0.), distance: 1., time: 1. }, + shifts: vec![create_default_vehicle_shift()], + capacity: vec![10], + skills: None, + limits: None, + min_shifts, + }], + profiles: create_default_matrix_profiles(), + resources: None, + }; + + let mut problem = create_empty_problem(); + problem.plan = Plan { jobs, relations: None, clustering: None }; + problem.fleet = fleet; + problem.objectives = objectives; + + let matrix = create_matrix_from_problem(&problem); + + (problem, matrix) +} + +#[test] +fn min_vehicle_shifts_constraint_enforces_usage() { + let (problem_without_requirement, matrix) = build_problem(Some(vec![Objective::MinimizeCost]), None); + let (problem_with_requirement, _) = build_problem( + Some(vec![Objective::MinimizeCost]), + Some(VehicleMinShifts { value: 1, allow_zero_usage: false }), + ); + let matrices = vec![matrix]; + + let solution_without_requirement = + solve_with_cheapest_insertion(problem_without_requirement, Some(matrices.clone())); + let solution_with_requirement = solve_with_cheapest_insertion(problem_with_requirement, Some(matrices)); + + assert_eq!(solution_without_requirement.tours.len(), 1); + assert_eq!(solution_with_requirement.tours.len(), 2); +} diff --git a/vrp-pragmatic/tests/features/fleet/mod.rs b/vrp-pragmatic/tests/features/fleet/mod.rs index 48ff5510a..9636737a2 100644 --- a/vrp-pragmatic/tests/features/fleet/mod.rs +++ b/vrp-pragmatic/tests/features/fleet/mod.rs @@ -1,3 +1,4 @@ +mod balance_and_min_shifts; mod basic_multi_shift; mod basic_open_end; mod multi_dimens; diff --git a/vrp-pragmatic/tests/generator/vehicles.rs b/vrp-pragmatic/tests/generator/vehicles.rs index c36a50465..336e83985 100644 --- a/vrp-pragmatic/tests/generator/vehicles.rs +++ b/vrp-pragmatic/tests/generator/vehicles.rs @@ -34,6 +34,7 @@ prop_compose! { capacity, skills, limits, + min_shifts: None, } } } diff --git a/vrp-pragmatic/tests/helpers/problem.rs b/vrp-pragmatic/tests/helpers/problem.rs index 0d5c57782..729bddd0e 100644 --- a/vrp-pragmatic/tests/helpers/problem.rs +++ b/vrp-pragmatic/tests/helpers/problem.rs @@ -254,6 +254,7 @@ pub fn create_vehicle_with_capacity(id: &str, capacity: Vec) -> VehicleType capacity, skills: None, limits: None, + min_shifts: None, } } diff --git a/vrp-pragmatic/tests/regression/break_test.rs b/vrp-pragmatic/tests/regression/break_test.rs index c31024914..8c2c03c9e 100644 --- a/vrp-pragmatic/tests/regression/break_test.rs +++ b/vrp-pragmatic/tests/regression/break_test.rs @@ -142,6 +142,7 @@ fn can_handle_properly_invalid_break_removal() { capacity: vec![5], skills: None, limits: None, + min_shifts: None, }], ..create_default_fleet() }, diff --git a/vrp-pragmatic/tests/unit/checker/relations_test.rs b/vrp-pragmatic/tests/unit/checker/relations_test.rs index f8e08168b..92bbb0f92 100644 --- a/vrp-pragmatic/tests/unit/checker/relations_test.rs +++ b/vrp-pragmatic/tests/unit/checker/relations_test.rs @@ -103,6 +103,7 @@ mod single { capacity: vec![5], skills: None, limits: None, + min_shifts: None, }], ..create_default_fleet() }, diff --git a/vrp-pragmatic/tests/unit/format/problem/model_test.rs b/vrp-pragmatic/tests/unit/format/problem/model_test.rs index c130270fd..36ed5fcaa 100644 --- a/vrp-pragmatic/tests/unit/format/problem/model_test.rs +++ b/vrp-pragmatic/tests/unit/format/problem/model_test.rs @@ -1,5 +1,6 @@ use super::*; use crate::helpers::{SIMPLE_MATRIX, SIMPLE_PROBLEM}; +use serde_json::from_str; use std::io::BufReader; fn assert_time_windows(actual: &Option>>, expected: (&str, &str)) { @@ -64,3 +65,17 @@ fn can_deserialize_matrix() { assert_eq!(matrix.distances.len(), 16); assert_eq!(matrix.travel_times.len(), 16); } + +#[test] +fn can_deserialize_balance_shifts_objective_with_saturation() { + let objective: Objective = from_str(r#"{ "type": "balance-shifts", "saturation": 0.2, "weight": 3.5 }"#) + .expect("failed to deserialize objective"); + + match objective { + Objective::BalanceShifts { saturation, weight } => { + assert!((saturation.unwrap() - 0.2).abs() < 1e-9); + assert!((weight.unwrap() - 3.5).abs() < 1e-9); + } + _ => panic!("unexpected objective variant"), + } +} diff --git a/vrp-pragmatic/tests/unit/format/problem/reader_test.rs b/vrp-pragmatic/tests/unit/format/problem/reader_test.rs index e4fbb50a5..84800a4b5 100644 --- a/vrp-pragmatic/tests/unit/format/problem/reader_test.rs +++ b/vrp-pragmatic/tests/unit/format/problem/reader_test.rs @@ -168,6 +168,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) }), + min_shifts: None, }], ..create_default_fleet() }, diff --git a/vrp-pragmatic/tests/unit/validation/vehicles_test.rs b/vrp-pragmatic/tests/unit/validation/vehicles_test.rs index 8606759e6..ee86e1b69 100644 --- a/vrp-pragmatic/tests/unit/validation/vehicles_test.rs +++ b/vrp-pragmatic/tests/unit/validation/vehicles_test.rs @@ -64,8 +64,8 @@ parameterized_test! {can_handle_rescheduling_with_required_break, (latest, expec }} can_handle_rescheduling_with_required_break! { - case01: (None, Some("E1307".to_string())), - case02: (Some(1.), Some("E1307".to_string())), + case01: (None, None), + case02: (Some(1.), None), case03: (Some(0.), None), } @@ -92,13 +92,10 @@ fn can_handle_rescheduling_with_required_break_impl(latest: Option, expec ..create_empty_problem() }; - let result = check_e1307_vehicle_offset_break_rescheduling(&ValidationContext::new( - &problem, - None, - &CoordIndex::new(&problem), - )); + let result = validate_vehicles(&ValidationContext::new(&problem, None, &CoordIndex::new(&problem))); - assert_eq!(result.err().map(|err| err.code), expected); + let error_code = result.err().and_then(|err| err.errors.first().map(|err| err.code.clone())); + assert_eq!(error_code, expected); } parameterized_test! {can_handle_reload_resources, (resources, expected), {