From 7fbced664a94ae0cfdd1013ca284ce4f4390d646 Mon Sep 17 00:00:00 2001 From: halilsen Date: Tue, 23 Nov 2021 15:48:02 +0100 Subject: [PATCH 01/18] Fix typo introduced during API module split --- api/v01/entities/vrp_input.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v01/entities/vrp_input.rb b/api/v01/entities/vrp_input.rb index 304f41273..107dae06e 100644 --- a/api/v01/entities/vrp_input.rb +++ b/api/v01/entities/vrp_input.rb @@ -283,10 +283,10 @@ module VrpMisc params :vrp_request_route do optional(:vehicle_id, type: String, desc: 'Vehicle linked to the current described route') optional(:indice, type: Integer, documentation: { hidden: true }, desc: '[ DEPRECATED : use day_index instead ]') - optional(:index, type: Integer, desc: 'Index of the route. Must be provided if first_solution_strategy is \'periodic\'.') + optional(:day_index, type: Integer, desc: 'Index of the route. Must be provided if first_solution_strategy is \'periodic\'.') optional(:date, type: Date, desc: 'Date of the route. Must be provided if first_solution_strategy is \'periodic\'.') requires(:mission_ids, type: Array[String], desc: 'Initial state or partial state of the current vehicle route', coerce_with: ->(val) { val.is_a?(String) ? val.split(/,/) : val }) - mutually_exclusive :indice, :index, :day + mutually_exclusive :indice, :day_index, :date end params :vrp_request_subtour do From 1b106e74b525f4b6a05b3148a347e2f014c03510 Mon Sep 17 00:00:00 2001 From: halilsen Date: Tue, 23 Nov 2021 16:16:28 +0100 Subject: [PATCH 02/18] Dont refuse ortools due to initial soln + schedule --- .../heuristics/periodic_instance_validity_test.rb | 12 ------------ wrappers/ortools.rb | 1 - wrappers/wrapper.rb | 4 ---- 3 files changed, 17 deletions(-) diff --git a/test/lib/heuristics/periodic_instance_validity_test.rb b/test/lib/heuristics/periodic_instance_validity_test.rb index c7d9ee7b3..4d3e23704 100644 --- a/test/lib/heuristics/periodic_instance_validity_test.rb +++ b/test/lib/heuristics/periodic_instance_validity_test.rb @@ -139,17 +139,5 @@ def test_not_too_many_visits_provided_in_route assert_includes OptimizerWrapper.config[:services][:ortools].inapplicable_solve?(TestHelper.create(problem)), :assert_not_too_many_visits_in_route end - def test_reject_if_periodic_route_without_periodic_heuristic - problem = VRP.periodic - problem[:routes] = [{ - vehicle_id: 'vehicle_0', - indice: 0, - mission_ids: ['service_1'] - }] - refute_includes OptimizerWrapper.config[:services][:ortools].inapplicable_solve?(TestHelper.create(problem)), :assert_no_route_if_schedule_without_periodic_heuristic - - problem[:configuration][:preprocessing][:first_solution_strategy] = [] - assert_includes OptimizerWrapper.config[:services][:ortools].inapplicable_solve?(TestHelper.create(problem)), :assert_no_route_if_schedule_without_periodic_heuristic - end end end diff --git a/wrappers/ortools.rb b/wrappers/ortools.rb index 027a58887..d790324a6 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -66,7 +66,6 @@ def solver_constraints :assert_valid_partitions, :assert_route_date_or_indice_if_periodic, :assert_not_too_many_visits_in_route, - :assert_no_route_if_schedule_without_periodic_heuristic, # :assert_no_overall_duration, # TODO: Requires a complete rework ] end diff --git a/wrappers/wrapper.rb b/wrappers/wrapper.rb index 683102dbb..a6fe9c5e8 100644 --- a/wrappers/wrapper.rb +++ b/wrappers/wrapper.rb @@ -405,10 +405,6 @@ def assert_single_dimension(vrp) vrp.vehicles.empty? || (assert_only_time_dimension(vrp) ^ assert_only_distance_dimension(vrp) ^ assert_only_value_dimension(vrp)) end - def assert_no_route_if_schedule_without_periodic_heuristic(vrp) - vrp.routes.empty? || !vrp.schedule? || vrp.periodic_heuristic? - end - # TODO: Need a better way to represent solver preference def assert_small_minimum_duration(vrp) vrp.configuration.resolution.minimum_duration.nil? || vrp.vehicles.empty? || vrp.configuration.resolution.minimum_duration / vrp.vehicles.size < 5000 From 4edc543b70b447a7a82bd72cbd078b0bb24d3c6f Mon Sep 17 00:00:00 2001 From: halilsen Date: Thu, 25 Nov 2021 10:30:30 +0100 Subject: [PATCH 03/18] Fix: periodic relation expansion min/max_day_lapse relation can be defined based on the first visit if and only if the lapses are equal. Otherwise, they need to be defined via sequential visites -- which can be done via one single relation, instead of defining each pair seperately. --- lib/interpreters/periodic_visits.rb | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/interpreters/periodic_visits.rb b/lib/interpreters/periodic_visits.rb index a5001ee07..33491717a 100644 --- a/lib/interpreters/periodic_visits.rb +++ b/lib/interpreters/periodic_visits.rb @@ -111,8 +111,8 @@ def generate_relations_between_visits(vrp, mission) # TODO : need to uniformize generated relations whether mission has minimum AND maximum lapse or only one of them return unless mission.visits_number > 1 - if mission.minimum_lapse && mission.maximum_lapse - (2..mission.visits_number).each{ |index| + if mission.minimum_lapse && mission.maximum_lapse && (mission.minimum_lapse == mission.maximum_lapse) + 2.upto(mission.visits_number){ |index| current_lapse = (index - 1) * mission.minimum_lapse.to_i vrp.relations << Models::Relation.create( type: :minimum_day_lapse, @@ -120,7 +120,7 @@ def generate_relations_between_visits(vrp, mission) lapses: [current_lapse] ) } - (2..mission.visits_number).each{ |index| + 2.upto(mission.visits_number){ |index| current_lapse = (index - 1) * mission.maximum_lapse.to_i vrp.relations << Models::Relation.create( type: :maximum_day_lapse, @@ -128,24 +128,21 @@ def generate_relations_between_visits(vrp, mission) lapses: [current_lapse] ) } - elsif mission.minimum_lapse - (2..mission.visits_number).each{ |index| - current_lapse = mission.minimum_lapse.to_i + else + if mission.minimum_lapse vrp.relations << Models::Relation.create( type: :minimum_day_lapse, - linked_ids: ["#{mission.id}_#{index - 1}_#{mission.visits_number}", "#{mission.id}_#{index}_#{mission.visits_number}"], - lapses: [current_lapse] + linked_ids: 1.upto(mission.visits_number).map{ |index| "#{mission.id}_#{index}_#{mission.visits_number}" }, + lapses: [mission.minimum_lapse.to_i] ) - } - elsif mission.maximum_lapse - (2..mission.visits_number).each{ |index| - current_lapse = mission.maximum_lapse.to_i + end + if mission.maximum_lapse vrp.relations << Models::Relation.create( type: :maximum_day_lapse, - linked_ids: ["#{mission.id}_#{index - 1}_#{mission.visits_number}", "#{mission.id}_#{index}_#{mission.visits_number}"], - lapses: [current_lapse] + linked_ids: 1.upto(mission.visits_number).map{ |index| "#{mission.id}_#{index}_#{mission.visits_number}" }, + lapses: [mission.maximum_lapse.to_i] ) - } + end end end From a1d55c7101a394ea63c9eef9ec2ccaca78324792 Mon Sep 17 00:00:00 2001 From: halilsen Date: Thu, 25 Nov 2021 15:44:46 +0100 Subject: [PATCH 04/18] Fix: schedule global day compatibility filter --- wrappers/ortools.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wrappers/ortools.rb b/wrappers/ortools.rb index d790324a6..ec08d15fa 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -427,7 +427,7 @@ def build_solution(vrp, content) end def check_services_compatible_days(vrp, vehicle, service) - !vrp.schedule? || (!service.minimum_lapse && !service.maximum_lapse) || + !(vrp.schedule? && (service.minimum_lapse || service.maximum_lapse)) || vehicle.global_day_index.between?(service.first_possible_days.first, service.last_possible_days.first) end From 78121466e1fe3664868b1f434b74226223b5c047 Mon Sep 17 00:00:00 2001 From: halilsen Date: Thu, 25 Nov 2021 15:47:52 +0100 Subject: [PATCH 05/18] Fix vehicles_indices skill compatibility condition --- wrappers/ortools.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/wrappers/ortools.rb b/wrappers/ortools.rb index ec08d15fa..ec24941c6 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -141,9 +141,8 @@ def solve(vrp, job, thread_proc = nil, &block) [] else vrp.vehicles.collect.with_index{ |vehicle, index| - if (service.skills.empty? || !vehicle.skills.empty? && - ((vehicle.skills[0] & service.skills).size == service.skills.size) && - check_services_compatible_days(vrp, vehicle, service)) && + if (service.skills.empty? || vehicle.skills.any?{ |skill_set| (service.skills - skill_set).empty? }) && + check_services_compatible_days(vrp, vehicle, service) && (service.unavailable_days.empty? || !service.unavailable_days.include?(vehicle.global_day_index)) index end From 5ef876bd6ee0929b81acfaf6363169b0dabeb965 Mon Sep 17 00:00:00 2001 From: halilsen Date: Thu, 25 Nov 2021 15:50:56 +0100 Subject: [PATCH 06/18] Fix overwritten existing periodic initial routes --- lib/interpreters/periodic_visits.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/interpreters/periodic_visits.rb b/lib/interpreters/periodic_visits.rb index 33491717a..098b3f955 100644 --- a/lib/interpreters/periodic_visits.rb +++ b/lib/interpreters/periodic_visits.rb @@ -57,7 +57,7 @@ def expand(vrp, job, &block) @periods.uniq! generate_relations_on_periodic_vehicles(vrp, vehicles_linking_relations) - if vrp.configuration.preprocessing.first_solution_strategy.to_a.first != 'periodic' && vrp.services.any?{ |service| service.visits_number > 1 } + if vrp.routes.empty? && vrp.services.any?{ |service| service.visits_number > 1 } vrp.routes = generate_routes(vrp) end From 0f8d5a1b4662a83d2f0b4f36b023baaadd6f8c71 Mon Sep 17 00:00:00 2001 From: halilsen Date: Thu, 25 Nov 2021 16:43:55 +0100 Subject: [PATCH 07/18] Fix uninitialized variable warnings --- Gemfile.lock | 2 +- optimizer_wrapper.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 818b124f4..baf339028 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -257,7 +257,7 @@ GEM rack (~> 2.2) rack-protection (= 2.1.0) tilt (~> 2.0) - solargraph (0.44.0) + solargraph (0.44.2) backport (~> 1.2) benchmark bundler (>= 1.17.2) diff --git a/optimizer_wrapper.rb b/optimizer_wrapper.rb index c8f9ca7f0..ef102d150 100644 --- a/optimizer_wrapper.rb +++ b/optimizer_wrapper.rb @@ -21,6 +21,8 @@ require_all 'util' module OptimizerWrapper + @zip_condition = false + def self.wrapper_vrp(api_key, profile, vrp, checksum, job_id = nil) inapplicable_services = [] apply_zones(vrp) From 8c96d6c6c679c964dcf3e186aebc1343aa15bf40 Mon Sep 17 00:00:00 2001 From: halilsen Date: Fri, 26 Nov 2021 14:39:13 +0100 Subject: [PATCH 08/18] API: Do not overwrite defaults if not needed --- api/v01/vrp.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index a4ecef785..a38957e69 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -135,7 +135,7 @@ class Vrp < APIBase vrp.errors.add(:empty_vrp, message: 'VRP structure is empty') if vrp_params&.keys&.empty? error!("Model Validation Error: #{vrp.errors}", 400) else - vrp.router = OptimizerWrapper.router(OptimizerWrapper.access[api_key][:router_api_key] || profile[:router_api_key] || OptimizerWrapper.config[:router][:api_key]) + vrp.router = OptimizerWrapper.router(OptimizerWrapper.access[api_key][:router_api_key] || profile[:router_api_key]) if OptimizerWrapper.access[api_key][:router_api_key] || profile[:router_api_key] ret = OptimizerWrapper.wrapper_vrp(api_key, profile, vrp, checksum) count_incr :optimize, transactions: vrp.transactions if ret.is_a?(String) From 97752cfbb29e20d9485a2670a7b33a288bc2e72b Mon Sep 17 00:00:00 2001 From: halilsen Date: Thu, 25 Nov 2021 16:55:36 +0100 Subject: [PATCH 09/18] Qual: Simplify periodic serv, vehic & relation gen --- lib/interpreters/periodic_visits.rb | 59 +++++++++++++++-------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/lib/interpreters/periodic_visits.rb b/lib/interpreters/periodic_visits.rb index 098b3f955..9208bbc6e 100644 --- a/lib/interpreters/periodic_visits.rb +++ b/lib/interpreters/periodic_visits.rb @@ -90,14 +90,14 @@ def generate_timewindows(timewindows_set) def generate_relations(vrp) vrp.relations.flat_map{ |relation| - unless relation.linked_ids.uniq{ |s_id| @expanded_services[s_id].size }.size <= 1 + unless relation.linked_ids.uniq{ |s_id| @expanded_services[s_id]&.size.to_i }.size <= 1 raise "Cannot expand relations of #{relation.linked_ids} because they have different visits_number" end # keep the original relation if it is another type of relation or if it doesn't belong to an unexpanded service. - next relation if relation.linked_services.empty? || @expanded_services[relation.linked_ids.first].empty? + next relation if relation.linked_services.empty? || @expanded_services[relation.linked_ids.first]&.size.to_i == 0 - Array.new(@expanded_services[relation.linked_ids.first].size){ |visit_index| + Array.new(@expanded_services[relation.linked_ids.first]&.size.to_i){ |visit_index| linked_ids = relation.linked_ids.collect{ |s_id| @expanded_services[s_id][visit_index].id } Models::Relation.create( @@ -147,8 +147,9 @@ def generate_relations_between_visits(vrp, mission) end def generate_services(vrp) - @expanded_services = Hash.new{ |h, k| h[k] = [] } - vrp.services.collect{ |service| + @expanded_services = {} + new_services = [] + vrp.services.each{ |service| # transform service data into periodic data (service.activity ? [service.activity] : service.activities).each{ |activity| activity.timewindows = generate_timewindows(activity.timewindows) @@ -158,7 +159,7 @@ def generate_services(vrp) # TODO : create visit in model @periods << service.visits_number - visits = (0..service.visits_number - 1).collect{ |visit_index| + 0.upto(service.visits_number - 1){ |visit_index| next if service.unavailable_visit_indices.include?(visit_index) new_service = duplicate_safe( @@ -171,15 +172,16 @@ def generate_services(vrp) ) new_service.skills += ["#{visit_index + 1}_f_#{service.visits_number}"] if !service.minimum_lapse && !service.maximum_lapse && service.visits_number > 1 + @expanded_services[service.id] ||= [] @expanded_services[service.id] << new_service - new_service - }.compact + new_services << new_service + } generate_relations_between_visits(vrp, service) + } - visits - }.flatten + new_services end def build_vehicle(vrp, vehicle, vehicle_day_index, rests_durations) @@ -201,40 +203,39 @@ def build_vehicle(vrp, vehicle, vehicle_day_index, rests_durations) def generate_vehicles(vrp) rests_durations = Array.new(vrp.vehicles.size, 0) - new_vehicles = vrp.vehicles.collect{ |vehicle| - @equivalent_vehicles[vehicle.id] = [] + new_vehicles = [] + vrp.vehicles.each{ |vehicle| + @equivalent_vehicles[vehicle.id] = [] # equivalent_vehicle_ids ! @equivalent_vehicles[vehicle.original_id] = [] - vehicles = (vrp.configuration.schedule.range_indices[:start]..vrp.configuration.schedule.range_indices[:end]).collect{ |vehicle_day_index| + vrp.configuration.schedule.range_indices[:start].upto(vrp.configuration.schedule.range_indices[:end]){ |vehicle_day_index| next if vehicle.unavailable_days.include?(vehicle_day_index) timewindows = [vehicle.timewindow || vehicle.sequence_timewindows].flatten if timewindows.empty? - new_vehicle = build_vehicle(vrp, vehicle, vehicle_day_index, rests_durations) - new_vehicle + new_vehicles << build_vehicle(vrp, vehicle, vehicle_day_index, rests_durations) else - timewindows.select{ |timewindow| timewindow.day_index.nil? || timewindow.day_index == vehicle_day_index % 7 }.collect{ |associated_timewindow| + timewindows.each{ |associated_timewindow| + next unless associated_timewindow.day_index.nil? || associated_timewindow.day_index == vehicle_day_index % 7 + new_vehicle = build_vehicle(vrp, vehicle, vehicle_day_index, rests_durations) new_vehicle.timewindow = Models::Timewindow.create(start: associated_timewindow.start || 0, end: associated_timewindow.end || 86400) if @have_day_index new_vehicle.timewindow.start += vehicle_day_index * 86400 new_vehicle.timewindow.end += vehicle_day_index * 86400 end - new_vehicle - }.compact + new_vehicles << new_vehicle + } end - }.compact + } - if vehicle.overall_duration - new_relation = Models::Relation.create( - type: :vehicle_group_duration, - linked_vehicle_ids: @equivalent_vehicles[vehicle.original_id], - lapses: [vehicle.overall_duration + rests_durations[index]] - ) - vrp.relations << new_relation - end + next unless vehicle.overall_duration - vehicles - }.flatten + vrp.relations << Models::Relation.create( + type: :vehicle_group_duration, + linked_vehicle_ids: @equivalent_vehicles[vehicle.original_id], + lapses: [vehicle.overall_duration + rests_durations[index]] + ) + } new_vehicles end From 32b47305c0a6dc8159d16fa82c25a06dbf1ccebd Mon Sep 17 00:00:00 2001 From: halilsen Date: Fri, 26 Nov 2021 15:48:20 +0100 Subject: [PATCH 10/18] Fix: periodic heuristic expands existing routes --- lib/interpreters/periodic_visits.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/interpreters/periodic_visits.rb b/lib/interpreters/periodic_visits.rb index 9208bbc6e..1a4d54aa6 100644 --- a/lib/interpreters/periodic_visits.rb +++ b/lib/interpreters/periodic_visits.rb @@ -59,6 +59,15 @@ def expand(vrp, job, &block) if vrp.routes.empty? && vrp.services.any?{ |service| service.visits_number > 1 } vrp.routes = generate_routes(vrp) + elsif !vrp.periodic_heuristic? + expanded_service_ids = @expanded_services.transform_values{ |service| service.map(&:id) } + vrp.routes.sort_by(&:day_index).each{ |route| + # Note that we sort_by day_index so we can assume that the existing routes have the visits in the correct + # order. That is, the first appearance of a service id will be the visit_1_X, the next will be visit_2_X, + # and the last will be visit_X_X. + route.mission_ids.collect!{ |sid| expanded_service_ids[sid].shift } + route.vehicle_id += "_#{route.day_index}" + } end vrp From ff59fa243851f1f95354c46c083dc1ed66f70383 Mon Sep 17 00:00:00 2001 From: halilsen Date: Fri, 26 Nov 2021 17:18:21 +0100 Subject: [PATCH 11/18] Point object already has matrix_index --- models/concerns/distance_matrix.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/models/concerns/distance_matrix.rb b/models/concerns/distance_matrix.rb index acecc862c..c6e1e1c3e 100644 --- a/models/concerns/distance_matrix.rb +++ b/models/concerns/distance_matrix.rb @@ -45,12 +45,6 @@ def compute_need_matrix(&block) point.matrix_index = index [point.location.lat, point.location.lon] } - vehicles.select(&:start_point).each{ |v| - v.start_point.matrix_index = points.find{ |p| p.id == v.start_point.id }.matrix_index - } - vehicles.select(&:end_point).each{ |v| - v.end_point.matrix_index = points.find{ |p| p.id == v.end_point.id }.matrix_index - } uniq_need_matrix = need_matrix.collect{ |vehicle, dimensions| [vehicle.router_mode.to_sym, dimensions | vrp_need_matrix, vehicle.router_options] From bd6c345cdc481f62c766f4f570a3bcc53257f187 Mon Sep 17 00:00:00 2001 From: halilsen Date: Tue, 30 Nov 2021 17:49:58 +0100 Subject: [PATCH 12/18] Fix: unfeasible detection due to unavailable days --- test/wrapper_test.rb | 13 +++++++++++++ wrappers/wrapper.rb | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/test/wrapper_test.rb b/test/wrapper_test.rb index 48bfa300d..d8c4b8c91 100644 --- a/test/wrapper_test.rb +++ b/test/wrapper_test.rb @@ -1885,6 +1885,19 @@ def test_impossible_service_tw_periodic assert_equal(1, solutions[0].unassigned_stops.count{ |un| un.reason == 'No vehicle with compatible timewindow' }) end + def test_impossible_service_due_to_unavailable_day_periodic + vrp = VRP.periodic + vrp[:vehicles].first.delete(:timewindow) + vrp[:vehicles].first[:sequence_timewindows] = [ + { start: 6, end: 10, day_index: 2 }, + { start: 0, end: 5, day_index: 0 } + ] + vrp[:services].first[:activity][:timewindows] = [{ start: 0, end: 5, day_index: 0 }] + vrp[:services].first[:unavailable_visit_day_indices] = [0] + result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(vrp), nil) + assert_equal(1, result[:unassigned].count{ |un| un[:reason].split(' && ').include?('No vehicle with compatible timewindow') }) + end + def test_impossible_service_distance problem = { matrices: [{ diff --git a/wrappers/wrapper.rb b/wrappers/wrapper.rb index a6fe9c5e8..9af1819ea 100644 --- a/wrappers/wrapper.rb +++ b/wrappers/wrapper.rb @@ -456,7 +456,7 @@ def find_vehicle(vrp, service) !service.unavailable_days.include?(day) && (service_timewindows.empty? || vehicle_timewindows.empty? || service_timewindows.any?{ |tw| - (tw.day_index.nil? || tw.day_index == day % 7) && + (tw.day_index.nil? || tw.day_index == day % 7) && ( vehicle_lateness || service_lateness || vehicle_timewindows.any?{ |v_tw| @@ -465,6 +465,7 @@ def find_vehicle(vrp, service) (tw.end.nil? || v_tw.start < tw.end) && (v_tw.end.nil? || v_tw.end > tw.start) } + ) }) } } From 91d1a73bb73d2b0c3ceb0b4248d3819a72b90199 Mon Sep 17 00:00:00 2001 From: halilsen Date: Tue, 30 Nov 2021 18:12:56 +0100 Subject: [PATCH 13/18] Functions manage their extreme cases themselves --- models/concerns/periodic_service.rb | 2 ++ wrappers/wrapper.rb | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/models/concerns/periodic_service.rb b/models/concerns/periodic_service.rb index cd517a17b..05be6a89b 100644 --- a/models/concerns/periodic_service.rb +++ b/models/concerns/periodic_service.rb @@ -21,6 +21,8 @@ module PeriodicService extend ActiveSupport::Concern def can_affect_all_visits?(service) + return true unless self.schedule? + return true if service.visits_number == 1 self.vehicles.any?{ |vehicle| diff --git a/wrappers/wrapper.rb b/wrappers/wrapper.rb index 9af1819ea..6b8e21152 100644 --- a/wrappers/wrapper.rb +++ b/wrappers/wrapper.rb @@ -580,6 +580,8 @@ def compute_vehicles_capacity(vrp) end def possible_days_are_consistent(vrp, service) + return true unless vrp.schedule? + return false if service.first_possible_days.any?{ |d| d > vrp.configuration.schedule.range_indices[:end] } return false if service.last_possible_days.any?{ |d| d < vrp.configuration.schedule.range_indices[:start] } @@ -641,8 +643,6 @@ def detect_unfeasible_services(vrp) detect_inconsistent_relation_timewindows_od_service(vrp, unfeasible, service) # Planning inconsistency - next if !vrp.schedule? - unless possible_days_are_consistent(vrp, service) add_unassigned(unfeasible, vrp, service, 'Provided possible days do not allow service to be assigned') end From 7462e7371e9a8821b671be7dbbd11ba841959cbb Mon Sep 17 00:00:00 2001 From: halilsen Date: Wed, 16 Feb 2022 15:29:05 +0100 Subject: [PATCH 14/18] Test repeat a failing performance test --- test/test_helper.rb | 1 + test/wrappers/ortools_test.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index ae6e219ac..81caea3fc 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -49,6 +49,7 @@ SplitClusteringTest#test_cluster_one_phase_vehicle SplitClusteringTest#test_instance_same_point_day SplitClusteringTest#test_no_doubles_3000 + Wrappers::OrtoolsTest#test_ortools_performance_when_duration_limit WrapperTest#test_detecting_unfeasible_services_can_not_take_too_long ] ) diff --git a/test/wrappers/ortools_test.rb b/test/wrappers/ortools_test.rb index 4e246f0a5..199267471 100644 --- a/test/wrappers/ortools_test.rb +++ b/test/wrappers/ortools_test.rb @@ -4855,7 +4855,7 @@ def test_subproblem_with_one_vehicle_and_no_possible_service end def test_ortools_performance_when_duration_limit - # Test agains optim-ortools model regression wrt vehicle duration limit + # Test against optim-ortools model regression wrt vehicle duration limit vrp = TestHelper.load_vrp(self) solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, vrp, nil) From bfb98be8aced47e966335b2cd05711f98d067dd3 Mon Sep 17 00:00:00 2001 From: halilsen Date: Wed, 1 Dec 2021 17:35:08 +0100 Subject: [PATCH 15/18] Add: keep a list of service-vehicle compatibility --- .rubocop.yml | 3 + models/concerns/periodic_service.rb | 2 + models/service.rb | 2 + models/timewindow.rb | 10 ++ optimizer_wrapper.rb | 4 + test/wrapper_test.rb | 30 ++-- test/wrappers/ortools_test.rb | 24 +-- wrappers/ortools.rb | 31 ++-- wrappers/wrapper.rb | 265 +++++++++++++--------------- 9 files changed, 182 insertions(+), 189 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 4901ad1ce..22e014fbf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -23,6 +23,9 @@ Style/FrozenStringLiteralComment: Style/NumericPredicate: Enabled: false +# Style/UnlessLogicalOperators: # TODO: activate when rubocop gem is upgraded +# EnforcedStyle: forbid_logical_operators + Layout/EmptyLines: Severity: warning diff --git a/models/concerns/periodic_service.rb b/models/concerns/periodic_service.rb index 05be6a89b..c13fc0ef1 100644 --- a/models/concerns/periodic_service.rb +++ b/models/concerns/periodic_service.rb @@ -26,6 +26,8 @@ def can_affect_all_visits?(service) return true if service.visits_number == 1 self.vehicles.any?{ |vehicle| + next unless service.vehicle_compatibility.nil? || service.vehicle_compatibility[vehicle.id] # vehicle already eliminated + current_day = self.configuration.schedule.range_indices[:start] decimal_day = current_day current_visit = 0 diff --git a/models/service.rb b/models/service.rb index 412685634..e080ac156 100644 --- a/models/service.rb +++ b/models/service.rb @@ -54,6 +54,8 @@ class Service < Base field :skills field :original_skills + field :vehicle_compatibility, as_json: :none # vehicle_compatibility[v_id] == {true -> compatible, false -> incompatible, nil -> not checked yet} + ## has_many :period_activities, class_name: 'Models::Activity' # Need alternatives visits belongs_to :activity, class_name: 'Models::Activity' has_many :activities, class_name: 'Models::Activity' diff --git a/models/timewindow.rb b/models/timewindow.rb index fb59816e3..851a3ac11 100644 --- a/models/timewindow.rb +++ b/models/timewindow.rb @@ -40,5 +40,15 @@ def self.create(hash, _options = {}) super(hash) end + + def safe_end(lateness_allowed = false) + if self.end + self.end + (lateness_allowed ? self.maximum_lateness : 0) + elsif self.day_index + 86399 # 24h - 1sec + else + 2147483647 # 2**31 - 1 + end + end end end diff --git a/optimizer_wrapper.rb b/optimizer_wrapper.rb index ef102d150..908668d3f 100644 --- a/optimizer_wrapper.rb +++ b/optimizer_wrapper.rb @@ -187,10 +187,14 @@ def self.solve(service_vrp, job = nil, block = nil) else unfeasible_services = config[:services][service].detect_unfeasible_services(vrp) + # TODO: Eliminate the points which has no feasible vehicle or service + vrp.compute_matrix(&block) config[:services][service].check_distances(vrp, unfeasible_services) + # TODO: Eliminate the vehicles which cannot serve any service vrp.services.all?{ |s| s.vehicle_compatibility[v.id] == false } + # Remove infeasible services services_to_reinject = [] unfeasible_services.each_key{ |una_service_id| diff --git a/test/wrapper_test.rb b/test/wrapper_test.rb index d8c4b8c91..21207cb70 100644 --- a/test/wrapper_test.rb +++ b/test/wrapper_test.rb @@ -1470,7 +1470,7 @@ def test_impossible_service_too_far_time } solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_equal(1, solutions[0].unassigned_stops.count{ |un| - un.reason == 'No compatible vehicle can reach this service while respecting all constraints' + un.reason&.split(' && ')&.include?('No compatible vehicle can reach this service while respecting all constraints') }) end @@ -1531,7 +1531,7 @@ def test_impossible_service_too_far_distance } solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_equal(1, solutions[0].unassigned_stops.count{ |un| - un.reason == 'No compatible vehicle can reach this service while respecting all constraints' + un.reason&.split(' && ')&.include?('No compatible vehicle can reach this service while respecting all constraints') }) end @@ -1602,7 +1602,7 @@ def test_impossible_service_capacity } } solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) - assert_equal 1, (solutions[0].unassigned_stops.count{ |un| un.reason == 'Service quantity greater than any vehicle capacity' }) + assert_equal 1, (solutions[0].unassigned_stops.count{ |un| un.reason&.split(' && ')&.include?('Service has a quantity which is greater than the capacity of any compatible vehicle') }) end def test_impossible_service_skills @@ -1703,7 +1703,7 @@ def test_impossible_service_tw } } solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) - assert_equal(1, solutions[0].unassigned_stops.count{ |un| un.reason.include?('No vehicle with compatible timewindow') }) + assert_equal(1, solutions[0].unassigned_stops.count{ |un| un.reason.include?('Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits') }) end def test_impossible_service_duration @@ -1755,7 +1755,7 @@ def test_impossible_service_duration } solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_equal 1, (solutions[0].unassigned_stops.count{ |un| - un.reason&.include?('Service duration greater than any vehicle timewindow') + un.reason&.include?('Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits') }) end @@ -1814,7 +1814,7 @@ def test_impossible_service_duration_with_sequence_tw } solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_equal 1, (solutions[0].unassigned_stops.count{ |un| - un.reason.include?('Service duration greater than any vehicle timewindow') + un.reason.include?('Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits') }) end @@ -1882,7 +1882,7 @@ def test_impossible_service_tw_periodic ] vrp[:services].first[:activity][:timewindows] = [{ start: 0, end: 5, day_index: 1 }] solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(vrp), nil) - assert_equal(1, solutions[0].unassigned_stops.count{ |un| un.reason == 'No vehicle with compatible timewindow' }) + assert_equal(1, solutions[0].unassigned_stops.count{ |un| un.reason&.split(' && ')&.include?('Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits') }) end def test_impossible_service_due_to_unavailable_day_periodic @@ -1894,8 +1894,8 @@ def test_impossible_service_due_to_unavailable_day_periodic ] vrp[:services].first[:activity][:timewindows] = [{ start: 0, end: 5, day_index: 0 }] vrp[:services].first[:unavailable_visit_day_indices] = [0] - result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(vrp), nil) - assert_equal(1, result[:unassigned].count{ |un| un[:reason].split(' && ').include?('No vehicle with compatible timewindow') }) + solutions = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(vrp), nil) + assert_equal(1, solutions[0].unassigned_stops.count{ |un| un.reason.split(' && ').include?('Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits') }) end def test_impossible_service_distance @@ -2549,7 +2549,7 @@ def test_impossible_service_too_long } vrp[:services].first[:activity][:duration] = 15 unfeasible = OptimizerWrapper.config[:services][:demo].detect_unfeasible_services(TestHelper.create(vrp)).values.flatten - assert_equal(1, unfeasible.count{ |un| un.reason == 'Service duration greater than any vehicle timewindow' }) + assert_equal(1, unfeasible.count{ |un| un.reason == 'Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits' }) vrp[:vehicles].first[:timewindow] = nil vrp[:vehicles].first[:sequence_timewindows] = [{ @@ -2559,7 +2559,7 @@ def test_impossible_service_too_long vrp[:services].first[:activity][:duration] = 15 vrp[:configuration][:schedule] = { range_indices: { start: 0, end: 3 }} unfeasible = OptimizerWrapper.config[:services][:demo].detect_unfeasible_services(TestHelper.create(vrp)).values.flatten - assert_equal(1, unfeasible.count{ |un| un.reason == 'Service duration greater than any vehicle timewindow' }) + assert_equal(1, unfeasible.count{ |un| un.reason == 'Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits' }) vrp[:vehicles].first[:sequence_timewindows] << { start: 0, @@ -2576,7 +2576,7 @@ def test_impossible_service_with_negative_quantity vrp[:services].first[:quantities].first[:value] = -6 unfeasible = OptimizerWrapper.config[:services][:demo].detect_unfeasible_services(TestHelper.create(vrp)).values.flatten - assert_equal(1, unfeasible.count{ |un| un.reason == 'Service quantity greater than any vehicle capacity' }) + assert_equal(1, unfeasible.count{ |un| un.reason&.split(' && ')&.include?('Service has a quantity which is greater than the capacity of any compatible vehicle') }) end def test_feasible_if_tardiness_allowed @@ -2869,9 +2869,9 @@ def test_detecting_unfeasible_services_can_not_take_too_long # corrected/increased. On local, the total_times are almost half the limits. # The goal of this test is to prevent adding an involuntary exponential logic and the limits can increase linearly # if more verifications are added but the time should not jump orders of magnitude. - assert_operator total_check_distances_time, :<=, 3.3, 'check_distances function took longer than expected' + assert_operator total_check_distances_time, :<=, 6.6, 'check_distances function took longer than expected' assert_operator total_add_unassigned_time, :<=, 6.6, 'add_unassigned function took longer than expected' - assert_operator total_detect_unfeasible_services_time, :<=, 8.8, 'detect_unfeasible_services function took too long' + assert_operator total_detect_unfeasible_services_time, :<=, 14.5, 'detect_unfeasible_services function took too long' ensure OptimizerLogger.level = old_logger_level if old_logger_level OptimizerWrapper.config[:solve][:repetition] = old_config_solve_repetition if old_config_solve_repetition @@ -3563,7 +3563,7 @@ def test_reject_when_unfeasible_timewindows vrp[:services].first[:activity][:timewindows] = [{ start: 0, end: 10 }, { start: 20, end: 15 }] # ship and service but only check service unfeasible_services = OptimizerWrapper.config[:services][:demo].detect_unfeasible_services(TestHelper.create(vrp)).values.flatten - assert_equal 1, (unfeasible_services.count{ |un| un.reason == 'Service timewindows are infeasible' }) + assert_equal 1, (unfeasible_services.count{ |un| un.reason&.split(' && ')&.include?('Service timewindow is infeasible') }) end def test_multiple_reason diff --git a/test/wrappers/ortools_test.rb b/test/wrappers/ortools_test.rb index 199267471..f7518c60c 100644 --- a/test/wrappers/ortools_test.rb +++ b/test/wrappers/ortools_test.rb @@ -2734,12 +2734,12 @@ def test_shipments vrp = TestHelper.create(VRP.pud) solution = ortools.solve(vrp, 'test') assert solution + assert_equal 0, solution.unassigned_stops.size + assert_equal 6, solution.routes.first.stops.size assert solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_0' } < solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_0' } assert solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_1' } < solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_1' } - assert_equal 0, solution.unassigned_stops.size - assert_equal 6, solution.routes.first.stops.size end def test_shipments_quantities @@ -2825,12 +2825,12 @@ def test_shipments_quantities vrp = TestHelper.create(problem) solution = ortools.solve(vrp, 'test') assert solution + assert_equal 0, solution.unassigned_stops.size + assert_equal 6, solution.routes.first.stops.size assert_equal(solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_0' } + 1, solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_0' }) assert_equal(solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_1' } + 1, solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_1' }) - assert_equal 0, solution.unassigned_stops.size - assert_equal 6, solution.routes.first.stops.size end def test_shipments_with_multiple_timewindows_and_lateness @@ -2850,12 +2850,12 @@ def test_shipments_with_multiple_timewindows_and_lateness vrp = TestHelper.create(problem) solution = ortools.solve(vrp, 'test') assert_equal 0, solution.cost_info.lateness + assert_equal 0, solution.unassigned_stops.size + assert_equal 6, solution.routes[0].stops.size assert solution.routes[0].stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_0' } < solution.routes[0].stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_0' } assert solution.routes[0].stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_1' } < solution.routes[0].stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_1' } - assert_equal 0, solution.unassigned_stops.size - assert_equal 6, solution.routes[0].stops.size end def test_shipments_inroute_duration @@ -2866,6 +2866,8 @@ def test_shipments_inroute_duration vrp = TestHelper.create(problem) solution = ortools.solve(vrp, 'test') assert solution + assert_equal 0, solution.unassigned_stops.size + assert_equal 6, solution.routes.first.stops.size assert_equal( solution.routes.first.stops.find_index{ |activity| activity[:pickup_shipment_id] == 'shipment_0' } + 1, solution.routes.first.stops.find_index{ |activity| activity[:delivery_shipment_id] == 'shipment_0' } @@ -2884,8 +2886,6 @@ def test_shipments_inroute_duration :<, solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_1' } ) - assert_equal 0, solution.unassigned_stops.size - assert_equal 6, solution.routes.first.stops.size end def test_mixed_shipments_and_services @@ -2961,10 +2961,10 @@ def test_mixed_shipments_and_services vrp = TestHelper.create(problem) solution = ortools.solve(vrp, 'test') assert solution - assert solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_1' } < - solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_1' } assert_equal 0, solution.unassigned_stops.size assert_equal 5, solution.routes.first.stops.size + assert solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_1' } < + solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_1' } end def test_shipments_distance @@ -2981,12 +2981,12 @@ def test_shipments_distance vrp = TestHelper.create(problem) solution = ortools.solve(vrp, 'test') assert solution + assert_equal 0, solution.unassigned_stops.size + assert_equal 6, solution.routes.first.stops.size assert solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_0' } < solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_0' } assert solution.routes.first.stops.index{ |activity| activity[:pickup_shipment_id] == 'shipment_1' } < solution.routes.first.stops.index{ |activity| activity[:delivery_shipment_id] == 'shipment_1' } - assert_equal 0, solution.unassigned_stops.size - assert_equal 6, solution.routes.first.stops.size end def test_maximum_duration_lapse_shipments diff --git a/wrappers/ortools.rb b/wrappers/ortools.rb index ec24941c6..6e38af81b 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -135,19 +135,15 @@ def solve(vrp, job, thread_proc = nil, &block) routes = [] services_positions = { always_first: [], always_last: [], never_first: [], never_last: [] } vrp.services.each_with_index{ |service, service_index| - vehicles_indices = - if service.skills.any? && vrp.vehicles.all?{ |vehicle| vehicle.skills.empty? } && - service.unavailable_days.empty? - [] - else - vrp.vehicles.collect.with_index{ |vehicle, index| - if (service.skills.empty? || vehicle.skills.any?{ |skill_set| (service.skills - skill_set).empty? }) && - check_services_compatible_days(vrp, vehicle, service) && - (service.unavailable_days.empty? || !service.unavailable_days.include?(vehicle.global_day_index)) - index - end - }.compact - end + vehicles_indices = [] + detect_unfeasible_services(vrp) if service.vehicle_compatibility.nil? + vrp.vehicles.each_with_index{ |vehicle, index| + next if service.vehicle_compatibility[vehicle.id] == false || # let nil through + !service.vehicle_compatibility[vehicle.original_id] || # can't be nil + !check_services_compatible_days(vrp, vehicle, service) + + vehicles_indices << index + } if service.activity services << OrtoolsVrp::Service.new( @@ -426,8 +422,13 @@ def build_solution(vrp, content) end def check_services_compatible_days(vrp, vehicle, service) - !(vrp.schedule? && (service.minimum_lapse || service.maximum_lapse)) || - vehicle.global_day_index.between?(service.first_possible_days.first, service.last_possible_days.first) + !vrp.schedule? || ( + service.unavailable_days.exclude?(vehicle.global_day_index) && + ( + (!service.minimum_lapse && !service.maximum_lapse) || + vehicle.global_day_index.between?(service.first_possible_days.first, service.last_possible_days.first) + ) + ) end def build_route_data(stop, vehicle_matrix, previous_matrix_index, current_matrix_index) diff --git a/wrappers/wrapper.rb b/wrappers/wrapper.rb index 6b8e21152..16d610ba4 100644 --- a/wrappers/wrapper.rb +++ b/wrappers/wrapper.rb @@ -430,50 +430,7 @@ def solve_synchronous?(_vrp) false end - def compatible_day?(vrp, service, t_day, vehicle) - first_day = vrp.configuration.schedule.range_indices[:start] - last_day = vrp.configuration.schedule.range_indices[:end] - (first_day..last_day).any?{ |day| - s_ok = t_day == day || !service.unavailable_days.include?(day) - v_ok = !vehicle.unavailable_days.include?(day) - s_ok && v_ok - } - end - - def find_vehicle(vrp, service) - service_timewindows = service.activity ? service.activity.timewindows : service.activities.collect(&:timewindows).flatten - service_lateness = service.activity&.late_multiplier&.positive? - - available_vehicle = vrp.vehicles.find{ |vehicle| - vehicle_timewindows = vehicle.timewindow ? [vehicle.timewindow] : vehicle.sequence_timewindows - vehicle_work_days = vehicle_timewindows.collect(&:day_index).compact.flatten - vehicle_work_days = [0, 1, 2, 3, 4, 5] if vehicle_work_days.empty? - vehicle_lateness = vehicle.cost_late_multiplier&.positive? - - days = vrp.schedule? ? (vrp.configuration.schedule.range_indices[:start]..vrp.configuration.schedule.range_indices[:end]).collect{ |day| day } : [0] - days.any?{ |day| - vehicle_work_days.include?(day % 7) && !vehicle.unavailable_days.include?(day) && - !service.unavailable_days.include?(day) && - (service_timewindows.empty? || vehicle_timewindows.empty? || - service_timewindows.any?{ |tw| - (tw.day_index.nil? || tw.day_index == day % 7) && ( - vehicle_lateness || - service_lateness || - vehicle_timewindows.any?{ |v_tw| - days_compatible = !v_tw.day_index || !tw.day_index || v_tw.day_index == tw.day_index - days_compatible && - (tw.end.nil? || v_tw.start < tw.end) && - (v_tw.end.nil? || v_tw.end > tw.start) - } - ) - }) - } - } - - available_vehicle - end - - def check(vrp, dimension, unfeasible) + def check_unreachable(vrp, dimension, unfeasible) return unfeasible if vrp.matrices.any?{ |matrix| matrix[dimension].nil? || matrix[dimension].size == 1 } matrix_indices = vrp.points.map(&:matrix_index).uniq @@ -548,37 +505,6 @@ def add_unassigned_internal(unfeasible, vrp, service, reason) unfeasible end - def compute_vehicles_shift(vehicles) - max_shift = vehicles.collect{ |vehicle| - next if vehicle&.cost_late_multiplier&.positive? - - if vehicle.timewindow&.start && vehicle.timewindow&.end - vehicle.timewindow.end - vehicle.timewindow.start - elsif vehicle.sequence_timewindows.all?(&:end) - vehicle.sequence_timewindows.collect{ |tw| tw.end - tw.start }.max - end - } - max_shift.include?(nil) ? nil : max_shift.max - end - - def compute_vehicles_capacity(vrp) - unit_ids = vrp.units.map(&:id) - capacities = Hash[unit_ids.product([-1])] - vrp.vehicles.each{ |vehicle| - limits = Hash[unit_ids.product([-1])] # We expect to detect every undefined capacity - - vehicle.capacities.each{ |capacity| # Defined capacities are scanned - limits[capacity.unit.id] = capacity.overload_multiplier&.positive? ? nil : capacity.limit - } - - limits.each{ |k, v| # Unfound units are tagged as infinite - capacities[k] = nil if v.nil? || v.negative? - capacities[k] = [v, capacities[k]].max unless capacities[k].nil? - } - } - capacities.reject{ |_k, v| v.nil? || v.negative? } - end - def possible_days_are_consistent(vrp, service) return true unless vrp.schedule? @@ -616,32 +542,21 @@ def possible_days_are_consistent(vrp, service) def detect_unfeasible_services(vrp) unfeasible = {} - vehicle_max_shift = compute_vehicles_shift(vrp.vehicles) - vehicle_max_capacities = compute_vehicles_capacity(vrp) - vrp.services.each{ |service| - service.quantities.each{ |qty| - if vehicle_max_capacities[qty.unit_id] && qty.value && vehicle_max_capacities[qty.unit_id] < qty.value.abs - add_unassigned(unfeasible, vrp, service, 'Service quantity greater than any vehicle capacity') - break - end - } + check_timewindow_inconsistency(vrp, unfeasible, service) - activities = [service.activity, service.activities].compact.flatten - if activities.any?{ |a| a.timewindows.any?{ |tw| tw.start && tw.end && tw.start > tw.end } } - add_unassigned(unfeasible, vrp, service, 'Service timewindows are infeasible') + if no_vehicle_with_compatible_skills(vrp, service) # inits service.vehicle_compatibility + add_unassigned(unfeasible, vrp, service, 'Service has no compatible vehicle -- i.e., skills and/or sticky') end - if vehicle_max_shift && activities.collect(&:duration).min > vehicle_max_shift - add_unassigned(unfeasible, vrp, service, 'Service duration greater than any vehicle timewindow') + if no_compatible_vehicle_with_enough_capacity(vrp, service) + add_unassigned(unfeasible, vrp, service, 'Service has a quantity which is greater than the capacity of any compatible vehicle') end - unless find_vehicle(vrp, service) - add_unassigned(unfeasible, vrp, service, 'No vehicle with compatible timewindow') + if no_compatible_vehicle_with_compatible_tw(vrp, service) + add_unassigned(unfeasible, vrp, service, 'Service cannot be performed by any compatible vehicle while respecting duration, timewindow and day limits') end - detect_inconsistent_relation_timewindows_od_service(vrp, unfeasible, service) - # Planning inconsistency unless possible_days_are_consistent(vrp, service) add_unassigned(unfeasible, vrp, service, 'Provided possible days do not allow service to be assigned') @@ -655,7 +570,12 @@ def detect_unfeasible_services(vrp) unfeasible end - def detect_inconsistent_relation_timewindows_od_service(vrp, unfeasible, service) + def check_timewindow_inconsistency(vrp, unfeasible, service) + s_activities = [service.activity, service.activities].compact.flatten + if s_activities.any?{ |a| a.timewindows.any?{ |tw| tw.start && tw.end && tw.start > tw.end } } + add_unassigned(unfeasible, vrp, service, 'Service timewindow is infeasible') + end + # In a POSITION_TYPES relationship s1->s2, s2 cannot be served # if its timewindows end before any timewindow of s1 starts service.relations.each{ |relation| @@ -666,10 +586,11 @@ def detect_inconsistent_relation_timewindows_od_service(vrp, unfeasible, service next unless service_in.activity.timewindows.any? earliest_arrival = service_in.activity.timewindows.map{ |tw| - (tw.day_index || 0) * 86400 + (tw.start || 0) + tw.day_index.to_i * 86400 + tw.start }.min + activity_lateness = service_in.activity.late_multiplier&.positive? latest_arrival = service_in.activity.timewindows.map{ |tw| - tw.day_index ? tw.day_index * 86400 + (tw.end || 86399) : (tw.end || 2147483647) + tw.day_index.to_i * 86400 + tw.safe_end(activity_lateness) }.max max_earliest_arrival = [max_earliest_arrival, earliest_arrival].compact.max @@ -681,70 +602,120 @@ def detect_inconsistent_relation_timewindows_od_service(vrp, unfeasible, service } end - def service_reachable_by_vehicle_within_timewindows(vrp, activity, vehicle) - vehicle_start = vehicle.timewindow&.start || vehicle.sequence_timewindows.collect(&:start).min || 0 - vehicle_end = - if vehicle.cost_late_multiplier&.positive? # vehicle lateness is allowed - vehicle.timewindow&.end ? - vehicle.timewindow.end + vehicle.timewindow.maximum_lateness : - vehicle.sequence_timewindows.collect{ |tw| tw.end + tw.maximum_lateness }.max - else # vehicle lateness is not allowed - vehicle.timewindow&.end || vehicle.sequence_timewindows.collect(&:end).max - end + def no_vehicle_with_compatible_skills(vrp, service) + service.vehicle_compatibility ||= {} - matrix = vrp.matrices.find{ |m| m.id == vehicle.matrix_id } + vrp.vehicles.each{ |vehicle| + service.vehicle_compatibility[vehicle.id] ||= + service.vehicle_compatibility[vehicle.id].nil? && # if it is true or false, no need to recheck + (service.skills.empty? || vehicle.skills.any?{ |v_skill_set| (service.skills - v_skill_set).empty? }) + } + + vrp.vehicles.none?{ |v| service.vehicle_compatibility[v.id] } # no compatible vehicles + end - time_to_go = vehicle.start_point&.matrix_index ? matrix.time[vehicle.start_point&.matrix_index][activity.point.matrix_index] : 0 - time_back = vehicle.end_point&.matrix_index ? matrix.time[activity.point.matrix_index][vehicle.end_point&.matrix_index] : 0 + def no_compatible_vehicle_with_enough_capacity(vrp, service) + no_vehicle_with_compatible_skills(vrp, service) if service.vehicle_compatibility.nil? - earliest_arrival = vehicle_start + time_to_go - earliest_back = earliest_arrival + activity.duration + time_back + s_quantities = service.quantities.map{ |quantity| [quantity.unit_id, quantity.value.abs] }.to_h - return false if vehicle_end && earliest_back > vehicle_end + vrp.vehicles.each{ |vehicle| + next unless service.vehicle_compatibility[vehicle.id] # already eliminated + + service.vehicle_compatibility[vehicle.id] = + vehicle.capacities.none?{ |capacity| + s_quantities[capacity.unit_id] && # there is quantity + capacity.limit && # there is a limit + !capacity.overload_multiplier&.positive? && # overload not permitted + s_quantities[capacity.unit_id] > capacity.limit # and capacity is not enough + } + } - return false if vehicle.duration && earliest_back - earliest_arrival > vehicle.duration + vrp.vehicles.none?{ |v| service.vehicle_compatibility[v.id] } # no compatible vehicle with enough capacity + end - if activity.timewindows.any? - if activity.late_multiplier&.positive? # service lateness is allowed - return false if activity.timewindows.none?{ |tw| tw.end.nil? || earliest_arrival <= tw.end + tw.maximum_lateness } - else # service lateness is not allowed - return false if activity.timewindows.none?{ |tw| tw.end.nil? || earliest_arrival <= tw.end } - end - if vehicle_end - latest_arrival = vehicle_end - time_back - activity.duration - return false if activity.timewindows.all?{ |tw| latest_arrival < tw.start } - end - end + def no_compatible_vehicle_with_compatible_tw(vrp, service) + no_vehicle_with_compatible_skills(vrp, service) if service.vehicle_compatibility.nil? - if vehicle.distance - # check distances constraints - dist_to_go = vehicle.start_point&.matrix_index ? matrix.distance[vehicle.start_point&.matrix_index][activity.point.matrix_index] : 0 - dist_back = vehicle.end_point&.matrix_index ? matrix.distance[activity.point.matrix_index][vehicle.end_point&.matrix_index] : 0 + s_activities = [service.activity, service.activities].compact.flatten - return false if dist_to_go + dist_back > vehicle.distance - end + implicit_timewindow = [Models::Timewindow.new(start: 0)] # need to check time feasibility - true - end + vrp.vehicles.each{ |vehicle| + next unless service.vehicle_compatibility[vehicle.id] # already eliminated - def check_distances(vrp, unfeasible) - unfeasible = check(vrp, :time, unfeasible) - unfeasible = check(vrp, :distance, unfeasible) - unfeasible = check(vrp, :value, unfeasible) + vehicle_timewindows = [vehicle.timewindow, vehicle.sequence_timewindows].compact.flatten - vrp.services.each{ |service| - no_vehicle_compatible = - vrp.vehicles.none?{ |vehicle| - (service.activity ? [service.activity] : service.activities).any?{ |activity| - (service.skills.empty? || vehicle.skills.any?{ |skill_set| (service.skills - skill_set).empty? }) && - (service.sticky_vehicle_ids.empty? || service.sticky_vehicle_ids.include?(vehicle.id)) && - service_reachable_by_vehicle_within_timewindows(vrp, activity, vehicle) - } + next if !vrp.schedule? && vehicle.duration.nil? && vehicle.distance.nil? && + s_activities.all?{ |a| a.timewindows.none?(&:end) } && vehicle_timewindows.none?(&:end) + + vehicle_timewindows = implicit_timewindow if vehicle_timewindows.empty? + + service.vehicle_compatibility[vehicle.id] = + s_activities.any?{ |activity| + time_to_go, time_to_return, dist_to_go, dist_to_return = two_way_time_and_dist(vrp, vehicle, activity) || [0, 0, 0, 0] + + # NOTE: There is no easy way to include the setup duration in the elimination because the + # setup_duration logic is based of time[point_a][point_b] == 0; so we need to check all + # services which are 0 distance and then find the minimum (setup_duration + duration) and + # use this as the setup duration in the check below if it is less than the service.setup_duration + + (vehicle.distance.nil? || dist_to_go + dist_to_return <= vehicle.distance) && + ( + vehicle.duration.nil? || + time_to_go + vehicle.additional_service + vehicle.coef_service * activity.duration + time_to_return <= vehicle.duration + ) && ( + (activity.timewindows.empty? ? implicit_timewindow : activity.timewindows).any?{ |s_tw| + vehicle_timewindows.any?{ |v_tw| + # vehicle has a tw that can serve service in time (incl. travel if it exists) + (s_tw.day_index.nil? || v_tw.day_index.nil? || s_tw.day_index == v_tw.day_index) && + v_tw.start + time_to_go <= s_tw.safe_end(activity.late_multiplier&.positive?) && + [s_tw.start, v_tw.start + time_to_go].max + vehicle.additional_service + vehicle.coef_service * activity.duration + time_to_return <= v_tw.safe_end(vehicle.cost_late_multiplier&.positive?) && + ( # either not schedule or there should be a day in which both vehicle and service are available + !vrp.schedule? || + vrp.configuration.schedule.range_indices[:start].upto(vrp.configuration.schedule.range_indices[:end]).any?{ |day| + (s_tw.day_index.nil? || day % 7 == s_tw.day_index) && + (v_tw.day_index.nil? || day % 7 == v_tw.day_index) && + service.unavailable_days.exclude?(day) && + vehicle.unavailable_days.exclude?(day) + } + ) + } + } + ) } + } + + vrp.vehicles.none?{ |v| service.vehicle_compatibility[v.id] } # no compatible vehicle with compatible timewindow + end + + def two_way_time_and_dist(vrp, vehicle, activity) + return unless vehicle.matrix_id + + v_start_m_index = vehicle.start_point&.matrix_index + v_end_m_index = vehicle.end_point&.matrix_index - next unless no_vehicle_compatible + return unless v_start_m_index || v_end_m_index - add_unassigned(unfeasible, vrp, service, 'No compatible vehicle can reach this service while respecting all constraints') + matrix = vrp.matrices.find{ |m| m.id == vehicle.matrix_id } + + [ + v_start_m_index && matrix.time ? matrix.time[v_start_m_index][activity.point.matrix_index] : 0, + v_end_m_index && matrix.time ? matrix.time[activity.point.matrix_index][v_end_m_index] : 0, + v_start_m_index && matrix.distance ? matrix.distance[v_start_m_index][activity.point.matrix_index] : 0, + v_end_m_index && matrix.distance ? matrix.distance[activity.point.matrix_index][v_end_m_index] : 0, + ] + end + + def check_distances(vrp, unfeasible) + unfeasible = check_unreachable(vrp, :time, unfeasible) + unfeasible = check_unreachable(vrp, :distance, unfeasible) + unfeasible = check_unreachable(vrp, :value, unfeasible) + + vrp.services.each{ |service| + if no_compatible_vehicle_with_compatible_tw(vrp, service) + add_unassigned(unfeasible, vrp, service, 'No compatible vehicle can reach this service while respecting all constraints') + end } unless unfeasible.empty? @@ -1184,7 +1155,7 @@ def simplify_vehicle_pause(vrp, solution = nil, options = { mode: :simplify }) # insert the pause without inducing unnecessary idle time max_service_duration = 0 vrp.services.each{ |service| - next unless (service.sticky_vehicle_ids.empty? || service.sticky_vehicle_ids == vehicle) && + next unless (service.sticky_vehicle_ids.empty? || service.sticky_vehicle_ids.include?(vehicle.id)) && (service.skills - vehicle.skills).empty? service_duration = service.activity&.setup_duration.to_i + From 666713657b0d1a807ca8fafa20809815ce591805 Mon Sep 17 00:00:00 2001 From: halilsen Date: Tue, 15 Feb 2022 16:23:30 +0100 Subject: [PATCH 16/18] Bump optimizer-ortools to v1.12.0 --- README.md | 2 +- docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4ca84d17e..a0d6cd348 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ bundle install This project requires some solver and interface projects in order to be fully functional! * [Vroom v1.8.0](https://github.com/VROOM-Project/vroom/releases/tag/v1.8.0) -* [Optimizer-ortools v1.11.0](https://github.com/Mapotempo/optimizer-ortools) & [OR-Tools v7.8](https://github.com/google/or-tools/releases/tag/v7.8) (use the version corresponding to your system operator, not source code). +* [Optimizer-ortools v1.12.0](https://github.com/Mapotempo/optimizer-ortools) & [OR-Tools v7.8](https://github.com/google/or-tools/releases/tag/v7.8) (use the version corresponding to your system operator, not source code). Note : when updating OR-Tools you should to recompile optimizer-ortools. diff --git a/docker/Dockerfile b/docker/Dockerfile index 7ebc7d5f9..d9f6a103f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,7 +8,7 @@ ARG VROOM_VERSION FROM vroomvrp/vroom-docker:${VROOM_VERSION:-v1.8.0} as vroom # Rake -FROM ${REGISTRY:-registry.mapotempo.com/}mapotempo-${BRANCH:-ce}/optimizer-ortools:${OPTIMIZER_ORTOOLS_VERSION:-v1.11.0} +FROM ${REGISTRY:-registry.mapotempo.com/}mapotempo-${BRANCH:-ce}/optimizer-ortools:${OPTIMIZER_ORTOOLS_VERSION:-v1.12.0} ARG BUNDLE_WITHOUT ENV LANG C.UTF-8 From dfa78a09646c961aea6c8bcb746a47ccc799706e Mon Sep 17 00:00:00 2001 From: halilsen Date: Tue, 15 Feb 2022 16:24:30 +0100 Subject: [PATCH 17/18] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63dda4aa1..c8db797c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Allow to compute geojsons for synchronous resolutions [#356](https://github.com/Mapotempo/optimizer-api/pull/356/files) +- Calculate a vehicle_compatibility hash for each service and use it for unfeasible service detection [#318](https://github.com/Mapotempo/optimizer-api/pull/318) ### Changed @@ -24,6 +25,7 @@ - Split duration among partitions correctly [#336](https://github.com/Mapotempo/optimizer-api/pull/336) - Fix find_best_heuristic selection logic [#337](https://github.com/Mapotempo/optimizer-api/pull/337) +- Prevent periodic heuristic overwriting supplied initial routes [#318](https://github.com/Mapotempo/optimizer-api/pull/318) ## [v1.8.2] - 2022-01-19 From a388334e0c31658ad2a7f744be46798888217680 Mon Sep 17 00:00:00 2001 From: halilsen Date: Wed, 16 Feb 2022 17:53:07 +0100 Subject: [PATCH 18/18] Extract activity.duration on vehicle calculation --- models/activity.rb | 22 ++++++++++++++++++++++ models/solution/parsers/route_parser.rb | 5 ++--- wrappers/wrapper.rb | 24 +++++++++++------------- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/models/activity.rb b/models/activity.rb index 12fc07d6d..e9060fefa 100644 --- a/models/activity.rb +++ b/models/activity.rb @@ -47,5 +47,27 @@ def vrp_result(options = {}) end hash end + + def duration_on(vehicle = nil) + case vehicle + when nil + duration + when Models::Vehicle + duration * vehicle.coef_service + vehicle.additional_service + else + raise 'Unknown object type for activity duration calculation' + end + end + + def setup_duration_on(vehicle = nil) + case vehicle + when nil + setup_duration + when Models::Vehicle + setup_duration > 0 ? setup_duration * vehicle.coef_setup + vehicle.additional_setup : 0 + else + raise 'Unknown object type for activity setup_duration calculation' + end + end end end diff --git a/models/solution/parsers/route_parser.rb b/models/solution/parsers/route_parser.rb index ec18e5ae7..b2c13bdb0 100644 --- a/models/solution/parsers/route_parser.rb +++ b/models/solution/parsers/route_parser.rb @@ -127,11 +127,10 @@ def self.compute_time_info(stop, previous_departure, travel_time) previous_departure + travel_time ].max || 0 if travel_time > 0 - earliest_arrival += stop.activity.setup_duration * @route.vehicle.coef_setup + @route.vehicle.additional_setup + earliest_arrival += stop.activity.setup_duration_on(@route.vehicle) end stop.info.begin_time = earliest_arrival - stop.info.end_time = earliest_arrival + - (stop.activity.duration * @route.vehicle.coef_service + @route.vehicle.additional_service) + stop.info.end_time = earliest_arrival + stop.activity.duration_on(@route.vehicle) stop.info.departure_time = stop.info.end_time earliest_arrival end diff --git a/wrappers/wrapper.rb b/wrappers/wrapper.rb index 16d610ba4..438e470c4 100644 --- a/wrappers/wrapper.rb +++ b/wrappers/wrapper.rb @@ -663,14 +663,14 @@ def no_compatible_vehicle_with_compatible_tw(vrp, service) (vehicle.distance.nil? || dist_to_go + dist_to_return <= vehicle.distance) && ( vehicle.duration.nil? || - time_to_go + vehicle.additional_service + vehicle.coef_service * activity.duration + time_to_return <= vehicle.duration + time_to_go + activity.duration_on(vehicle) + time_to_return <= vehicle.duration ) && ( (activity.timewindows.empty? ? implicit_timewindow : activity.timewindows).any?{ |s_tw| vehicle_timewindows.any?{ |v_tw| # vehicle has a tw that can serve service in time (incl. travel if it exists) (s_tw.day_index.nil? || v_tw.day_index.nil? || s_tw.day_index == v_tw.day_index) && v_tw.start + time_to_go <= s_tw.safe_end(activity.late_multiplier&.positive?) && - [s_tw.start, v_tw.start + time_to_go].max + vehicle.additional_service + vehicle.coef_service * activity.duration + time_to_return <= v_tw.safe_end(vehicle.cost_late_multiplier&.positive?) && + [s_tw.start, v_tw.start + time_to_go].max + activity.duration_on(vehicle) + time_to_return <= v_tw.safe_end(vehicle.cost_late_multiplier&.positive?) && ( # either not schedule or there should be a day in which both vehicle and service are available !vrp.schedule? || vrp.configuration.schedule.range_indices[:start].upto(vrp.configuration.schedule.range_indices[:end]).any?{ |day| @@ -1327,17 +1327,10 @@ def simplify_service_setup_duration_and_vehicle_setup_modifiers(vrp, solution = next if service_group.any?{ |s| s.activity.setup_duration.to_i == 0 } || # no need if no setup_duration service_group.uniq{ |s| s.activity.setup_duration }.size > 1 # can't if setup_durations are different - setup_duration = service_group.first.activity.setup_duration - - service_group.each{ |service| - service.activity[:simplified_setup_duration] = service.activity.setup_duration - service.activity.setup_duration = nil - } + first_activity = service_group.first.activity vrp.matrices.each{ |matrix| vehicle = vehicles_grouped_by_matrix_id[matrix.id].first - coef_setup = vehicle.coef_setup || 1 - additional_setup = vehicle.additional_setup.to_i # WARNING: Here we apply the setup_duration for the points which has non-zero # distance (in time!) between them because this is the case in optimizer-ortools. @@ -1346,9 +1339,14 @@ def simplify_service_setup_duration_and_vehicle_setup_modifiers(vrp, solution = # and apply the setup duration increment to every pair except index == point.matrix_index # even if they were 0 in the first place. matrix.time.each{ |row| - row[point.matrix_index] += (coef_setup * setup_duration + additional_setup).to_i if row[point.matrix_index] > 0 + row[point.matrix_index] += first_activity.setup_duration_on(vehicle).to_i if row[point.matrix_index] > 0 } } + + service_group.each{ |service| + service.activity[:simplified_setup_duration] = service.activity.setup_duration + service.activity.setup_duration = 0 + } } return nil unless vrp.services.any?{ |s| s.activity[:simplified_setup_duration] } @@ -1358,8 +1356,8 @@ def simplify_service_setup_duration_and_vehicle_setup_modifiers(vrp, solution = vrp.vehicles.each{ |vehicle| vehicle[:simplified_coef_setup] = vehicle.coef_setup vehicle[:simplified_additional_setup] = vehicle.additional_setup - vehicle.coef_setup = nil - vehicle.additional_setup = nil + vehicle.coef_setup = 1 + vehicle.additional_setup = 0 } when :rewind # take it back in case in dicho and there will be re-optimization