Skip to content

Commit

Permalink
Merge pull request #283 from senhalil/feat/fix_maxsplit_with_multitour
Browse files Browse the repository at this point in the history
Split solve respects vehicle relations inc. trips
  • Loading branch information
fab-girard committed Sep 29, 2021
2 parents 08f8349 + b82ddc6 commit 8096f3c
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 5 deletions.
74 changes: 69 additions & 5 deletions lib/interpreters/split_clustering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ def self.split_solve_core(service_vrp, job = nil, &block)
unless sides.size == 2 && sides.none?(&:empty?)
# this might happen under certain cases (skills etc can force all points to be on one side)
# and not necessarily a problem but it should happen very rarely (in real instances)
# it might also happen because the vehicles are in a linking relation (like vehicle_trips)
# which forces them to be stay on one side.
log 'There should be exactly two clusters in split_solve_core!', level: :warn
ss_data[:cannot_split_further] = true
end
Expand Down Expand Up @@ -298,7 +300,8 @@ def self.create_sub_vrp(split_solve_data)
sub_vrp = ::Models::Vrp.create({}, false)

# Select the vehicles and services belonging to this sub-problem from the service_vehicle_assignments
sub_vrp.vehicles = ss_data[:current_vehicles] + ss_data[:transferred_vehicles]
# transfer a vehicle with a forcing linked relation only if all of its linked vehicles can be transferred as well
sub_vrp.vehicles = ss_data[:current_vehicles] + transfer_unused_vehicles(ss_data)
sub_vrp.services = ss_data[:current_vehicles].flat_map{ |v| ss_data[:service_vehicle_assignments][v.id] }
sub_vrp.services.concat ss_data[:transferred_empties_or_fills]

Expand Down Expand Up @@ -386,7 +389,13 @@ def self.create_representative_vrp(split_solve_data)
# go through the original relations and force the services and vehicles to stay in the same sub-vrp if necessary
split_solve_data[:original_vrp].relations.select{ |r| FORCING_RELATIONS.include?(r.type) }.each{ |relation|
if relation.linked_vehicle_ids.any? && relation.linked_services.none?
relations << { type: :same_route, linked_ids: relation.linked_vehicle_ids }
linked_ids = []
relation.linked_vehicle_ids.map{ |v_id|
s_id = "0_representative_vrp_s_#{v_id}"
linked_ids << s_id if services.any?{ |s| s[:id] == s_id }
}

relations << { type: :same_route, linked_ids: linked_ids } if linked_ids.size > 1
elsif relation.linked_vehicle_ids.none? && relation.linked_services.any?
linked_ids = []
relation.linked_services.each{ |linked_service|
Expand Down Expand Up @@ -441,6 +450,33 @@ def self.create_representative_sub_vrp(split_solve_data)
r_sub_vrp
end

def self.transfer_unused_vehicles(split_solve_data)
# transfers an unused vehicle to the current sub-problem if and only if
# all forcing relations can be respected (vehicle_trips, meetup etc.)
ss_data = split_solve_data
o_vrp = ss_data[:original_vrp]

unused_vehicle_ids = ss_data[:transferred_vehicles].map(&:id)
current_and_unused_vehicle_ids = ss_data[:current_vehicles].map(&:id) + unused_vehicle_ids

related_forcing_relations = o_vrp.relations.select{ |r|
FORCING_RELATIONS.include?(r.type) && (r.linked_vehicle_ids.to_a & unused_vehicle_ids).any?
}

# FIXME: this operation can be done faster by exploiting the fact that some unused vehicles might share a relation
selected_vehicles_to_use = []
ss_data[:transferred_vehicles].each{ |transferred_vehicle|
can_have_all_its_linked_vehicles_if_transferred = related_forcing_relations.all?{ |relation|
relation.linked_vehicle_ids.exclude?(transferred_vehicle.id) ||
(relation.linked_vehicle_ids - current_and_unused_vehicle_ids).empty?
}

selected_vehicles_to_use << transferred_vehicle if can_have_all_its_linked_vehicles_if_transferred
}

selected_vehicles_to_use
end

def self.select_existing_relations(relations, vrp)
relations.select{ |relation|
next if relation.linked_vehicle_ids.empty? && relation.linked_ids.empty?
Expand Down Expand Up @@ -468,12 +504,28 @@ def self.transfer_unused_resources(split_solve_data, vrp, result)
(vrp.resolution_vehicle_limit.nil? || result[:routes].size == vrp.resolution_vehicle_limit)
remove_poorly_populated_routes(vrp, result, 0.1)
end

used_vehicle_ids = result[:routes].map{ |r| r[:vehicle_id] }
forcing_vehicle_relations = split_solve_data[:original_vrp].relations.select{ |r|
FORCING_RELATIONS.include?(r.type) && r.linked_vehicle_ids&.any?
}

split_solve_data[:transferred_vehicles].delete_if{ |vehicle|
result[:routes].any?{ |r| r[:vehicle_id] == vehicle.id } # used
# mark used (delete from transferred_vehicles) if used or there is a used linked vehicle from a forcing relation
next true if used_vehicle_ids.include?(vehicle.id)

related_relations = forcing_vehicle_relations.select{ |r| r.linked_vehicle_ids.include?(vehicle.id) }
(used_vehicle_ids & related_relations.flat_map(&:linked_vehicle_ids)).any?
}

split_solve_data[:transferred_vehicles].concat(split_solve_data[:current_vehicles].select{ |vehicle|
result[:routes].none?{ |r| r[:vehicle_id] == vehicle.id } # not used
# mark unused (add to transferred_vehicles) if not used and there is no used linked vehicle from a forcing relation
next false if used_vehicle_ids.include?(vehicle.id)

related_relations = forcing_vehicle_relations.select{ |r| r.linked_vehicle_ids.include?(vehicle.id) }
(used_vehicle_ids & related_relations.flat_map(&:linked_vehicle_ids)).empty?
})

split_solve_data[:transferred_vehicles].each{ |v|
v.matrix_id = nil unless split_solve_data[:vehicle_has_complete_matrix][v.id]
}
Expand All @@ -498,9 +550,13 @@ def self.remove_empty_routes(result)
end

def self.remove_poorly_populated_routes(vrp, result, limit)
forcing_relation_vehicle_ids = vrp.relations.flat_map{ |relation|
FORCING_RELATIONS.include?(relation.type) ? relation.linked_vehicle_ids.to_a : []
}.uniq

emptied_routes = false
result[:routes].delete_if{ |route|
vehicle = vrp.vehicles.find{ |current_vehicle| current_vehicle.id == route[:vehicle_id] }
vehicle = vrp.vehicles.find{ |v| v.id == route[:vehicle_id] }
loads = route[:activities].last[:detail][:quantities]
load_flag = vehicle.capacities.empty? || vehicle.capacities.all?{ |capacity|
current_load = loads.find{ |unit_load| unit_load[:unit] == capacity.unit.id }
Expand All @@ -511,6 +567,14 @@ def self.remove_poorly_populated_routes(vrp, result, limit)

log "route #{route[:vehicle_id]} time: #{route_duration}/#{vehicle_worktime} percent: #{((route_duration / (vehicle_worktime || route_duration).to_f) * 100).to_i}%", level: :info

# Do not remove a poorly populated routes if it is in a forcing relation
# TODO: Ideally, we wouldn't remove a poorly populated vehicle only if, any of its linked vehicles is
# "non-removable" (i.e., either it is well-used or it has a well-used link)
# Or we would calculate the "overall" stats for all linked vehicles and remove/leave them together
# NOTE: This might need a recursive logic because different vehicles might be connected via different
# FORCING_RELATIONS.
next if forcing_relation_vehicle_ids.include?(route[:vehicle_id])

time_flag = vehicle_worktime && route_duration < limit * vehicle_worktime

if load_flag && time_flag
Expand Down
41 changes: 41 additions & 0 deletions test/wrappers/ortools_multi_trips_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,45 @@ def test_lapse_between_trips
last_route_start = result[:routes][1][:activities].first[:begin_time]
assert_operator first_route_end + 3600, :<=, last_route_start
end

def test_multi_trips_with_max_split
vrp = VRP.lat_lon_capacitated

# 2 vehicles with 4 trips each (to make sure there will be unused but un-transferable vehicles)
vrp[:relations] = []
2.times{ |i|
vrp[:vehicles] << vrp[:vehicles].first.dup.merge({ id: 'other_vehicle' }) if i == 1

linked_vehicle_ids = [vrp[:vehicles].last[:id]]
1.upto(3).each{ |trip|
vrp[:vehicles] << vrp[:vehicles][-trip].dup.merge({ id: "#{vrp[:vehicles][-trip][:id]}_#{trip+1}_trip" })
linked_vehicle_ids << vrp[:vehicles].last[:id]
}
vrp[:relations] << { type: :vehicle_trips, linked_vehicle_ids: linked_vehicle_ids }
}
# make sure split uses all vehicles
vrp[:services].first[:sticky_vehicle_ids] = [vrp[:vehicles].first[:id]]
vrp[:services].last[:sticky_vehicle_ids] = [vrp[:vehicles].last[:id]]
# activate max_split
vrp[:configuration][:preprocessing] ||= {}
vrp[:configuration][:preprocessing][:max_split_size] = 1
vrp[:configuration][:preprocessing][:first_solution_strategy] = 'global_cheapest_arc'

vrp = TestHelper.create(vrp)

OptimizerWrapper.stub(:solve, lambda{ |service_vrp, _job, _block| # stub with empty solution
sub_vrp_vehicle_ids = service_vrp[:vrp].vehicles.map(&:id)

# check vehicle trips are not split
vrp.relations.each{ |relation|
assert (relation.linked_vehicle_ids - sub_vrp_vehicle_ids).empty? ||
(relation.linked_vehicle_ids & sub_vrp_vehicle_ids).empty?,
'All trips of a vehicle should be in the same subproblem'
}

OptimizerWrapper.send(:__minitest_stub__solve, service_vrp) # call original solve method
}) do
OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:ortools] }}, vrp, nil)
end
end
end

0 comments on commit 8096f3c

Please sign in to comment.