diff --git a/CHANGELOG.md b/CHANGELOG.md index 5adfe988d..a2ea25859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Fixed +- Capacity violation from periodic heuristic [#227](https://github.com/Mapotempo/optimizer-api/pull/227) + ## [v1.7.1] - 2021-05-20 ### Added diff --git a/lib/heuristics/concerns/scheduling_data_initialisation.rb b/lib/heuristics/concerns/scheduling_data_initialisation.rb index fd6f39369..36b0dc4d5 100644 --- a/lib/heuristics/concerns/scheduling_data_initialisation.rb +++ b/lib/heuristics/concerns/scheduling_data_initialisation.rb @@ -58,7 +58,7 @@ def initialize_routes(routes) defined_route.mission_ids.each{ |id| next if !@services_data.has_key?(id) # id has been removed when detecting unfeasible services in wrapper - best_index = find_best_index(id, associated_route) if associated_route + best_index = find_best_index(id, associated_route, false) if associated_route considered_ids << id if best_index insert_point_in_route(associated_route, best_index, false) diff --git a/lib/heuristics/concerns/scheduling_end_phase.rb b/lib/heuristics/concerns/scheduling_end_phase.rb index 8afc17afa..2a03440ed 100644 --- a/lib/heuristics/concerns/scheduling_end_phase.rb +++ b/lib/heuristics/concerns/scheduling_end_phase.rb @@ -36,14 +36,14 @@ def refine_solution(&block) end end - def days_respecting_lapse(id, vehicle_id) + def days_respecting_lapse(id, vehicle_routes) min_lapse = @services_data[id][:raw].minimum_lapse max_lapse = @services_data[id][:raw].maximum_lapse used_days = @services_data[id][:used_days] - return @candidate_routes[vehicle_id].keys if used_days.empty? + return vehicle_routes.keys if used_days.empty? - @candidate_routes[vehicle_id].keys.select{ |day| + vehicle_routes.keys.select{ |day| smaller_lapse_with_other_days = used_days.collect{ |used_day| (used_day - day).abs }.min (min_lapse.nil? || smaller_lapse_with_other_days >= min_lapse) && (max_lapse.nil? || smaller_lapse_with_other_days <= max_lapse) @@ -100,7 +100,7 @@ def add_missing_visits until costs.empty? # select best visit to insert max_priority = costs.keys.collect{ |id| @services_data[id][:raw].priority + 1 }.max - best_cost = costs.min_by{ |id, info| ((@services_data[id][:raw].priority.to_f + 1) / max_priority) * (info[:cost][:additional_route_time] / @services_data[id][:raw].visits_number**2) } + best_cost = costs.min_by{ |id, info| ((@services_data[id][:raw].priority.to_f + 1) / max_priority) * (info[:additional_route_time] / @services_data[id][:raw].visits_number**2) } id = best_cost[0] day = best_cost[1][:day] @@ -108,7 +108,7 @@ def add_missing_visits log "It is interesting to add #{id} at day #{day} on #{vehicle_id}", level: :debug @ids_to_renumber |= [id] - insert_point_in_route(@candidate_routes[vehicle_id][day], best_cost[1][:cost]) + insert_point_in_route(@candidate_routes[vehicle_id][day], best_cost[1]) @output_tool&.add_single_visit(day, @services_data[id][:used_days], id, @services_data[id][:raw].visits_number) costs = update_costs(costs, best_cost) @@ -120,15 +120,14 @@ def add_missing_visits def update_costs(costs, best_cost) # update costs for inserted id, available_days changed uninserted_set = @uninserted.select{ |_key, info| info[:original_id] == best_cost[0] }.keys - @uninserted.delete(uninserted_set.first) + @uninserted.delete(uninserted_set.first) # faire en fct nb in seq if uninserted_set.size > 1 - available_days = days_respecting_lapse(best_cost[0], best_cost[1][:vehicle]) + available_days = days_respecting_lapse(best_cost[0], @candidate_routes[best_cost[1][:vehicle]]) - day, cost = find_best_day_cost(available_days, @candidate_routes[best_cost[1][:vehicle]], best_cost[0]) + cost = find_best_day_cost(@candidate_routes[best_cost[1][:vehicle]], best_cost[0], available_days) if cost - costs[best_cost[0]][:day] = day - costs[best_cost[0]][:cost] = cost + costs[best_cost[0]] = cost else costs.delete(best_cost[0]) end @@ -140,14 +139,10 @@ def update_costs(costs, best_cost) costs.each{ |id, info| next if info[:day] != best_cost[1][:day] && info[:vehicle] != best_cost[1][:vehicle] - day, cost = find_best_cost(id, info[:vehicle]) + cost = find_best_day_cost(@candidate_routes[info[:vehicle]], id) if cost - costs[id] = { - day: day, - vehicle: info[:vehicle], - cost: cost - } + costs[id] = cost else costs.delete(id) end @@ -156,32 +151,19 @@ def update_costs(costs, best_cost) costs end - def find_best_day_cost(available_days, vehicle_routes, id) - return [nil, nil] if available_days.empty? + def find_best_day_cost(vehicle_routes, id, available_days = nil) + available_days ||= days_respecting_lapse(id, vehicle_routes) - day = available_days[0] - if @same_point_day && @services_data[id][:group_capacity].all?{ |need, quantity| quantity <= vehicle_routes[day][:capacity_left][need] } || - !@same_point_day && @services_data[id][:capacity].all?{ |need, quantity| quantity <= vehicle_routes[day][:capacity_left][need] } - cost = find_best_index(id, vehicle_routes[day]) - end + return [nil, nil] unless available_days.any? - index = 1 + index = 0 + cost = nil while cost.nil? && index < available_days.size - day = available_days[index] - - if @same_point_day && @services_data[id][:group_capacity].all?{ |need, quantity| quantity <= vehicle_routes[day][:capacity_left][need] } || - !@same_point_day && @services_data[id][:capacity].all?{ |need, quantity| quantity <= vehicle_routes[day][:capacity_left][need] } - cost = find_best_index(id, vehicle_routes[day]) - end + cost = find_best_index(id, vehicle_routes[available_days[index]], false) index += 1 end - [day, cost] - end - - def find_best_cost(id, vehicle_id) - available_days = days_respecting_lapse(id, vehicle_id) - find_best_day_cost(available_days, @candidate_routes[vehicle_id], id) + cost end def compute_first_costs @@ -189,15 +171,8 @@ def compute_first_costs @missing_visits.collect{ |vehicle, list| list.collect{ |service_id| - day, cost = find_best_cost(service_id, vehicle) - - next if cost.nil? - - costs[service_id] = { - day: day, - vehicle: vehicle, - cost: cost - } + cost = find_best_day_cost(@candidate_routes[vehicle], service_id) + costs[service_id] = cost if cost } } @@ -309,7 +284,7 @@ def reaffect_in_non_empty_route(still_removed) referent_route ||= route_data insertion_costs = compute_costs_for_route(route_data, remaining_ids) insertion_costs.each{ |cost| - cost[:vehicle] = vehicle + cost[:vehicle] = vehicle_id cost[:day] = day } insertion_costs @@ -333,7 +308,7 @@ def reaffect_in_non_empty_route(still_removed) insert_point_in_route(@candidate_routes[point_to_add[:vehicle]][point_to_add[:day]], point_to_add, false) @output_tool&.add_single_visit(point_to_add[:day], @services_data[point_to_add[:id]][:used_days], point_to_add[:id], @services_data[point_to_add[:id]][:raw].visits_number) still_removed.delete(still_removed.find{ |removed| removed.first == point_to_add[:id] }) - @uninserted.delete(@uninserted.find{ |_id, data| data[:original_id] == to_plan[:service] }[0]) + @uninserted.delete(@uninserted.find{ |_id, data| data[:original_id] == point_to_add[:id] }[0]) end end diff --git a/lib/heuristics/scheduling_heuristic.rb b/lib/heuristics/scheduling_heuristic.rb index 8aadcd1ea..94833ed5c 100644 --- a/lib/heuristics/scheduling_heuristic.rb +++ b/lib/heuristics/scheduling_heuristic.rb @@ -513,45 +513,23 @@ def compute_route_from(new_route, solver_route) end def compute_costs_for_route(route_data, set = nil) - vehicle_id = route_data[:vehicle_original_id] - day = route_data[:day] - - ### compute the cost, for each remaining service to assign, of assigning it to [route_data] ### - insertion_costs = [] - set ||= @same_point_day ? @to_plan_service_ids.reject{ |id| @services_data[id][:raw].visits_number == 1 } : @to_plan_service_ids - # we will assign services with one vehicle in relaxed_same_point_day part - set.select{ |service_id| - # quantities are respected - ((@same_point_day && @services_data[service_id][:group_capacity].all?{ |need, quantity| quantity <= route_data[:capacity_left][need] }) || - (!@same_point_day && @services_data[service_id][:capacity].all?{ |need, quantity| quantity <= route_data[:capacity_left][need] })) && - (@services_data[service_id][:sticky_vehicles_ids].empty? || @services_data[service_id][:sticky_vehicles_ids].include?(vehicle_id)) - }.each{ |service_id| - next if @services_data[service_id][:used_days] && !days_respecting_lapse(service_id, vehicle_id).include?(day) - - point = @services_data[service_id][:points_ids].first if @same_point_day || @relaxed_same_point_day # there can be only on point in points_ids - next if @relaxed_same_point_day && - !@points_vehicles_and_days[point][:vehicles].empty? && - (!@points_vehicles_and_days[point][:vehicles].include?(vehicle_id) || !(@points_vehicles_and_days[point][:maximum_visits_number] < @services_data[service_id][:raw].visits_number || @points_vehicles_and_days[point][:days].include?(day))) - - next if @same_point_day && @unlocked.include?(service_id) && (!@points_vehicles_and_days[point][:vehicles].include?(vehicle_id) || !@points_vehicles_and_days[point][:days].include?(day)) - - period = @services_data[service_id][:heuristic_period] - - next if !(period.nil? || - route_data[:available_ids].include?(service_id) && (day + period..@schedule_end).step(period).find{ |current_day| @candidate_routes[vehicle_id][current_day.floor] && @candidate_routes[vehicle_id][current_day.floor][:completed] || @candidate_routes[vehicle_id][current_day.ceil] && @candidate_routes[vehicle_id][current_day.ceil][:completed] }.nil? && - same_point_compatibility(service_id, vehicle_id, day)) - - next if two_visits_and_can_not_assign_second(vehicle_id, day, service_id) + ### for each remaining service to assign, computes the cost of assigning it to [route_data] ### + unless set + set = @to_plan_service_ids + set.delete_if{ |id| @services_data[id][:raw].visits_number == 1 } if @same_point_day + end - other_indices = find_best_index(service_id, route_data) - insertion_costs << other_indices if other_indices - } + day = route_data[:day] + set.collect{ |service_id| + next if @services_data[service_id][:used_days] && + !days_respecting_lapse(service_id, @candidate_routes[route_data[:vehicle_original_id]]).include?(day) - insertion_costs.compact + find_best_index(service_id, route_data) + }.compact end def two_visits_and_can_not_assign_second(vehicle_id, day, service_id) - return false unless @services_data[service_id][:raw].visits_number == 2 # || @end_phase ? + return false unless @services_data[service_id][:raw].visits_number == 2 next_day = day + @services_data[service_id][:heuristic_period] day_to_insert = @candidate_routes[vehicle_id].keys.select{ |potential_day| potential_day >= next_day.round }.min @@ -871,16 +849,52 @@ def compatible_days(service_id, day) def compatible_vehicle(service_id, route_data) # WARNING : this does not consider vehicle alternative skills properly # we would need to know which skill_set is required in order that all services on same vehicle are compatible - route_data[:skills].any?{ |skill_set| (@services_data[service_id][:raw].skills - skill_set).empty? } + service_data = @services_data[service_id] + route_data[:skills].any?{ |skill_set| (service_data[:raw].skills - skill_set).empty? } && + (service_data[:sticky_vehicles_ids].empty? || service_data[:sticky_vehicles_ids].include?(route_data[:vehicle_original_id])) end - def service_compatible_with_route(service_id, route_data) - compatible_days(service_id, route_data[:day]) && - compatible_vehicle(service_id, route_data) + def service_does_not_violate_capacity(service_id, route_data, first_visit) + needed_capacity = @services_data[service_id][:group_capacity] if first_visit && @same_point_day + needed_capacity ||= @services_data[service_id][:capacity] # if no same point day or not its group representative + needed_capacity.all?{ |need, quantity| quantity <= route_data[:capacity_left][need] } + end + + def relaxed_or_same_point_day_constraint_respected(service_id, vehicle_id, day) + return true unless @same_point_day || @relaxed_same_point_day + + # there can be only on point in points_ids because of these options : + point = @services_data[service_id][:points_ids].first + + return true if @points_vehicles_and_days[point][:vehicles].empty? + + if @relaxed_same_point_day + @points_vehicles_and_days[point][:vehicles].include?(vehicle_id) && + (@points_vehicles_and_days[point][:days].include?(day) || + @points_vehicles_and_days[point][:maximum_visits_number] < @services_data[service_id][:raw].visits_number) + else # @same_point_day is on : + !@unlocked.include?(service_id) || + @points_vehicles_and_days[point][:vehicles].include?(vehicle_id) && + @points_vehicles_and_days[point][:days].include?(day) + end + end + + def service_compatible_with_route(service_id, route_data, first_visit) + vehicle_id = route_data[:vehicle_original_id] + day = route_data[:day] + + compatible_days(service_id, day) && + compatible_vehicle(service_id, route_data) && + service_does_not_violate_capacity(service_id, route_data, first_visit) && + (!first_visit || + !two_visits_and_can_not_assign_second(vehicle_id, day, service_id) && + route_data[:available_ids].include?(service_id) && + relaxed_or_same_point_day_constraint_respected(service_id, vehicle_id, day) && + same_point_compatibility(service_id, vehicle_id, day)) end def find_best_index(service_id, route_data, first_visit = true) - return nil unless service_compatible_with_route(service_id, route_data) + return nil unless service_compatible_with_route(service_id, route_data, first_visit) ### find the best position in [route_data] to insert [service] ### route = route_data[:stops] @@ -1202,19 +1216,14 @@ def select_point(insertion_costs, empty_route) def try_to_insert_at(vehicle_id, day, service_id, visit_number) # when adjusting routes, tries to insert [service_id] at [day] for [vehicle] - if !@candidate_routes[vehicle_id][day][:completed] && - @services_data[service_id][:capacity].all?{ |need, qty| @candidate_routes[vehicle_id][day][:capacity_left][need] - qty >= 0 } && - @services_data[service_id][:sticky_vehicles_ids].empty? || @services_data[service_id][:sticky_vehicles_ids].include?(vehicle_id) + return if @candidate_routes[vehicle_id][day][:completed] - best_index = find_best_index(service_id, @candidate_routes[vehicle_id][day], false) + best_index = find_best_index(service_id, @candidate_routes[vehicle_id][day], false) + return unless best_index - if best_index - insert_point_in_route(@candidate_routes[vehicle_id][day], best_index, false) - @candidate_routes[vehicle_id][day][:stops].find{ |stop| stop[:id] == service_id }[:number_in_sequence] = visit_number - - day - end - end + insert_point_in_route(@candidate_routes[vehicle_id][day], best_index, false) + @candidate_routes[vehicle_id][day][:stops].find{ |stop| stop[:id] == service_id }[:number_in_sequence] = visit_number + day end def find_corresponding_timewindow(day, arrival_time, timewindows, duration) diff --git a/test/real_cases_scheduling_test.rb b/test/real_cases_scheduling_test.rb index 663e83317..102438ef7 100644 --- a/test/real_cases_scheduling_test.rb +++ b/test/real_cases_scheduling_test.rb @@ -157,7 +157,7 @@ def test_performance_13vl # voluntarily equal to watch evolution of scheduling algorithm performance assert_equal expected, seen, 'Do not have the expected number of total visits' - assert_equal 294, unassigned_visits.sum, 'Do not have the expected number of unassigned visits' + assert_equal 292, unassigned_visits.sum, 'Do not have the expected number of unassigned visits' end def test_fill_days_and_post_processing