From 5ea4a6f6972b4c38cdbd100a1c561aa730b2ca86 Mon Sep 17 00:00:00 2001 From: halilsen Date: Wed, 1 Dec 2021 17:35:08 +0100 Subject: [PATCH] Add: keep a list of service-vehicle compatibility --- models/concerns/periodic_service.rb | 2 + models/service.rb | 2 + models/timewindow.rb | 10 ++ optimizer_wrapper.rb | 4 + test/wrapper_test.rb | 28 +-- test/wrappers/ortools_test.rb | 24 +-- wrappers/ortools.rb | 31 ++-- wrappers/wrapper.rb | 264 ++++++++++++---------------- 8 files changed, 177 insertions(+), 188 deletions(-) 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 7c6dda181..6d0117c16 100644 --- a/models/service.rb +++ b/models/service.rb @@ -54,6 +54,8 @@ class Service < Base field :skills field :original_skills + field :vehicle_compatibility # 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 e37bb30a9..003b58922 100644 --- a/models/timewindow.rb +++ b/models/timewindow.rb @@ -40,5 +40,15 @@ def self.create(hash) 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 e90b81c6b..150d97c56 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 09334a132..1498034bc 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.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.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.count{ |un| un.reason == 'Service quantity greater than any vehicle capacity' }) + assert_equal 1, (solutions[0].unassigned.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.count{ |un| un.reason.include?('No vehicle with compatible timewindow') }) + assert_equal(1, solutions[0].unassigned.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.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.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.count{ |un| un.reason == 'No vehicle with compatible timewindow' }) + assert_equal(1, solutions[0].unassigned.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 @@ -1895,7 +1895,7 @@ 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') }) + assert_equal(1, result[:unassigned].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 timewindows are infeasible') }) end def test_multiple_reason diff --git a/test/wrappers/ortools_test.rb b/test/wrappers/ortools_test.rb index 7c05dad41..b96b116c2 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.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.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.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.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.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.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.size + assert_equal 6, solution.routes[0].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.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.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.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.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 f378c1119..dbf070435 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -136,19 +136,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 unless (service.vehicle_compatibility[vehicle.id].nil? || service.vehicle_compatibility[vehicle.id]) && + service.vehicle_compatibility[vehicle.original_id] && + check_services_compatible_days(vrp, vehicle, service) + + vehicles_indices << index + } if service.activity services << OrtoolsVrp::Service.new( @@ -430,8 +426,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 073286d89..3b3425b9b 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,119 @@ 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? }) && + (service.sticky_vehicles.empty? || service.sticky_vehicle_ids.include?(vehicle.id)) + } + + 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 + activity.duration.to_i + 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 + activity.duration.to_i + 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.schedule_range_indices[:start].upto(vrp.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? @@ -1185,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 +