diff --git a/lib/heuristics/dichotomous_approach.rb b/lib/heuristics/dichotomous_approach.rb index 12204cdf..e29607c8 100644 --- a/lib/heuristics/dichotomous_approach.rb +++ b/lib/heuristics/dichotomous_approach.rb @@ -250,12 +250,16 @@ def self.build_initial_routes(solutions) next if solution.nil? solution.routes.map{ |route| - mission_ids = route.stops.map(&:service_id).compact - next if mission_ids.empty? + missions = route.stops.map{ |stop| + next if stop.is_a?(Models::Solution::StopDepot) || stop.mission.is_a?(Models::Rest) + + stop.mission + }.compact + next if missions.empty? Models::Route.create( vehicle: route.vehicle, - mission_ids: mission_ids + missions: missions ) } }.compact diff --git a/lib/heuristics/periodic_heuristic.rb b/lib/heuristics/periodic_heuristic.rb index 60e3d0ec..28f96575 100644 --- a/lib/heuristics/periodic_heuristic.rb +++ b/lib/heuristics/periodic_heuristic.rb @@ -890,6 +890,7 @@ def add_same_freq_located_points(best_index, route_data) @services_assignment[service_id][:vehicles] |= [route_data[:vehicle_original_id]] route_data[:stops].insert(best_index[:position] + i + 1, id: service_id, + mission: @services_data[service_id][:raw], point_id: best_index[:point], start: start, arrival: start, @@ -1398,7 +1399,7 @@ def prepare_output_and_collect_routes(vrp) vrp_routes << { vehicle_id: vrp_vehicle.id, - mission_ids: computed_stops.collect{ |stop| stop[:service_id] }.compact + missions: computed_stops.map{ |stop| stop[:mission] }.compact } solution_routes << Models::Solution::Route.new(stops: computed_stops, @@ -1552,10 +1553,10 @@ def construct_sub_vrp(vrp, vehicle, current_route) route_vrp end - def generate_route(vehicle, services) + def generate_route(vehicle, stops) { vehicle: vehicle, - mission_ids: services.collect{ |service| service[:id] } + missions: stops.map{ |stop| stop[:mission] }.compact } end diff --git a/lib/interpreters/multi_trip.rb b/lib/interpreters/multi_trip.rb index 881d351d..c255f901 100644 --- a/lib/interpreters/multi_trip.rb +++ b/lib/interpreters/multi_trip.rb @@ -37,7 +37,7 @@ def presolve(service_vrp, job = nil, &block) solution.unassigned_stops = [] vehicles = under_used_routes.map(&:vehicle) - reload_depots = vehicles.flat_map(&:reload_depots) + reload_depots = vehicles.flat_map(&:reload_depots).uniq points = vehicles.map(&:start_point) + vehicles.map(&:end_point) + diff --git a/models/activity.rb b/models/activity.rb index 53936927..c7d3d537 100644 --- a/models/activity.rb +++ b/models/activity.rb @@ -15,10 +15,10 @@ # along with Mapotempo. If not, see: # # -require './models/base' +require './models/mission' module Models - class Activity < Base + class Activity < Mission field :duration, default: 0 field :setup_duration, default: 0 field :additional_value, default: 0 diff --git a/models/mission.rb b/models/mission.rb new file mode 100644 index 00000000..fd91ca4d --- /dev/null +++ b/models/mission.rb @@ -0,0 +1,23 @@ +# Copyright © Cartoway, 2025 +# +# This file is part of Cartoway Optimizer. +# +# Cartoway Planner is free software. You can redistribute it and/or +# modify since you respect the terms of the GNU Affero General +# Public License as published by the Free Software Foundation, +# either version 3 of the License, or (at your option) any later version. +# +# Cartoway Optimizer is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Cartoway Optimizer. If not, see: +# +# + +require './models/base' + +module Models + class Mission < Base; end +end diff --git a/models/route.rb b/models/route.rb index ad17a3f9..bad948be 100644 --- a/models/route.rb +++ b/models/route.rb @@ -22,5 +22,25 @@ class Route < Base field :mission_ids, default: [] field :day_index belongs_to :vehicle, class_name: 'Models::Vehicle', as_json: :id + + has_many :missions, class_name: 'Models::Mission', as_json: :ids + + def initialize(hash) + hash[:missions] ||= [] + if hash[:mission_ids].present? + hash[:missions] += + hash[:mission_ids]&.map{ |mission_id| + Models::Service.find_by_id(mission_id) || + Models::ReloadDepot.find_by_id(mission_id) || + Models::Rest.find_by_id(mission_id) + }&.compact + hash.delete(:mission_ids) + end + super(hash) + end + + def mission_ids + missions.map{ |mission| mission.original_id || mission.id } + end end end diff --git a/models/service.rb b/models/service.rb index 1cdd1040..c856002f 100644 --- a/models/service.rb +++ b/models/service.rb @@ -18,7 +18,7 @@ require './models/base' module Models - class Service < Base + class Service < Mission field :id field :original_id, default: nil diff --git a/models/solution/parsers/stop_parser.rb b/models/solution/parsers/stop_parser.rb index 0e139ac2..29be65ad 100644 --- a/models/solution/parsers/stop_parser.rb +++ b/models/solution/parsers/stop_parser.rb @@ -42,6 +42,7 @@ def self.parse(service, options) alternative: options[:index], # nil if unassigned but return by default the last activity loads: build_loads(service, options), activity: dup_activity, + mission: service, info: options[:info] || Models::Solution::Stop::Info.new({}), reason: options[:reason], skills: options[:skills] || service.skills, @@ -88,6 +89,7 @@ def self.parse(reload_depot, options) type: :reload_depot, loads: options[:loads], activity: Models::ReloadDepot.new(reload_depot.as_json), + mission: reload_depot, info: options[:info] || Models::Solution::Stop::Info.new({}) } end @@ -100,6 +102,7 @@ def self.parse(rest, options) rest_id: rest.original_id || rest.id, type: :rest, activity: Models::Rest.new(rest.as_json), + mission: rest, info: options[:info] || Models::Solution::Stop::Info.new({}) } end diff --git a/models/solution/stop.rb b/models/solution/stop.rb index 9246c4c4..f0d3ef29 100644 --- a/models/solution/stop.rb +++ b/models/solution/stop.rb @@ -37,6 +37,7 @@ class Stop < Base field :exclusion_cost has_many :loads, class_name: 'Models::Solution::Load' + belongs_to :mission, class_name: 'Models::Mission', vrp_result: :hide belongs_to :activity, class_name: 'Models::Activity' belongs_to :info, class_name: 'Models::Solution::Stop::Info', vrp_result: :hide diff --git a/wrappers/ortools.rb b/wrappers/ortools.rb index 779480ee..00af6c33 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -85,7 +85,7 @@ def solve(vrp, job, thread_proc = nil, &block) }.each{ |relation| order_route = { vehicle: vrp.vehicles.size == 1 ? vrp.vehicles.first : nil, - mission_ids: relation.linked_service_ids + missions: relation.linked_services } vrp.routes += [order_route] } @@ -136,7 +136,7 @@ def solve(vrp, job, thread_proc = nil, &block) @job = job @previous_result = nil relations = [] - services = [] + ortools_services = [] routes = [] services_activity_positions = { always_first: [], always_last: [], never_first: [], never_last: [] } vrp.services.each_with_index{ |service, service_index| @@ -161,7 +161,7 @@ def solve(vrp, job, thread_proc = nil, &block) } if service.activity - services << OrtoolsVrp::Service.new( + ortools_services << OrtoolsVrp::Service.new( time_windows: service.activity.timewindows.collect{ |tw| OrtoolsVrp::TimeWindow.new(start: tw.start, end: tw.end || 2147483647, maximum_lateness: tw.maximum_lateness) @@ -208,11 +208,12 @@ def solve(vrp, job, thread_proc = nil, &block) alternative_index: 0 ) - services = update_services_activity_positions(services, services_activity_positions, service.id, - service.activity.position, service_index, 0) + ortools_services = + update_services_activity_positions(ortools_services, services_activity_positions, service.id, + service.activity.position, service_index, 0) elsif service.activities service.activities.each_with_index{ |possible_activity, activity_index| - services << OrtoolsVrp::Service.new( + ortools_services << OrtoolsVrp::Service.new( time_windows: possible_activity.timewindows.collect{ |tw| OrtoolsVrp::TimeWindow.new(start: tw.start, end: tw.end || 2147483647, maximum_lateness: tw.maximum_lateness) @@ -257,8 +258,9 @@ def solve(vrp, job, thread_proc = nil, &block) alternative_index: activity_index ) - services = update_services_activity_positions(services, services_activity_positions, service.id, - possible_activity.position, service_index, activity_index) + ortools_services = + update_services_activity_positions(ortools_services, services_activity_positions, service.id, + possible_activity.position, service_index, activity_index) } end } @@ -275,17 +277,17 @@ def solve(vrp, job, thread_proc = nil, &block) } vehicles = build_problem_vehicles(vrp, total_quantities) - build_problem_relations(vrp, services, relations) + build_problem_relations(vrp, ortools_services, relations) vrp.routes.collect{ |route| - next if route.vehicle.nil? || route.mission_ids.empty? + next if route.vehicle.nil? || route.missions.empty? - service_ids = corresponding_mission_ids(services.collect(&:id), route.mission_ids) - next if service_ids.empty? + ortools_service_ids = corresponding_mission_ids(ortools_services, route.missions) + next if ortools_service_ids.empty? routes << OrtoolsVrp::Route.new( vehicle_id: route.vehicle.id.to_s, - service_ids: service_ids.map(&:to_s) + service_ids: ortools_service_ids.map(&:to_s) ) } @@ -308,7 +310,7 @@ def solve(vrp, job, thread_proc = nil, &block) problem = OrtoolsVrp::Problem.new( vehicles: vehicles, - services: services, + services: ortools_services, matrices: matrices, relations: relations, routes: routes @@ -710,8 +712,9 @@ def update_services_activity_positions(services, services_activity_positions, } end - def corresponding_mission_ids(available_ids, mission_ids) - mission_ids.collect{ |mission_id| + def corresponding_mission_ids(available_ortools_services, missions) + available_ids = available_ortools_services.map(&:id) + missions.map(&:id).collect{ |mission_id| correct_id = if available_ids.include?(mission_id) mission_id diff --git a/wrappers/pyvrp.rb b/wrappers/pyvrp.rb index 089c7ab8..6888ffe7 100644 --- a/wrappers/pyvrp.rb +++ b/wrappers/pyvrp.rb @@ -24,7 +24,7 @@ def solver_constraints :assert_no_ride_constraint, :assert_no_service_duration_modifiers, :assert_vehicles_no_alternative_skills, - :assert_vehicles_no_force_start, + :assert_vehicles_no_force_start, # Use shift_preference instead :assert_vehicles_no_initial_load, :assert_vehicles_no_late_multiplier, :assert_vehicles_no_overload_multiplier, @@ -173,7 +173,7 @@ def read_depot_end(vrp, vehicle) end def read_reload_depot_trip(vrp, vehicle, reload_depot_index) - reload_depot = @depot_hash[reload_depot_index] + reload_depot = @reload_depots[@depots.size - reload_depot_index] return nil if reload_depot.nil? route_data = compute_route_data(vrp, vehicle, reload_depot.point) @@ -224,6 +224,11 @@ def pyvrp_problem(vrp) # Skills can be considered as capacities @skills_index_hash = {} + + # to keep the client and depot indices consistent, the depots should be built before the clients and the matrices + @point_hash = vrp.points.index_by(&:id) + depots = build_depots(vrp) + vrp.vehicles.map(&:skills).flatten.uniq.each_with_index{ |skill, index| @skills_index_hash[skill] = index } used_matrices = vrp.vehicles.map(&:matrix_id).uniq matrices = used_matrices.map { |id| vrp.matrices.find { |m| m.id == id } } @@ -233,30 +238,27 @@ def pyvrp_problem(vrp) distance_matrices = duration_matrices if distance_matrices.empty? - # to keep the client indices consistent, the depots should be built before the clients - depots = build_depots(vrp) + @reload_depot_index_hash = {} + vrp.reload_depots.each_with_index{ |depot, index| @reload_depot_index_hash[depot.id] = depots.size + index } clients, groups = build_clients_and_groups(vrp) + vehicles = build_vehicles(vrp) + routes = build_routes(vrp) { depots: depots, clients: clients, - vehicle_types: build_vehicles(vrp), + vehicle_types: vehicles, distance_matrices: distance_matrices, duration_matrices: duration_matrices, - groups: groups + groups: groups, + routes: routes }.delete_if { |_, v| v.nil? || v.empty? } end def expand_matrices(vrp, distance_matrices, duration_matrices) - depot_points = - vrp.vehicles.flat_map{ |veh| - [veh.start_point, veh.end_point] - }.uniq - additive_setups = Array.new(depot_points.size, 0) + additive_setups = Array.new(@depots.size, 0) reload_depot_points = - vrp.vehicles.flat_map{ |veh| - veh.reload_depots.map(&:point) - } + vrp.reload_depots.map(&:point) additive_setups += Array.new(reload_depot_points.size, 0) client_points = vrp.services.flat_map{ |service| @@ -268,7 +270,7 @@ def expand_matrices(vrp, distance_matrices, duration_matrices) points } - all_points = (depot_points + reload_depot_points + client_points) + all_points = (@depots + reload_depot_points + client_points) distance_matrices.map! do |matrix| matrix = @@ -300,16 +302,6 @@ def distance(matrix, point1, point2) def build_vehicles(vrp) used_matrices = vrp.vehicles.map(&:matrix_id).uniq - depot_hash = - vrp.vehicles.flat_map{ |veh| - [veh.start_point, veh.end_point] - }.uniq.each_with_index.map { |pt, idx| [pt&.id, idx] }.to_h - reload_depot_hash = - vrp.vehicles.flat_map{ |veh| - veh.reload_depots.map.with_index{ |depot, idx| - ["#{veh.id}_#{depot.id}", idx + depot_hash.size] - } - }.to_h all_units = vrp.units.index_by(&:id) vrp.vehicles.map { |veh| @@ -327,8 +319,8 @@ def build_vehicles(vrp) { num_available: 1, capacity: capacity_hash.values + capacity_skills, - start_depot: depot_hash[veh.start_point&.id], - end_depot: depot_hash[veh.end_point&.id], + start_depot: @vehicle_start_point_index_hash[veh.id], + end_depot: @vehicle_end_point_index_hash[veh.id], fixed_cost: veh.cost_fixed.to_i, tw_early: veh.timewindow&.start || 0, tw_late: veh.timewindow&.end || MAX_INT64, @@ -338,7 +330,7 @@ def build_vehicles(vrp) unit_duration_cost: veh.cost_time_multiplier.to_i, profile: used_matrices.index(veh.matrix_id), start_late: nil, - reload_depots: veh.reload_depots.map{ |depot| reload_depot_hash["#{veh.id}_#{depot.id}"] }, + reload_depots: veh.reload_depots.map{ |depot| @reload_depot_hash[depot.id] }, max_reloads: veh.maximum_reloads || 0, name: veh.id.to_s } @@ -416,37 +408,163 @@ def build_clients_and_groups(vrp) [client_list, groups] end + def add_depot_point(point, index_hash, criteria = nil) + return if point.nil? + + return index_hash[point.id] if index_hash.key?(point.id) && index_hash[point.id].is_a?(Integer) + + return index_hash[point.id][criteria] if index_hash[point.id].is_a?(Hash) && index_hash[point.id].key?(criteria) + + @depots << point + if criteria + index_hash[point.id] ||= {} + index_hash[point.id][criteria] = index_hash[point.id].size + else + index_hash[point.id] = index_hash.size + end + end + def build_depots(vrp) - depot_points = vrp.vehicles.flat_map { |vehicle| [vehicle.start_point, vehicle.end_point] }.uniq - @depot_hash = Array.new(depot_points.size, nil) - depots = - depot_points.map do |point| + @depots = [] + @vehicle_start_point_index_hash = {} + @vehicle_end_point_index_hash = {} + @depot_points_standard_index_hash = {} + @depot_points_force_start_by_timewindow_start_index_hash = {} + @depot_points_force_end_by_timewindow_end_index_hash = {} + vrp.vehicles.group_by(&:shift_preference).each do |shift_preference, vehicles| + vehicles.group_by(&:timewindow).each do |timewindow, sub_vehicles| + case shift_preference + when :force_start + sub_vehicles.each do |vehicle| + @vehicle_start_point_index_hash[vehicle.id] = + add_depot_point( + vehicle.start_point, + @depot_points_force_start_by_timewindow_start_index_hash, + timewindow.start + ) + @vehicle_end_point_index_hash[vehicle.id] = + add_depot_point(vehicle.end_point, @depot_points_standard_index_hash) + end + when :force_end + sub_vehicles.each do |vehicle| + @vehicle_start_point_index_hash[vehicle.id] = + add_depot_point(vehicle.start_point, @depot_points_standard_index_hash) + @vehicle_end_point_index_hash[vehicle.id] = + add_depot_point( + vehicle.end_point, + @depot_points_force_end_by_timewindow_end_index_hash, + timewindow.end + ) + end + when :minimize_span + sub_vehicles.each do |vehicle| + @vehicle_start_point_index_hash[vehicle.id] = + add_depot_point(vehicle.start_point, @depot_points_standard_index_hash) + @vehicle_end_point_index_hash[vehicle.id] = + add_depot_point(vehicle.end_point, @depot_points_standard_index_hash) + end + end + end + end + depots = Array.new(@depots.size, nil) + @depot_points_standard_index_hash.map { |point_id, index| + depots[index] = { - x: point&.location&.lon || 0, - y: point&.location&.lat || 0, + x: @point_hash[point_id]&.location&.lon || 0, + y: @point_hash[point_id]&.location&.lat || 0, tw_early: 0, tw_late: MAX_INT64, - name: point&.id&.to_s || '_null_store' + name: "#{point_id}_standard" || '_null_store' } - end - depots += - vrp.vehicles.flat_map { |vehicle| - vehicle.reload_depots.map{ |depot| - @depot_hash << depot + } + @depot_points_force_start_by_timewindow_start_index_hash.each{ |point_id, (timewindow_start, point_indices)| + point_indices.map { |point_index| + depots[point_index] = { - x: depot.point&.location&.lon || 0, - y: depot.point&.location&.lat || 0, - tw_early: depot.timewindows.first&.start || 0, - tw_late: depot.timewindows.first&.end || MAX_INT64, - service_duration: depot.duration.to_i, - name: "#{vehicle.id}_#{depot.id}" + x: @point_hash[point_id]&.location&.lon || 0, + y: @point_hash[point_id]&.location&.lat || 0, + tw_early: timewindow_start || 0, + tw_late: timewindow_start, + name: "#{point_id}_#{timewindow_start}_force_start" || '_null_store' } + } + } + @depot_points_force_end_by_timewindow_end_index_hash.keys.flat_map { |point_id, (timewindow_end, point_indices)| + point_indices.map { |point_index| + depots[point_index] = { + x: @point_hash[point_id]&.location&.lon || 0, + y: @point_hash[point_id]&.location&.lat || 0, + tw_early: timewindow_end || 0, + tw_late: timewindow_end || MAX_INT64, + name: "#{point_id}_#{timewindow_end}_force_end" || '_null_store' } } + } + + @reload_depots = [] + @reload_depot_hash = {} + vrp.reload_depots.each do |depot| + next if @reload_depot_hash.key?(depot.id) + + @reload_depots << depot + @reload_depot_hash[depot.id] = @depots.size + depots << + { + x: depot.point&.location&.lon || 0, + y: depot.point&.location&.lat || 0, + tw_early: depot.timewindows.first&.start || 0, + tw_late: depot.timewindows.first&.end || MAX_INT64, + service_duration: depot.duration.to_i, + name: "reload_#{depot&.id&.to_s || 'null_store'}" + } + end @service_index_map += depots.map{ nil } depots end + def build_routes(vrp) + return if vrp.routes.empty? + + vrp.routes.map{ |route| + next if route.missions.none?{ |mission| mission.is_a?(Models::Service) } + + vehicle_type = vrp.vehicles.find_index{ |v| v.id == route.vehicle.id } + { + visits: build_trips(vrp, route, vehicle_type), + vehicle_type: vehicle_type + } + }.compact + end + + def build_trips(vrp, route, vehicle_type) + trips = [] + vehicle = vrp.vehicles[vehicle_type] + end_depot = @vehicle_end_point_index_hash[vehicle.id] + current_trip = { + visits: [], + vehicle_type: vehicle_type, + start_depot: @vehicle_start_point_index_hash[vehicle.id], + end_depot: end_depot + } + route.missions.each do |mission| + if mission.is_a?(Models::Service) + current_trip[:visits] << @service_index_map.find_index{ |service| service && service.id == mission.id } + elsif mission.is_a?(Models::ReloadDepot) + reload_depot = @reload_depot_hash[mission.id] + current_trip[:end_depot] = reload_depot + trips << current_trip + current_trip = { + visits: [], + vehicle_type: vehicle_type, + start_depot: reload_depot, + end_depot: end_depot + } + end + end + trips << current_trip + trips + end + def run_pyvrp(problem, timeout = nil) input = Tempfile.new('optimize-pyvrp-input', @tmp_dir) diff --git a/wrappers/pyvrp_wrapper.py b/wrappers/pyvrp_wrapper.py index eef9f4f4..71df6356 100644 --- a/wrappers/pyvrp_wrapper.py +++ b/wrappers/pyvrp_wrapper.py @@ -2,7 +2,7 @@ import math import sys import numpy as np -from pyvrp import Model, ProblemData, Client, Depot, VehicleType, ClientGroup, SolveParams, PenaltyParams, solve +from pyvrp import Model, ProblemData, Client, Depot, VehicleType, ClientGroup, SolveParams, PenaltyParams, solve, Solution, Route, Trip from pyvrp.stop import MaxRuntime def _problem_data_from_dict(cls, data: dict): @@ -24,8 +24,39 @@ def _problem_data_from_dict(cls, data: dict): groups=groups, ) +def _route_from_dict(route_dict: dict, data: ProblemData): + """ + Creates a :class:`~pyvrp._pyvrp.Route` instance from a dictionary. + """ + trips = [] + for trip_dict in route_dict.get("visits", []): + trip = Trip( + data, + visits=trip_dict.get("visits", []), + vehicle_type=trip_dict.get("vehicle_type", 0), + start_depot=trip_dict.get("start_depot"), + end_depot=trip_dict.get("end_depot") + ) + trips.append(trip) + + return Route( + data, + visits=trips, + vehicle_type=route_dict.get("vehicle_type", 0) + ) + +def _solution_from_dict(cls, json_data: dict, data: ProblemData): + routes = [_route_from_dict(route, data) for route in json_data.get("routes", [])] + if not routes: + return None + return Solution( + data=data, + routes=routes, + ) + # Monkey-patch setattr(ProblemData, "from_dict", classmethod(_problem_data_from_dict)) +setattr(Solution, "from_dict", classmethod(_solution_from_dict)) def main(input_path, output_path, timeout=None): # Load problem data from JSON @@ -33,7 +64,7 @@ def main(input_path, output_path, timeout=None): json_data = json.loads(f.read()) data = ProblemData.from_dict(json_data) - m = Model.from_data(data) + initial_solution = Solution.from_dict(json_data, data) # Solve the problem # ProblemData exposes clients as a method, not as a list attribute. clients = list(data.clients()) @@ -49,6 +80,7 @@ def main(input_path, output_path, timeout=None): stop=MaxRuntime(int(timeout)), params=solve_params, display=True, + initial_solution=initial_solution, ) best_solution = result.best