From ba4e3902ff89b392c2b232e7159ebe5187929b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Fri, 23 Jan 2026 12:35:35 +0100 Subject: [PATCH 1/6] PyVRP with initial routes --- lib/heuristics/dichotomous_approach.rb | 10 ++- lib/heuristics/periodic_heuristic.rb | 7 +- lib/interpreters/multi_trip.rb | 2 +- models/activity.rb | 4 +- models/mission.rb | 23 ++++++ models/route.rb | 20 +++++ models/service.rb | 2 +- models/solution/parsers/stop_parser.rb | 3 + models/solution/stop.rb | 1 + wrappers/ortools.rb | 35 +++++---- wrappers/pyvrp.rb | 104 ++++++++++++++++++------- wrappers/pyvrp_wrapper.py | 36 ++++++++- 12 files changed, 189 insertions(+), 58 deletions(-) create mode 100644 models/mission.rb 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..80ca86b0 100644 --- a/wrappers/pyvrp.rb +++ b/wrappers/pyvrp.rb @@ -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[reload_depot_index] return nil if reload_depot.nil? route_data = compute_route_data(vrp, vehicle, reload_depot.point) @@ -235,14 +235,20 @@ def pyvrp_problem(vrp) # 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 @@ -254,9 +260,7 @@ def expand_matrices(vrp, distance_matrices, duration_matrices) additive_setups = Array.new(depot_points.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| @@ -300,16 +304,10 @@ def distance(matrix, point1, point2) def build_vehicles(vrp) used_matrices = vrp.vehicles.map(&:matrix_id).uniq - depot_hash = + @depot_index_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 +325,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: @depot_index_hash[veh.start_point&.id], + end_depot: @depot_index_hash[veh.end_point&.id], fixed_cost: veh.cost_fixed.to_i, tw_early: veh.timewindow&.start || 0, tw_late: veh.timewindow&.end || MAX_INT64, @@ -338,7 +336,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| @depot_hash[depot.id] }, max_reloads: veh.maximum_reloads || 0, name: veh.id.to_s } @@ -418,7 +416,9 @@ def build_clients_and_groups(vrp) 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) + @reload_depots = Array.new(depot_points.size, nil) + @depot_hash = {} + @depot_vehicle_hash = {} depots = depot_points.map do |point| { @@ -429,24 +429,68 @@ def build_depots(vrp) name: point&.id&.to_s || '_null_store' } end - depots += - vrp.vehicles.flat_map { |vehicle| - vehicle.reload_depots.map{ |depot| - @depot_hash << depot - { - 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}" - } + vrp.reload_depots.each do |depot| + next if @depot_hash.key?(depot.id) + + @reload_depots << depot + @depot_hash[depot.id] = depot_points.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 = @depot_index_hash[vehicle.end_point&.id] + current_trip = { + visits: [], + vehicle_type: vehicle_type, + start_depot: @depot_index_hash[vehicle.start_point&.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 = @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 From 311330d7c077603359841a16ebacf024f348e2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Thu, 29 Jan 2026 15:53:00 +0100 Subject: [PATCH 2/6] Pyvrp introduce shift preferences using depot timewindows --- wrappers/pyvrp.rb | 140 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 107 insertions(+), 33 deletions(-) diff --git a/wrappers/pyvrp.rb b/wrappers/pyvrp.rb index 80ca86b0..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 = @reload_depots[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,9 +238,6 @@ 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) @@ -253,11 +255,7 @@ def pyvrp_problem(vrp) 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.reload_depots.map(&:point) @@ -272,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 = @@ -304,10 +302,6 @@ def distance(matrix, point1, point2) def build_vehicles(vrp) used_matrices = vrp.vehicles.map(&:matrix_id).uniq - @depot_index_hash = - vrp.vehicles.flat_map{ |veh| - [veh.start_point, veh.end_point] - }.uniq.each_with_index.map { |pt, idx| [pt&.id, idx] }.to_h all_units = vrp.units.index_by(&:id) vrp.vehicles.map { |veh| @@ -325,8 +319,8 @@ def build_vehicles(vrp) { num_available: 1, capacity: capacity_hash.values + capacity_skills, - start_depot: @depot_index_hash[veh.start_point&.id], - end_depot: @depot_index_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, @@ -336,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| @depot_hash[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 } @@ -414,26 +408,106 @@ 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 - @reload_depots = Array.new(depot_points.size, nil) - @depot_hash = {} - @depot_vehicle_hash = {} - 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 + } + @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: @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 @depot_hash.key?(depot.id) + next if @reload_depot_hash.key?(depot.id) @reload_depots << depot - @depot_hash[depot.id] = depot_points.size + @reload_depot_hash[depot.id] = @depots.size depots << { x: depot.point&.location&.lon || 0, @@ -465,18 +539,18 @@ def build_routes(vrp) def build_trips(vrp, route, vehicle_type) trips = [] vehicle = vrp.vehicles[vehicle_type] - end_depot = @depot_index_hash[vehicle.end_point&.id] + end_depot = @vehicle_end_point_index_hash[vehicle.id] current_trip = { visits: [], vehicle_type: vehicle_type, - start_depot: @depot_index_hash[vehicle.start_point&.id], + 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 = @depot_hash[mission.id] + reload_depot = @reload_depot_hash[mission.id] current_trip[:end_depot] = reload_depot trips << current_trip current_trip = { From 1d2da8c12b0128d3e80541e44c1ba3a42a2912a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 2 Mar 2026 10:30:15 +0100 Subject: [PATCH 3/6] Introduce Delaunay graph --- .dockerignore | 10 + .gitignore | 11 + Dockerfile | 5 +- Gemfile | 3 + Rakefile | 17 + api/v01/vrp.rb | 60 +++ config/initializers/vrp_delaunay.rb | 8 + docs/Graph.md | 52 +++ ext/vrp_delaunay/Cargo.lock | 176 ++++++++ ext/vrp_delaunay/Cargo.toml | 18 + ext/vrp_delaunay/src/delaunay.rs | 51 +++ ext/vrp_delaunay/src/main.rs | 48 +++ lib/vrp_graph.rb | 30 ++ lib/vrp_graph/README.md | 47 +++ lib/vrp_graph/batch_assigner.rb | 110 +++++ lib/vrp_graph/capacity_compatibility.rb | 196 +++++++++ lib/vrp_graph/delaunay_adapter.rb | 53 +++ lib/vrp_graph/ensure_binary.rb | 60 +++ lib/vrp_graph/graph_builder.rb | 380 ++++++++++++++++++ lib/vrp_graph/knn_neighborhood.rb | 68 ++++ lib/vrp_graph/skills_compatibility.rb | 83 ++++ lib/vrp_graph/timewindow_compatibility.rb | 71 ++++ lib/vrp_graph/version.rb | 5 + models/graph.rb | 134 ++++++ models/vrp.rb | 1 + test/api/v01/graph_test.rb | 55 +++ test/lib/interpreters/multi_trip_test.rb | 18 + .../vrp_graph/capacity_compatibility_test.rb | 186 +++++++++ test/lib/vrp_graph/delaunay_adapter_test.rb | 41 ++ test/lib/vrp_graph/ensure_binary_test.rb | 20 + test/lib/vrp_graph/graph_builder_test.rb | 39 ++ 31 files changed, 2055 insertions(+), 1 deletion(-) create mode 100644 config/initializers/vrp_delaunay.rb create mode 100644 docs/Graph.md create mode 100644 ext/vrp_delaunay/Cargo.lock create mode 100644 ext/vrp_delaunay/Cargo.toml create mode 100644 ext/vrp_delaunay/src/delaunay.rs create mode 100644 ext/vrp_delaunay/src/main.rs create mode 100644 lib/vrp_graph.rb create mode 100644 lib/vrp_graph/README.md create mode 100644 lib/vrp_graph/batch_assigner.rb create mode 100644 lib/vrp_graph/capacity_compatibility.rb create mode 100644 lib/vrp_graph/delaunay_adapter.rb create mode 100644 lib/vrp_graph/ensure_binary.rb create mode 100644 lib/vrp_graph/graph_builder.rb create mode 100644 lib/vrp_graph/knn_neighborhood.rb create mode 100644 lib/vrp_graph/skills_compatibility.rb create mode 100644 lib/vrp_graph/timewindow_compatibility.rb create mode 100644 lib/vrp_graph/version.rb create mode 100644 models/graph.rb create mode 100644 test/api/v01/graph_test.rb create mode 100644 test/lib/interpreters/multi_trip_test.rb create mode 100644 test/lib/vrp_graph/capacity_compatibility_test.rb create mode 100644 test/lib/vrp_graph/delaunay_adapter_test.rb create mode 100644 test/lib/vrp_graph/ensure_binary_test.rb create mode 100644 test/lib/vrp_graph/graph_builder_test.rb diff --git a/.dockerignore b/.dockerignore index f97c0ae4..60635c90 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,13 @@ docker/redis* docker/production.rb .env + +ext/**/target/ +/exe/ + +# Python cache +__pycache__/ +*.pyc +*.pyo +venv/ +.venv/ diff --git a/.gitignore b/.gitignore index bdd2a54b..137361c9 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,14 @@ TAGS docker/redis* docker/production.rb .env + +ext/**/target/ +/exe/ + +__pycache__/ +*.pyo +.Python +venv/ +.venv/ +*.egg-info/ +.eggs/ diff --git a/Dockerfile b/Dockerfile index 50cd3b9f..4e87ec95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,7 @@ WORKDIR /srv/app RUN apt update && \ libgeos=$(apt-cache search 'libgeos-' | grep -P 'libgeos-\d.*' | awk '{print $1}') && \ - apt install -y git libgeos-dev ${libgeos} libicu-dev libglpk-dev nano + apt install -y git libgeos-dev ${libgeos} libicu-dev libglpk-dev nano cargo ADD ./Gemfile /srv/app/ ADD ./Gemfile.lock /srv/app/ @@ -56,6 +56,9 @@ RUN bundle install --full-index --without ${BUNDLE_WITHOUT} ADD . /srv/app +# Build vrp_delaunay Rust binary (Delaunay triangulation for graph endpoint) +RUN bundle exec rake ext:vrp_delaunay 2>/dev/null || true + EXPOSE 80 HEALTHCHECK \ diff --git a/Gemfile b/Gemfile index e6552df7..845e1175 100644 --- a/Gemfile +++ b/Gemfile @@ -50,6 +50,9 @@ gem 'polylines' gem 'rgeo' gem 'rgeo-geojson', require: 'rgeo/geo_json' +# Constraint programming for batch assignment (optional: requires OR-Tools C++ library) +gem 'or-tools' + gem 'sentry-resque' gem 'sentry-ruby' diff --git a/Rakefile b/Rakefile index 01e9c338..23f97be2 100644 --- a/Rakefile +++ b/Rakefile @@ -68,3 +68,20 @@ end task :environment do require './environment' end + +# VrpDelaunay Rust binary (Spade) +namespace :ext do + desc 'Compile VrpDelaunay binary (Spade - requires cargo)' + task :vrp_delaunay do + ext_dir = File.expand_path('ext/vrp_delaunay', __dir__) + Dir.chdir(ext_dir) do + sh 'cargo build --release' + bin = File.join(ext_dir, 'target/release/vrp_delaunay') + raise 'Build failed: no binary produced' unless File.executable?(bin) + + dest = File.expand_path('exe', __dir__) + FileUtils.mkdir_p(dest) + FileUtils.cp(bin, File.join(dest, 'vrp_delaunay')) + end + end +end diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index 4080999c..8ea3c42c 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -196,6 +196,66 @@ class Vrp < APIBase ::Models.delete_all end + resource :graph do + desc 'Get Delaunay graph from VRP', { + nickname: 'get_vrp_graph', + success: [{ + code: 200, + message: 'Graph built from VRP' + }], + failure: [{ + code: 400, + message: 'Bad Request', + model: ::Api::V01::Status + }, { + code: 501, + message: 'vrp_delaunay binary not built (rake ext:vrp_delaunay)', + model: ::Api::V01::Status + }], + detail: 'Build and return the proximity graph (Delaunay, skills/timewindow compatibilities, K-NN) from a VRP instance.' + } + params { + use(:input) + optional(:format, type: Symbol, values: [:json, :geojson], default: :json, + desc: 'Output format: json (full graph) or geojson') + } + post do + d_params = declared(params, include_missing: false) + vrp_params = d_params[:points] ? d_params : d_params[:vrp] + vrp = ::Models::Vrp.create(vrp_params) + + if !vrp.valid? || vrp_params.nil? || vrp_params.keys.empty? + vrp.errors.add(:empty_file, message: 'JSON file is empty') if vrp_params.nil? + vrp.errors.add(:empty_vrp, message: 'VRP structure is empty') if vrp_params&.keys&.empty? + error!("Model Validation Error: #{vrp.errors}", 400) + end + + graph = begin + VrpGraph::GraphBuilder.new(vrp).build + rescue LoadError => e + error!({ message: "Graph build failed: #{e.message}" }, 501) + end + + if graph + vrp.graph = graph + end + + if graph.nil? + error!({ message: 'No services with points to build graph' }, 400) + end + + if params[:format] == :geojson + content_type 'application/vnd.geo+json' + graph.to_geojson + else + status 200 + present graph.to_hash + end + ensure + ::Models.delete_all + end + end + resource :jobs do desc 'Fetch vrp job status', { nickname: 'get_job', diff --git a/config/initializers/vrp_delaunay.rb b/config/initializers/vrp_delaunay.rb new file mode 100644 index 00000000..c2fa278e --- /dev/null +++ b/config/initializers/vrp_delaunay.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Build vrp_delaunay Rust binary at startup if missing (server + workers). +# Graph endpoint returns 501 when binary is absent; this avoids manual rake ext:vrp_delaunay. + +require File.expand_path('../../lib/vrp_graph/ensure_binary', __dir__) + +VrpGraph::EnsureBinary.ensure_built! diff --git a/docs/Graph.md b/docs/Graph.md new file mode 100644 index 00000000..51b3af4d --- /dev/null +++ b/docs/Graph.md @@ -0,0 +1,52 @@ +# Graph + +Build and retrieve the proximity graph (Delaunay triangulation, skills/timewindow compatibilities, K-NN) from a VRP instance. + +## Endpoint + +**POST** `/0.1/vrp/graph` + +Same request body as [submit](Home.md#submit). Additional parameters: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `format` | String | `"json"` | Output format: `"json"` (full graph structure) or `"geojson"` | + +## Example request + +```bash +curl -X POST "http://localhost:1791/0.1/vrp/graph?api_key=your_key" \ + -H "Content-Type: application/json" \ + -d '{"vrp": {...}}' +``` + +## Response (format=json) + +```json +{ + "nodes": { + "service_1": { + "point": {"lat": 48.1, "lon": -1.6}, + "skills": [], + "timewindows": [{"start": 0, "end": 86400}] + } + }, + "edges": [["service_1", "service_2", 120], ...], + "incompatibilities": [["service_a", "service_b"], ...], + "knn_neighbors": { + "service_1": ["service_2", "service_3", ...] + }, + "metadata": { + "delaunay_built_at": "2025-01-29T...", + "matrix_id_used": "..." + } +} +``` + +## Response (format=geojson) + +Returns a GeoJSON FeatureCollection with Point features for each service and LineString features for each edge. + +## Requirements + +The `vrp_delaunay` binary must be built: `rake ext:vrp_delaunay` diff --git a/ext/vrp_delaunay/Cargo.lock b/ext/vrp_delaunay/Cargo.lock new file mode 100644 index 00000000..44e25d24 --- /dev/null +++ b/ext/vrp_delaunay/Cargo.lock @@ -0,0 +1,176 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spade" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +dependencies = [ + "hashbrown", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vrp_delaunay" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "spade", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/ext/vrp_delaunay/Cargo.toml b/ext/vrp_delaunay/Cargo.toml new file mode 100644 index 00000000..3654d00a --- /dev/null +++ b/ext/vrp_delaunay/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "vrp_delaunay" +version = "0.1.0" +edition = "2021" +description = "Delaunay triangulation for VRP optimizer - standalone binary using Spade" + +[[bin]] +name = "vrp_delaunay" +path = "src/main.rs" + +[dependencies] +spade = "2.15" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[profile.release] +lto = true +codegen-units = 1 diff --git a/ext/vrp_delaunay/src/delaunay.rs b/ext/vrp_delaunay/src/delaunay.rs new file mode 100644 index 00000000..ee8a6c52 --- /dev/null +++ b/ext/vrp_delaunay/src/delaunay.rs @@ -0,0 +1,51 @@ +//! Delaunay triangulation using spade crate. +//! Builds a triangulation from (lon, lat) points and returns undirected edges as (index_a, index_b) pairs. + +use spade::{DelaunayTriangulation, HasPosition, Point2, Triangulation}; + +/// Vertex with index for mapping back to service order +#[derive(Clone, Copy)] +struct IndexedPoint { + point: Point2, + index: usize, +} + +impl HasPosition for IndexedPoint { + type Scalar = f64; + + fn position(&self) -> Point2 { + self.point + } +} + +/// Builds Delaunay triangulation from points and returns edges as (i, j) index pairs. +/// +/// # Arguments +/// * `points` - Slice of (lon, lat) coordinates. Index in array = service index. +/// +/// # Returns +/// Vector of (index_a, index_b) for each undirected edge, with index_a < index_b to avoid duplicates. +pub fn compute_edges(points: &[(f64, f64)]) -> Result, spade::InsertionError> { + let mut triangulation: DelaunayTriangulation = DelaunayTriangulation::new(); + + for (index, &(lon, lat)) in points.iter().enumerate() { + triangulation.insert(IndexedPoint { + point: Point2::new(lon, lat), + index, + })?; + } + + let mut edges = std::collections::HashSet::new(); + for face in triangulation.inner_faces() { + let vertices = face.vertices(); + let indices: Vec = vertices.iter().map(|v| v.data().index).collect(); + for i in 0..3 { + let a = indices[i]; + let b = indices[(i + 1) % 3]; + let (lo, hi) = if a < b { (a, b) } else { (b, a) }; + edges.insert((lo, hi)); + } + } + + Ok(edges.into_iter().collect()) +} diff --git a/ext/vrp_delaunay/src/main.rs b/ext/vrp_delaunay/src/main.rs new file mode 100644 index 00000000..7f0659f3 --- /dev/null +++ b/ext/vrp_delaunay/src/main.rs @@ -0,0 +1,48 @@ +//! Standalone CLI: reads JSON points from stdin, outputs JSON edges to stdout. +//! Input: [[lon, lat], [lon, lat], ...] +//! Output: [[i, j], [i, j], ...] + +mod delaunay; + +use std::io::{self, Read}; +use std::process::exit; + +fn main() { + let mut input = String::new(); + if io::stdin().read_to_string(&mut input).is_err() { + eprintln!("Failed to read stdin"); + exit(1); + } + + let points: Vec> = match serde_json::from_str(&input) { + Ok(p) => p, + Err(e) => { + eprintln!("Invalid JSON: {}", e); + exit(1); + } + }; + + let coords: Vec<(f64, f64)> = points + .iter() + .map(|p| { + if p.len() < 2 { + (0.0, 0.0) + } else { + (p[0], p[1]) + } + }) + .collect(); + + match delaunay::compute_edges(&coords) { + Ok(edges) => { + if let Err(e) = serde_json::to_writer(io::stdout(), &edges) { + eprintln!("Output error: {}", e); + exit(1); + } + } + Err(e) => { + eprintln!("Delaunay error: {}", e); + exit(1); + } + } +} diff --git a/lib/vrp_graph.rb b/lib/vrp_graph.rb new file mode 100644 index 00000000..7a31103d --- /dev/null +++ b/lib/vrp_graph.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# VRP Graph: Delaunay triangulation (Spade), compatibility checks, K-NN, GeoJSON export, batch assignment. +# +# Requires vrp_delaunay binary. Build with: rake ext:vrp_delaunay + +require_relative 'vrp_graph/version' +require_relative 'vrp_graph/delaunay_adapter' +require_relative 'vrp_graph/skills_compatibility' +require_relative 'vrp_graph/timewindow_compatibility' +require_relative 'vrp_graph/knn_neighborhood' +require_relative 'vrp_graph/graph_builder' +require_relative 'vrp_graph/batch_assigner' diff --git a/lib/vrp_graph/README.md b/lib/vrp_graph/README.md new file mode 100644 index 00000000..3b874b96 --- /dev/null +++ b/lib/vrp_graph/README.md @@ -0,0 +1,47 @@ +# VrpGraph + +Library for building and using Delaunay-based proximity graphs for VRP instances. + +## Features + +- **Delaunay triangulation** of service points via Spade (Rust extension) +- **Skills compatibility**: mark service pairs incompatible when no vehicle can serve both +- **Timewindow compatibility**: mark pairs incompatible when travel time + duration makes sequencing infeasible +- **K-Nearest Neighbors**: enrich neighborhood using travel time matrix, filtered by compatibility +- **GeoJSON export**: visualize the graph +- **Batch assignment**: group routes into lots (max size constraint) favoring proximity + +## Usage + +```ruby +require 'vrp_graph' + +# Build graph from VRP +graph = VrpGraph::GraphBuilder.new(vrp).build + +# Export to GeoJSON +geojson = graph.to_geojson + +# Get connected pairs for each route in a solution +connectivity = graph.tours_connectivity_from_solution(solution) + +# Assign routes to batches +assigner = VrpGraph::BatchAssigner.new(graph, solution, max_routes_per_batch: 5) +route_to_batch = assigner.assign +``` + +## Spade binary (required) + +Delaunay triangulation uses a standalone Rust binary (Spade). Build it with: + +```bash +rake ext:vrp_delaunay +``` + +Requires Rust toolchain (cargo). The binary is placed in `exe/vrp_delaunay`. + +## Dependencies + +- Spade Rust extension (required) +- `rgeo`, `rgeo-geojson` (for GeoJSON export) +- `or-tools` (optional, for CP-based batch assignment) diff --git a/lib/vrp_graph/batch_assigner.rb b/lib/vrp_graph/batch_assigner.rb new file mode 100644 index 00000000..122d9a64 --- /dev/null +++ b/lib/vrp_graph/batch_assigner.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# Assigns routes to batches (lots) with max size constraint, maximizing proximity. +# Uses greedy algorithm; optional OR-Tools CP-SAT when gem available. + +module VrpGraph + class BatchAssigner + def initialize(graph, solution, max_routes_per_batch: 5) + @graph = graph + @solution = solution + @max_routes_per_batch = max_routes_per_batch + end + + # @return [Hash] route_index => batch_index + def assign + if defined?(ORTools) && defined?(ORTools::Sat::CpSolver) + assign_with_ortools + else + assign_greedy + end + end + + private + + def assign_greedy + n_routes = @solution.routes.size + batches = [] + route_to_batch = {} + + # Build proximity matrix between routes (number of shared graph neighbors) + proximity = build_proximity_matrix + + # Greedy: assign routes to batches, always adding to the batch that maximizes total proximity + n_routes.times do |r| + best_batch = nil + best_score = -Float::INFINITY + + batches.each_with_index do |batch, bi| + next if batch.size >= @max_routes_per_batch + + score = batch.sum{ |other| proximity[r][other] || 0 } + if score > best_score + best_score = score + best_batch = bi + end + end + + if best_batch && best_score >= 0 + batches[best_batch] << r + route_to_batch[r] = best_batch + else + route_to_batch[r] = batches.size + batches << [r] + end + end + + route_to_batch + end + + # Proximity = number of graph edges between services of the two routes + def build_proximity_matrix + n = @solution.routes.size + prox = Array.new(n){ Array.new(n, 0) } + + @solution.routes.each_with_index do |route_a, i| + ids_a = route_stop_point_ids(route_a) + @solution.routes.each_with_index do |route_b, j| + next if i >= j + + ids_b = route_stop_point_ids(route_b) + # Count K-NN links (point a in route_a, neighbor point b in route_b) + count = 0 + ids_a.each do |point_id| + (@graph.neighbors_for_point(point_id) || []).each do |nb_point_id| + count += 1 if ids_b.include?(nb_point_id) + end + end + prox[i][j] = prox[j][i] = count + end + end + prox + end + + def route_stop_point_ids(route) + route.stops.filter_map { |s| s.activity&.point_id }.compact.to_set + end + + def assign_with_ortools + # Placeholder: when or-tools is available, implement CP model + assign_greedy + end + end +end diff --git a/lib/vrp_graph/capacity_compatibility.rb b/lib/vrp_graph/capacity_compatibility.rb new file mode 100644 index 00000000..9390cef8 --- /dev/null +++ b/lib/vrp_graph/capacity_compatibility.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +module VrpGraph + # Checks capacity compatibility: two services at different points are incompatible + # if no vehicle (that can serve both) has enough capacity to deliver both in one route. + # Uses max capacity per unit across vehicles that are skill-compatible with both. + # Pickup (collected) and delivery (delivered) are checked independently per unit. + module CapacityCompatibility + module_function + + # @param vrp [Models::Vrp] + # @return [Hash] nested: incompat[service_id_a][service_id_b] = true (bidirectional) + def compute_incompatibilities(vrp) + services = vrp.services + vehicles = vrp.vehicles + + # Precompute: service => [pickup_hash, delivery_hash] (avoids O(n^2) recomputation) + t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + pd_by_service = services.map{ |s| [s.id, pickup_delivery_per_unit(s)] }.to_h + log_duration('capacity_precompute_pickup_delivery', t1) + + # Factor vehicles by config (skills.first + capacities); many vehicles share the same config + t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config_to_caps = {} + config_keys_by_service = {} + vehicles.each do |v| + config_key = vehicle_config_key(v) + config_to_caps[config_key] ||= vehicle_capacity_hash(v) + end + services.each do |s| + config_keys_by_service[s.id] = config_to_caps.keys.select{ |ck| skills_match_config?(ck, s) } + end + log_duration('capacity_precompute_vehicle_configs', t2, "configs=#{config_to_caps.size} vehicles=#{vehicles.size}") + + # Indices of services that have quantities (capacity-relevant) + services_with_qty = {} + services.each_index do |i| + p, d = pd_by_service[services[i].id] + services_with_qty[i] = true if p.any? || d.any? + end + + # Group service indices by point_id (for cross-point pairs only) + point_to_indices = Hash.new{ |h, k| h[k] = [] } + services.each_with_index do |s, i| + pid = s.activity&.point_id || s.activity&.point&.id + point_to_indices[pid] << i if pid + end + point_ids = point_to_indices.keys + + # Hash.new { |h, k| h[k] = {} } creates a new hash per key (Hash.new({}) would share one default) + incompat = Hash.new { |h, k| h[k] = {} } + pairs_checked = 0 + + t4 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + point_ids.each_with_index do |pid_a, ia| + ib = ia + 1 + while ib < point_ids.size + pid_b = point_ids[ib] + indices_a = point_to_indices[pid_a] + indices_b = point_to_indices[pid_b] + + indices_a.each do |i| + indices_b.each do |j| + s1 = services[i] + s2 = services[j] + # Skip if both have no quantities (always compatible) + next if !services_with_qty[i] && !services_with_qty[j] + + pairs_checked += 1 + next if capacity_compatible_fast?(s1, s2, config_keys_by_service, config_to_caps, pd_by_service) + + id_a = s1.id + id_b = s2.id + incompat[id_a][id_b] = true + incompat[id_b][id_a] = true + end + end + ib += 1 + end + end + log_duration('capacity_pair_checks', t4, pairs_checked) + incompat + end + + def capacity_compatible_fast?(s1, s2, config_keys_by_service, config_to_caps, pd_by_service) + compatible_configs = (config_keys_by_service[s1.id] || []) & (config_keys_by_service[s2.id] || []) + return true if compatible_configs.empty? + + p1, d1 = pd_by_service[s1.id] + p2, d2 = pd_by_service[s2.id] + + compatible_configs.any? do |config_key| + max_caps = config_to_caps[config_key] + next true if max_caps.nil? || max_caps.empty? + + max_caps.all? do |unit_id, limit| + pickup_total = (p1[unit_id] || 0) + (p2[unit_id] || 0) + delivery_total = (d1[unit_id] || 0) + (d2[unit_id] || 0) + pickup_total <= limit && delivery_total <= limit + end + end + end + + # Config key = [skills, capacities] for grouping vehicles with same config + def vehicle_config_key(vehicle) + skills = (vehicle.skills.first || []).to_a.map(&:to_s).sort + caps = vehicle_capacity_hash(vehicle).sort + [skills, caps] + end + + # Service is incompatible with a vehicle config if not all its skills are covered by the config. + def skills_match_config?(config_key, service) + return true if service.skills.nil? || service.skills.empty? + + config_skills = config_key[0] || [] + service_skills = service.skills.to_a.map(&:to_s) + (service_skills - config_skills).size == service_skills.size + end + + def vehicle_capacity_hash(vehicle) + h = {} + vehicle.capacities.each do |cap| + next if cap.unit_id.nil? + next if cap.overload_multiplier&.positive? + + limit = cap.limit + next if limit.nil? + + h[cap.unit_id] = [h[cap.unit_id], limit].compact.max + end + h + end + + def merge_max_capacities(hashes) + return {} if hashes.empty? + + result = hashes.first.dup + hashes[1..].each do |h| + h.each do |uid, lim| + result[uid] = [result[uid], lim].compact.max + end + end + result + end + + # Returns [pickup_hash, delivery_hash] per unit. + # Pickup = units collected; delivery = units delivered. + # Convention: value < 0 => delivery, value > 0 => pickup (matches Vroom/PyVRP). + def pickup_delivery_per_unit(service) + pickup = {} + delivery = {} + (service.quantities || []).each do |q| + next if q.empty? + next if q.unit_id.nil? + + uid = q.unit_id + pickup[uid] = (pickup[uid] || 0) + q.pickup if q.pickup + delivery[uid] = (delivery[uid] || 0) + q.delivery if q.delivery + next if (q.value || 0).zero? + + if (q.value || 0).negative? + delivery[uid] = (delivery[uid] || 0) + q.value.abs + else + pickup[uid] = (pickup[uid] || 0) + q.value + end + end + [pickup, delivery] + end + + def log_duration(label, start_time, extra = nil) + return unless defined?(OptimizerLogger) + + elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2) + msg = "VrpGraph #{label}: #{elapsed_ms}ms" + msg += " (pairs=#{extra})" if extra + OptimizerLogger.log(msg, level: :info) + end + end +end diff --git a/lib/vrp_graph/delaunay_adapter.rb b/lib/vrp_graph/delaunay_adapter.rb new file mode 100644 index 00000000..54b58e83 --- /dev/null +++ b/lib/vrp_graph/delaunay_adapter.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# Delaunay triangulation via Spade Rust binary. +# Requires: rake ext:vrp_delaunay + +require 'open3' +require 'json' + +module VrpGraph + module DelaunayAdapter + BINARY_PATH = File.expand_path('../../exe/vrp_delaunay', __dir__) + + module_function + + # Computes Delaunay triangulation edges from points using Spade. + # @param points [Array>] Array of [lon, lat] coordinates + # @return [Array>] Array of [i, j] index pairs for each undirected edge (i < j) + # @raise [LoadError] if vrp_delaunay binary is not built + def compute_edges(points) + return [] if points.size < 2 + + unless File.executable?(BINARY_PATH) + raise LoadError, "vrp_delaunay binary not found. Run: rake ext:vrp_delaunay (expected: #{BINARY_PATH})" + end + + input = points.map { |lon, lat| [lon.to_f, lat.to_f] }.to_json + out, err, status = Open3.capture3(BINARY_PATH, stdin_data: input) + + unless status.success? + raise "vrp_delaunay failed: #{err.presence || out}" + end + + JSON.parse(out) + end + end +end diff --git a/lib/vrp_graph/ensure_binary.rb b/lib/vrp_graph/ensure_binary.rb new file mode 100644 index 00000000..ea4c1345 --- /dev/null +++ b/lib/vrp_graph/ensure_binary.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# Ensures vrp_delaunay Rust binary is built at startup if missing. +# Used by server (puma) and workers (resque) so graph endpoint works without manual rake. + +module VrpGraph + module EnsureBinary + BINARY_PATH = File.expand_path('../../exe/vrp_delaunay', __dir__) + EXT_DIR = File.expand_path('../../ext/vrp_delaunay', __dir__) + + module_function + + # Builds vrp_delaunay if binary is missing. Idempotent. + # Logs and returns false if cargo is unavailable or build fails. + def ensure_built! + return true if File.executable?(BINARY_PATH) + + return false unless cargo_available? + + build! + end + + def cargo_available? + system('which cargo > /dev/null 2>&1') + end + + def build! + Dir.chdir(EXT_DIR) do + return false unless system('cargo build --release', out: $stdout, err: $stderr) + + bin = File.join(EXT_DIR, 'target/release/vrp_delaunay') + return false unless File.executable?(bin) + + dest = File.expand_path('../../exe', __dir__) + FileUtils.mkdir_p(dest) + FileUtils.cp(bin, File.join(dest, 'vrp_delaunay')) + true + end + rescue Errno::ENOENT + false + end + end +end diff --git a/lib/vrp_graph/graph_builder.rb b/lib/vrp_graph/graph_builder.rb new file mode 100644 index 00000000..f6179d46 --- /dev/null +++ b/lib/vrp_graph/graph_builder.rb @@ -0,0 +1,380 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# Graph builder for VRP services: Delaunay triangulation, compatibility checks, K-NN. +# Uses travel time matrix (routing or precomputed). + +require_relative 'delaunay_adapter' +require_relative 'skills_compatibility' +require_relative 'timewindow_compatibility' +require_relative 'capacity_compatibility' +require_relative 'knn_neighborhood' + +module VrpGraph + # Orchestrates Delaunay triangulation, compatibility checks, K-NN, and Graph model creation. + class GraphBuilder + def initialize(vrp, options = {}) + @vrp = vrp + @matrix_id = options[:matrix_id] || vrp.vehicles.first&.matrix_id + end + + def build + t_build_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + services = @vrp.services.reject{ |s| s.activity.nil? || s.activity.point.nil? } + return nil if services.empty? + + # Unique points (1 point can have several services). Source: vrp.points filtered by usage in services. + used_point_ids = services.map { |s| s.activity.point_id || s.activity.point&.id }.compact.uniq + graph_points = @vrp.points.select { |p| used_point_ids.include?(p.id) } + # Fallback if vrp.points is empty: derive from services + graph_points = services.map{ |s| s.activity.point }.compact.uniq(&:id) if graph_points.empty? + + point_by_index = graph_points.each_with_index.to_h { |p, i| [i, p] } + services_by_point_id = {} + services.each do |s| + pid = (s.activity.point_id || s.activity.point&.id).to_s + (services_by_point_id[pid] ||= []) << s + end + + # One coordinate list per point (no duplicate points in Delaunay) + delaunay_points = graph_points.map { |p| point_location_coords(p) } + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delaunay_edges = DelaunayAdapter.compute_edges(delaunay_points) + log_duration('graph_delaunay', t0) + + # Build nodes (keyed by point_id) and map service_id -> point_id + nodes = {} + service_point = {} + services.each do |s| + activity = s.activity + pt = activity.point + next unless pt + + loc = pt.location + pid = pt.id + + node = (nodes[pid] ||= { + point: { lat: loc&.lat, lon: loc&.lon }, + services: [] + }) + + node[:services] << { + id: s.id, + skills: s.skills.to_a.map(&:to_s), + timewindows: (activity.timewindows || []).map{ |tw| { start: tw.start, end: tw.end } }, + duration: activity.duration, + setup_duration: activity.setup_duration, + quantities: s.quantities.map{ |q| + { + unit_id: q.unit_id, + value: q.value, + pickup: q.pickup, + delivery: q.delivery, + fill: q.fill, + empty: q.empty + } + } + } + + service_point[s.id] = pid + end + + # Use VRP matrix only if already present (no compute). For K-NN without matrix: rectangular via router only. + vrp_matrix = @vrp.matrices.find{ |m| m.id == @matrix_id } + vrp_time_matrix = vrp_matrix&.time + + # Skills incompatibilities + t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + skills_incompat = SkillsCompatibility.compute_incompatibilities(@vrp) + log_duration('graph_skills_compatibility', t2) + + # Timewindow incompatibilities (timewindows + duration only, no travel time) + t3 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + tw_incompat = TimewindowCompatibility.compute_incompatibilities(@vrp) + log_duration('graph_timewindow_compatibility', t3) + + # Capacity incompatibilities (pairs at different points: sum of quantities > max vehicle capacity) + t4 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + cap_incompat = CapacityCompatibility.compute_incompatibilities(@vrp) + log_duration('graph_capacity_compatibility', t4) + + # Merge nested incompatibilities (each is incompat[a][b] = true) + all_incompat = merge_nested_incompat(skills_incompat, tw_incompat, cap_incompat) + + # Map Delaunay edges (point indices) to point IDs and filter: keep edge if at least one service pair is compatible + t5 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + original_degree = Hash.new(0) + delaunay_edges.each do |i, j| + pid_a = point_by_index[i]&.id + pid_b = point_by_index[j]&.id + next unless pid_a && pid_b && pid_a != pid_b + + original_degree[pid_a] += 1 + original_degree[pid_b] += 1 + end + + edges = [] + delaunay_edges.each do |i, j| + pid_a = point_by_index[i]&.id + pid_b = point_by_index[j]&.id + next unless pid_a && pid_b + next if pid_a == pid_b + + # Edge is valid if at least one (service at A, service at B) pair is not incompatible + compatible = + (services_by_point_id[pid_a] || []).any? { |s_a| + (services_by_point_id[pid_b] || []).any? { |s_b| + !all_incompat.dig(s_a.id, s_b.id) + } + } + next unless compatible + + edges << [pid_a, pid_b] + end + + # Track degree per point after filtering + filtered_degree = Hash.new(0) + edges.each do |a, b| + filtered_degree[a] += 1 + filtered_degree[b] += 1 + end + + # Points that lost at least one Delaunay edge + repaired_nodes = {} + original_degree.each do |pid, deg| + next if deg <= (filtered_degree[pid] || 0) + + repaired_nodes[pid] = true + end + + removed = delaunay_edges.size - edges.size + log_duration( + 'graph_edges_filter', + t5, + "delaunay=#{delaunay_edges.size} kept=#{edges.size} removed=#{removed} repaired_nodes=#{repaired_nodes.size}" + ) + + # K-NN: use square matrix in memory when available; otherwise compute rectangular (repaired × other) via router only. + t6 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + repaired_pids = repaired_nodes.keys + all_pids = graph_points.map { |p| p.id } + other_pids = all_pids - repaired_pids + point_incompat = build_point_incompat(services_by_point_id, all_incompat, all_pids) + pid_to_point = graph_points.to_h { |p| [p.id, p] } + knn_matrix = + if vrp_time_matrix + { square: vrp_time_matrix, pid_to_point: pid_to_point } + elsif repaired_pids.any? && other_pids.any? + { rectangular: compute_knn_rectangular_via_router(repaired_pids, other_pids, pid_to_point) } + else + { rectangular: [] } + end + k_per_point = (repaired_pids.empty? || other_pids.empty?) ? 0 : other_pids.size + knn_neighbors = KnnNeighborhood.compute_knn_points( + repaired_pids, other_pids, knn_matrix, point_incompat, k: k_per_point + ) + # Add K-NN arcs as edges (point-to-point) + edge_set = edges.map{ |a, b| [a, b].sort }.to_h{ |p| [p, true] } + knn_added = 0 + knn_segments = [] + knn_edge_indices = [] + knn_neighbors.each do |pid_a, neighbor_pids| + next unless repaired_nodes[pid_a] + + missing = (original_degree[pid_a] || 0) - (filtered_degree[pid_a] || 0) + next if missing <= 0 + + added = 0 + neighbor_pids.each do |pid_b| + pair = [pid_a, pid_b].sort + next if edge_set.key?(pair) + + edges << [pid_a, pid_b] + edge_set[pair] = true + knn_added += 1 + added += 1 + + node_a = nodes[pid_a] + node_b = nodes[pid_b] + if node_a && node_b + pa = node_a[:point] || node_a['point'] + pb = node_b[:point] || node_b['point'] + if pa && pb && pa[:lat] && pa[:lon] && pb[:lat] && pb[:lon] + knn_segments << [pa[:lat].to_f, pa[:lon].to_f, pb[:lat].to_f, pb[:lon].to_f] + knn_edge_indices << (edges.size - 1) + end + end + + break if added >= missing + end + end + + add_knn_traces!(edges, knn_segments, knn_edge_indices) if knn_segments.any? + + knn_matrix_type = vrp_time_matrix ? 'square_in_memory' : "rectangular=#{repaired_pids.size}x#{other_pids.size}" + log_duration('graph_knn', t6, "knn_added=#{knn_added} #{knn_matrix_type}") + + log_duration('graph_build_total', t_build_start) + + Models::Graph.new( + nodes: nodes, + edges: edges, + incompatibilities: nested_incompat_to_pairs(all_incompat), + knn_neighbors: knn_neighbors, + metadata: { + delaunay_built_at: Time.now.iso8601, + matrix_id_used: @matrix_id + } + ) + end + + private + + def merge_nested_incompat(*hashes) + result = Hash.new { |h, k| h[k] = {} } + hashes.each do |h| + h.each do |a, inner| + inner.each_key { |b| result[a][b] = true } + end + end + result + end + + def nested_incompat_to_pairs(nested) + pairs = [] + seen = {} + nested.each do |a, inner| + inner.each_key do |b| + pair = [a.to_s, b.to_s].sort + next if seen[pair] + + seen[pair] = true + pairs << pair + end + end + pairs + end + + def point_coords(service) + loc = service.activity.point&.location + [loc&.lon || 0.0, loc&.lat || 0.0] + end + + # [lon, lat] for Delaunay (one entry per point, no duplicates) + def point_location_coords(point) + loc = point&.location + [loc&.lon.to_f || 0.0, loc&.lat.to_f || 0.0] + end + + def log_duration(label, start_time, extra = nil) + return unless defined?(OptimizerLogger) + + elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2) + msg = "VrpGraph #{label}: #{elapsed_ms}ms" + msg += " (#{extra})" if extra + OptimizerLogger.log(msg, level: :info) + end + + # Point-level incompatibility: [pid_a, pid_b] is incompatible iff no (service at A, service at B) pair is compatible. + def build_point_incompat(services_by_point_id, all_incompat, all_pids) + incompat = {} + all_pids.each do |pid_a| + all_pids.each do |pid_b| + next if pid_a == pid_b + + compatible = + (services_by_point_id[pid_a] || []).any? { |s_a| + (services_by_point_id[pid_b] || []).any? { |s_b| + !all_incompat.dig(s_a.id, s_b.id) + } + } + incompat[[pid_a, pid_b].sort] = true unless compatible + end + end + incompat + end + + # Compute rectangular matrix (repaired × other) via router when no square matrix is available. + def compute_knn_rectangular_via_router(repaired_pids, other_pids, pid_to_point) + return [] unless @vrp.router + return [] unless defined?(OptimizerWrapper) && OptimizerWrapper.config[:router]&.dig(:url) + + from_points = + repaired_pids.map { |pid| + pt = pid_to_point[pid] + pt&.location ? [pt.location.lat.to_f, pt.location.lon.to_f] : nil + }.compact + to_points = + other_pids.map { |pid| + pt = pid_to_point[pid] + pt&.location ? [pt.location.lat.to_f, pt.location.lon.to_f] : nil + }.compact + return [] if from_points.size != repaired_pids.size || to_points.size != other_pids.size + + vehicle = @vrp.vehicles.first + mode = vehicle&.router_mode&.to_sym || :car + router_matrices = @vrp.router.matrix( + OptimizerWrapper.config[:router][:url], + mode, + [:time], + from_points, + to_points, + vehicle&.router_options || {} + ) + router_matrices&.first || [] + end + + def add_knn_traces!(edges, segments, edge_indices) + return unless @vrp.router + return unless defined?(OptimizerWrapper) && OptimizerWrapper.config[:router]&.dig(:url) + + vehicle = @vrp.vehicles.first + mode = vehicle&.router_mode&.to_sym || :car + dimension = vehicle&.router_dimension || :time + options = vehicle&.router_options || {} + + info = @vrp.router.compute_batch( + OptimizerWrapper.config[:router][:url], + mode, + dimension, + segments, + false, # polyline: false -> raw coordinates + options + ) + return unless info + + info.each_with_index do |data, idx| + next unless data + + _dist, _time, trace = data + next if trace.nil? || !trace.is_a?(Array) || trace.empty? + + edge_idx = edge_indices[idx] + next unless edge_idx + + a, b = edges[edge_idx][0], edges[edge_idx][1] + edges[edge_idx] = [a, b, trace] + end + rescue StandardError => e + log_duration('graph_knn_geometry_error', Process.clock_gettime(Process::CLOCK_MONOTONIC), e.message) + end + end +end diff --git a/lib/vrp_graph/knn_neighborhood.rb b/lib/vrp_graph/knn_neighborhood.rb new file mode 100644 index 00000000..fca958a6 --- /dev/null +++ b/lib/vrp_graph/knn_neighborhood.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# K-Nearest Neighbors using travel time matrix (point-based), filtering incompatible point pairs. +module VrpGraph + module KnnNeighborhood + module_function + + # K-NN for points. Matrix base is always points. + # knn_matrix: { square: points_matrix, pid_to_point: {} } when square matrix in memory, + # or { rectangular: [[...], ...] } when only rectangular (repaired × other) was computed. + # + # @param repaired_point_ids [Array] point ids that lost Delaunay edges (rows) + # @param other_point_ids [Array] other point ids, candidate neighbors (columns) + # @param knn_matrix [Hash] :square + :pid_to_point for direct lookup, or :rectangular + # @param point_incompat [Hash, Array] incompatible point pairs + # @param k [Integer] max neighbors per repaired point + # @return [Hash] point_id => [neighbor_point_id, ...] sorted by travel time + def compute_knn_points(repaired_point_ids, other_point_ids, knn_matrix, point_incompat, k: 10) + incompat_set = point_incompat.is_a?(Hash) ? point_incompat : point_incompat.to_h { |a, b| [[a.to_s, b.to_s].sort, true] } + result = {} + + repaired_point_ids.each_with_index do |pid, i| + neighbors = + other_point_ids.each_with_index.filter_map do |other_pid, j| + pair = [pid, other_pid].sort + next if incompat_set.key?(pair) + + travel = travel_from_matrix(pid, other_pid, i, j, knn_matrix) + [other_pid, travel] + end + neighbors.sort_by!{ |_, t| t } + result[pid] = neighbors.first(k).map(&:first) + end + result + end + + def travel_from_matrix(pid_a, pid_b, row_idx, col_idx, knn_matrix) + if knn_matrix[:square] && knn_matrix[:pid_to_point] + pt_a = knn_matrix[:pid_to_point][pid_a] + pt_b = knn_matrix[:pid_to_point][pid_b] + return 0 unless pt_a&.matrix_index && pt_b&.matrix_index + + knn_matrix[:square][pt_a.matrix_index]&.[](pt_b.matrix_index) || 0 + elsif knn_matrix[:rectangular]&.any? + (knn_matrix[:rectangular][row_idx] || [])[col_idx] || 0 + else + 0 + end + end + end +end diff --git a/lib/vrp_graph/skills_compatibility.rb b/lib/vrp_graph/skills_compatibility.rb new file mode 100644 index 00000000..2c0b191e --- /dev/null +++ b/lib/vrp_graph/skills_compatibility.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# Checks skills compatibility between services: two services are incompatible +# if no vehicle can serve both (skills never appear together on a vehicle). +module VrpGraph + module SkillsCompatibility + module_function + + # @param vrp [Models::Vrp] + # @return [Hash] nested: incompat[service_id_a][service_id_b] = true (bidirectional) + def compute_incompatibilities(vrp) + incompat_skills = build_incompat_skills(vrp.vehicles) + incompat = Hash.new { |h, k| h[k] = {} } + services = vrp.services.to_a + + services.each_with_index do |s1, i| + j = i + 1 + while j < services.size + s2 = services[j] + if services_incompatible?(s1, s2, incompat_skills) + id_a = s1.id + id_b = s2.id + incompat[id_a][id_b] = true + incompat[id_b][id_a] = true + end + j += 1 + end + end + incompat + end + + # incompat_skills[skill1][skill2] = true when skill1 and skill2 never appear together on a vehicle. + # Factor by unique skill config (vehicle.skills.first) to avoid redundant iterations. + def build_incompat_skills(vehicles) + unique_skill_configs = vehicles.map { |v| v.skills.first.to_a.map(&:to_s).sort }.uniq + + compatible_pairs = {} + unique_skill_configs.each do |skills| + skills.each do |a| + skills.each do |b| + compatible_pairs[[a, b].sort] = true + end + end + end + + all_skills = unique_skill_configs.flatten.uniq + + incompat = Hash.new { |h, k| h[k] = {} } + all_skills.each do |a| + all_skills.each do |b| + next if a == b + next if compatible_pairs[[a, b].sort] + + incompat[a][b] = true + end + end + incompat + end + + def services_incompatible?(s1, s2, incompat_skills) + sk1 = s1.skills.to_a.map(&:to_s) + sk2 = s2.skills.to_a.map(&:to_s) + sk1.any? { |a| sk2.any? { |b| incompat_skills[a].key?(b) } } + end + end +end diff --git a/lib/vrp_graph/timewindow_compatibility.rb b/lib/vrp_graph/timewindow_compatibility.rb new file mode 100644 index 00000000..2d6545e9 --- /dev/null +++ b/lib/vrp_graph/timewindow_compatibility.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Copyright © Cartoway, 2026 +# +# 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: +# +# +module VrpGraph + # Checks time window compatibility: two services are incompatible if + # duration of first makes it impossible to serve both in either order. + # Uses only timewindows and duration (no travel time). + module TimewindowCompatibility + module_function + + # @param vrp [Models::Vrp] + # @return [Hash] nested: incompat[service_id_a][service_id_b] = true (bidirectional) + def compute_incompatibilities(vrp) + incompat = Hash.new { |h, k| h[k] = {} } + services = vrp.services.to_a + + services.each_with_index do |s1, i| + j = i + 1 + while j < services.size + s2 = services[j] + if s1&.activity && s2&.activity && !can_sequence?(s1, s2) && !can_sequence?(s2, s1) + id_a = s1.id + id_b = s2.id + incompat[id_a][id_b] = true + incompat[id_b][id_a] = true + end + j += 1 + end + end + incompat + end + + # Can we serve first then second? (arrival at second feasible, travel_time = 0) + def can_sequence?(first, second) + tws_first = first.activity&.timewindows.to_a + tws_second = second.activity&.timewindows.to_a + # No timewindows means no constraint: necessarily compatible + return true if tws_first.empty? || tws_second.empty? + + duration_first = first.activity&.duration_on || 0 + setup_first = first.activity&.setup_duration_on || 0 + + tws_first.any? do |tw1| + # Earliest we can leave first and arrive at second (no travel). We can wait if we arrive early. + earliest_arrival = (tw1.start || 0) + duration_first + setup_first + + tws_second.any? do |tw2| + next true if tw2.end.nil? # No end: necessarily compatible + + earliest_arrival <= tw2.end + end + end + end + end +end diff --git a/lib/vrp_graph/version.rb b/lib/vrp_graph/version.rb new file mode 100644 index 00000000..4acdb907 --- /dev/null +++ b/lib/vrp_graph/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module VrpGraph + VERSION = '0.1.0' +end diff --git a/models/graph.rb b/models/graph.rb new file mode 100644 index 00000000..510251c8 --- /dev/null +++ b/models/graph.rb @@ -0,0 +1,134 @@ +# Copyright © Cartoway, 2026 +# +# 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: +# +# +# Graph model for VRP: Delaunay triangulation, compatibilities, K-NN neighborhood. +# Nodes and edges are point-based (point_id). knn_neighbors: point_id => [neighbor_point_id, ...]. +# Plain value object (not ActiveHash). + +module Models + class Graph < Base + field :id + field :nodes, default: {} + field :edges, default: [] + field :incompatibilities, default: [] + field :knn_neighbors, default: {} + field :metadata, default: {} + + def to_hash + { + nodes: nodes || {}, + edges: edges || [], + incompatibilities: incompatibilities || [], + knn_neighbors: knn_neighbors || {}, + metadata: metadata || {} + } + end + + def incompatible?(service_id_a, service_id_b) + pair = [service_id_a, service_id_b].sort + (incompatibilities || []).any?{ |a, b| [a, b].sort == pair } + end + + # K-NN is point-based: returns neighbor point_ids for the given point_id. + def neighbors_for_point(point_id) + (knn_neighbors || {})[point_id] || [] + end + + def edge_set + @edge_set ||= (edges || []).map{ |e| [[e[0], e[1]].sort, true] }.to_h + end + + def connected?(point_id_a, point_id_b) + edge_set.key?([point_id_a, point_id_b].sort) + end + + # Returns connected point pairs within a route's point set. + # @param point_ids [Array] Point IDs in the route + # @return [Array<[String, String]>] Pairs of connected point IDs + def tours_connectivity(point_ids) + ids = point_ids.to_set + edges.select{ |e| ids.include?(e[0]) && ids.include?(e[1]) }.map{ |e| [e[0], e[1]].sort }.uniq + end + + # @param solution [Models::Solution] + # @return [Hash] route_index => [[point_id_a, point_id_b], ...] + def tours_connectivity_from_solution(solution) + result = {} + solution.routes.each_with_index do |route, idx| + point_ids = route.stops.filter_map{ |s| s.activity&.point_id }.compact + result[idx] = tours_connectivity(point_ids) + end + result + end + + def to_geojson + require 'rgeo' + require 'rgeo/geo_json' + geo_factory = RGeo::Geographic.spherical_factory(srid: 4326) + entity_factory = RGeo::GeoJSON::EntityFactory.instance + features = [] + + # Points for each node (point-level); each node aggregates its services and constraints + nodes.each do |point_id, data| + pt = data[:point] || data['point'] + next unless pt + + lon = pt[:lon] || pt['lon'] + lat = pt[:lat] || pt['lat'] + next unless lon && lat + + point_geom = geo_factory.point(lon.to_f, lat.to_f) + + props = { + point_id: point_id, + services: data[:services] || data['services'] + }.delete_if{ |_k, v| v.nil? } + + features << entity_factory.feature(point_geom, point_id, props) + end + + # LineStrings for each edge (format: [a, b] or [a, b, geometry]) + edges.each do |e| + a, b = e[0], e[1] + geometry = e[2] + node_a = nodes[a] || nodes[a.to_s] + node_b = nodes[b] || nodes[b.to_s] + next unless node_a && node_b + + pt_a = node_a[:point] || node_a['point'] + pt_b = node_b[:point] || node_b['point'] + next unless pt_a && pt_b + + pts = + if geometry.is_a?(Array) && geometry.any? + # geometry from router: [[lon,lat], [lon,lat], ...] + geometry.map{ |lon, lat| geo_factory.point(lon.to_f, lat.to_f) } + else + [ + geo_factory.point((pt_a[:lon] || pt_a['lon']).to_f, (pt_a[:lat] || pt_a['lat']).to_f), + geo_factory.point((pt_b[:lon] || pt_b['lon']).to_f, (pt_b[:lat] || pt_b['lat']).to_f) + ] + end + line_geom = geo_factory.line_string(pts) + features << entity_factory.feature(line_geom, nil, { from: a, to: b, incompatible: incompatible?(a, b) }) + end + + collection = entity_factory.feature_collection(features) + RGeo::GeoJSON.encode(collection) + end + end +end diff --git a/models/vrp.rb b/models/vrp.rb index a191a438..94c430b0 100644 --- a/models/vrp.rb +++ b/models/vrp.rb @@ -48,6 +48,7 @@ class Vrp < Base has_many :subtours, class_name: 'Models::Subtour' has_many :zones, class_name: 'Models::Zone' belongs_to :configuration, class_name: 'Models::Configuration' + belongs_to :graph, class_name: 'Models::Graph', as_json: :none, vrp_result: :hide def self.create(hash, options = {}) options = { delete: true, check: true }.merge(options) diff --git a/test/api/v01/graph_test.rb b/test/api/v01/graph_test.rb new file mode 100644 index 00000000..fc36a5df --- /dev/null +++ b/test/api/v01/graph_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require './test/test_helper' +require './test/api/v01/helpers/request_helper' + +module Api + module V01 + class GraphTest < Minitest::Test + include Rack::Test::Methods + include TestHelper + + def app + Api::Root + end + + def setup + skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' unless + File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) + end + + def test_get_graph_returns_full_structure + post '/0.1/vrp/graph', + { api_key: 'demo', vrp: VRP.toy }.to_json, + 'CONTENT_TYPE' => 'application/json' + + assert_equal 200, last_response.status, last_response.body + data = JSON.parse(last_response.body) + assert data['nodes'].is_a?(Hash) + assert data['edges'].is_a?(Array) + assert data['incompatibilities'].is_a?(Array) + assert data['knn_neighbors'].is_a?(Hash) + assert data['metadata'].is_a?(Hash) + end + + def test_get_graph_geojson_format + post '/0.1/vrp/graph', + { api_key: 'demo', vrp: VRP.toy, format: 'geojson' }.to_json, + 'CONTENT_TYPE' => 'application/json' + + assert_equal 200, last_response.status, last_response.body + data = JSON.parse(last_response.body) + assert_equal 'FeatureCollection', data['type'] + assert data['features'].is_a?(Array) + end + + def test_get_graph_invalid_vrp_returns_400 + post '/0.1/vrp/graph', + { api_key: 'demo', vrp: {} }.to_json, + 'CONTENT_TYPE' => 'application/json' + + assert_equal 400, last_response.status + end + end + end +end diff --git a/test/lib/interpreters/multi_trip_test.rb b/test/lib/interpreters/multi_trip_test.rb new file mode 100644 index 00000000..4131105f --- /dev/null +++ b/test/lib/interpreters/multi_trip_test.rb @@ -0,0 +1,18 @@ +require './test/test_helper' + +class MultiTripInterpreterTest < Minitest::Test + def test_class_presolve_delegates_to_instance + service_vrp = Minitest::Mock.new + job_id = 42 + + multi_trip_instance = Minitest::Mock.new + multi_trip_instance.expect(:presolve, :result, [service_vrp, job_id]) + + Interpreters::MultiTrip.stub(:new, multi_trip_instance) do + result = Interpreters::MultiTrip.presolve(service_vrp, job_id) + assert_equal :result, result + end + + multi_trip_instance.verify + end +end diff --git a/test/lib/vrp_graph/capacity_compatibility_test.rb b/test/lib/vrp_graph/capacity_compatibility_test.rb new file mode 100644 index 00000000..d4bc7462 --- /dev/null +++ b/test/lib/vrp_graph/capacity_compatibility_test.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require './test/test_helper' + +module VrpGraph + class CapacityCompatibilityTest < Minitest::Test + # VRP with 2 services at different points, quantities 2 each, vehicle capacity 5 + # Sum 4 <= 5 => compatible + def test_pair_compatible_when_sum_within_max_capacity + vrp = TestHelper.create( + units: [{ id: 'kg' }], + matrices: [{ + id: 'm1', + time: [[0, 10, 10], [10, 0, 10], [10, 10, 0]] + }], + points: [ + { id: 'p0', matrix_index: 0, location: { lat: 45.0, lon: 5.0 } }, + { id: 'p1', matrix_index: 1, location: { lat: 45.01, lon: 5.01 } }, + { id: 'p2', matrix_index: 2, location: { lat: 45.02, lon: 5.02 } } + ], + vehicles: [{ + id: 'v1', + matrix_id: 'm1', + start_point_id: 'p0', + capacities: [{ unit_id: 'kg', limit: 5 }] + }], + services: [ + { id: 's1', visits_number: 1, activity: { point_id: 'p1' }, quantities: [{ unit_id: 'kg', value: 2 }] }, + { id: 's2', visits_number: 1, activity: { point_id: 'p2' }, quantities: [{ unit_id: 'kg', value: 2 }] } + ], + configuration: { resolution: { duration: 100 }, restitution: { intermediate_solutions: false } } + ) + + incompat = CapacityCompatibility.compute_incompatibilities(vrp) + refute incompat.dig('s1', 's2'), "s1+s2=4 <= 5 should be compatible, got #{incompat.inspect}" + end + + # VRP with 2 services at different points, quantities 3 each, vehicle capacity 5 + # Sum 6 > 5 => incompatible + def test_pair_incompatible_when_sum_exceeds_max_capacity + vrp = TestHelper.create( + units: [{ id: 'kg' }], + matrices: [{ + id: 'm1', + time: [[0, 10, 10], [10, 0, 10], [10, 10, 0]] + }], + points: [ + { id: 'p0', matrix_index: 0, location: { lat: 45.0, lon: 5.0 } }, + { id: 'p1', matrix_index: 1, location: { lat: 45.01, lon: 5.01 } }, + { id: 'p2', matrix_index: 2, location: { lat: 45.02, lon: 5.02 } } + ], + vehicles: [{ + id: 'v1', + matrix_id: 'm1', + start_point_id: 'p0', + capacities: [{ unit_id: 'kg', limit: 5 }] + }], + services: [ + { id: 's1', visits_number: 1, activity: { point_id: 'p1' }, quantities: [{ unit_id: 'kg', value: 3 }] }, + { id: 's2', visits_number: 1, activity: { point_id: 'p2' }, quantities: [{ unit_id: 'kg', value: 3 }] } + ], + configuration: { resolution: { duration: 100 }, restitution: { intermediate_solutions: false } } + ) + + incompat = CapacityCompatibility.compute_incompatibilities(vrp) + assert incompat.dig('s1', 's2'), "s1+s2=6 > 5 should be incompatible, got #{incompat.inspect}" + end + + # Max capacity across vehicles: v1 cap 3, v2 cap 6 => max 6. s1(2)+s2(3)=5 <= 6 + def test_uses_max_capacity_across_vehicles + vrp = TestHelper.create( + units: [{ id: 'kg' }], + matrices: [{ + id: 'm1', + time: [[0, 10, 10], [10, 0, 10], [10, 10, 0]] + }], + points: [ + { id: 'p0', matrix_index: 0, location: { lat: 45.0, lon: 5.0 } }, + { id: 'p1', matrix_index: 1, location: { lat: 45.01, lon: 5.01 } }, + { id: 'p2', matrix_index: 2, location: { lat: 45.02, lon: 5.02 } } + ], + vehicles: [ + { id: 'v1', matrix_id: 'm1', start_point_id: 'p0', capacities: [{ unit_id: 'kg', limit: 3 }] }, + { id: 'v2', matrix_id: 'm1', start_point_id: 'p0', capacities: [{ unit_id: 'kg', limit: 6 }] } + ], + services: [ + { id: 's1', visits_number: 1, activity: { point_id: 'p1' }, quantities: [{ unit_id: 'kg', value: 2 }] }, + { id: 's2', visits_number: 1, activity: { point_id: 'p2' }, quantities: [{ unit_id: 'kg', value: 3 }] } + ], + configuration: { resolution: { duration: 100 }, restitution: { intermediate_solutions: false } } + ) + + incompat = CapacityCompatibility.compute_incompatibilities(vrp) + refute incompat.dig('s1', 's2'), "max cap 6, s1+s2=5 => compatible" + end + + # Pickup and delivery checked independently: s1 delivery 4, s2 pickup 3 => compatible (both <= 5) + def test_pickup_and_delivery_checked_independently + vrp = TestHelper.create( + units: [{ id: 'kg' }], + matrices: [{ + id: 'm1', + time: [[0, 10, 10], [10, 0, 10], [10, 10, 0]] + }], + points: [ + { id: 'p0', matrix_index: 0, location: { lat: 45.0, lon: 5.0 } }, + { id: 'p1', matrix_index: 1, location: { lat: 45.01, lon: 5.01 } }, + { id: 'p2', matrix_index: 2, location: { lat: 45.02, lon: 5.02 } } + ], + vehicles: [{ + id: 'v1', + matrix_id: 'm1', + start_point_id: 'p0', + capacities: [{ unit_id: 'kg', limit: 5 }] + }], + services: [ + { id: 's1', visits_number: 1, activity: { point_id: 'p1' }, quantities: [{ unit_id: 'kg', delivery: 4 }] }, + { id: 's2', visits_number: 1, activity: { point_id: 'p2' }, quantities: [{ unit_id: 'kg', pickup: 3 }] } + ], + configuration: { resolution: { duration: 100 }, restitution: { intermediate_solutions: false } } + ) + + incompat = CapacityCompatibility.compute_incompatibilities(vrp) + refute incompat.dig('s1', 's2'), "delivery 4 + pickup 3, checked independently => compatible (both <= 5)" + end + + # Two deliveries 4+3=7 > 5 => incompatible + def test_two_deliveries_exceeding_capacity_incompatible + vrp = TestHelper.create( + units: [{ id: 'kg' }], + matrices: [{ + id: 'm1', + time: [[0, 10, 10], [10, 0, 10], [10, 10, 0]] + }], + points: [ + { id: 'p0', matrix_index: 0, location: { lat: 45.0, lon: 5.0 } }, + { id: 'p1', matrix_index: 1, location: { lat: 45.01, lon: 5.01 } }, + { id: 'p2', matrix_index: 2, location: { lat: 45.02, lon: 5.02 } } + ], + vehicles: [{ + id: 'v1', + matrix_id: 'm1', + start_point_id: 'p0', + capacities: [{ unit_id: 'kg', limit: 5 }] + }], + services: [ + { id: 's1', visits_number: 1, activity: { point_id: 'p1' }, quantities: [{ unit_id: 'kg', delivery: 4 }] }, + { id: 's2', visits_number: 1, activity: { point_id: 'p2' }, quantities: [{ unit_id: 'kg', delivery: 3 }] } + ], + configuration: { resolution: { duration: 100 }, restitution: { intermediate_solutions: false } } + ) + + incompat = CapacityCompatibility.compute_incompatibilities(vrp) + assert incompat.dig('s1', 's2'), "delivery 4+3=7 > 5 => incompatible" + end + + # Same point => skip (not checked by capacity, different-points only) + def test_skips_services_at_same_point + vrp = TestHelper.create( + units: [{ id: 'kg' }], + matrices: [{ + id: 'm1', + time: [[0, 10], [10, 0]] + }], + points: [ + { id: 'p0', matrix_index: 0, location: { lat: 45.0, lon: 5.0 } }, + { id: 'p1', matrix_index: 1, location: { lat: 45.01, lon: 5.01 } } + ], + vehicles: [{ + id: 'v1', + matrix_id: 'm1', + start_point_id: 'p0', + capacities: [{ unit_id: 'kg', limit: 2 }] + }], + services: [ + { id: 's1', visits_number: 1, activity: { point_id: 'p1' }, quantities: [{ unit_id: 'kg', value: 2 }] }, + { id: 's2', visits_number: 1, activity: { point_id: 'p1' }, quantities: [{ unit_id: 'kg', value: 2 }] } + ], + configuration: { resolution: { duration: 100 }, restitution: { intermediate_solutions: false } } + ) + + incompat = CapacityCompatibility.compute_incompatibilities(vrp) + refute incompat.dig('s1', 's2'), "same point => not checked, no capacity incompat added" + end + end +end diff --git a/test/lib/vrp_graph/delaunay_adapter_test.rb b/test/lib/vrp_graph/delaunay_adapter_test.rb new file mode 100644 index 00000000..0745de06 --- /dev/null +++ b/test/lib/vrp_graph/delaunay_adapter_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require './test/test_helper' + +module VrpGraph + class DelaunayAdapterTest < Minitest::Test + def setup + skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' unless File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) + end + + def test_compute_edges_returns_empty_for_insufficient_points + assert_equal [], DelaunayAdapter.compute_edges([]) + assert_equal [], DelaunayAdapter.compute_edges([[1.0, 2.0]]) + end + + def test_compute_edges_two_points + points = [[0.0, 0.0], [1.0, 1.0]] + edges = DelaunayAdapter.compute_edges(points) + assert_equal [[0, 1]], edges + end + + def test_compute_edges_three_points_triangle + points = [[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]] + edges = DelaunayAdapter.compute_edges(points) + assert_equal 3, edges.size + assert_includes edges, [0, 1] + assert_includes edges, [1, 2] + assert_includes edges, [0, 2] + end + + def test_compute_edges_four_points + points = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + edges = DelaunayAdapter.compute_edges(points) + assert edges.size >= 3 + edges.each do |a, b| + assert a < b, "Edges should be ordered (i < j): #{[a, b]}" + assert a < points.size && b < points.size + end + end + end +end diff --git a/test/lib/vrp_graph/ensure_binary_test.rb b/test/lib/vrp_graph/ensure_binary_test.rb new file mode 100644 index 00000000..4421671f --- /dev/null +++ b/test/lib/vrp_graph/ensure_binary_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require './test/test_helper' + +module VrpGraph + class EnsureBinaryTest < Minitest::Test + def test_ensure_built_returns_true_when_binary_exists + skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' unless + File.executable?(EnsureBinary::BINARY_PATH) + + assert EnsureBinary.ensure_built!, 'ensure_built! should return true when binary exists' + end + + def test_cargo_available_or_skip + skip 'cargo not in PATH' unless EnsureBinary.cargo_available? + + assert true + end + end +end diff --git a/test/lib/vrp_graph/graph_builder_test.rb b/test/lib/vrp_graph/graph_builder_test.rb new file mode 100644 index 00000000..b8b18d15 --- /dev/null +++ b/test/lib/vrp_graph/graph_builder_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require './test/test_helper' + +module VrpGraph + class GraphBuilderTest < Minitest::Test + def setup + skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' unless File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) + end + + def test_build_creates_graph_from_vrp + vrp = TestHelper.create(TestHelper.load_vrp(self, fixture_file: 'instance_andalusia')) + graph = GraphBuilder.new(vrp).build + assert graph + assert graph.is_a?(Models::Graph) + assert graph.nodes.any? + assert graph.edges.any? || graph.nodes.size < 2 + end + + def test_graph_to_geojson + vrp = TestHelper.create(TestHelper.load_vrp(self, fixture_file: 'instance_andalusia')) + graph = GraphBuilder.new(vrp).build + geojson = graph.to_geojson + assert geojson + assert geojson['type'] == 'FeatureCollection' + assert geojson['features'].is_a?(Array) + end + + def test_tours_connectivity_from_solution + vrp = TestHelper.create(TestHelper.load_vrp(self, fixture_file: 'instance_andalusia')) + graph = GraphBuilder.new(vrp).build + solution = OptimizerWrapper.wrapper_vrp('demo', { services: { vrp: [:demo] } }, vrp, nil).first + return skip 'No solution' unless solution&.routes&.any? + + conn = graph.tours_connectivity_from_solution(solution) + assert conn.is_a?(Hash) + end + end +end From cfb4401b04272252c44d9df3f39fbf764fe87687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Fri, 20 Mar 2026 10:09:34 +0100 Subject: [PATCH 4/6] Init RePartition --- Gemfile | 4 +- Gemfile.lock | 8 + core/strategies/orchestration.rb | 5 +- lib/heuristics/dichotomous_approach.rb | 10 +- lib/interpreters/re_partition.rb | 555 +++++++++++++++++++++ lib/vrp_graph/batch_assigner.rb | 30 +- lib/vrp_graph/graph_builder.rb | 285 +++++++---- models/graph.rb | 244 ++++++++- models/solution/route.rb | 10 + models/solution/solution.rb | 11 + models/vrp.rb | 19 + test/lib/interpreters/re_partition_test.rb | 204 ++++++++ test/lib/vrp_graph/graph_builder_test.rb | 159 +++++- test/wrapper_test.rb | 24 + wrappers/pyvrp.rb | 5 + wrappers/vroom.rb | 5 + wrappers/wrapper.rb | 33 +- 17 files changed, 1467 insertions(+), 144 deletions(-) create mode 100644 lib/interpreters/re_partition.rb create mode 100644 test/lib/interpreters/re_partition_test.rb diff --git a/Gemfile b/Gemfile index 845e1175..a1018f67 100644 --- a/Gemfile +++ b/Gemfile @@ -50,8 +50,8 @@ gem 'polylines' gem 'rgeo' gem 'rgeo-geojson', require: 'rgeo/geo_json' -# Constraint programming for batch assignment (optional: requires OR-Tools C++ library) -gem 'or-tools' +# Graph partitioning / clustering +gem 'graph-clustering', git: 'https://github.com/braktar/graph-clustering', glob: 'ruby/*.gemspec' gem 'sentry-resque' gem 'sentry-ruby' diff --git a/Gemfile.lock b/Gemfile.lock index 35d46cae..8a49e311 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,10 @@ +GIT + remote: https://github.com/braktar/graph-clustering + revision: 0ba22c3a2dbf8c8e57e2ae4afae42f9034ccf853 + glob: ruby/*.gemspec + specs: + graph-clustering (0.1.0) + GIT remote: https://github.com/cartoway/rubocop-policy.git revision: 209bde9a8088d4204b22829b2a9150860c16dcac @@ -312,6 +319,7 @@ DEPENDENCIES grape-swagger grape-swagger-entity grape_logging + graph-clustering! http_accept_language i18n minitest diff --git a/core/strategies/orchestration.rb b/core/strategies/orchestration.rb index 21310d36..2981786b 100644 --- a/core/strategies/orchestration.rb +++ b/core/strategies/orchestration.rb @@ -1,3 +1,5 @@ +require './lib/interpreters/re_partition.rb' + module Core module Strategies module Orchestration @@ -92,9 +94,8 @@ def define_process(service_vrp, job = nil, &block) expected_activity_count = vrp.visits # Calls define_process recursively + solution ||= Interpreters::RePartition.repartition(service_vrp, job, &block) solution ||= Interpreters::SplitClustering.split_clusters(service_vrp, job, &block) - # Calls define_process recursively - solution ||= Interpreters::Dichotomous.dichotomous_heuristic(service_vrp, job, &block) solution ||= Interpreters::MultiTrip.presolve(service_vrp, job, &block) diff --git a/lib/heuristics/dichotomous_approach.rb b/lib/heuristics/dichotomous_approach.rb index e29607c8..b116fd4e 100644 --- a/lib/heuristics/dichotomous_approach.rb +++ b/lib/heuristics/dichotomous_approach.rb @@ -23,6 +23,8 @@ module Interpreters class Dichotomous + # Deprecated: this interpreter is kept until RePartition is fully implemented + # Dichotomous heuristic is no longer called by the main orchestration pipeline. def self.dichotomous_candidate?(service_vrp) config = service_vrp.vrp.configuration service_vrp.dicho_level&.positive? || @@ -249,12 +251,8 @@ def self.build_initial_routes(solutions) solutions.flat_map{ |solution| next if solution.nil? - solution.routes.map{ |route| - missions = route.stops.map{ |stop| - next if stop.is_a?(Models::Solution::StopDepot) || stop.mission.is_a?(Models::Rest) - - stop.mission - }.compact + solution.routes.filter_map{ |route| + missions = route.missions_for_initial_routes next if missions.empty? Models::Route.create( diff --git a/lib/interpreters/re_partition.rb b/lib/interpreters/re_partition.rb new file mode 100644 index 00000000..aff44894 --- /dev/null +++ b/lib/interpreters/re_partition.rb @@ -0,0 +1,555 @@ +require './lib/vrp_graph' +require './lib/interpreters/split_clustering' +require './api/v01/api_base' + +module Interpreters + class RePartition + # Raised when a candidate solution assigns the same mission more often than allowed or mixes assigned/unassigned. + class DuplicateAssignmentError < StandardError; end + + # Shared-neighbor counts are raised to this power before roulette sampling. + # 1.0 = linear, < 1.0 reduces probability advantage of route neighbors. + NEIGHBOR_WEIGHT_POWER = 0.55 + + class << self + def repartition(service_vrp, job = nil, &block) + return nil unless candidate?(service_vrp) + + vrp = service_vrp.vrp + + # Number of repartition iterations (excluding the initial solution). + max_iterations = 4 + + iteration_duration = + if vrp.configuration.resolution.duration + (vrp.configuration.resolution.duration / (max_iterations + 1).to_f).floor + end + + graph = build_graph(vrp) + return nil unless graph + + repartition_default_json = build_default_service_vrp_json(service_vrp) + + initial_solution = initialize_resolution(repartition_default_json, iteration_duration) + return nil unless initial_solution + + validate_repartition_solution_no_duplicates!(initial_solution, vrp, 'RePartition initial solution') + + log "RePartition initial solution: #{initial_solution.routes.size} routes", level: :info + + best_solution = initial_solution + best_score = score_solution(best_solution, vrp) + + max_iterations.times do |iter| + improved_solution = + repartition_once( + repartition_context_payload, graph, best_solution, job, iteration_duration, + iteration_index: iter, + &block + ) + improved_score = score_solution(improved_solution, vrp) + + log "RePartition current best score: #{best_score[0]} unassigned, #{best_score[1]} cost", level: :info + unless better_score?(improved_score, best_score) + next + end + + log "RePartition improved solution: #{improved_score[0]} unassigned, #{improved_score[1]} cost", level: :info + + best_solution = improved_solution + best_score = improved_score + end + + best_solution + end + + private + + def initialize_resolution(repartition_default_json, iteration_duration) + # VROOM only for the initial SplitClustering tree; batch repartitions keep the caller's solver. + initial_split_json = + if OptimizerWrapper.config[:services]&.key?(:vroom) + repartition_default_json.merge(service: :vroom) + else + repartition_default_json + end + split_service_vrp = Models::ResolutionContext.new(initial_split_json) + split_vrp = split_service_vrp.vrp + + split_resolution = split_vrp.configuration.resolution + + # Make sure vehicle_limit allows splitting on vehicles dimension. + split_resolution.vehicle_limit ||= split_vrp.vehicles.size + + # Apply per-iteration budget to the initial SplitClustering attempt. + if iteration_duration && (split_resolution.duration.nil? || split_resolution.duration > iteration_duration) + split_resolution.duration = iteration_duration + end + + Interpreters::SplitClustering.split_clusters(split_service_vrp, job, &block) + end + + # Detect impossible overlaps after merging sub-solutions (same service over-assigned, same vehicle twice, etc.). + def validate_repartition_solution_no_duplicates!(solution, vrp, context_message) + return if solution.nil? || !solution.is_a?(Models::Solution) + + mission_keys = [] + solution.routes.each do |route| + route.stops.each do |stop| + next if stop.is_a?(Models::Solution::StopDepot) + + # One logical mission per stop (pickup/delivery vs plain service). + if stop.pickup_shipment_id + mission_keys << [:pickup, stop.pickup_shipment_id] + elsif stop.delivery_shipment_id + mission_keys << [:delivery, stop.delivery_shipment_id] + elsif stop.service_id + mission_keys << [:service, stop.service_id] + end + end + end + + counts = mission_keys.tally + service_by_id = vrp.services.each_with_object({}){ |s, h| h[s.id] = s } + + counts.each do |(kind, id), cnt| + case kind + when :service + svc = service_by_id[id] + max_visits = svc ? [svc.visits_number.to_i, 1].max : 1 + next if cnt <= max_visits + + raise DuplicateAssignmentError.new( + "#{context_message}: service #{id.inspect} appears #{cnt} times on routes (max #{max_visits})" + ) + when :pickup, :delivery + next if cnt <= 1 + + raise DuplicateAssignmentError.new( + "#{context_message}: #{kind} shipment #{id.inspect} appears #{cnt} times on routes" + ) + end + end + + assigned_service_ids = + counts.select{ |(k, _), c| k == :service && c.positive? }.keys.map{ |(_, sid)| sid }.to_set + solution.unassigned_stops.each do |stop| + sid = stop.service_id + next if sid.nil? + + next unless assigned_service_ids.include?(sid) + + raise DuplicateAssignmentError.new( + "#{context_message}: service #{sid.inspect} is both assigned on a route and listed as unassigned" + ) + end + + vehicle_route_counts = Hash.new(0) + solution.routes.each do |route| + has_mission_stop = + route.stops.any? do |stop| + if stop.is_a?(Models::Solution::StopDepot) + false + else + stop.service_id || stop.pickup_shipment_id || stop.delivery_shipment_id + end + end + next unless has_mission_stop + + vehicle_route_counts[route.vehicle_id] += 1 + end + + vehicle_route_counts.each do |vid, n| + next if n <= 1 + + raise DuplicateAssignmentError.new( + "#{context_message}: vehicle #{vid.inspect} has #{n} routes carrying missions (expected at most 1)" + ) + end + end + + def damped_neighbor_weight(raw_count) + r = raw_count.to_f + return 0.0 unless r.positive? + + r**NEIGHBOR_WEIGHT_POWER + end + + # Per-iteration RNG so batching varies between repartition rounds but stays reproducible + # when resolution.random_seed is set. + def clustering_rng(vrp, iteration_index) + seed = vrp.configuration.resolution&.random_seed + return Random.new if seed.nil? + + Random.new(seed.to_i + iteration_index) + end + + def build_default_service_vrp_json(service_vrp) + raw = service_vrp.as_json + sanitize_repartition_vrp_json!(raw[:vrp]) if raw[:vrp] + + raw.merge(split_solve_data: {}) + end + + # In-place fixes for safe Vrp.create after JSON round-trip. + def sanitize_repartition_vrp_json!(vrp_json) + return unless vrp_json + + # Sub-problems compute their own routing matrices; stale matrix_index + # without matrices triggers DiscordantProblemError. + vrp_json[:points]&.each { |p| p.delete(:matrix_index) } + + vrp_json[:relations]&.each do |r| + r[:linked_service_ids] ||= [] + r[:linked_vehicle_ids] ||= [] + end + + vrp_json[:matrices] ||= [] + vrp_json[:vehicles]&.each { |v| v.delete(:matrix_id) if vrp_json[:matrices].empty? } + end + + def candidate?(service_vrp) + vrp = service_vrp.vrp + resolution = vrp.configuration.resolution + + vehicle_limit = resolution.dicho_algorithm_vehicle_limit.to_i + service_limit = resolution.dicho_algorithm_service_limit + + return false if service_vrp.service == :vroom + return false if vehicle_limit <= 0 || service_limit <= 0 + return false if service_limit.nil? || vrp.services.size <= service_limit && vrp.vehicles.size <= vehicle_limit + return false if vrp.schedule? + return false if vrp.points.empty? + return false if vrp.points.any?{ |p| p.location.nil? } + + true + end + + def build_graph(vrp) + VrpGraph::GraphBuilder.new(vrp).build_per_skill + end + + # Greedy probabilistic grouping of routes into batches using + # service-level graph neighborhood. Each step picks the next route + # via weighted random selection (probability proportional to shared + # neighbor count with the current group). + def cluster_vehicles_for_solution(solution, graph, rng: Random::DEFAULT) + vehicle_ids = solution.routes.map(&:vehicle_id).compact.uniq + return {} if vehicle_ids.empty? + + target_size = rand(10..15) + k = (vehicle_ids.size / target_size.to_f).ceil + return {} if k < 2 + + route_services = {} + solution.routes.each do |route| + vid = route.vehicle_id + next unless vid + + sids = {} + route.stops.each do |stop| + sid = stop.service_id + sids[sid] = true if sid + end + route_services[vid] = sids + end + + # Build route-to-route compatibility: number of shared graph neighbors. + service_to_vehicle = {} + route_services.each do |vid, sids| + sids.each_key { |sid| service_to_vehicle[sid] ||= vid } + end + + compatibility = Hash.new(0) + route_services.each do |vid_a, sids_a| + sids_a.each_key do |sid| + graph.neighbors_for_service(sid).each do |nb_sid| + vid_b = service_to_vehicle[nb_sid] + next unless vid_b + next if vid_a == vid_b + + key = vid_a.to_s < vid_b.to_s ? "#{vid_a}|#{vid_b}" : "#{vid_b}|#{vid_a}" + compatibility[key] += 1 + end + end + end + + log "RePartition greedy clustering: vehicles=#{vehicle_ids.size} target_size=#{target_size} " \ + "k=#{k} compatibility_pairs=#{compatibility.size}", + level: :info + + remaining = vehicle_ids.map(&:to_s).to_a + vehicle_to_cluster = {} + cluster_idx = 0 + + while remaining.any? + # Pick seed: weighted random among remaining, weight = total + # compatibility with other remaining routes. + seed_scores = + remaining.map { |vid| + score = + remaining.sum { |other| + next 0 if vid == other + + k2 = vid < other ? "#{vid}|#{other}" : "#{other}|#{vid}" + compatibility[k2] + } + [vid, damped_neighbor_weight(score)] + } + + seed = weighted_random_pick(seed_scores, rng: rng) + remaining.delete(seed) + group = [seed] + vehicle_to_cluster[seed] = cluster_idx + + while group.size < target_size && remaining.any? + candidates = + remaining.map { |vid| + score = + group.sum { |gv| + k2 = vid < gv ? "#{vid}|#{gv}" : "#{gv}|#{vid}" + compatibility[k2] + } + [vid, damped_neighbor_weight(score)] + } + + pick = weighted_random_pick(candidates, rng: rng) + break unless pick + + remaining.delete(pick) + group << pick + vehicle_to_cluster[pick] = cluster_idx + end + + cluster_idx += 1 + end + + log "RePartition greedy result: #{vehicle_to_cluster.values.tally}", level: :info + + vehicle_to_cluster + end + + def weighted_random_pick(candidates_with_scores, rng: Random::DEFAULT) + return nil if candidates_with_scores.empty? + + total = candidates_with_scores.sum { |_, s| s } + return candidates_with_scores.sample(random: rng)&.first if total <= 0 + + r = rng.rand * total + cumulative = 0.0 + candidates_with_scores.each do |candidate, score| + cumulative += score + return candidate if r <= cumulative + end + candidates_with_scores.last&.first + end + + def repartition_once( + repartition_context_payload, graph, solution, job = nil, iteration_duration = nil, + iteration_index: 0, &block + ) + ref_context = Models::ResolutionContext.new(repartition_context_payload) + vrp = ref_context.vrp + rng = clustering_rng(vrp, iteration_index) + vehicle_to_cluster = cluster_vehicles_for_solution(solution, graph, rng: rng) + + batches = Hash.new{ |h, k| h[k] = [] } + solution.routes.each_with_index do |route, idx| + vid = route.vehicle_id + batch_index = vehicle_to_cluster[vid.to_s] || 0 + batches[batch_index] << [idx, route] + end + + log "RePartition batches: #{batches.transform_values{ |routes| routes.map{ |_idx, r| r.vehicle_id }.uniq }}", + level: :info + + return nil if batches.empty? + + all_vehicle_ids = vrp.vehicles.map(&:id) + + vehicles_by_batch = {} + batches.each do |batch_index, routes| + vehicles_by_batch[batch_index] = routes.map{ |_idx, route| route.vehicle.id }.uniq + end + + used_vehicle_ids = vehicles_by_batch.values.flatten.uniq + unused_vehicle_ids = all_vehicle_ids - used_vehicle_ids + + vehicle_has_services = {} + solution.routes.each do |route| + vid = route.vehicle.id + has_services = route.stops.any?(&:service_id) + vehicle_has_services[vid] ||= has_services + end + + initial_unassigned = solution.unassigned_stops.dup + + # Pre-compute service_ids per batch (for graph neighborhood scoring) + batch_service_ids = {} + batches.each do |batch_index, routes| + sids = + routes.flat_map{ |_idx, route| + route.stops.filter_map(&:service_id) + }.compact.uniq + batch_service_ids[batch_index] = sids.each_with_object({}){ |sid, h| h[sid] = true } + end + + # Associate unassigned services to the most compatible batch via + # service-level graph neighborhood. + extra_services_per_batch = Hash.new{ |h, k| h[k] = Hash.new(true) } + assigned_service_ids = {} + + initial_unassigned.each do |stop| + service_id = stop.service_id + next unless service_id + next if assigned_service_ids.key?(service_id) + + neighbors = graph.neighbors_for_service(service_id) + + best_batch = nil + best_score = -1 + + batches.each_key do |batch_index| + sid_hash = batch_service_ids[batch_index] + score = + if neighbors.empty? || sid_hash.empty? + 0 + else + neighbors.count{ |sid| sid_hash[sid] } + end + + if score > best_score + best_score = score + best_batch = batch_index + end + end + + if best_batch.nil? + best_batch = + batches.min_by{ |_idx, routes| routes.size }&.first + end + + next if best_batch.nil? + + extra_services_per_batch[best_batch][service_id] = true + assigned_service_ids[service_id] = true + end + + sub_solutions = [] + + batch_count = batches.size + per_batch_duration = + if iteration_duration && batch_count.positive? + (iteration_duration.to_f / batch_count).floor + end + per_batch_duration = nil if per_batch_duration && per_batch_duration <= 0 + + batches.each do |batch_index, routes| + log "RePartition solving batch #{batch_index}/#{batches.size} with #{routes.size} routes", level: :info + service_ids = routes.flat_map{ |_idx, route| + route.stops.map(&:service_id) + }.compact.uniq + + vehicle_ids = vehicles_by_batch[batch_index].dup + + desired_cluster_size = (10..15).to_a.sample + if vehicle_ids.size < desired_cluster_size + free_unused = + unused_vehicle_ids.select{ |vid| + vehicle_has_services[vid] == false || vehicle_has_services[vid].nil? + } + needed = desired_cluster_size - vehicle_ids.size + extra = free_unused.first(needed) + unless extra.empty? + vehicle_ids.concat(extra) + vehicle_ids.uniq! + unused_vehicle_ids -= extra + end + end + + service_ids.concat(extra_services_per_batch[batch_index].keys) + service_ids.uniq! + + next if service_ids.empty? + + vehicle_indices = + vrp.vehicles.each_with_index.filter_map{ |vehicle, v_idx| + v_idx if vehicle_ids.include?(vehicle.id) + } + + next if vehicle_indices.empty? + + # Hot start: subset of all_vrp_routes for this sub-problem's vehicles. + batch_vrp_routes = solution.vrp_routes(vehicle_ids) + + sub_service_vrp = + Interpreters::SplitClustering.build_partial_service_vrp( + Models::ResolutionContext.new(repartition_context_payload), + service_ids, + vehicle_indices + ) + + if batch_vrp_routes.any? + sub_vrp = sub_service_vrp.vrp + hot_routes = sub_vrp.routes_from_initial_specs(batch_vrp_routes) + if hot_routes.any? + hot_vehicle_ids = hot_routes.map(&:vehicle_id).each_with_object({}){ |vid, h| h[vid] = true } + sub_vrp.routes = + sub_vrp.routes.reject{ |r| hot_vehicle_ids[r.vehicle_id] } + hot_routes + end + end + + sub_service_vrp.split_solve_data = + (sub_service_vrp.split_solve_data || {}).merge(cannot_split_further: true) + + sub_resolution = sub_service_vrp.vrp.configuration.resolution + + # Prevent recursive re-entry into RePartition for sub-problems. + sub_resolution.dicho_algorithm_vehicle_limit = 0 + sub_resolution.dicho_algorithm_service_limit = 0 + + if per_batch_duration && (sub_resolution.duration.nil? || sub_resolution.duration > per_batch_duration) + sub_resolution.duration = per_batch_duration + end + + log "RePartition solving sub-problem with #{sub_service_vrp.vrp.services.size} services \ + and #{sub_service_vrp.vrp.vehicles.size} vehicles", level: :info + sub_solution = Core::Strategies::Orchestration.solve(sub_service_vrp, job, block) + next unless sub_solution + + sub_solutions << sub_solution + end + + return nil if sub_solutions.empty? + + combined = sub_solutions.reduce(&:+) + + leftover_unassigned = + initial_unassigned.reject{ |stop| assigned_service_ids.key?(stop.service_id) } + combined.unassigned_stops.concat(leftover_unassigned) + + validate_repartition_solution_no_duplicates!(combined, vrp, "RePartition iteration batch merge") + + combined + end + + def score_solution(solution, vrp) + unassigned = solution&.unassigned_stops&.size || vrp.services.size + cost = + if solution.respond_to?(:cost) && !solution.cost.nil? + solution.cost.to_f + else + Float::INFINITY + end + [unassigned, cost] + end + + def better_score?(lhs, rhs) + return true if lhs[0] < rhs[0] + return false if lhs[0] > rhs[0] + + lhs[1] < rhs[1] + end + end + end +end diff --git a/lib/vrp_graph/batch_assigner.rb b/lib/vrp_graph/batch_assigner.rb index 122d9a64..df6ca203 100644 --- a/lib/vrp_graph/batch_assigner.rb +++ b/lib/vrp_graph/batch_assigner.rb @@ -22,10 +22,11 @@ module VrpGraph class BatchAssigner - def initialize(graph, solution, max_routes_per_batch: 5) + def initialize(graph, solution, max_routes_per_batch: 5, route_vehicle_skills: {}) @graph = graph @solution = solution @max_routes_per_batch = max_routes_per_batch + @route_vehicle_skills = route_vehicle_skills end # @return [Hash] route_index => batch_index @@ -74,22 +75,31 @@ def assign_greedy route_to_batch end - # Proximity = number of graph edges between services of the two routes + # Proximity = number of graph KNN links between services of the two routes, + # filtered through the vehicle's compatible skill-set graphs. def build_proximity_matrix n = @solution.routes.size prox = Array.new(n){ Array.new(n, 0) } + use_filtered = @route_vehicle_skills.any? && @graph.respond_to?(:neighbors_for_service_with_vehicle_skills) @solution.routes.each_with_index do |route_a, i| - ids_a = route_stop_point_ids(route_a) + ids_a = route_stop_service_ids(route_a) + v_skills_a = @route_vehicle_skills[i] + @solution.routes.each_with_index do |route_b, j| next if i >= j - ids_b = route_stop_point_ids(route_b) - # Count K-NN links (point a in route_a, neighbor point b in route_b) + ids_b = route_stop_service_ids(route_b) count = 0 - ids_a.each do |point_id| - (@graph.neighbors_for_point(point_id) || []).each do |nb_point_id| - count += 1 if ids_b.include?(nb_point_id) + ids_a.each_key do |sid| + neighbors = + if use_filtered && v_skills_a&.any? + @graph.neighbors_for_service_with_vehicle_skills(sid, v_skills_a) + else + @graph.neighbors_for_service(sid) + end + (neighbors || []).each do |nb_sid| + count += 1 if ids_b.key?(nb_sid) end end prox[i][j] = prox[j][i] = count @@ -98,8 +108,8 @@ def build_proximity_matrix prox end - def route_stop_point_ids(route) - route.stops.filter_map { |s| s.activity&.point_id }.compact.to_set + def route_stop_service_ids(route) + route.stops.filter_map { |s| s.service_id }.compact.each_with_object({}) { |sid, h| h[sid] = true } end def assign_with_ortools diff --git a/lib/vrp_graph/graph_builder.rb b/lib/vrp_graph/graph_builder.rb index f6179d46..53f4c992 100644 --- a/lib/vrp_graph/graph_builder.rb +++ b/lib/vrp_graph/graph_builder.rb @@ -18,6 +18,8 @@ # # # Graph builder for VRP services: Delaunay triangulation, compatibility checks, K-NN. +# Nodes are keyed by service_id (one node per service). Edges are service-level pairs. +# Delaunay and KNN operate at point level, then results are expanded to service-level. # Uses travel time matrix (routing or precomputed). require_relative 'delaunay_adapter' @@ -34,34 +36,111 @@ def initialize(vrp, options = {}) @matrix_id = options[:matrix_id] || vrp.vehicles.first&.matrix_id end + # Builds a single graph from all services (backward-compatible entry point). def build + all_services = @vrp.services.reject{ |s| s.activity.nil? || s.activity.point.nil? } + return nil if all_services.empty? + + shared = precompute_shared(all_services) + build_for_services(all_services, shared, label: nil) + end + + # Builds one Delaunay graph per unique skill-set (sorted combination of + # skills) found across services. Each graph includes all services whose + # skill-set shares at least one skill with the graph key (intersection), + # and have no extra skills through union of skills. + # plus all no-skill services as universal bridges. + # Returns a Models::MultiGraph wrapping { skill_set_key => Models::Graph }. + def build_per_skill + t_total = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + all_services = @vrp.services.reject{ |s| s.activity.nil? || s.activity.point.nil? } + return nil if all_services.empty? + + shared = precompute_shared(all_services) + + groups = all_services.group_by{ |s| s.skills.to_a.map(&:to_s).sort } + no_skill_services = groups.delete([]) || [] + + if groups.empty? + graph = build_for_services(no_skill_services, shared, label: nil) + log_duration('graph_per_skill_total', t_total, 'no_skills_single_graph') + return graph && Models::MultiGraph.new(graphs: { nil => graph }) + end + + graphs = {} + groups.each_key do |skill_set| + key = skill_set.join(',') + compatible = [] + groups.each do |other_set, svcs| + compatible.concat(svcs) if (skill_set & other_set).any? && (skill_set | other_set).size == skill_set.size + end + compatible.concat(no_skill_services) + graphs[key] = build_for_services(compatible, shared, label: key) + end + + log_duration('graph_per_skill_total', t_total, "skill_sets=#{groups.size} graphs=#{graphs.size}") + + Models::MultiGraph.new(graphs: graphs) + end + + private + + # Pre-computes state shared across all per-skill sub-builds: + # incompatibilities (tw+capacity), vehicle_skill_sets, time matrix. + # Skills incompatibility is handled structurally by the per-skill-set + # graph partitioning — no need to precompute it here. + def precompute_shared(services) + t3 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + tw_incompat = TimewindowCompatibility.compute_incompatibilities(@vrp) + log_duration('graph_timewindow_compatibility', t3) + + t4 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + cap_incompat = CapacityCompatibility.compute_incompatibilities(@vrp) + log_duration('graph_capacity_compatibility', t4) + + all_incompat = merge_nested_incompat(tw_incompat, cap_incompat) + vehicle_skill_sets = @vrp.vehicles.map { |v| v.skills.first.to_a.map(&:to_s) } + + vrp_matrix = @vrp.matrices.find{ |m| m.id == @matrix_id } + + { + all_incompat: all_incompat, + vehicle_skill_sets: vehicle_skill_sets, + vrp_time_matrix: vrp_matrix&.time, + service_by_id: services.each_with_object({}) { |s, h| h[s.id] = s } + } + end + + # Builds a Models::Graph from a subset of services using pre-computed shared data. + def build_for_services(services, shared, label: nil) t_build_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + log_prefix = label ? "graph[#{label}]" : 'graph' - services = @vrp.services.reject{ |s| s.activity.nil? || s.activity.point.nil? } - return nil if services.empty? + all_incompat = shared[:all_incompat] + vehicle_skill_sets = shared[:vehicle_skill_sets] + vrp_time_matrix = shared[:vrp_time_matrix] + service_by_id = shared[:service_by_id] - # Unique points (1 point can have several services). Source: vrp.points filtered by usage in services. used_point_ids = services.map { |s| s.activity.point_id || s.activity.point&.id }.compact.uniq graph_points = @vrp.points.select { |p| used_point_ids.include?(p.id) } - # Fallback if vrp.points is empty: derive from services graph_points = services.map{ |s| s.activity.point }.compact.uniq(&:id) if graph_points.empty? point_by_index = graph_points.each_with_index.to_h { |p, i| [i, p] } services_by_point_id = {} + point_id_to_service_ids = {} services.each do |s| pid = (s.activity.point_id || s.activity.point&.id).to_s (services_by_point_id[pid] ||= []) << s + (point_id_to_service_ids[pid] ||= []) << s.id end - # One coordinate list per point (no duplicate points in Delaunay) delaunay_points = graph_points.map { |p| point_location_coords(p) } t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) delaunay_edges = DelaunayAdapter.compute_edges(delaunay_points) - log_duration('graph_delaunay', t0) + log_duration("#{log_prefix}_delaunay", t0) - # Build nodes (keyed by point_id) and map service_id -> point_id nodes = {} - service_point = {} services.each do |s| activity = s.activity pt = activity.point @@ -70,13 +149,9 @@ def build loc = pt.location pid = pt.id - node = (nodes[pid] ||= { + nodes[s.id] = { + point_id: pid, point: { lat: loc&.lat, lon: loc&.lon }, - services: [] - }) - - node[:services] << { - id: s.id, skills: s.skills.to_a.map(&:to_s), timewindows: (activity.timewindows || []).map{ |tw| { start: tw.start, end: tw.end } }, duration: activity.duration, @@ -92,33 +167,9 @@ def build } } } - - service_point[s.id] = pid end - # Use VRP matrix only if already present (no compute). For K-NN without matrix: rectangular via router only. - vrp_matrix = @vrp.matrices.find{ |m| m.id == @matrix_id } - vrp_time_matrix = vrp_matrix&.time - - # Skills incompatibilities - t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - skills_incompat = SkillsCompatibility.compute_incompatibilities(@vrp) - log_duration('graph_skills_compatibility', t2) - - # Timewindow incompatibilities (timewindows + duration only, no travel time) - t3 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - tw_incompat = TimewindowCompatibility.compute_incompatibilities(@vrp) - log_duration('graph_timewindow_compatibility', t3) - - # Capacity incompatibilities (pairs at different points: sum of quantities > max vehicle capacity) - t4 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - cap_incompat = CapacityCompatibility.compute_incompatibilities(@vrp) - log_duration('graph_capacity_compatibility', t4) - - # Merge nested incompatibilities (each is incompat[a][b] = true) - all_incompat = merge_nested_incompat(skills_incompat, tw_incompat, cap_incompat) - - # Map Delaunay edges (point indices) to point IDs and filter: keep edge if at least one service pair is compatible + # Expand Delaunay edges (point-level) to service-level compatible pairs t5 = Process.clock_gettime(Process::CLOCK_MONOTONIC) original_degree = Hash.new(0) @@ -132,32 +183,56 @@ def build end edges = [] + edge_set = {} + filtered_degree = Hash.new(0) + delaunay_edges.each do |i, j| pid_a = point_by_index[i]&.id pid_b = point_by_index[j]&.id next unless pid_a && pid_b next if pid_a == pid_b - # Edge is valid if at least one (service at A, service at B) pair is not incompatible - compatible = - (services_by_point_id[pid_a] || []).any? { |s_a| - (services_by_point_id[pid_b] || []).any? { |s_b| - !all_incompat.dig(s_a.id, s_b.id) - } - } - next unless compatible + added_for_pair = false - edges << [pid_a, pid_b] - end + (services_by_point_id[pid_a] || []).each do |s_a| + (services_by_point_id[pid_b] || []).each do |s_b| + next if all_incompat.dig(s_a.id, s_b.id) - # Track degree per point after filtering - filtered_degree = Hash.new(0) - edges.each do |a, b| - filtered_degree[a] += 1 - filtered_degree[b] += 1 + pair = [s_a.id, s_b.id].sort + next if edge_set.key?(pair) + + edges << [s_a.id, s_b.id] + edge_set[pair] = true + added_for_pair = true + end + end + + next unless added_for_pair + + filtered_degree[pid_a] += 1 + filtered_degree[pid_b] += 1 end - # Points that lost at least one Delaunay edge + # Intra-point edges: compatible co-located service pairs. + # Requires vehicle-level feasibility (at least one vehicle covers both). + # point_id_to_service_ids.each do |_pid, sids| + # next if sids.size < 2 + + # sids.each_with_index do |sid_a, idx_a| + # (idx_a + 1).upto(sids.size - 1) do |idx_b| + # sid_b = sids[idx_b] + # next if all_incompat.dig(sid_a, sid_b) + # next unless any_vehicle_covers_both?(service_by_id[sid_a], service_by_id[sid_b], vehicle_skill_sets) + + # pair = [sid_a, sid_b].sort + # next if edge_set.key?(pair) + + # edges << [sid_a, sid_b] + # edge_set[pair] = true + # end + # end + # end + repaired_nodes = {} original_degree.each do |pid, deg| next if deg <= (filtered_degree[pid] || 0) @@ -165,17 +240,16 @@ def build repaired_nodes[pid] = true end - removed = delaunay_edges.size - edges.size log_duration( - 'graph_edges_filter', + "#{log_prefix}_edges_filter", t5, - "delaunay=#{delaunay_edges.size} kept=#{edges.size} removed=#{removed} repaired_nodes=#{repaired_nodes.size}" + "delaunay=#{delaunay_edges.size} service_edges=#{edges.size} repaired_nodes=#{repaired_nodes.size}" ) - # K-NN: use square matrix in memory when available; otherwise compute rectangular (repaired × other) via router only. + # K-NN: point-level computation, then expand to service-level t6 = Process.clock_gettime(Process::CLOCK_MONOTONIC) repaired_pids = repaired_nodes.keys - all_pids = graph_points.map { |p| p.id } + all_pids = graph_points.map(&:id) other_pids = all_pids - repaired_pids point_incompat = build_point_incompat(services_by_point_id, all_incompat, all_pids) pid_to_point = graph_points.to_h { |p| [p.id, p] } @@ -188,66 +262,87 @@ def build { rectangular: [] } end k_per_point = (repaired_pids.empty? || other_pids.empty?) ? 0 : other_pids.size - knn_neighbors = KnnNeighborhood.compute_knn_points( + point_knn_neighbors = KnnNeighborhood.compute_knn_points( repaired_pids, other_pids, knn_matrix, point_incompat, k: k_per_point ) - # Add K-NN arcs as edges (point-to-point) - edge_set = edges.map{ |a, b| [a, b].sort }.to_h{ |p| [p, true] } + knn_added = 0 knn_segments = [] knn_edge_indices = [] - knn_neighbors.each do |pid_a, neighbor_pids| + point_knn_neighbors.each do |pid_a, neighbor_pids| next unless repaired_nodes[pid_a] missing = (original_degree[pid_a] || 0) - (filtered_degree[pid_a] || 0) next if missing <= 0 - added = 0 + point_added = 0 neighbor_pids.each do |pid_b| - pair = [pid_a, pid_b].sort - next if edge_set.key?(pair) - - edges << [pid_a, pid_b] - edge_set[pair] = true - knn_added += 1 - added += 1 - - node_a = nodes[pid_a] - node_b = nodes[pid_b] - if node_a && node_b - pa = node_a[:point] || node_a['point'] - pb = node_b[:point] || node_b['point'] - if pa && pb && pa[:lat] && pa[:lon] && pb[:lat] && pb[:lon] - knn_segments << [pa[:lat].to_f, pa[:lon].to_f, pb[:lat].to_f, pb[:lon].to_f] - knn_edge_indices << (edges.size - 1) + break if point_added >= missing + + first_edge_idx = nil + (services_by_point_id[pid_a] || []).each do |s_a| + (services_by_point_id[pid_b] || []).each do |s_b| + next if all_incompat.dig(s_a.id, s_b.id) + + pair = [s_a.id, s_b.id].sort + next if edge_set.key?(pair) + + edges << [s_a.id, s_b.id] + edge_set[pair] = true + knn_added += 1 + first_edge_idx ||= edges.size - 1 end end - break if added >= missing + next unless first_edge_idx + + point_added += 1 + loc_a = pid_to_point[pid_a]&.location + loc_b = pid_to_point[pid_b]&.location + if loc_a && loc_b + knn_segments << [loc_a.lat.to_f, loc_a.lon.to_f, loc_b.lat.to_f, loc_b.lon.to_f] + knn_edge_indices << first_edge_idx + end + end + end + + service_knn_neighbors = {} + point_knn_neighbors.each do |pid_a, neighbor_pids| + sids_a = point_id_to_service_ids[pid_a] || [] + sids_a.each do |sid_a| + neighbors_for_sid = [] + neighbor_pids.each do |pid_b| + sids_b = point_id_to_service_ids[pid_b] || [] + sids_b.each do |sid_b| + next if all_incompat.dig(sid_a, sid_b) + + neighbors_for_sid << sid_b + end + end + service_knn_neighbors[sid_a] = neighbors_for_sid unless neighbors_for_sid.empty? end end add_knn_traces!(edges, knn_segments, knn_edge_indices) if knn_segments.any? knn_matrix_type = vrp_time_matrix ? 'square_in_memory' : "rectangular=#{repaired_pids.size}x#{other_pids.size}" - log_duration('graph_knn', t6, "knn_added=#{knn_added} #{knn_matrix_type}") + log_duration("#{log_prefix}_knn", t6, "knn_added=#{knn_added} #{knn_matrix_type}") - log_duration('graph_build_total', t_build_start) + log_duration("#{log_prefix}_build", t_build_start) Models::Graph.new( nodes: nodes, edges: edges, incompatibilities: nested_incompat_to_pairs(all_incompat), - knn_neighbors: knn_neighbors, + knn_neighbors: service_knn_neighbors, metadata: { delaunay_built_at: Time.now.iso8601, - matrix_id_used: @matrix_id + matrix_id_used: @matrix_id, + skill_label: label } ) end - private - def merge_nested_incompat(*hashes) result = Hash.new { |h, k| h[k] = {} } hashes.each do |h| @@ -293,6 +388,20 @@ def log_duration(label, start_time, extra = nil) OptimizerLogger.log(msg, level: :info) end + # Returns true if at least one vehicle has the skills to serve both services. + def any_vehicle_covers_both?(service_a, service_b, vehicle_skill_sets) + return true unless service_a && service_b + + skills_a = service_a.skills.to_a.map(&:to_s) + skills_b = service_b.skills.to_a.map(&:to_s) + return true if skills_a.empty? && skills_b.empty? + + vehicle_skill_sets.any? { |v_skills| + (skills_a.empty? || (skills_a - v_skills).empty?) && + (skills_b.empty? || (skills_b - v_skills).empty?) + } + end + # Point-level incompatibility: [pid_a, pid_b] is incompatible iff no (service at A, service at B) pair is compatible. def build_point_incompat(services_by_point_id, all_incompat, all_pids) incompat = {} diff --git a/models/graph.rb b/models/graph.rb index 510251c8..6c6577e7 100644 --- a/models/graph.rb +++ b/models/graph.rb @@ -16,7 +16,9 @@ # # # Graph model for VRP: Delaunay triangulation, compatibilities, K-NN neighborhood. -# Nodes and edges are point-based (point_id). knn_neighbors: point_id => [neighbor_point_id, ...]. +# Nodes are keyed by service_id (one node per service). Each node carries point_id as back-reference. +# Edges are service-level pairs [service_id_a, service_id_b]. +# knn_neighbors: service_id => [neighbor_service_id, ...]. # Plain value object (not ActiveHash). module Models @@ -43,34 +45,55 @@ def incompatible?(service_id_a, service_id_b) (incompatibilities || []).any?{ |a, b| [a, b].sort == pair } end - # K-NN is point-based: returns neighbor point_ids for the given point_id. + # Returns neighbor service_ids for the given service_id, + # combining Delaunay edges and KNN-repair edges. + def neighbors_for_service(service_id) + adjacency[service_id] || [] + end + + # Backward-compatible lookup: collects all services at the given point_id + # and returns the union of their KNN neighbors. def neighbors_for_point(point_id) - (knn_neighbors || {})[point_id] || [] + sids = service_ids_for_point(point_id) + return [] if sids.empty? + + result = [] + sids.each do |sid| + result.concat(neighbors_for_service(sid)) + end + result.uniq + end + + # Returns service_ids whose node has the given point_id. + def service_ids_for_point(point_id) + @service_ids_by_point ||= build_service_ids_by_point + @service_ids_by_point[point_id] || @service_ids_by_point[point_id.to_s] || [] end def edge_set @edge_set ||= (edges || []).map{ |e| [[e[0], e[1]].sort, true] }.to_h end - def connected?(point_id_a, point_id_b) - edge_set.key?([point_id_a, point_id_b].sort) + def connected?(id_a, id_b) + edge_set.key?([id_a, id_b].sort) end - # Returns connected point pairs within a route's point set. - # @param point_ids [Array] Point IDs in the route - # @return [Array<[String, String]>] Pairs of connected point IDs - def tours_connectivity(point_ids) - ids = point_ids.to_set - edges.select{ |e| ids.include?(e[0]) && ids.include?(e[1]) }.map{ |e| [e[0], e[1]].sort }.uniq + # Returns connected service pairs within a set of service_ids. + # Also accepts point_ids for backward compatibility (expanded to service_ids). + # @param ids [Array] service_ids or point_ids + # @return [Array<[String, String]>] Pairs of connected IDs + def tours_connectivity(ids) + id_hash = ids.each_with_object({}) { |id, h| h[id] = true } + edges.select{ |e| id_hash[e[0]] && id_hash[e[1]] }.map{ |e| [e[0], e[1]].sort }.uniq end # @param solution [Models::Solution] - # @return [Hash] route_index => [[point_id_a, point_id_b], ...] + # @return [Hash] route_index => [[service_id_a, service_id_b], ...] def tours_connectivity_from_solution(solution) result = {} solution.routes.each_with_index do |route, idx| - point_ids = route.stops.filter_map{ |s| s.activity&.point_id }.compact - result[idx] = tours_connectivity(point_ids) + service_ids = route.stops.filter_map{ |s| s.service_id }.compact + result[idx] = tours_connectivity(service_ids) end result end @@ -82,8 +105,8 @@ def to_geojson entity_factory = RGeo::GeoJSON::EntityFactory.instance features = [] - # Points for each node (point-level); each node aggregates its services and constraints - nodes.each do |point_id, data| + # Point feature per service node + (nodes || {}).each do |service_id, data| pt = data[:point] || data['point'] next unless pt @@ -94,19 +117,22 @@ def to_geojson point_geom = geo_factory.point(lon.to_f, lat.to_f) props = { - point_id: point_id, - services: data[:services] || data['services'] + service_id: service_id, + point_id: data[:point_id] || data['point_id'], + skills: data[:skills] || data['skills'], + timewindows: data[:timewindows] || data['timewindows'], + batch: data[:batch] }.delete_if{ |_k, v| v.nil? } - features << entity_factory.feature(point_geom, point_id, props) + features << entity_factory.feature(point_geom, service_id, props) end - # LineStrings for each edge (format: [a, b] or [a, b, geometry]) - edges.each do |e| + # LineStrings for each edge (format: [sid_a, sid_b] or [sid_a, sid_b, geometry]) + (edges || []).each do |e| a, b = e[0], e[1] geometry = e[2] - node_a = nodes[a] || nodes[a.to_s] - node_b = nodes[b] || nodes[b.to_s] + node_a = (nodes || {})[a] || (nodes || {})[a.to_s] + node_b = (nodes || {})[b] || (nodes || {})[b.to_s] next unless node_a && node_b pt_a = node_a[:point] || node_a['point'] @@ -115,7 +141,6 @@ def to_geojson pts = if geometry.is_a?(Array) && geometry.any? - # geometry from router: [[lon,lat], [lon,lat], ...] geometry.map{ |lon, lat| geo_factory.point(lon.to_f, lat.to_f) } else [ @@ -130,5 +155,176 @@ def to_geojson collection = entity_factory.feature_collection(features) RGeo::GeoJSON.encode(collection) end + + private + + # Full adjacency list: Delaunay edges + KNN-repair edges, cached. + def adjacency + @adjacency_cache ||= begin + adj = Hash.new { |h, k| h[k] = [] } + (edges || []).each do |e| + adj[e[0]] << e[1] + adj[e[1]] << e[0] + end + (knn_neighbors || {}).each do |sid, neighbors| + neighbors.each do |nb| + adj[sid] << nb unless adj[sid].include?(nb) + adj[nb] << sid unless adj[nb].include?(sid) + end + end + adj + end + end + + def build_service_ids_by_point + result = Hash.new { |h, k| h[k] = [] } + (nodes || {}).each do |service_id, data| + pid = data[:point_id] || data['point_id'] + result[pid] << service_id if pid + end + result + end + end + + # Wraps multiple per-skill Graph instances and exposes a unified interface. + # Duck-types with Graph for neighbors_for_service, knn_neighbors, nodes, edges, to_geojson. + class MultiGraph + attr_reader :graphs + + # @param graphs [Hash{String|nil => Models::Graph}] skill_key => graph + def initialize(graphs:) + @graphs = graphs || {} + end + + def neighbors_for_service(service_id) + result = [] + @graphs.each_value do |g| + result.concat(g.neighbors_for_service(service_id)) + end + result.uniq + end + + # Returns neighbors only from graphs whose skill-set intersects with the + # given vehicle skills. Nil-keyed graphs (no-skill) are always included. + def neighbors_for_service_with_vehicle_skills(service_id, vehicle_skills) + v_skills = vehicle_skills.map(&:to_s) + result = [] + @graphs.each do |key, g| + if key.nil? + result.concat(g.neighbors_for_service(service_id)) + else + graph_skills = key.split(',') + result.concat(g.neighbors_for_service(service_id)) if (v_skills & graph_skills).any? + end + end + result.uniq + end + + def neighbors_for_point(point_id) + result = [] + @graphs.each_value do |g| + result.concat(g.neighbors_for_point(point_id)) + end + result.uniq + end + + def knn_neighbors + @knn_neighbors_cache ||= begin + merged = {} + @graphs.each_value do |g| + (g.knn_neighbors || {}).each do |sid, neighbors| + (merged[sid] ||= []).concat(neighbors) + end + end + merged.each_value(&:uniq!) + merged + end + end + + def nodes + @nodes_cache ||= begin + merged = {} + @graphs.each_value { |g| merged.merge!(g.nodes || {}) } + merged + end + end + + def edges + @edges_cache ||= begin + seen = {} + result = [] + @graphs.each_value do |g| + (g.edges || []).each do |e| + pair = [e[0], e[1]].sort + next if seen[pair] + + seen[pair] = true + result << e + end + end + result + end + end + + def edge_set + @edge_set_cache ||= edges.each_with_object({}) { |e, h| h[[e[0], e[1]].sort] = true } + end + + def connected?(id_a, id_b) + edge_set.key?([id_a, id_b].sort) + end + + def incompatible?(service_id_a, service_id_b) + @graphs.values.first&.incompatible?(service_id_a, service_id_b) || false + end + + def service_ids_for_point(point_id) + result = [] + @graphs.each_value { |g| result.concat(g.service_ids_for_point(point_id)) } + result.uniq + end + + def tours_connectivity(ids) + id_hash = ids.each_with_object({}) { |id, h| h[id] = true } + edges.select{ |e| id_hash[e[0]] && id_hash[e[1]] }.map{ |e| [e[0], e[1]].sort }.uniq + end + + def tours_connectivity_from_solution(solution) + result = {} + solution.routes.each_with_index do |route, idx| + service_ids = route.stops.filter_map{ |s| s.service_id }.compact + result[idx] = tours_connectivity(service_ids) + end + result + end + + def incompatibilities + @graphs.values.first&.incompatibilities || [] + end + + def metadata + { + skill_keys: @graphs.keys, + graph_count: @graphs.size + } + end + + def to_geojson + merged_graph = Models::Graph.new( + nodes: nodes, + edges: edges, + incompatibilities: incompatibilities, + knn_neighbors: knn_neighbors + ) + merged_graph.to_geojson + end + + def to_hash + { + graphs: @graphs.transform_values(&:to_hash), + merged_nodes: nodes.size, + merged_edges: edges.size + } + end end end diff --git a/models/solution/route.rb b/models/solution/route.rb index fe1151f5..3d4a9b8a 100644 --- a/models/solution/route.rb +++ b/models/solution/route.rb @@ -62,6 +62,16 @@ def count_services stops.count(&:service_id) end + # Missions in stop order for building VRP input routes (excludes depot and rest stops). + def missions_for_initial_routes + stops.filter_map{ |stop| + next if stop.is_a?(Models::Solution::StopDepot) || stop.mission.is_a?(Models::Rest) + next unless stop.mission + + stop.mission + } + end + def insert_stop(_vrp, stop, index, idle_time = 0) stops.insert(index, stop) shift_route_times(idle_time + stop.activity.duration, index) diff --git a/models/solution/solution.rb b/models/solution/solution.rb index 96032bd9..daeffaa1 100644 --- a/models/solution/solution.rb +++ b/models/solution/solution.rb @@ -102,5 +102,16 @@ def insert_stop(vrp, route, stop, index, idle_time = 0) route.insert_stop(vrp, stop, index, idle_time) Parsers::SolutionParser.parse(self, vrp) end + + def vrp_routes(vehicle_ids = nil) + routes.filter_map{ |route| + next if vehicle_ids && !vehicle_ids.include?(route.vehicle_id) + + missions = route.missions_for_initial_routes + next if missions.empty? + + { vehicle_id: route.vehicle_id, mission_ids: missions.map(&:id) } + } + end end end diff --git a/models/vrp.rb b/models/vrp.rb index 94c430b0..7a2ead4b 100644 --- a/models/vrp.rb +++ b/models/vrp.rb @@ -111,6 +111,25 @@ def empty_solution(solver, unassigned_with_reason = [], already_expanded = true) solution.parse(self) end + # Resolves a mission id on this VRP (services and reload depots), e.g. after a partial reload. + def find_mission_by_id(mission_id) + services.find{ |s| s.id == mission_id || s.original_id == mission_id } || + reload_depots.find{ |rd| rd.id == mission_id || rd.original_id == mission_id } + end + + # Builds Models::Route list from rows produced by Solution#vrp_routes (optionally filtered via Solution.vrp_routes_for_vehicles). + def routes_from_initial_specs(route_specs) + route_specs.filter_map{ |spec| + vehicle = vehicles.find{ |v| v.id == spec[:vehicle_id] } + next unless vehicle + + missions = spec[:mission_ids].filter_map{ |mid| find_mission_by_id(mid) } + next if missions.empty? + + Models::Route.create(vehicle: vehicle, missions: missions) + } + end + def empty_route(vehicle) route_start_time = [[vehicle.timewindow], vehicle.sequence_timewindows].compact.flatten[0]&.start.to_i route_end_time = route_start_time diff --git a/test/lib/interpreters/re_partition_test.rb b/test/lib/interpreters/re_partition_test.rb new file mode 100644 index 00000000..aa8eb8b0 --- /dev/null +++ b/test/lib/interpreters/re_partition_test.rb @@ -0,0 +1,204 @@ +require './test/test_helper' +require './lib/interpreters/re_partition' + +class RePartitionTest < IsolatedTest + # Happy path: Solution#vrp_routes rows survive Models.delete_all + Vrp.reload. + def test_vrp_routes_rebuild_after_delete_all + vrp = TestHelper.create(VRP.basic) + vehicle = vrp.vehicles.first + service = vrp.services.first + stop = Models::Solution::Stop.new(service) + solution_route = Models::Solution::Route.new(vehicle: vehicle, stops: [stop]) + solution = Models::Solution.new(routes: [solution_route]) + + built = + solution.routes.filter_map{ |route| + missions = route.missions_for_initial_routes + next if missions.empty? + + Models::Route.create(vehicle: route.vehicle, missions: missions) + } + refute_empty built + + rows = solution.vrp_routes + assert_equal built.size, rows.size + + vrp_hash = vrp.as_json + Models.delete_all + vrp2 = Models::Vrp.create(vrp_hash, check: false) + + rebuilt = vrp2.routes_from_initial_specs(rows) + assert_equal built.map(&:mission_ids), rebuilt.map(&:mission_ids) + assert_equal built.map(&:vehicle_id), rebuilt.map(&:vehicle_id) + end + + def test_validate_repartition_raises_on_duplicate_service_assignment + vrp = TestHelper.create(VRP.basic) + vehicle = vrp.vehicles.first + s1 = vrp.services.first + r = + Models::Solution::Route.new( + vehicle: vehicle, + stops: [Models::Solution::Stop.new(s1), Models::Solution::Stop.new(s1)] + ) + sol = Models::Solution.new(routes: [r], unassigned_stops: []) + err = + assert_raises(Interpreters::RePartition::DuplicateAssignmentError) do + Interpreters::RePartition.send(:validate_repartition_solution_no_duplicates!, sol, vrp, 'test') + end + assert_match(/service/, err.message) + end + + def test_validate_repartition_raises_on_two_mission_routes_same_vehicle + vrp = TestHelper.create(VRP.basic) + vehicle = vrp.vehicles.first + s1, s2 = vrp.services.first(2) + r1 = Models::Solution::Route.new(vehicle: vehicle, stops: [Models::Solution::Stop.new(s1)]) + r2 = Models::Solution::Route.new(vehicle: vehicle, stops: [Models::Solution::Stop.new(s2)]) + sol = Models::Solution.new(routes: [r1, r2], unassigned_stops: []) + err = + assert_raises(Interpreters::RePartition::DuplicateAssignmentError) do + Interpreters::RePartition.send(:validate_repartition_solution_no_duplicates!, sol, vrp, 'test') + end + assert_match(/2 routes/, err.message) + end + + def test_damped_neighbor_weight + rp = Interpreters::RePartition + assert_equal 0.0, rp.send(:damped_neighbor_weight, 0) + assert_equal 0.0, rp.send(:damped_neighbor_weight, -3) + p = Interpreters::RePartition::NEIGHBOR_WEIGHT_POWER + assert_in_delta 9.0**p, rp.send(:damped_neighbor_weight, 9), 1e-9 + end + + def test_weighted_random_pick_deterministic_with_same_rng + rp = Interpreters::RePartition + cands = [%w[low 1.0], %w[high 4.0]] + rng = Random.new(123_456) + a = rp.send(:weighted_random_pick, cands, rng: rng) + b = rp.send(:weighted_random_pick, cands, rng: Random.new(123_456)) + assert_equal a, b + end + + def test_batch_covers_unassigned_service_skills_and_days + problem = + VRP.basic.merge( + vehicles: [ + { + id: 'v_skill_a_mon', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + skills: [['skill_a']], + timewindow: { day_index: 1, start: 0, end: 86_400 }, + }, + { + id: 'v_skill_b_tue', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + skills: [['skill_b']], + timewindow: { day_index: 2, start: 0, end: 86_400 }, + }, + ], + services: + VRP.basic[:services] + [ + { + id: 's_extra_a', + skills: [:skill_a], + activity: { + point_id: 'point_1', + timewindows: [{ day_index: 1, start: 0, end: 500 }], + }, + }, + { + id: 's_extra_b', + skills: [:skill_b], + activity: { + point_id: 'point_2', + timewindows: [{ day_index: 2, start: 0, end: 500 }], + }, + }, + { + id: 's_skill_a_wrong_day', + skills: [:skill_a], + activity: { + point_id: 'point_3', + timewindows: [{ day_index: 3, start: 0, end: 500 }], + }, + }, + ] + ) + + vrp = TestHelper.create(problem) + s_a = vrp.services.find{ |s| s.id == 's_extra_a' } + s_b = vrp.services.find{ |s| s.id == 's_extra_b' } + s_wrong = vrp.services.find{ |s| s.id == 's_skill_a_wrong_day' } + vehicles_by_batch = { 0 => ['v_skill_a_mon'], 1 => ['v_skill_b_tue'] } + + assert Interpreters::RePartition.send(:batch_covers_unassigned_service?, vrp, vehicles_by_batch, 0, s_a) + refute Interpreters::RePartition.send(:batch_covers_unassigned_service?, vrp, vehicles_by_batch, 1, s_a) + refute Interpreters::RePartition.send(:batch_covers_unassigned_service?, vrp, vehicles_by_batch, 0, s_b) + assert Interpreters::RePartition.send(:batch_covers_unassigned_service?, vrp, vehicles_by_batch, 1, s_b) + refute Interpreters::RePartition.send(:batch_covers_unassigned_service?, vrp, vehicles_by_batch, 0, s_wrong) + refute Interpreters::RePartition.send(:batch_covers_unassigned_service?, vrp, vehicles_by_batch, 1, s_wrong) + end + + def test_vrp_routes_for_vehicles_filters_by_fleet + vrp = TestHelper.create(VRP.basic) + vehicle = vrp.vehicles.first + service = vrp.services.first + stop = Models::Solution::Stop.new(service) + solution_route = Models::Solution::Route.new(vehicle: vehicle, stops: [stop]) + solution = Models::Solution.new(routes: [solution_route]) + + rows = solution.vrp_routes + refute_empty rows + + assert_empty Models::Solution.vrp_routes_for_vehicles(rows, []) + assert_equal rows, + Models::Solution.vrp_routes_for_vehicles(rows, [vehicle.id]) + end + + def test_repartition_disabled_for_small_vrp + vrp = TestHelper.create(VRP.basic) + + vrp.configuration.resolution.dicho_algorithm_vehicle_limit = 10 + vrp.configuration.resolution.dicho_algorithm_service_limit = 100 + + service_vrp = Models::ResolutionContext.new(service: :ortools, vrp: vrp) + + result = Interpreters::RePartition.repartition(service_vrp) + + assert_nil result + end + + def test_repartition_integration_with_define_process + problem = VRP.lat_lon_two_vehicles + problem[:configuration] ||= {} + problem[:configuration][:resolution] ||= {} + problem[:configuration][:resolution][:duration] ||= 100 + problem[:configuration][:resolution][:dicho_algorithm_vehicle_limit] = 1 + problem[:configuration][:resolution][:dicho_algorithm_service_limit] = 5 + problem[:configuration][:restitution] ||= { intermediate_solutions: false } + + vrp = TestHelper.create(problem) + service_vrp = Models::ResolutionContext.new(service: :ortools, vrp: vrp) + + inner_graph = Models::Graph.new( + nodes: {}, edges: [], incompatibilities: [], knn_neighbors: {}, metadata: {} + ) + fake_graph = Models::MultiGraph.new(graphs: { nil => inner_graph }) + + Interpreters::RePartition.stub(:build_graph, fake_graph) do + solutions = + Core::Strategies::Orchestration.define_main_process( + [service_vrp], + nil + ) { |_wrapper, _avancement, _total, _message, _cost, _time, _solution| } + + solution = solutions.first + + assert_kind_of Models::Solution, solution + refute_nil solution.routes + end + end +end diff --git a/test/lib/vrp_graph/graph_builder_test.rb b/test/lib/vrp_graph/graph_builder_test.rb index b8b18d15..fdbc420a 100644 --- a/test/lib/vrp_graph/graph_builder_test.rb +++ b/test/lib/vrp_graph/graph_builder_test.rb @@ -8,13 +8,30 @@ def setup skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' unless File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) end - def test_build_creates_graph_from_vrp + def test_build_creates_graph_with_service_level_nodes vrp = TestHelper.create(TestHelper.load_vrp(self, fixture_file: 'instance_andalusia')) graph = GraphBuilder.new(vrp).build assert graph assert graph.is_a?(Models::Graph) assert graph.nodes.any? - assert graph.edges.any? || graph.nodes.size < 2 + + # Each node key should be a service_id and carry a point_id back-reference + graph.nodes.each do |service_id, data| + assert_kind_of String, service_id.to_s + assert data[:point_id], "Node #{service_id} should have :point_id" + assert data[:point], "Node #{service_id} should have :point" + assert data[:point][:lat], "Node #{service_id} should have lat" + assert data[:point][:lon], "Node #{service_id} should have lon" + end + + # Edges should reference existing node keys (service_ids) + graph.edges.each do |e| + sid_a, sid_b = e[0], e[1] + assert graph.nodes.key?(sid_a) || graph.nodes.key?(sid_a.to_s), + "Edge references unknown service_id: #{sid_a}" + assert graph.nodes.key?(sid_b) || graph.nodes.key?(sid_b.to_s), + "Edge references unknown service_id: #{sid_b}" + end end def test_graph_to_geojson @@ -22,8 +39,16 @@ def test_graph_to_geojson graph = GraphBuilder.new(vrp).build geojson = graph.to_geojson assert geojson - assert geojson['type'] == 'FeatureCollection' + assert_equal 'FeatureCollection', geojson['type'] assert geojson['features'].is_a?(Array) + + point_features = geojson['features'].select{ |f| f['geometry']['type'] == 'Point' } + assert point_features.any? + point_features.each do |f| + props = f['properties'] + assert props['service_id'], "GeoJSON Point feature should have service_id" + assert props['point_id'], "GeoJSON Point feature should have point_id" + end end def test_tours_connectivity_from_solution @@ -35,5 +60,133 @@ def test_tours_connectivity_from_solution conn = graph.tours_connectivity_from_solution(solution) assert conn.is_a?(Hash) end + + def test_neighbors_for_service_and_point + vrp = TestHelper.create(TestHelper.load_vrp(self, fixture_file: 'instance_andalusia')) + graph = GraphBuilder.new(vrp).build + + if graph.knn_neighbors.any? + sid = graph.knn_neighbors.keys.first + neighbors = graph.neighbors_for_service(sid) + assert neighbors.is_a?(Array) + + pid = graph.nodes[sid][:point_id] + point_neighbors = graph.neighbors_for_point(pid) + assert point_neighbors.is_a?(Array) + end + end + + def test_intra_point_edges_between_colocated_services + # Build a minimal VRP where 2 services share the same point + problem = VRP.basic + point_id = problem[:points].first[:id] + problem[:services] = [ + { id: 'svc_a', activity: { point_id: point_id, duration: 10 }, skills: ['alpha'] }, + { id: 'svc_b', activity: { point_id: point_id, duration: 10 }, skills: ['alpha'] } + ] + problem[:vehicles] = [ + { id: 'v1', start_point_id: point_id, skills: [['alpha']], + router_mode: 'car', speed_multiplier: 1.0 } + ] + + vrp = TestHelper.create(problem) + graph = GraphBuilder.new(vrp).build + + if graph + assert graph.nodes.key?('svc_a'), 'Should have node for svc_a' + assert graph.nodes.key?('svc_b'), 'Should have node for svc_b' + + assert_equal graph.nodes['svc_a'][:point_id], graph.nodes['svc_b'][:point_id], + 'Co-located services should share the same point_id' + + intra_edge = graph.edges.any?{ |e| + [e[0], e[1]].sort == ['svc_a', 'svc_b'].sort + } + assert intra_edge, 'Compatible co-located services should have an intra-point edge' + end + end + + def test_build_per_skill_returns_multi_graph + vrp = TestHelper.create(TestHelper.load_vrp(self, fixture_file: 'instance_andalusia')) + multi = GraphBuilder.new(vrp).build_per_skill + assert multi, 'build_per_skill should return a non-nil object' + assert_kind_of Models::MultiGraph, multi + assert multi.graphs.is_a?(Hash) + + multi.graphs.each do |skill_key, graph| + assert_kind_of Models::Graph, graph + assert graph.nodes.any?, "Graph for skill '#{skill_key}' should have nodes" + end + + assert multi.nodes.any?, 'Merged nodes should be non-empty' + assert multi.edges.is_a?(Array) + end + + def test_build_per_skill_separate_skill_groups + problem = VRP.basic + points = problem[:points] + problem[:services] = [ + { id: 'sa', activity: { point_id: points[0][:id], duration: 10 }, skills: ['alpha'] }, + { id: 'sb', activity: { point_id: points[1][:id], duration: 10 }, skills: ['beta'] }, + { id: 'sc', activity: { point_id: points[0][:id], duration: 10 } }, + { id: 'sd', activity: { point_id: points[1][:id], duration: 10 }, skills: %w[alpha beta] } + ] + problem[:vehicles] = [ + { id: 'v1', start_point_id: points[0][:id], skills: [['alpha']], + router_mode: 'car', speed_multiplier: 1.0 }, + { id: 'v2', start_point_id: points[0][:id], skills: [['beta']], + router_mode: 'car', speed_multiplier: 1.0 }, + { id: 'v3', start_point_id: points[0][:id], skills: [%w[alpha beta]], + router_mode: 'car', speed_multiplier: 1.0 } + ] + + vrp = TestHelper.create(problem) + multi = GraphBuilder.new(vrp).build_per_skill + + assert_kind_of Models::MultiGraph, multi + + # Graph keys = unique skill-set combinations + assert multi.graphs.key?('alpha'), 'Should have graph for skill-set [alpha]' + assert multi.graphs.key?('beta'), 'Should have graph for skill-set [beta]' + assert multi.graphs.key?('alpha,beta'), 'Should have graph for skill-set [alpha,beta]' + refute multi.graphs.key?(nil), 'No separate nil graph when skilled services exist' + + # Each service is in its own skill-set graph + assert multi.graphs['alpha'].nodes.key?('sa'), 'sa should be in alpha graph' + assert multi.graphs['beta'].nodes.key?('sb'), 'sb should be in beta graph' + assert multi.graphs['alpha,beta'].nodes.key?('sd'), 'sd should be in alpha,beta graph' + + # No-skill service sc (bridge): present in ALL graphs + assert multi.graphs['alpha'].nodes.key?('sc'), 'sc (no skills) should be in alpha graph' + assert multi.graphs['beta'].nodes.key?('sc'), 'sc (no skills) should be in beta graph' + assert multi.graphs['alpha,beta'].nodes.key?('sc'), 'sc (no skills) should be in alpha,beta graph' + + # Skill intersection: sd [alpha,beta] shares skills with both [alpha] and [beta] + assert multi.graphs['alpha'].nodes.key?('sd'), 'sd [alpha,beta] should be in alpha graph (shares alpha)' + assert multi.graphs['beta'].nodes.key?('sd'), 'sd [alpha,beta] should be in beta graph (shares beta)' + + # sa [alpha] shares alpha with [alpha,beta] graph + assert multi.graphs['alpha,beta'].nodes.key?('sa'), 'sa [alpha] should be in alpha,beta graph (shares alpha)' + # sb [beta] shares beta with [alpha,beta] graph + assert multi.graphs['alpha,beta'].nodes.key?('sb'), 'sb [beta] should be in alpha,beta graph (shares beta)' + + # sa [alpha] does NOT share any skill with [beta] graph + refute multi.graphs['beta'].nodes.key?('sa'), 'sa [alpha] should not be in beta graph (no common skill)' + # sb [beta] does NOT share any skill with [alpha] graph + refute multi.graphs['alpha'].nodes.key?('sb'), 'sb [beta] should not be in alpha graph (no common skill)' + end + + def test_build_per_skill_no_skills_single_graph + problem = VRP.basic + problem[:services].each { |s| s.delete(:skills) } + problem[:vehicles].each { |v| v.delete(:skills) } + + vrp = TestHelper.create(problem) + multi = GraphBuilder.new(vrp).build_per_skill + + assert_kind_of Models::MultiGraph, multi + assert_equal 1, multi.graphs.size, 'Should have a single graph when no skills exist' + assert multi.graphs.key?(nil), 'Single graph should be keyed by nil' + end end end diff --git a/test/wrapper_test.rb b/test/wrapper_test.rb index 14ffb5f3..ab0f808e 100644 --- a/test/wrapper_test.rb +++ b/test/wrapper_test.rb @@ -3100,6 +3100,30 @@ def test_prioritize_first_available_trips_and_vehicles assert there_is_a_skipped_trip_when_simplification_is_off, assert_msg end + def test_prioritize_first_available_trips_fixed_cost_per_skill_group + base = VRP.basic + v0 = base[:vehicles].first + problem = + base.merge( + vehicles: [ + v0.merge(id: 'va1', skills: [['skill_a']]), + v0.merge(id: 'vb1', skills: [['skill_b']]), + v0.merge(id: 'va2', skills: [['skill_a']]), + v0.merge(id: 'vb2', skills: [['skill_b']]) + ] + ) + vrp = TestHelper.create(problem) + demo = OptimizerWrapper.config[:services][:demo] + assert demo.prioritize_first_available_trips_and_vehicles(vrp, nil, mode: :simplify) + + inc = 1e-4 + %w[va1 vb1 va2 vb2].zip([inc, inc, 2 * inc, 2 * inc]).each do |vid, expected_delta| + vehicle = vrp.vehicles.find{ |v| v.id == vid } + before = vehicle[:fixed_cost_before_adjustment] + assert_in_delta expected_delta, vehicle.cost_fixed - before, 1e-12, "vehicle #{vid}" + end + end + def test_protobuf_receives_correct_simplified_complex_shipments vrp = TestHelper.load_vrp(self, fixture_file: 'vrp_multipickup_singledelivery_shipments') diff --git a/wrappers/pyvrp.rb b/wrappers/pyvrp.rb index 6888ffe7..af9f0823 100644 --- a/wrappers/pyvrp.rb +++ b/wrappers/pyvrp.rb @@ -109,6 +109,11 @@ def solve(vrp, _job = nil, _thread_proc = nil) info: Models::Solution::Route::Info.new( start_time: route[:start_time], end_time: route[:end_time] + ), + cost_info: Models::Solution::CostInfo.new( + fixed: vehicle.cost_fixed || 0, + time: (route[:duration] || 0) * vehicle.cost_time_multiplier || 0, + distance: (route[:distance] || 0) * vehicle.cost_distance_multiplier || 0 ) ) } diff --git a/wrappers/vroom.rb b/wrappers/vroom.rb index ebf42fb6..633a9bd0 100644 --- a/wrappers/vroom.rb +++ b/wrappers/vroom.rb @@ -118,6 +118,11 @@ def solve(vrp, job = nil, _thread_proc = nil) info: Models::Solution::Route::Info.new( start_time: stops.first.info.begin_time, end_time: stops.last.info.begin_time + stops.last.activity.duration + ), + cost_info: Models::Solution::CostInfo.new( + fixed: vehicle.cost_fixed || 0, + time: (route['duration'] || 0) * vehicle.cost_time_multiplier, + distance: (route['distance'] || 0) * vehicle.cost_distance_multiplier ) ) } diff --git a/wrappers/wrapper.rb b/wrappers/wrapper.rb index 4457d094..67fe7b3f 100644 --- a/wrappers/wrapper.rb +++ b/wrappers/wrapper.rb @@ -1026,8 +1026,8 @@ def patch_and_rewind_simplified_constraints(vrp, solution) end def prioritize_first_available_trips_and_vehicles(vrp, solution = nil, options = { mode: :simplify }) - # For each vehicle group, it applies a small but increasing fixed_cost so that or-tools can - # distinguish two identical vehicles and use the one that comes first. + # vehicle_trips chains use one global increasing fixed_cost sequence (legacy). + # Loner vehicles only: within each skill-equivalence group, increasing fixed_cost; groups are independent. # This is to handle two cases: # i. Skipped intermediary trips of the same vehicle_trips relation # ii. Skipped intermediary "similar" vehicles of vrp.vehicles list @@ -1039,7 +1039,7 @@ def prioritize_first_available_trips_and_vehicles(vrp, solution = nil, options = # TODO: if needed this limitation can be partially removed for the cases where a group of vehicles # have one cost and another group has another cost. The below logic can be applied such groups of vehicles # separately but the code would be messier. Wait for a real use case. - return unless vrp.vehicles.uniq(&:cost_fixed).size == 1 + return if vrp.vehicles.uniq(&:cost_fixed).size > 1 simplification_active = true @@ -1048,7 +1048,24 @@ def prioritize_first_available_trips_and_vehicles(vrp, solution = nil, options = loner_vehicles = vrp.vehicles.map(&:id) - all_vehicle_trips_relations.flat_map(&:linked_vehicle_ids) cost_increment = 1e-4 # cost is multiplied with 1e6 (CUSTOM_BIGNUM_COST) inside optimizer-ortools - cost_adjustment = cost_increment + # All vehicle_trips-linked vehicles share one monotonic sequence (not split by skills). + trip_priority_adjustment = cost_increment + + # Loner vehicles only: nth within the same skill group gets n * cost_increment (groups are independent). + loner_index_in_skill_group = Hash.new(0) + + apply_loner_fixed_cost_prioritization = + lambda do |veh| + next if veh.nil? + + # Vehicles sharing the same alternative skill sets belong to one group; + skill_group_key = veh.skills.to_a.map{ |set| set.to_a.sort }.sort + loner_index_in_skill_group[skill_group_key] += 1 + adjustment = cost_increment * loner_index_in_skill_group[skill_group_key] + + veh[:fixed_cost_before_adjustment] = veh.cost_fixed + veh.cost_fixed += adjustment + end # Below both (i. and ii.) cases are handled # The vehicles that are the "leader" trip of a vehicle trip relation handle the @@ -1063,13 +1080,11 @@ def prioritize_first_available_trips_and_vehicles(vrp, solution = nil, options = # WARNING: this logic depends on the fact that each vehicle can appear in at most one vehicle_trips # relation ensured by check_vehicle_trips_relation_consistency (models/concerns/validate_data.rb) linked_vehicle[:fixed_cost_before_adjustment] = linked_vehicle.cost_fixed - linked_vehicle.cost_fixed += cost_adjustment - cost_adjustment += cost_increment + linked_vehicle.cost_fixed += trip_priority_adjustment + trip_priority_adjustment += cost_increment } when *loner_vehicles - vehicle[:fixed_cost_before_adjustment] = vehicle.cost_fixed - vehicle.cost_fixed += cost_adjustment - cost_adjustment += cost_increment + apply_loner_fixed_cost_prioritization.call(vehicle) end } when :rewind From b8ae8160200a7409e3a5ab08c342b5545f68ff40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Tue, 24 Mar 2026 09:44:03 +0100 Subject: [PATCH 5/6] Remove unused variables --- lib/vrp_graph/graph_builder.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/vrp_graph/graph_builder.rb b/lib/vrp_graph/graph_builder.rb index 53f4c992..523da3f7 100644 --- a/lib/vrp_graph/graph_builder.rb +++ b/lib/vrp_graph/graph_builder.rb @@ -118,9 +118,7 @@ def build_for_services(services, shared, label: nil) log_prefix = label ? "graph[#{label}]" : 'graph' all_incompat = shared[:all_incompat] - vehicle_skill_sets = shared[:vehicle_skill_sets] vrp_time_matrix = shared[:vrp_time_matrix] - service_by_id = shared[:service_by_id] used_point_ids = services.map { |s| s.activity.point_id || s.activity.point&.id }.compact.uniq graph_points = @vrp.points.select { |p| used_point_ids.include?(p.id) } From 28dd315bede263097a1ec6983567696e061d1426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Tue, 24 Mar 2026 11:19:18 +0100 Subject: [PATCH 6/6] Fix lint --- api/v01/vrp.rb | 13 ++-- core/strategies/orchestration.rb | 2 +- lib/vrp_graph/batch_assigner.rb | 2 +- lib/vrp_graph/delaunay_adapter.rb | 4 +- lib/vrp_graph/knn_neighborhood.rb | 7 +- models/graph.rb | 84 +++++++++++---------- models/vrp.rb | 3 +- test/lib/vrp_graph/delaunay_adapter_test.rb | 8 +- test/lib/vrp_graph/graph_builder_test.rb | 11 ++- 9 files changed, 76 insertions(+), 58 deletions(-) diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index 8ea3c42c..875c7644 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -217,7 +217,7 @@ class Vrp < APIBase params { use(:input) optional(:format, type: Symbol, values: [:json, :geojson], default: :json, - desc: 'Output format: json (full graph) or geojson') + desc: 'Output format: json (full graph) or geojson') } post do d_params = declared(params, include_missing: false) @@ -230,11 +230,12 @@ class Vrp < APIBase error!("Model Validation Error: #{vrp.errors}", 400) end - graph = begin - VrpGraph::GraphBuilder.new(vrp).build - rescue LoadError => e - error!({ message: "Graph build failed: #{e.message}" }, 501) - end + graph = + begin + VrpGraph::GraphBuilder.new(vrp).build + rescue LoadError => e + error!({ message: "Graph build failed: #{e.message}" }, 501) + end if graph vrp.graph = graph diff --git a/core/strategies/orchestration.rb b/core/strategies/orchestration.rb index 2981786b..b60af873 100644 --- a/core/strategies/orchestration.rb +++ b/core/strategies/orchestration.rb @@ -1,4 +1,4 @@ -require './lib/interpreters/re_partition.rb' +require './lib/interpreters/re_partition' module Core module Strategies diff --git a/lib/vrp_graph/batch_assigner.rb b/lib/vrp_graph/batch_assigner.rb index df6ca203..25b9ff03 100644 --- a/lib/vrp_graph/batch_assigner.rb +++ b/lib/vrp_graph/batch_assigner.rb @@ -109,7 +109,7 @@ def build_proximity_matrix end def route_stop_service_ids(route) - route.stops.filter_map { |s| s.service_id }.compact.each_with_object({}) { |sid, h| h[sid] = true } + route.stops.filter_map(&:service_id).compact.each_with_object({}) { |sid, h| h[sid] = true } end def assign_with_ortools diff --git a/lib/vrp_graph/delaunay_adapter.rb b/lib/vrp_graph/delaunay_adapter.rb index 54b58e83..344cda66 100644 --- a/lib/vrp_graph/delaunay_adapter.rb +++ b/lib/vrp_graph/delaunay_adapter.rb @@ -37,7 +37,9 @@ def compute_edges(points) return [] if points.size < 2 unless File.executable?(BINARY_PATH) - raise LoadError, "vrp_delaunay binary not found. Run: rake ext:vrp_delaunay (expected: #{BINARY_PATH})" + raise LoadError.new( + "vrp_delaunay binary not found. Run: rake ext:vrp_delaunay (expected: #{BINARY_PATH})" + ) end input = points.map { |lon, lat| [lon.to_f, lat.to_f] }.to_json diff --git a/lib/vrp_graph/knn_neighborhood.rb b/lib/vrp_graph/knn_neighborhood.rb index fca958a6..b97c42bb 100644 --- a/lib/vrp_graph/knn_neighborhood.rb +++ b/lib/vrp_graph/knn_neighborhood.rb @@ -33,7 +33,12 @@ module KnnNeighborhood # @param k [Integer] max neighbors per repaired point # @return [Hash] point_id => [neighbor_point_id, ...] sorted by travel time def compute_knn_points(repaired_point_ids, other_point_ids, knn_matrix, point_incompat, k: 10) - incompat_set = point_incompat.is_a?(Hash) ? point_incompat : point_incompat.to_h { |a, b| [[a.to_s, b.to_s].sort, true] } + incompat_set = + if point_incompat.is_a?(Hash) + point_incompat + else + point_incompat.to_h { |a, b| [[a.to_s, b.to_s].sort, true] } + end result = {} repaired_point_ids.each_with_index do |pid, i| diff --git a/models/graph.rb b/models/graph.rb index 6c6577e7..b77bfa9d 100644 --- a/models/graph.rb +++ b/models/graph.rb @@ -92,7 +92,7 @@ def tours_connectivity(ids) def tours_connectivity_from_solution(solution) result = {} solution.routes.each_with_index do |route, idx| - service_ids = route.stops.filter_map{ |s| s.service_id }.compact + service_ids = route.stops.filter_map(&:service_id).compact result[idx] = tours_connectivity(service_ids) end result @@ -160,20 +160,21 @@ def to_geojson # Full adjacency list: Delaunay edges + KNN-repair edges, cached. def adjacency - @adjacency_cache ||= begin - adj = Hash.new { |h, k| h[k] = [] } - (edges || []).each do |e| - adj[e[0]] << e[1] - adj[e[1]] << e[0] - end - (knn_neighbors || {}).each do |sid, neighbors| - neighbors.each do |nb| - adj[sid] << nb unless adj[sid].include?(nb) - adj[nb] << sid unless adj[nb].include?(sid) + @adjacency ||= + begin + adj = Hash.new { |h, k| h[k] = [] } + (edges || []).each do |e| + adj[e[0]] << e[1] + adj[e[1]] << e[0] + end + (knn_neighbors || {}).each do |sid, neighbors| + neighbors.each do |nb| + adj[sid] << nb unless adj[sid].include?(nb) + adj[nb] << sid unless adj[nb].include?(sid) + end end + adj end - adj - end end def build_service_ids_by_point @@ -229,45 +230,48 @@ def neighbors_for_point(point_id) end def knn_neighbors - @knn_neighbors_cache ||= begin - merged = {} - @graphs.each_value do |g| - (g.knn_neighbors || {}).each do |sid, neighbors| - (merged[sid] ||= []).concat(neighbors) + @knn_neighbors ||= + begin + merged = {} + @graphs.each_value do |g| + (g.knn_neighbors || {}).each do |sid, neighbors| + (merged[sid] ||= []).concat(neighbors) + end end + merged.each_value(&:uniq!) + merged end - merged.each_value(&:uniq!) - merged - end end def nodes - @nodes_cache ||= begin - merged = {} - @graphs.each_value { |g| merged.merge!(g.nodes || {}) } - merged - end + @nodes ||= + begin + merged = {} + @graphs.each_value { |g| merged.merge!(g.nodes || {}) } + merged + end end def edges - @edges_cache ||= begin - seen = {} - result = [] - @graphs.each_value do |g| - (g.edges || []).each do |e| - pair = [e[0], e[1]].sort - next if seen[pair] - - seen[pair] = true - result << e + @edges ||= + begin + seen = {} + result = [] + @graphs.each_value do |g| + (g.edges || []).each do |e| + pair = [e[0], e[1]].sort + next if seen[pair] + + seen[pair] = true + result << e + end end + result end - result - end end def edge_set - @edge_set_cache ||= edges.each_with_object({}) { |e, h| h[[e[0], e[1]].sort] = true } + @edge_set ||= edges.each_with_object({}) { |e, h| h[[e[0], e[1]].sort] = true } end def connected?(id_a, id_b) @@ -292,7 +296,7 @@ def tours_connectivity(ids) def tours_connectivity_from_solution(solution) result = {} solution.routes.each_with_index do |route, idx| - service_ids = route.stops.filter_map{ |s| s.service_id }.compact + service_ids = route.stops.filter_map(&:service_id).compact result[idx] = tours_connectivity(service_ids) end result diff --git a/models/vrp.rb b/models/vrp.rb index 7a2ead4b..93ebd005 100644 --- a/models/vrp.rb +++ b/models/vrp.rb @@ -117,7 +117,8 @@ def find_mission_by_id(mission_id) reload_depots.find{ |rd| rd.id == mission_id || rd.original_id == mission_id } end - # Builds Models::Route list from rows produced by Solution#vrp_routes (optionally filtered via Solution.vrp_routes_for_vehicles). + # Builds Models::Route list from rows produced by Solution#vrp_routes + # (optionally filtered via Solution.vrp_routes_for_vehicles). def routes_from_initial_specs(route_specs) route_specs.filter_map{ |spec| vehicle = vehicles.find{ |v| v.id == spec[:vehicle_id] } diff --git a/test/lib/vrp_graph/delaunay_adapter_test.rb b/test/lib/vrp_graph/delaunay_adapter_test.rb index 0745de06..2cefdfa9 100644 --- a/test/lib/vrp_graph/delaunay_adapter_test.rb +++ b/test/lib/vrp_graph/delaunay_adapter_test.rb @@ -5,12 +5,14 @@ module VrpGraph class DelaunayAdapterTest < Minitest::Test def setup - skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' unless File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) + return if File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) + + skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' end def test_compute_edges_returns_empty_for_insufficient_points - assert_equal [], DelaunayAdapter.compute_edges([]) - assert_equal [], DelaunayAdapter.compute_edges([[1.0, 2.0]]) + assert_empty DelaunayAdapter.compute_edges([]) + assert_empty DelaunayAdapter.compute_edges([[1.0, 2.0]]) end def test_compute_edges_two_points diff --git a/test/lib/vrp_graph/graph_builder_test.rb b/test/lib/vrp_graph/graph_builder_test.rb index fdbc420a..5dba86b2 100644 --- a/test/lib/vrp_graph/graph_builder_test.rb +++ b/test/lib/vrp_graph/graph_builder_test.rb @@ -5,7 +5,9 @@ module VrpGraph class GraphBuilderTest < Minitest::Test def setup - skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' unless File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) + return if File.executable?(VrpGraph::DelaunayAdapter::BINARY_PATH) + + skip 'vrp_delaunay binary not built (rake ext:vrp_delaunay)' end def test_build_creates_graph_with_service_level_nodes @@ -99,9 +101,10 @@ def test_intra_point_edges_between_colocated_services assert_equal graph.nodes['svc_a'][:point_id], graph.nodes['svc_b'][:point_id], 'Co-located services should share the same point_id' - intra_edge = graph.edges.any?{ |e| - [e[0], e[1]].sort == ['svc_a', 'svc_b'].sort - } + intra_edge = + graph.edges.any?{ |e| + [e[0], e[1]].sort == ['svc_a', 'svc_b'].sort + } assert intra_edge, 'Compatible co-located services should have an intra-point edge' end end