From df05b13d28a59eb056ce91a9602b60dfff399c3a Mon Sep 17 00:00:00 2001 From: fonsecadeline Date: Fri, 6 Aug 2021 17:07:35 +0200 Subject: [PATCH] Relation[:lapse] become relation[:lapses] --- api/v01/entities/vrp_input.rb | 6 +- lib/interpreters/periodic_visits.rb | 18 ++-- models/concerns/validate_data.rb | 87 ++++++++++++---- models/relation.rb | 33 +++++- models/vrp.rb | 31 ++++-- test/lib/interpreters/interpreter_test.rb | 8 +- test/models/vrp_consistency_test.rb | 117 +++++++++++++++++++--- test/models/vrp_test.rb | 30 ++++-- test/test_helper.rb | 20 +++- test/wrapper_test.rb | 11 +- test/wrappers/ortools_multi_trips_test.rb | 2 +- test/wrappers/ortools_test.rb | 63 +++++++++--- wrappers/ortools.rb | 33 +++--- 13 files changed, 355 insertions(+), 104 deletions(-) diff --git a/api/v01/entities/vrp_input.rb b/api/v01/entities/vrp_input.rb index e45dc3ad1..8bb354574 100644 --- a/api/v01/entities/vrp_input.rb +++ b/api/v01/entities/vrp_input.rb @@ -263,7 +263,11 @@ module VrpMisc shipment, meetup, minimum_duration_lapse, maximum_duration_lapse, vehicle_trips') optional(:lapse, type: Integer, values: ->(v) { v >= 0 }, - desc: 'Only used for relations implying a duration constraint. Lapse expressed in days for minimum/maximum day lapse, in seconds for minimum/maximum_duration_lapse and vehicle_trips.') + desc: '[ DEPRECATED ]') + optional(:lapses, + type: Array[Integer], values: ->(v) { v.all?{ |l| l >= 0 } }, + desc: 'For some relation types, specifies duration or number constraint. Lapse expressed in days for minimum/maximum day lapse, in seconds for minimum/maximum_duration_lapse and vehicle_trips. For consistent relation types, lapse can be specified for every consecutive elements.') + mutually_exclusive :lapse, :lapses optional(:linked_ids, type: Array[String], allow_blank: false, desc: 'List of activities involved in the relation', coerce_with: ->(val) { val.is_a?(String) ? val.split(/,/) : val }) optional(:linked_vehicle_ids, type: Array[String], allow_blank: false, desc: 'List of vehicles involved in the relation', coerce_with: ->(val) { val.is_a?(String) ? val.split(/,/) : val }) optional(:periodicity, type: Integer, documentation: { hidden: true }, desc: 'In the case of planning optimization, number of weeks/months to consider at the same time/in each relation : vehicle group duration on weeks/months') diff --git a/lib/interpreters/periodic_visits.rb b/lib/interpreters/periodic_visits.rb index 10fd85cb1..ea83bc9f9 100644 --- a/lib/interpreters/periodic_visits.rb +++ b/lib/interpreters/periodic_visits.rb @@ -101,7 +101,7 @@ def generate_relations(vrp) linked_ids = relation.linked_ids.collect{ |s_id| @expanded_services[s_id][visit_index].id } Models::Relation.create( - type: relation.type, linked_ids: linked_ids, lapse: relation.lapse, periodicity: relation.periodicity + type: relation.type, linked_ids: linked_ids, lapses: relation.lapses, periodicity: relation.periodicity ) } } @@ -117,7 +117,7 @@ def generate_relations_between_visits(vrp, mission) vrp.relations << Models::Relation.create( type: :minimum_day_lapse, linked_ids: ["#{mission.id}_1_#{mission.visits_number}", "#{mission.id}_#{index}_#{mission.visits_number}"], - lapse: current_lapse + lapses: [current_lapse] ) } (2..mission.visits_number).each{ |index| @@ -125,7 +125,7 @@ def generate_relations_between_visits(vrp, mission) vrp.relations << Models::Relation.create( type: :maximum_day_lapse, linked_ids: ["#{mission.id}_1_#{mission.visits_number}", "#{mission.id}_#{index}_#{mission.visits_number}"], - lapse: current_lapse + lapses: [current_lapse] ) } elsif mission.minimum_lapse @@ -134,7 +134,7 @@ def generate_relations_between_visits(vrp, mission) vrp.relations << Models::Relation.create( type: :minimum_day_lapse, linked_ids: ["#{mission.id}_#{index - 1}_#{mission.visits_number}", "#{mission.id}_#{index}_#{mission.visits_number}"], - lapse: current_lapse + lapses: [current_lapse] ) } elsif mission.maximum_lapse @@ -143,7 +143,7 @@ def generate_relations_between_visits(vrp, mission) vrp.relations << Models::Relation.create( type: :maximum_day_lapse, linked_ids: ["#{mission.id}_#{index - 1}_#{mission.visits_number}", "#{mission.id}_#{index}_#{mission.visits_number}"], - lapse: current_lapse + lapses: [current_lapse] ) } end @@ -230,7 +230,7 @@ def generate_vehicles(vrp) new_relation = Models::Relation.create( type: :vehicle_group_duration, linked_vehicle_ids: @equivalent_vehicles[vehicle.original_id], - lapse: vehicle.overall_duration + rests_durations[index] + lapses: [vehicle.overall_duration + rests_durations[index]] ) vrp.relations << new_relation end @@ -316,7 +316,7 @@ def generate_routes(vrp) last_computed_time: 0 } } - residual_time.push(r[:lapse]) + residual_time.push(r.lapses.first) idx += 1 } @@ -439,7 +439,7 @@ def cut_linking_vehicle_relation_by_period(relation, periods, relation_type) additional_relations << Models::Relation.create( linked_vehicle_ids: relation_vehicles, - lapse: relation.lapse, + lapses: relation.lapses, type: relation_type ) end @@ -466,7 +466,7 @@ def generate_relations_on_periodic_vehicles(vrp, vehicle_linking_relations) Models::Relation.create( type: :vehicle_group_duration, linked_vehicle_ids: relation[:linked_vehicle_ids].flat_map{ |v| @equivalent_vehicles[v] }, - lapse: relation.lapse + lapses: relation.lapses ) when :vehicle_group_duration_on_weeks schedule_week_indices = collect_weeks_in_schedule diff --git a/models/concerns/validate_data.rb b/models/concerns/validate_data.rb index 9060af3d7..c86186660 100644 --- a/models/concerns/validate_data.rb +++ b/models/concerns/validate_data.rb @@ -264,30 +264,77 @@ def check_trip_timewindows_consistency(relation_vehicles) } end + def check_consistent_ids_provided(relation) + case relation[:type] + when *Models::Relation::ON_VEHICLES_TYPES + if relation[:linked_ids].to_a.any? || relation[:linked_vehicle_ids].to_a.empty? + raise OptimizerWrapper::DiscordantProblemError.new( + "#{relation[:type]} relations do not support linked_ids and expect linked_vehicle_ids" + ) + end + when *Models::Relation::ON_SERVICES_TYPES + if relation[:linked_vehicle_ids].to_a.any? || relation[:linked_ids].to_a.empty? + raise OptimizerWrapper::DiscordantProblemError.new( + "#{relation[:type]} relations do not support linked_vehicle_ids and expect linked_ids" + ) + end + else + raise 'Unknown relation type' # in case we ever forget to handle a new relation type + end + end + + def check_consistent_lapses_provided(relation) + case relation[:type] + when *Models::Relation::NO_LAPSE_TYPES + if relation[:lapse] || relation[:lapses].to_a.any? + raise OptimizerWrapper::DiscordantProblemError.new( + "#{relation[:type]} relations do not expect any lapse" + ) + end + when *Models::Relation::ONE_LAPSE_TYPES + if relation[:lapse].nil? && relation[:lapses].to_a.size != 1 + raise OptimizerWrapper::DiscordantProblemError.new( + "#{relation[:type]} relations expect exactly one lapse" + ) + end + when *Models::Relation::SEVERAL_LAPSE_TYPES + relation[:lapses] = [0] if relation[:type] == :vehicle_trips && relation[:lapse].nil? && relation[:lapses].nil? + if relation[:lapse].nil? && relation[:lapses].to_a.size != 1 + exp_lapse_count = (relation[:linked_ids]&.size || relation[:linked_vehicle_ids]&.size) - 1 + if relation[:lapses].to_a.size != exp_lapse_count + raise OptimizerWrapper::DiscordantProblemError.new( + "#{relation[:type]} relations expect at least one lapse" + ) + end + end + else + raise 'Unknown relation type' # in case we ever forget to handle a new relation type + end + end + def check_relations(periodic_heuristic) return unless @hash[:relations].any? - @hash[:relations].group_by{ |relation| relation[:type] }.each{ |type, relations| - case type.to_sym + @hash[:relations].each{ |relation| + relation_services = + relation[:linked_ids].to_a.collect{ |s_id| @hash[:services].find{ |s| s[:id] == s_id } } + relation_vehicles = + relation[:linked_vehicle_ids].to_a.collect{ |v_id| @hash[:vehicles].find{ |v| v[:id] == v_id } } + + if relation_vehicles.any?(&:nil?) || relation_services.any?(&:nil?) + # FIXME: linked_vehicle_ids should be directly related to vehicle objects of the model + raise OptimizerWrapper::DiscordantProblemError.new( + 'At least one ID in relations does not match with any provided vehicle or service from the data' + ) + end + + check_consistent_ids_provided(relation) + check_consistent_lapses_provided(relation) + + case relation[:type].to_sym when :vehicle_trips - relations.each{ |relation| - relation_vehicles = - relation[:linked_vehicle_ids].to_a.collect{ |v_id| @hash[:vehicles].find{ |v| v[:id] == v_id } } - - if relation_vehicles.empty? - raise OptimizerWrapper::DiscordantProblemError.new( - 'A non empty list of vehicles IDs should be provided for vehicle_trips relations' - ) - elsif relation_vehicles.any?(&:nil?) - # FIXME: linked_vehicle_ids should be directly related to vehicle objects of the model - raise OptimizerWrapper::DiscordantProblemError.new( - 'At least one vehicle ID in relations does not match with any provided vehicle' - ) - end - - check_vehicle_trips_stores_consistency(relation_vehicles) - check_trip_timewindows_consistency(relation_vehicles) - } + check_vehicle_trips_stores_consistency(relation_vehicles) + check_trip_timewindows_consistency(relation_vehicles) end } diff --git a/models/relation.rb b/models/relation.rb index 465ffb7e0..093f7c9b3 100644 --- a/models/relation.rb +++ b/models/relation.rb @@ -19,8 +19,14 @@ module Models class Relation < Base + NO_LAPSE_TYPES = %i[same_vehicle same_route sequence order shipment meetup force_first never_first force_end].freeze + ONE_LAPSE_TYPES = %i[vehicle_group_number vehicle_group_duration vehicle_group_duration_on_weeks vehicle_group_duration_on_months].freeze + SEVERAL_LAPSE_TYPES = %i[minimum_day_lapse maximum_day_lapse minimum_duration_lapse maximum_duration_lapse vehicle_trips].freeze + ON_VEHICLES_TYPES = %i[vehicle_group_number vehicle_group_duration vehicle_group_duration_on_weeks vehicle_group_duration_on_months vehicle_trips].freeze + ON_SERVICES_TYPES = %i[same_vehicle same_route sequence order shipment meetup force_first never_first force_end minimum_day_lapse maximum_day_lapse minimum_duration_lapse maximum_duration_lapse].freeze + field :type, default: :same_route - field :lapse, default: nil + field :lapses, default: nil field :linked_ids, default: [] has_many :linked_services, class_name: 'Models::Service' field :linked_vehicle_ids, default: [] @@ -49,5 +55,30 @@ def self.create(hash) hash[:type] = hash[:type]&.to_sym if hash.key?(:type) super(hash) end + + def split_regarding_lapses + # TODO : can we create relations from here ? + # remove self.linked_ids + if Models::Relation::NO_LAPSE_TYPES.include?(self.type) + [[self.linked_ids, self.linked_vehicle_ids, nil]] + elsif Models::Relation::ONE_LAPSE_TYPES.include?(self.type) + [[self.linked_ids, self.linked_vehicle_ids, self.lapses.first]] + elsif self.type == :vehicle_trips && self.lapses.nil? + [[nil, self.linked_vehicle_ids, nil]] + elsif self.lapses.uniq.size == 1 + [[self.linked_ids, self.linked_vehicle_ids, self.lapses.first]] + else + [linked_ids, self.linked_vehicle_ids].collect.each_with_index{ |set, set_index| + set.collect.with_index{ |id, index| + next unless set[index + 1] + + portion = Array.new(3, []) + portion[set_index] = [id, set[index + 1]] + portion[2] = self.lapses[index] + portion + } + }.flatten(1).compact + end + end end end diff --git a/models/vrp.rb b/models/vrp.rb index 87cd524f1..8554c5990 100644 --- a/models/vrp.rb +++ b/models/vrp.rb @@ -174,7 +174,7 @@ def self.convert_shipments_to_services(hash) max_lapse = shipment[:maximum_inroute_duration] next unless max_lapse - hash[:relations] << { type: :maximum_duration_lapse, linked_ids: @linked_ids[shipment[:id]], lapse: max_lapse } + hash[:relations] << { type: :maximum_duration_lapse, linked_ids: @linked_ids[shipment[:id]], lapses: [max_lapse] } } convert_shipment_within_routes(hash) hash.delete(:shipments) @@ -186,7 +186,8 @@ def self.convert_relations_of_shipment_to_services(hash, shipment_id, pickup_ser when :minimum_duration_lapse, :maximum_duration_lapse relation[:linked_ids][0] = delivery_service_id if relation[:linked_ids][0] == shipment_id relation[:linked_ids][1] = pickup_service_id if relation[:linked_ids][1] == shipment_id - relation[:lapse] ||= 0 + relation[:lapses] ||= relation[:lapse] ? [relation[:lapse]] : [0] + relation.delete(:lapse) when :same_route, :same_vehicle relation[:linked_ids].each_with_index{ |id, id_i| next unless id == shipment_id @@ -328,6 +329,23 @@ def self.convert_geometry_polylines_to_geometry(hash) hash[:configuration][:restitution].delete(:geometry_polyline) end + def self.convert_relation_lapse_into_lapses(hash) + hash[:relations].to_a.each{ |relation| + if Models::Relation::ONE_LAPSE_TYPES.include?(relation[:type]) + if relation[:lapse] + relation[:lapses] = [relation[:lapse]] + relation.delete(:lapse) + end + elsif [:vehicle_trips, Models::Relation::SEVERAL_LAPSE_TYPES].flatten.include?(relation[:type]) + if relation[:lapse] + expected_size = relation[:linked_vehicle_ids].to_a.size + relation[:linked_ids].to_a.size - 1 + relation[:lapses] = Array.new(expected_size, relation[:lapse]) if expected_size > 0 + relation.delete(:lapse) + end + end + } + end + def self.ensure_retrocompatibility(hash) self.convert_position_relations(hash) self.deduce_first_solution_strategy(hash) @@ -335,6 +353,7 @@ def self.ensure_retrocompatibility(hash) self.deduce_solver_parameter(hash) self.convert_route_indice_into_index(hash) self.convert_geometry_polylines_to_geometry(hash) + self.convert_relation_lapse_into_lapses(hash) end def self.filter(hash) @@ -376,14 +395,6 @@ def self.remove_unnecessary_units(hash) def self.remove_unnecessary_relations(hash) return unless hash[:relations]&.any? - types_with_duration = - %i[minimum_day_lapse maximum_day_lapse - minimum_duration_lapse maximum_duration_lapse - vehicle_group_duration vehicle_group_duration_on_weeks - vehicle_group_duration_on_months vehicle_group_number] - - hash[:relations].delete_if{ |r| r[:lapse].nil? && types_with_duration.include?(r[:type]) } - # TODO : remove this filter, VRP with duplicated relations should not be accepted uniq_relations = [] hash[:relations].group_by{ |r| r[:type] }.each{ |_type, relations_set| diff --git a/test/lib/interpreters/interpreter_test.rb b/test/lib/interpreters/interpreter_test.rb index a791386cf..d7fdce1ce 100644 --- a/test/lib/interpreters/interpreter_test.rb +++ b/test/lib/interpreters/interpreter_test.rb @@ -1398,7 +1398,7 @@ def test_overall_duration_several_vehicles problem[:relations] = [{ type: :vehicle_group_duration_on_weeks, linked_vehicle_ids: ['vehicle_0', 'vehicle_1'], - lapse: 10, + lapses: [10], periodicity: 1 }] problem[:configuration][:schedule] = { @@ -1428,7 +1428,7 @@ def test_overall_duration_with_periodicity problem[:relations] = [{ type: :vehicle_group_duration_on_weeks, linked_vehicle_ids: ['vehicle_0', 'vehicle_1'], - lapse: 10, + lapses: [10], periodicity: 2 }] problem[:configuration][:schedule] = { @@ -1452,7 +1452,7 @@ def test_expand_relations_of_one_week_and_one_day problem[:relations] = [{ type: :vehicle_group_duration_on_weeks, linked_vehicle_ids: ['vehicle_0'], - lapse: 10 + lapses: [10] }] problem[:configuration][:schedule] = { range_indices: { start: 0, end: 7 } @@ -1476,7 +1476,7 @@ def test_expand_relations_of_one_month_and_one_day problem[:relations] = [{ type: :vehicle_group_duration_on_months, linked_vehicle_ids: ['vehicle_0'], - lapse: 10 + lapses: [10] }] problem[:configuration][:schedule] = { range_date: { start: Date.new(2020, 1, 1), end: Date.new(2020, 2, 1) } diff --git a/test/models/vrp_consistency_test.rb b/test/models/vrp_consistency_test.rb index bfd5bdc30..8d86f584c 100644 --- a/test/models/vrp_consistency_test.rb +++ b/test/models/vrp_consistency_test.rb @@ -40,20 +40,33 @@ def test_reject_if_service_with_activities_in_position_relation def test_reject_if_periodic_with_any_relation vrp = VRP.periodic - vrp[:vehicles].first[:end_point_id] = 'point_0' # vehicle_trips - %i[shipment meetup same_route sequence order vehicle_trips - minimum_day_lapse maximum_day_lapse minimum_duration_lapse maximum_duration_lapse - vehicle_group_duration vehicle_group_duration_on_weeks vehicle_group_duration_on_months].each{ |relation_type| - vrp[:relations] = [{ - type: relation_type, - lapse: 1, - linked_ids: ['service_1', 'service_2'], - linked_vehicle_ids: ['vehicle_0'] - }] - - assert_raises OptimizerWrapper::UnsupportedProblemError do - TestHelper.create(vrp) - end + vrp[:vehicles] << vrp[:vehicles].first.dup + vrp[:vehicles].last[:id] += '_dup' + vrp[:vehicles].first[:end_point_id] = 'point_0' # for vehicle_trips + + [Models::Relation::NO_LAPSE_TYPES, + Models::Relation::ONE_LAPSE_TYPES, + Models::Relation::SEVERAL_LAPSE_TYPES].each_with_index{ |types, index| + lapses = index.zero? ? [] : [1] + types.each{ |relation_type| + next if %i[force_end force_first never_first].include?(relation_type) # those are supported with periodic heuristic + + linked_ids = ['service_1', 'service_2'] if Models::Relation::ON_SERVICES_TYPES.include?(relation_type) + linked_vehicle_ids = ['vehicle_0', 'vehicle_0_dup'] if Models::Relation::ON_VEHICLES_TYPES.include?(relation_type) + + vrp[:relations] = [ + { + type: relation_type, + lapses: lapses, + linked_ids: linked_ids, + linked_vehicle_ids: linked_vehicle_ids + } + ] + + assert_raises OptimizerWrapper::UnsupportedProblemError do + TestHelper.create(vrp) + end + } } end @@ -556,5 +569,81 @@ def test_uniqueness_of_provided_services_or_vehicles_in_relation OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:demo] }}, TestHelper.create(problem.dup), nil) end end + + def test_relations_provided_lapse_consistency + problem = VRP.basic + service_ids = problem[:services].map{ |s| s[:id] } + vehicle_ids = problem[:vehicles].map{ |v| v[:id] } + + Models::Relation::NO_LAPSE_TYPES.each{ |type| + problem[:relations] = [{ type: type, lapse: 3 }] + Models::Relation::ON_SERVICES_TYPES.include?(type) ? + problem[:relations].first[:linked_ids] = service_ids : + problem[:relations].first[:linked_vehicle_ids] = vehicle_ids + assert_raises OptimizerWrapper::DiscordantProblemError do + check_consistency(problem) + end + + problem[:relations] = [{ type: type, lapses: [3, 4] }] + Models::Relation::ON_SERVICES_TYPES.include?(type) ? + problem[:relations].first[:linked_ids] = service_ids : + problem[:relations].first[:linked_vehicle_ids] = vehicle_ids + assert_raises OptimizerWrapper::DiscordantProblemError do + check_consistency(problem) + end + } + + Models::Relation::ONE_LAPSE_TYPES.each{ |type| + problem[:relations] = [{ type: type, lapse: 3 }] + Models::Relation::ON_SERVICES_TYPES.include?(type) ? + problem[:relations].first[:linked_ids] = service_ids : + problem[:relations].first[:linked_vehicle_ids] = vehicle_ids + check_consistency(problem) + + problem[:relations] = [{ type: type, lapses: [3, 4] }] + Models::Relation::ON_SERVICES_TYPES.include?(type) ? + problem[:relations].first[:linked_ids] = service_ids : + problem[:relations].first[:linked_vehicle_ids] = vehicle_ids + assert_raises OptimizerWrapper::DiscordantProblemError do + check_consistency(problem) + end + } + end + + def test_relations_number_of_lapses_consistency_when_authorized_lapses + problem = VRP.lat_lon_two_vehicles + + [:vehicle_trips, Models::Relation::SEVERAL_LAPSE_TYPES].flatten.each{ |type| + problem[:relations] = [{ type: type, lapse: 3 }] + if Models::Relation::ON_SERVICES_TYPES.include?(type) + problem[:relations].first[:linked_ids] = problem[:services].map{ |s| s[:id] } + else + problem[:relations].first[:linked_vehicle_ids] = [problem[:vehicles].first[:id]] + end + check_consistency(problem) + + case type + when :vehicle_trips + problem[:relations] = [TestHelper.vehicle_trips_relation(problem)] + problem[:relations].first[:lapses] = [2] + check_consistency(problem) + + problem[:relations].first[:lapses] = [2, 4] + else + problem[:relations] = [{ type: type, linked_ids: problem[:services][0..2].collect{ |s| s[:id] } }] + problem[:relations].first[:lapses] = [2] + check_consistency(problem) + + problem[:relations].first[:lapses] = [2, 2] + check_consistency(problem) + + problem[:relations].first[:lapses] = [2, 3, 2] + end + + assert_raises OptimizerWrapper::DiscordantProblemError do + check_consistency(problem) + end + } + end end end diff --git a/test/models/vrp_test.rb b/test/models/vrp_test.rb index 8264af69e..2376b0e2e 100644 --- a/test/models/vrp_test.rb +++ b/test/models/vrp_test.rb @@ -169,8 +169,7 @@ def test_deduce_consistent_relations vrp[:relations] = [{ type: :same_route, - linked_ids: ['service', 'shipment_0', 'shipment_1'], - lapse: 3 + linked_ids: ['service', 'shipment_0', 'shipment_1'] }] generated_vrp = TestHelper.create(vrp) assert_includes generated_vrp.relations.first.linked_ids, 'service' @@ -291,14 +290,6 @@ def test_available_interval def test_no_lapse_in_relation vrp = VRP.basic - vrp[:relations] = [{ - type: :vehicle_group_duration_on_months, - linked_vehicle_ids: ['vehicle_0'] - }] - - Models::Vrp.filter(vrp) - assert_empty vrp[:relations] # reject relation because lapse is mandatory - vrp[:relations] = [{ type: :vehicle_group_duration_on_months, linked_vehicle_ids: ['vehicle_0'], @@ -355,5 +346,24 @@ def test_same_vehicle_relation_converted_into_same_route_if_no_vehicle_partition assert(created_vrp.relations.any?{ |relation| relation.type == :same_vehicle }) assert(created_vrp.relations.none?{ |relation| relation.type == :same_route }) end + + def test_lapse_converted_into_lapses + problem = VRP.lat_lon_two_vehicles + problem[:relations] = [TestHelper.vehicle_trips_relation(problem)] + problem[:relations].first[:lapse] = 2 + + created_vrp = Models::Vrp.create(problem) + assert_nil problem[:relations].first[:lapse] + assert_equal [2], created_vrp.relations.first.lapses + + problem[:vehicles] << problem[:vehicles].first.dup + problem[:vehicles].last[:id] += '_dup' + problem[:relations] = [TestHelper.vehicle_trips_relation(problem)] + problem[:relations].first[:lapse] = 2 + + created_vrp = Models::Vrp.create(problem) + assert_nil problem[:relations].first[:lapse] + assert_equal [2, 2], created_vrp.relations.first.lapses + end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index f83295c20..8d2b81be4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -129,7 +129,25 @@ def self.coerce(vrp) vrp[:configuration][:preprocessing][:partitions]&.each{ |partition| partition[:entity] = partition[:entity].to_sym } if vrp[:configuration] && vrp[:configuration][:preprocessing] vrp.preprocessing_partitions&.each{ |partition| partition[:entity] = partition[:entity].to_sym } if vrp.is_a?(Models::Vrp) - vrp[:relations]&.each{ |r| r[:type] = r[:type]&.to_sym } + vrp[:relations]&.each{ |r| + r[:type] = r[:type]&.to_sym + next if [Models::Relation::NO_LAPSE_TYPES, + Models::Relation::ONE_LAPSE_TYPES, + Models::Relation::SEVERAL_LAPSE_TYPES].any?{ |set| + set.include?(r[:type]) + } + + raise 'This relation does not exit in any of NO_LAPSE_RELATIONS ONE_LAPSE_RELATIONS SEVERAL_LAPSE_RELATIONS, there is a risk of incorrect management' + } + + vrp[:relations]&.each{ |r| + r[:type] = r[:type]&.to_sym + next if [Models::Relation::ON_VEHICLES_TYPES, Models::Relation::ON_SERVICES_TYPES].any?{ |set| + set.include?(r[:type]) + } + + raise 'This relation does not exit in any of ON_VEHICLES_TYPES ON_SERVICES_TYPES there is a risk of incorrect management' + } vrp[:vehicles]&.each{ |v| next if v[:skills].to_a.empty? diff --git a/test/wrapper_test.rb b/test/wrapper_test.rb index 2f103e991..35927e2d3 100644 --- a/test/wrapper_test.rb +++ b/test/wrapper_test.rb @@ -2990,14 +2990,16 @@ def test_assert_inapplicable_relations problem = VRP.basic problem[:relations] = [{ type: :vehicle_group_duration, - linked_ids: [], - linked_vehicle_ids: [], - lapse: 1 + linked_vehicle_ids: ['vehicle_0'] }, { type: :shipment, linked_ids: ['service_1', 'service_2'] }] + assert_raises OptimizerWrapper::DiscordantProblemError do + TestHelper.create(problem) + end + problem[:relations] = [problem[:relations][1]] vrp = TestHelper.create(problem) refute_includes OptimizerWrapper.config[:services][:vroom].inapplicable_solve?(vrp), :assert_no_relations_except_simple_shipments @@ -3006,9 +3008,8 @@ def test_assert_inapplicable_relations problem[:relations] = [{ type: :vehicle_group_duration, - linked_ids: [], linked_vehicle_ids: ['vehicle_0'], - lapse: 1 + lapses: [1] }] vrp = TestHelper.create(problem) diff --git a/test/wrappers/ortools_multi_trips_test.rb b/test/wrappers/ortools_multi_trips_test.rb index 646a33d18..b957097f3 100644 --- a/test/wrappers/ortools_multi_trips_test.rb +++ b/test/wrappers/ortools_multi_trips_test.rb @@ -110,7 +110,7 @@ def test_lapse_between_trips vrp[:vehicles].each{ |vehicle| vehicle[:distance] = 100000 } vrp[:relations] = [TestHelper.vehicle_trips_relation(vrp)] - vrp[:relations].first[:lapse] = 3600 + vrp[:relations].first[:lapses] = [3600] result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(vrp), nil) assert(result[:routes].all?{ |route| route[:activities].size > 2 }) first_route_end = result[:routes][0][:activities].last[:begin_time] diff --git a/test/wrappers/ortools_test.rb b/test/wrappers/ortools_test.rb index 81b43625e..ecb64cf28 100644 --- a/test/wrappers/ortools_test.rb +++ b/test/wrappers/ortools_test.rb @@ -150,13 +150,13 @@ def test_group_number problem[:relations] = [{ type: :vehicle_group_number, linked_vehicle_ids: ['vehicle_0', 'vehicle_1', 'vehicle_2'], - lapse: 2 + lapses: [2] }] result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_equal 2, (result[:routes].count{ |r| r[:activities].any?{ |a| a[:type] == 'service' } }) # extreme case : lapse is 0 - problem[:relations].first[:lapse] = 0 + problem[:relations].first[:lapses] = [0] result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_empty(result[:routes].select{ |r| r[:activities].any?{ |a| a[:type] == 'service' } }) end @@ -2581,7 +2581,7 @@ def test_minimum_day_lapse problem[:vehicles].each{ |v| v.delete(:start_point_id) } problem[:relations] = [{ type: :minimum_day_lapse, - lapse: 0, + lapses: [0], linked_ids: ['service_1', 'service_2', 'service_3'] }] problem[:configuration][:schedule] = { range_indices: { start: 0, end: 4 }} @@ -2592,7 +2592,7 @@ def test_minimum_day_lapse assert_equal [0, 1, 2], result[:routes].collect{ |r| r[:activities].any? ? r[:day] : nil }.compact # standard case - problem[:relations].first[:lapse] = 2 + problem[:relations].first[:lapses] = [2] result = OptimizerWrapper.wrapper_vrp('ortools', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_equal 5, result[:routes].size # There should be a lapse of 2 between each visits : @@ -2604,7 +2604,7 @@ def test_maximum_day_lapse problem = VRP.basic relation = [{ type: :maximum_day_lapse, - lapse: 0, + lapses: [0], linked_ids: ['service_1', 'service_3'] }] problem[:relations] = relation @@ -2631,7 +2631,7 @@ def test_maximum_day_lapse (result[:routes].collect{ |r| r[:activities].collect{ |a| a[:service_id] } }) problem[:relations] = relation - problem[:relations].first[:lapse] = 1 + problem[:relations].first[:lapses] = [1] result = OptimizerWrapper.wrapper_vrp('ortools', { services: { vrp: [:ortools] }}, TestHelper.create(problem), nil) assert_equal [['service_1_1_1'], ['service_3_1_1'], ['service_2_1_1'], [], []], (result[:routes].collect{ |r| r[:activities].collect{ |a| a[:service_id] } }) @@ -2972,11 +2972,11 @@ def test_maximum_duration_lapse_shipments problem[:shipments][1][:delivery][:timewindows] = [{ start: 100, end: 200 }] problem[:relations] = [{ type: :maximum_duration_lapse, - lapse: 100, + lapses: [100], linked_ids: ['shipment_0_pickup', 'shipment_0_delivery'] }, { type: :maximum_duration_lapse, - lapse: 100, + lapses: [100], linked_ids: ['shipment_1_pickup', 'shipment_1_delivery'] }] result = ortools.solve(TestHelper.create(problem), 'test') @@ -2988,7 +2988,9 @@ def test_maximum_duration_lapse_shipments result[:routes][0][:activities][delivery_index][:begin_time] assert_equal 2, result[:unassigned].size - problem[:relations].each{ |r| r[:lapse] = 0 } + problem[:relations].each{ |r| + r[:lapses] = [0] if r[:lapses] + } result = ortools.solve(TestHelper.create(problem), 'test') # pickup and delivery at not at same location so it is impossible to assign with lapse 0 # we could use direct shipment instead @@ -4957,12 +4959,12 @@ def test_minimum_duration_lapse vrp[:relations] = [{ type: :minimum_duration_lapse, linked_ids: ['service_1', 'service_2'], - lapse: 0 + lapses: [0] }] result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(vrp), nil) assert_equal previous_result, (result[:routes].collect{ |r| r[:activities].collect{ |a| a[:service_id] } }) - vrp[:relations].first[:lapse] = 10 + vrp[:relations].first[:lapses] = [10] result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, TestHelper.create(vrp), nil) route = result[:routes].first[:activities] first_index = route.find_index{ |stop| stop[:service_id] == 'service_1' } @@ -4984,7 +4986,7 @@ def test_minimum_duration_lapse_shipments vrp.relations << Models::Relation.create( type: :minimum_duration_lapse, linked_ids: [delivery1.id, pickup0.id], - lapse: 1800 + lapses: [1800] ) result = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, vrp, nil) shipment1_route = result[:routes].find{ |r| @@ -5304,4 +5306,41 @@ def test_respect_timewindows_without_end assert_equal 20, result[:routes][0][:activities].last[:begin_time], 'Third service should be planned at 20' end + + def test_relations_sent_to_ortools_when_different_lapses + problem = VRP.lat_lon_two_vehicles + problem[:vehicles] << problem[:vehicles].last.dup + problem[:vehicles].last[:id] += '_dup' + relations = [ + { type: :sequence, linked_ids: problem[:services].slice(0..1).map{ |s| s[:id] } }, + { type: :vehicle_group_duration, linked_vehicle_ids: problem[:vehicles].map{ |s| s[:id] }, lapse: 2 }, + { type: :minimum_duration_lapse, linked_ids: problem[:services].slice(0..2).map{ |s| s[:id] }, lapse: 2 }, + { type: :minimum_duration_lapse, linked_ids: problem[:services].slice(0..2).map{ |s| s[:id] }, lapses: [2, 2] }, + { type: :minimum_duration_lapse, linked_ids: problem[:services].slice(0..2).map{ |s| s[:id] }, lapses: [2, 3] }, + { type: :vehicle_trips, linked_vehicle_ids: problem[:vehicles].slice(0..2).map{ |s| s[:id] }, lapses: [2, 3] }, + # same but we will provide schedule (index 6 :) + { type: :vehicle_trips, linked_vehicle_ids: problem[:vehicles].slice(0..2).map{ |s| s[:id] }, lapses: [2, 3] } + ] + expected_number_of_relations = [1, 1, 1, 1, 2, 2, 8] + + relations.each_with_index{ |relation, pb_index| + problem[:relations] = [relation] + if [1, 6].include?(pb_index) + problem[:configuration][:schedule] = { range_indices: { start: 0, end: 3 }} + else + problem[:configuration].delete(:schedule) + end + OptimizerWrapper.config[:services][:ortools].stub( + :run_ortools, + lambda { |ortools_problem, _, _| + # check number of relations sent to ortools + assert_equal expected_number_of_relations[pb_index], ortools_problem.relations.size + + 'Job killed' # Return "Job killed" to stop gracefully + } + ) do + OptimizerWrapper.solve(service: :ortools, vrp: TestHelper.create(problem.dup)) + end + } + end end diff --git a/wrappers/ortools.rb b/wrappers/ortools.rb index aa64b6a0f..80b9b52fb 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -318,22 +318,23 @@ def solve(vrp, job, thread_proc = nil, &block) ) } - relations += vrp.relations.collect{ |relation| - current_linked_ids = relation.linked_ids.select{ |mission_id| - services.one?{ |service| service.id == mission_id } - }.uniq - current_linked_vehicles = relation.linked_vehicle_ids.select{ |vehicle_id| - vrp.vehicles.one? { |vehicle| vehicle.id == vehicle_id } - }.uniq - next if current_linked_ids.empty? && current_linked_vehicles.empty? - - OrtoolsVrp::Relation.new( - type: relation.type, - linked_ids: current_linked_ids.map(&:to_s), - linked_vehicle_ids: current_linked_vehicles.map(&:to_s), - lapse: relation.lapse - ) - }.compact + vrp.relations.each{ |relation| + relation.split_regarding_lapses.flat_map{ |relation_portion| + portion_linked_ids, portion_vehicle_ids, portion_lapse = relation_portion + + current_linked_ids = (portion_linked_ids & services.map(&:id)).uniq if portion_linked_ids + current_linked_vehicles = (portion_vehicle_ids & vehicles.map(&:id)).uniq if portion_vehicle_ids + next if current_linked_ids.to_a.empty? && current_linked_vehicles.to_a.empty? + + # NOTE: we collect lapse because optimizer-ortools expects one lapse per relation for now + relations << OrtoolsVrp::Relation.new( + type: relation.type, + linked_ids: current_linked_ids&.map(&:to_s), + linked_vehicle_ids: current_linked_vehicles&.map(&:to_s), + lapse: portion_lapse + ) + } + } routes = vrp.routes.collect{ |route| next if route.vehicle.nil? || route.mission_ids.empty?