From 39a80ba17672844e1367fc38a4677328e81936ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Thu, 30 Jul 2026 11:38:01 +0200 Subject: [PATCH 1/4] Apply time penaly when max_ride_distance is violated --- test/wrappers/vroom_test.rb | 204 +++++++++++++++++++++++++++++++++++- wrappers/vroom.rb | 22 ++-- 2 files changed, 217 insertions(+), 9 deletions(-) diff --git a/test/wrappers/vroom_test.rb b/test/wrappers/vroom_test.rb index 33ca03d4..43da8720 100644 --- a/test/wrappers/vroom_test.rb +++ b/test/wrappers/vroom_test.rb @@ -721,6 +721,11 @@ def test_deform_matrix_skips_depot_legs def test_deform_matrix_inflates_inter_job_leg matrix = Models::Matrix.new( + time: [ + [0, 10, 20], + [10, 0, 30], + [20, 30, 0] + ], distance: [ [0, 100, 200], [100, 0, 300], @@ -730,15 +735,48 @@ def test_deform_matrix_inflates_inter_job_leg group = { maximum_ride_time: nil, maximum_ride_distance: 150, - ride_time_penalty: nil, + deform_distance: false, + ride_time_penalty: 100, ride_distance_penalty: 1_000, - depot_indices: Set[0] + depot_indices: Set[0], + max_end: 1_000 } deformed = @vroom.send(:deform_matrix_for_ride_constraints, matrix, group) assert_equal 200, deformed.distance[0][2] + assert_equal 300, deformed.distance[1][2] + assert_equal 10, deformed.time[0][1] + assert_equal 1_000, deformed.time[1][2] + end + + def test_deform_matrix_inflates_distance_when_distance_cost + matrix = Models::Matrix.new( + time: [ + [0, 10, 20], + [10, 0, 30], + [20, 30, 0] + ], + distance: [ + [0, 100, 200], + [100, 0, 300], + [200, 300, 0] + ] + ) + group = { + maximum_ride_time: nil, + maximum_ride_distance: 150, + deform_distance: true, + ride_time_penalty: 100, + ride_distance_penalty: 1_000, + depot_indices: Set[0], + max_end: 1_000 + } + + deformed = @vroom.send(:deform_matrix_for_ride_constraints, matrix, group) + assert_equal 1_000, deformed.distance[1][2] + assert_equal 1_000, deformed.time[1][2] end def test_ride_matrix_profiles_shared_across_vehicles @@ -783,6 +821,168 @@ def test_vroom_problem_deforms_matrix_for_maximum_ride_time assert_equal 6, durations[1][0] end + def test_vroom_problem_deforms_matrix_for_maximum_ride_distance + problem = { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 4, 5, 5], + [6, 0, 1, 5], + [1, 2, 0, 5], + [5, 5, 5, 0] + ], + distance: [ + [0, 100, 3, 3], + [100, 0, 1000, 1000], + [3, 1000, 0, 3], + [3, 1000, 3, 0] + ] + }], + points: (0..3).map { |i| { id: "point_#{i}", matrix_index: i } }, + vehicles: [{ + id: 'vehicle_0', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + cost_time_multiplier: 1, + cost_distance_multiplier: 0, + maximum_ride_distance: 4 + }], + services: (1..3).map { |i| + { id: "service_#{i}", activity: { point_id: "point_#{i}" } } + }, + configuration: { + resolution: { duration: 100 }, + restitution: { intermediate_solutions: false } + } + } + vrp = TestHelper.create(problem) + problem_json = @vroom.send(:vroom_problem, vrp, [:time, :distance]) + profile = problem_json[:vehicles].first[:profile] + durations = problem_json[:matrices][profile][:durations] + distances = problem_json[:matrices][profile][:distances] + + assert_equal 6, durations[1][0] + assert_operator durations[1][2], :>, 5 + assert_equal 1000, distances[1][2] + end + + def test_vroom_problem_deforms_distance_for_maximum_ride_distance_when_distance_cost + problem = { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 4, 5, 5], + [6, 0, 1, 5], + [1, 2, 0, 5], + [5, 5, 5, 0] + ], + distance: [ + [0, 100, 3, 3], + [100, 0, 1000, 1000], + [3, 1000, 0, 3], + [3, 1000, 3, 0] + ] + }], + points: (0..3).map { |i| { id: "point_#{i}", matrix_index: i } }, + vehicles: [{ + id: 'vehicle_0', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + cost_time_multiplier: 0, + cost_distance_multiplier: 1, + maximum_ride_distance: 4 + }], + services: (1..3).map { |i| + { id: "service_#{i}", activity: { point_id: "point_#{i}" } } + }, + configuration: { + resolution: { duration: 100 }, + restitution: { intermediate_solutions: false } + } + } + vrp = TestHelper.create(problem) + problem_json = @vroom.send(:vroom_problem, vrp, [:time, :distance]) + profile = problem_json[:vehicles].first[:profile] + distances = problem_json[:matrices][profile][:distances] + + assert_operator distances[1][2], :>, 100 + end + + def test_maximum_ride_distance_with_vroom_solver_multi_vehicle + problem = { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 1000, 1, 1], + [1000, 0, 1000, 1000], + [1, 1000, 0, 1], + [1, 1000, 1, 0] + ], + distance: [ + [0, 1000, 3, 3], + [1000, 0, 1000, 1000], + [3, 1000, 0, 3], + [3, 1000, 3, 0] + ] + }], + points: (0..3).map { |i| { id: "point_#{i}", matrix_index: i } }, + vehicles: [{ + id: 'vehicle_0', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + cost_time_multiplier: 1, + cost_distance_multiplier: 0, + maximum_ride_distance: 4 + }, { + id: 'vehicle_1', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + cost_time_multiplier: 1, + cost_distance_multiplier: 0, + maximum_ride_distance: 4 + }], + services: (1..3).map { |i| + { id: "service_#{i}", activity: { point_id: "point_#{i}" } } + }, + configuration: { + resolution: { duration: 30_000 }, + restitution: { intermediate_solutions: false } + } + } + vrp = TestHelper.create(problem) + + refute_includes OptimizerWrapper.config[:services][:vroom].inapplicable_solve?(vrp), + :assert_no_ride_constraint + + problem_json = @vroom.send(:vroom_problem, vrp, [:time, :distance]) + profile = problem_json[:vehicles].first[:profile] + assert_operator problem_json[:matrices][profile][:durations][1][2], :>, 1000 + refute problem_json[:vehicles].first[:costs].key?(:per_km) + + solution = @vroom.solve(vrp) + assert solution + + distance_matrix = vrp.matrices.first.distance + max_ride = problem[:vehicles].first[:maximum_ride_distance] + + solution.routes.each do |route| + service_stops = route.stops.select(&:service_id) + previous_index = nil + service_stops.each do |stop| + current_index = stop.activity.point.matrix_index + if previous_index + assert_operator distance_matrix[previous_index][current_index], :<=, max_ride, + 'Consecutive services should respect maximum_ride_distance when feasible' + end + previous_index = current_index + end + end + end + def test_maximum_ride_time_with_vroom_solver problem = { matrices: [{ diff --git a/wrappers/vroom.rb b/wrappers/vroom.rb index 4b2af81c..ffb63951 100644 --- a/wrappers/vroom.rb +++ b/wrappers/vroom.rb @@ -508,13 +508,19 @@ def ride_distance_penalty(vehicle, matrix, max_end) end def ride_matrix_group_key(vehicle, matrix, max_end) + needs_time_penalty = + vehicle.maximum_ride_time&.positive? || vehicle.maximum_ride_distance&.positive? + deform_distance = vehicle.cost_distance_multiplier.to_f.positive? { matrix_id: vehicle.matrix_id, maximum_ride_time: vehicle.maximum_ride_time, maximum_ride_distance: vehicle.maximum_ride_distance, - ride_time_penalty: vehicle.maximum_ride_time&.positive? ? ride_time_penalty(vehicle, max_end) : nil, + deform_distance: deform_distance, + ride_time_penalty: needs_time_penalty ? ride_time_penalty(vehicle, max_end) : nil, ride_distance_penalty: - vehicle.maximum_ride_distance&.positive? ? ride_distance_penalty(vehicle, matrix, max_end) : nil + if vehicle.maximum_ride_distance&.positive? && deform_distance + ride_distance_penalty(vehicle, matrix, max_end) + end } end @@ -532,10 +538,12 @@ def deform_matrix_for_ride_constraints(matrix, group) if time && group[:maximum_ride_time]&.positive? && time[i][j] > group[:maximum_ride_time] time[i][j] = group[:ride_time_penalty] end - if distance && group[:maximum_ride_distance]&.positive? && - distance[i][j] > group[:maximum_ride_distance] - distance[i][j] = group[:ride_distance_penalty] - end + next unless distance && group[:maximum_ride_distance]&.positive? && + distance[i][j] > group[:maximum_ride_distance] + + # VROOM optimizes on durations when cost_distance_multiplier is zero. + time[i][j] = group[:max_end] if time && group[:max_end] + distance[i][j] = group[:ride_distance_penalty] if group[:deform_distance] } } @@ -572,7 +580,7 @@ def build_vroom_matrix_profiles(vrp, max_end) matrix = matrices_by_id[group_key[:matrix_id]] next unless matrix - group = group_key.merge(depot_indices: depot_matrix_indices(vehicles)) + group = group_key.merge(depot_indices: depot_matrix_indices(vehicles), max_end: max_end) deformed = deform_matrix_for_ride_constraints(matrix, group) profile_id = "m#{matrix.id}_ride_#{Digest::MD5.hexdigest(Oj.dump(group_key))[0, 8]}" profiles[profile_id] = matrix_to_vroom_payload(deformed, max_end) From 12444cd574d7ea90cac3d421593703b9021ad51e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Thu, 3 Sep 2026 10:07:21 +0200 Subject: [PATCH 2/4] PyVRP: Bump & Edit penalties & Return Solution timings --- Dockerfile | 2 +- models/solution/parsers/route_parser.rb | 6 +- test/wrappers/pyvrp_test.rb | 526 ++++++++++++++++++++++- wrappers/pyvrp.rb | 547 ++++++++++++++++++------ wrappers/pyvrp_wrapper.py | 344 ++++++++++++--- 5 files changed, 1220 insertions(+), 205 deletions(-) diff --git a/Dockerfile b/Dockerfile index a892df98..46d481bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ RUN apt update -y && apt install -y \ python3-pip \ python3-venv -ARG PYVRP_VERSION=0.13.1 +ARG PYVRP_VERSION=0.14.0 # PYVRP_BRANCH can be: a branch name (e.g. "main"), a tag (e.g. "v0.12.1"), or a commit SHA # ARG PYVRP_BRANCH= ARG PYVRP_GIT_URL=https://github.com/PyVRP/PyVRP.git diff --git a/models/solution/parsers/route_parser.rb b/models/solution/parsers/route_parser.rb index 22e30d9e..577b2618 100644 --- a/models/solution/parsers/route_parser.rb +++ b/models/solution/parsers/route_parser.rb @@ -27,7 +27,7 @@ def self.parse(route, vrp, matrix, options = {}) compute_missing_dimensions(matrix) if options[:compute_dimensions] route_data = compute_route_travel_distances(vrp, matrix) compute_total_time - compute_route_waiting_times unless @route.stops.empty? + compute_route_waiting_times unless options[:preserve_solver_waiting_times] compute_route_total_dimensions(matrix) route.stops.each{ |stop| stop.info.set_schedule(vrp, route.vehicle) } return unless ([:polylines, :encoded_polylines] & vrp.configuration.restitution.geometry).any? && @@ -80,9 +80,7 @@ def self.compute_route_total_dimensions(matrix) @route.info.total_distance = total[:distance].round if dimensions.include?(:distance) @route.info.total_travel_value = total[:value].round if dimensions.include?(:value) - return unless @route.stops.all?{ |a| a.info.waiting_time } - - @route.info.total_waiting_time = @route.stops.collect{ |a| a.info.waiting_time }.sum.round + @route.info.total_waiting_time = @route.stops.sum{ |stop| stop.info.waiting_time.to_i }.round end def self.compute_missing_dimensions(matrix) diff --git a/test/wrappers/pyvrp_test.rb b/test/wrappers/pyvrp_test.rb index 95c8952c..11c34427 100644 --- a/test/wrappers/pyvrp_test.rb +++ b/test/wrappers/pyvrp_test.rb @@ -228,6 +228,107 @@ def test_ensure_total_time_and_travel_info_with_pyvrp }, 'At least one route total_travel_distance was not provided' end + def test_solver_schedule_is_preserved + problem = @minimal_problem.dup + problem[:vehicles][0][:end_point_id] = 'point_0' + problem[:services].each{ |service| service[:activity][:duration] = 10 } + @pyvrp.stub( + :run_pyvrp, lambda{ |_payload, _timeout| + map = @pyvrp.instance_variable_get(:@service_index_map) + indices = map.each_with_index.filter_map{ |service, idx| idx if service } + start_depot = @pyvrp.instance_variable_get(:@vehicle_start_point_index_hash)['vehicle_0'] + { + runtime: 0.01, + iterations: 1, + cost: 100, + feasible: true, + complete: true, + routes: [{ + vehicle_type: 0, + activities: [ + { type: 'client', idx: indices[0], start_time: 200, end_time: 210, wait_duration: 40 }, + { type: 'client', idx: indices[1], start_time: 250, end_time: 260, wait_duration: 0 } + ], + start_depot: start_depot, + end_depot: start_depot, + start_time: 100, + end_time: 300, + start_schedule: { start_time: 100, end_time: 100, wait_duration: 0 }, + end_schedule: { start_time: 300, end_time: 300, wait_duration: 0 } + }] + } + } + ) do + solution = @pyvrp.solve(TestHelper.create(problem), 'test') + route = solution.routes.first + assert_equal 100, route.stops.first.info.begin_time + assert_equal 200, route.stops[1].info.begin_time + assert_equal 40, route.stops[1].info.waiting_time + assert_equal 210, route.stops[1].info.end_time + assert_equal 250, route.stops[2].info.begin_time + assert_equal 300, route.stops.last.info.begin_time + assert_equal 100, route.info.start_time + assert_equal 300, route.info.end_time + end + end + + def test_solver_schedule_falls_back_to_route_times_for_depots + problem = @minimal_problem.dup + problem[:vehicles][0][:end_point_id] = 'point_0' + problem[:services].each{ |service| service[:activity][:duration] = 10 } + @pyvrp.stub( + :run_pyvrp, lambda{ |_payload, _timeout| + map = @pyvrp.instance_variable_get(:@service_index_map) + indices = map.each_with_index.filter_map{ |service, idx| idx if service } + start_depot = @pyvrp.instance_variable_get(:@vehicle_start_point_index_hash)['vehicle_0'] + { + runtime: 0.01, + iterations: 1, + cost: 100, + feasible: true, + complete: true, + routes: [{ + vehicle_type: 0, + activities: [ + { type: 'client', idx: indices[0], start_time: 200, end_time: 210, wait_duration: 0 }, + { type: 'client', idx: indices[1], start_time: 250, end_time: 260, wait_duration: 0 } + ], + start_depot: start_depot, + end_depot: start_depot, + start_time: 100, + end_time: 300 + }] + } + } + ) do + solution = @pyvrp.solve(TestHelper.create(problem), 'test') + route = solution.routes.first + assert_equal 100, route.stops.first.info.begin_time + assert_equal 300, route.stops.last.info.begin_time + assert_equal 300, route.stops.last.info.end_time + end + end + + def test_solver_schedule_respects_timewindows + problem = @minimal_problem.dup + problem[:vehicles][0][:end_point_id] = 'point_0' + problem[:vehicles][0][:timewindow] = { start: 50, end: 500 } + problem[:services].each{ |service| + service[:activity][:duration] = 10 + service[:activity][:timewindows] = [{ start: 100, end: 400 }] + } + solution = @pyvrp.solve(TestHelper.create(problem), 'test') + route = solution.routes.find{ |r| r.stops.any?(&:service_id) } + assert route, 'Expected an assigned route' + assert_operator route.stops.first.info.begin_time, :>=, 50 + refute_equal 0, route.stops.first.info.begin_time + route.stops.select(&:service_id).each{ |stop| + assert_operator stop.info.begin_time, :>=, 100 + assert_operator stop.info.begin_time, :<=, 400 + } + assert_operator route.stops.last.info.begin_time, :<=, 500 + end + def test_correct_route_collection problem = VRP.lat_lon_two_vehicles problem[:services].each{ |service| @@ -354,19 +455,207 @@ def test_partially_nil_capacities problem[:vehicles].first[:capacities] = [{ unit_id: 'kg', limit: 2 }] problem[:vehicles].last[:id] = 'vehicle_1' - pyvrp = Wrappers::PyVRP.new - pyvrp.stub( - :run_pyvrp, lambda{ |pyvrp_vrp, _job| - assert_equal [2 * Wrappers::PyVRP::CUSTOM_QUANTITY_BIGNUM], pyvrp_vrp[:vehicles].first[:capacity] - assert_equal pyvrp_vrp[:jobs].flat_map{ |job| job[:pickup].first }.sum, - pyvrp_vrp[:vehicles].last[:capacity].first - nil + empty_result = { feasible: true, complete: true, routes: [], runtime: 0, cost: 0 } + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + limited = payload[:vehicle_types].find{ |vehicle| vehicle[:name] == 'vehicle_0' } + unbounded = payload[:vehicle_types].find{ |vehicle| vehicle[:name] == 'vehicle_1' } + assert_equal 2 * Wrappers::PyVRP::CUSTOM_QUANTITY_BIGNUM, limited[:capacity].first + demand = payload[:clients].sum{ |client| client[:pickup].first } + assert_equal demand, unbounded[:capacity].first + empty_result + } + ) do + @pyvrp.solve(TestHelper.create(problem)) + end + end + + def test_unbounded_timewindows_use_horizon_not_max_int64 + problem = @minimal_problem.dup + empty_result = { feasible: true, complete: true, routes: [], runtime: 0, cost: 0 } + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + payload[:clients].each{ |client| + refute_equal Wrappers::PyVRP::MAX_INT64, client[:tw_late] + assert_operator client[:tw_late], :>, 0 + } + payload[:vehicle_types].each{ |vehicle| + refute_equal Wrappers::PyVRP::MAX_INT64, vehicle[:tw_late] + refute_equal Wrappers::PyVRP::MAX_INT64, vehicle[:shift_duration] + refute_equal Wrappers::PyVRP::MAX_INT64, vehicle[:max_distance] + } + payload[:depots].each{ |depot| + refute_equal Wrappers::PyVRP::MAX_INT64, depot[:tw_late] + } + empty_result + } + ) do + @pyvrp.solve(TestHelper.create(problem)) + end + end + + def test_time_horizon_follows_latest_timewindow_not_matrix_times_n + problem = @minimal_problem.dup + problem[:vehicles][0][:timewindow] = { start: 10_000, end: 20_000 } + empty_result = { feasible: true, complete: true, routes: [], runtime: 0, cost: 0 } + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + payload[:clients].each{ |client| + assert_equal 20_000, client[:tw_late] + } + vehicle = payload[:vehicle_types].first + assert_equal 20_000, vehicle[:tw_late] + assert_equal 20_000, vehicle[:shift_duration] + empty_result } ) do @pyvrp.solve(TestHelper.create(problem)) end end + def test_universal_skills_are_not_load_dimensions + problem = @minimal_problem.dup + problem[:vehicles] = [ + { + id: 'vehicle_0', + start_point_id: 'point_0', + matrix_id: 'matrix_0', + skills: [['common', 'sector_a']] + }, + { + id: 'vehicle_1', + start_point_id: 'point_0', + matrix_id: 'matrix_0', + skills: [['common', 'sector_b']] + } + ] + problem[:services][0][:skills] = ['common', 'sector_a'] + problem[:services][1][:skills] = ['common', 'sector_b'] + empty_result = { feasible: true, complete: true, routes: [], runtime: 0, cost: 0 } + demand = Wrappers::PyVRP::CUSTOM_QUANTITY_BIGNUM.round + capacity = problem[:services].size * demand + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + # Two discriminating skills, no unit quantities, no universal 'common' dim. + payload[:vehicle_types].each{ |vehicle| + assert_equal 2, vehicle[:capacity].size + } + payload[:clients].each{ |client| + assert_equal 2, client[:pickup].size + assert_equal demand, client[:pickup].sum + } + assert_equal [capacity, 0], payload[:vehicle_types][0][:capacity] + assert_equal [0, capacity], payload[:vehicle_types][1][:capacity] + empty_result + } + ) do + @pyvrp.solve(TestHelper.create(problem)) + end + end + + def test_shift_duration_is_vehicle_duration_not_timewindow_width + problem = @minimal_problem.dup + problem[:vehicles][0][:timewindow] = { start: 10_000, end: 20_000 } + empty_result = { feasible: true, complete: true, routes: [], runtime: 0, cost: 0 } + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + vehicle = payload[:vehicle_types].first + refute_equal 10_000, vehicle[:shift_duration] + assert_operator vehicle[:shift_duration], :>, 10_000 + refute_equal Wrappers::PyVRP::MAX_INT64, vehicle[:shift_duration] + empty_result + } + ) do + @pyvrp.solve(TestHelper.create(problem)) + end + + problem[:vehicles][0][:duration] = 4_000 + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + assert_equal 4_000, payload[:vehicle_types].first[:shift_duration] + empty_result + } + ) do + @pyvrp.solve(TestHelper.create(problem)) + end + end + + def test_multi_timewindow_group_is_required + problem = @minimal_problem.dup + problem[:services].first[:priority] = 0 + problem[:services].first[:activity][:timewindows] = [ + { start: 0, end: 10 }, + { start: 20, end: 30 } + ] + empty_result = { feasible: true, complete: true, routes: [], runtime: 0, cost: 0 } + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + grouped = payload[:clients].reject{ |client| client[:group].nil? } + assert_equal 2, grouped.size + grouped.each{ |client| + refute client[:required] + assert_equal 0, client[:prize] + } + assert_equal 1, payload[:groups].size + assert payload[:groups].first[:required] + assert_equal [0, 1], payload[:groups].first[:clients] + empty_result + } + ) do + @pyvrp.solve(TestHelper.create(problem)) + end + end + + def test_multi_timewindow_clients_share_locations_and_point_sized_matrices + problem = @minimal_problem.dup + problem[:services].first[:activity][:timewindows] = [ + { start: 0, end: 10 }, + { start: 20, end: 30 } + ] + vrp = TestHelper.create(problem) + payload = Wrappers::PyVRP.new.send(:pyvrp_problem, vrp) + + assert_equal 2, payload[:locations].size + assert_equal payload[:locations].size, payload[:duration_matrices].first.size + assert_equal payload[:locations].size, payload[:distance_matrices].first.size + assert_equal 3, payload[:clients].size + assert_equal payload[:locations].size, payload[:clients].map{ |client| client[:location] }.uniq.size + payload[:clients].first(2).each{ |client| + assert_equal payload[:clients].first[:location], client[:location] + } + refute payload[:clients].first.key?(:x) + refute payload[:depots].first.key?(:x) + payload[:locations].each{ |location| + refute location.key?(:x) + refute location.key?(:y) + } + end + + def test_seed_splits_route_when_capacity_exceeded + problem = VRP.lat_lon_capacitated + problem[:reload_depots] = [{ + id: 'reload_1', + point_id: 'point_0' + }] + problem[:vehicles].first[:reload_depot_ids] = ['reload_1'] + problem[:vehicles].first[:maximum_reloads] = 4 + + vrp = TestHelper.create(problem) + vehicle = vrp.vehicles.first + stops = vrp.services.map{ |service| Models::Solution::Stop.new(service) } + solution = Models::Solution.new( + routes: [Models::Solution::Route.new(vehicle: vehicle, stops: stops)], + unassigned_stops: [] + ) + + Wrappers::PyVRP.seed_vrp_routes_from_solution(vrp, solution) + missions = vrp.routes.first.missions + assert missions.any?{ |mission| mission.is_a?(Models::ReloadDepot) }, + 'Capacity overflow should insert reload depots in the seed' + assert_equal vrp.services.size, (missions.count { |mission| mission.is_a?(Models::Service) }) + assert_operator (missions.count { |mission| mission.is_a?(Models::ReloadDepot) }), :>=, 2 + end + def test_multiple_matrices problem = VRP.lat_lon_two_vehicles problem[:matrices] << problem[:matrices].first.dup @@ -466,7 +755,7 @@ def test_double_hard_time_windows_problem solution = pyvrp.solve(vrp, 'test') assert solution assert_equal 1, solution.routes.size - assert_equal problem[:services].size, solution.routes.first.stops.size + assert_equal problem[:services].size, solution.routes.first.stops.count(&:service_id) end def test_triple_hard_time_windows_problem @@ -541,7 +830,7 @@ def test_triple_hard_time_windows_problem solution = pyvrp.solve(vrp, 'test') assert solution assert_equal 1, solution.routes.size - assert_equal problem[:services].size, solution.routes.first.stops.size + assert_equal problem[:services].size, solution.routes.first.stops.count(&:service_id) end def test_skills @@ -702,4 +991,223 @@ def test_reload_depot_with_lat_lon_capacitated 'Route should contain 2 reload depots at indices 3 and 6' ) end + + def test_infeasible_reload_keeps_visits_after_depot + problem = VRP.lat_lon_capacitated + problem[:reload_depots] = [{ + id: 'reload_depot_1', + point_id: 'point_0', + duration: 300, + timewindows: [{ + start: 0, + end: 86400 + }] + }] + problem[:vehicles].first[:reload_depot_ids] = ['reload_depot_1'] + problem[:vehicles].first[:maximum_reloads] = 2 + + vrp = TestHelper.create(problem) + solver = @pyvrp + + solver.stub(:run_pyvrp, lambda { |_problem, _timeout| + service_indices = + solver.instance_variable_get(:@service_index_map).each_with_index.filter_map{ |service, idx| + idx if service + } + reload_index = solver.instance_variable_get(:@reload_depot_hash)['reload_depot_1'] + start_depot = solver.instance_variable_get(:@vehicle_start_point_index_hash)['vehicle_0'] + end_depot = solver.instance_variable_get(:@vehicle_end_point_index_hash)['vehicle_0'] + assert_equal 0, start_depot, 'Regression needs depot index 0 to stay truthy when reading the solution' + + # 6 services of 2kg, capacity 5 → two visits between reloads; combined load exceeds capacity. + activities = [] + service_indices.each_slice(2).with_index{ |visits, idx| + visits.each{ |visit_index| activities << { type: 'client', idx: visit_index } } + last_slice = idx == (service_indices.size / 2) - 1 + activities << { type: 'depot', idx: reload_index } unless last_slice + } + + { + runtime: 0.01, + iterations: 1, + cost: -1, + feasible: false, + complete: true, + routes: [{ + vehicle_type: 0, + activities: activities, + start_depot: start_depot, + end_depot: end_depot, + start_time: 0, + end_time: 1000 + }] + } + }) do + solution = solver.solve(vrp, 'test') + + assert_equal 0, solution.unassigned_stops.size + assert_equal vrp.services.size, solution.routes.first.stops.count(&:service_id) + assert_equal :depot, solution.routes.first.stops.first.type + assert_equal :depot, solution.routes.first.stops.last.type + assert_equal 2, (solution.routes.first.stops.count { |stop| stop.type == :reload_depot }) + end + end + + def test_initial_routes_are_sent_as_activities + problem = VRP.lat_lon_capacitated + problem[:reload_depots] = [{ + id: 'reload_1', + point_id: 'point_0' + }] + problem[:vehicles].first[:reload_depot_ids] = ['reload_1'] + problem[:vehicles].first[:maximum_reloads] = 4 + + vrp = TestHelper.create(problem) + vehicle = vrp.vehicles.first + stops = vrp.services.map{ |service| Models::Solution::Stop.new(service) } + solution = Models::Solution.new( + routes: [Models::Solution::Route.new(vehicle: vehicle, stops: stops)], + unassigned_stops: [] + ) + Wrappers::PyVRP.seed_vrp_routes_from_solution(vrp, solution) + + empty_result = { feasible: true, complete: true, routes: [], runtime: 0, cost: 0 } + @pyvrp.stub( + :run_pyvrp, lambda{ |payload, _timeout| + payload[:routes].each{ |route| + refute route.key?(:trips) + refute route.key?(:visits) + } + types = payload[:routes].first[:activities].map{ |activity| activity[:type] } + assert_includes types, 'client' + assert_includes types, 'depot' + empty_result + } + ) do + @pyvrp.solve(vrp) + end + end + + def test_solve_params_enable_group_ops_and_scale_neighbourhood + require 'open3' + + python = @pyvrp.send(:pyvrp_python) + script = <<~'PY' + import sys + sys.path.insert(0, '.') + from wrappers.pyvrp_wrapper import build_solve_params + + small = build_solve_params(10) + assert small.neighbourhood.num_neighbours == 50, small.neighbourhood.num_neighbours + medium = build_solve_params(499) + assert medium.neighbourhood.num_neighbours == 100, medium.neighbourhood.num_neighbours + assert medium.penalty.max_penalty == 1_000_000.0, medium.penalty.max_penalty + large = build_solve_params(3568) + assert large.neighbourhood.num_neighbours == 150, large.neighbourhood.num_neighbours + assert small.penalty.max_penalty == 100_000.0, small.penalty.max_penalty + assert large.penalty.max_penalty == 1_000_000.0, large.penalty.max_penalty + + class FakeData: + num_load_dimensions = 3 + + loads, duration, distance = medium.penalty.midpoint_penalties(FakeData()) + assert loads == [10.0, 10.0, 10.0], loads + assert duration == 10.0, duration + assert distance == 10.0, distance + names = [op.__name__ for op in large.operators] + for expected in ('RelocateAlternative', 'ReplaceGroup', 'RelocateWithDepot', 'RemoveAdjacentDepot'): + assert expected in names, names + print('ok') + PY + + stdout, stderr, status = Open3.capture3(python, '-c', script, chdir: File.expand_path('../..', __dir__)) + assert status.success?, "#{stderr}\n#{stdout}" + assert_includes stdout, 'ok' + end + + def test_problem_data_accepts_multi_tw_groups_with_depot_offset_clients + require 'open3' + + python = @pyvrp.send(:pyvrp_python) + script = <<~'PY' + import sys + sys.path.insert(0, '.') + from wrappers.pyvrp_wrapper import ProblemData + + payload = { + "depots": [{"x": 0, "y": 0, "name": "d0"}], + "clients": [ + {"x": 1, "y": 1, "group": 0, "required": False, "name": "s0_tw0"}, + {"x": 1, "y": 1, "group": 0, "required": False, "name": "s0_tw1"}, + ], + "vehicle_types": [{"num_available": 1, "start_depot": 0, "end_depot": 0}], + "distance_matrices": [[[0, 1, 1], [1, 0, 0], [1, 0, 0]]], + "duration_matrices": [[[0, 1, 1], [1, 0, 0], [1, 0, 0]]], + "groups": [{"clients": [1, 2], "required": True}], + } + data = ProblemData.from_dict(payload) + assert data.num_clients == 2, data.num_clients + assert data.num_groups == 1, data.num_groups + assert list(data.group(0).clients) == [0, 1], list(data.group(0).clients) + print('ok') + PY + + stdout, stderr, status = Open3.capture3(python, '-c', script, chdir: File.expand_path('../..', __dir__)) + assert status.success?, "#{stderr}\n#{stdout}" + assert_includes stdout, 'ok' + end + + def test_solution_serializes_inner_activities + require 'open3' + + python = @pyvrp.send(:pyvrp_python) + script = <<~'PY' + import sys + sys.path.insert(0, '.') + from wrappers.pyvrp_wrapper import ( + ProblemData, + Route, + Activity, + ActivityType, + _inner_route_activities, + _activity_to_dict, + ) + + payload = { + "depots": [ + {"x": 0, "y": 0, "name": "start"}, + {"x": 2, "y": 2, "name": "reload"}, + ], + "clients": [{"x": 1, "y": 1, "name": "c0"}], + "vehicle_types": [{ + "num_available": 1, + "start_depot": 0, + "end_depot": 0, + "reload_depots": [1], + "max_reloads": 2, + }], + "distance_matrices": [[[0, 1, 1], [1, 0, 1], [1, 1, 0]]], + "duration_matrices": [[[0, 1, 1], [1, 0, 1], [1, 1, 0]]], + } + data = ProblemData.from_dict(payload) + route = Route( + data, + activities=[ + Activity(ActivityType.CLIENT, 0), + Activity(ActivityType.DEPOT, 1), + ], + vehicle_type=0, + ) + inner = [_activity_to_dict(activity) for activity in _inner_route_activities(route)] + assert [(item["type"], item["idx"]) for item in inner] == [ + ("client", 0), + ("depot", 1), + ], inner + print('ok') + PY + + stdout, stderr, status = Open3.capture3(python, '-c', script, chdir: File.expand_path('../..', __dir__)) + assert status.success?, "#{stderr}\n#{stdout}" + assert_includes stdout, 'ok' + end end diff --git a/wrappers/pyvrp.rb b/wrappers/pyvrp.rb index 9fccb5fe..165c15f4 100644 --- a/wrappers/pyvrp.rb +++ b/wrappers/pyvrp.rb @@ -3,9 +3,7 @@ module Wrappers class PyVRP < Wrapper CUSTOM_QUANTITY_BIGNUM = 1e3 - MAX_PENALTY = 1e10 MAX_INT64 = 2**63 - 1 - MAX_INT_UNITS = 2**60 - 1 def solver_constraints super + [ @@ -37,7 +35,7 @@ def solver_constraints :assert_no_activity_with_position, :assert_no_empty_or_fill, :assert_services_no_late_multiplier, - :assert_no_complex_setup_durations, + :assert_no_complex_setup_durations, # Assume that a sinlge point always have the same setup_duration :assert_only_one_visit, # Solver @@ -83,41 +81,49 @@ def solve(vrp, _job = nil, _thread_proc = nil) vehicle = vrp.vehicles[route[:vehicle_type]] stops = [] @previous = nil - if route[:start_depot] - start_stop = read_depot_start(vrp, vehicle) + # Depot index 0 is a valid PyVRP depot; `if start_depot` would skip it. + unless route[:start_depot].nil? + start_stop = read_depot_start(vrp, vehicle, depot_schedule(route[:start_schedule], route[:start_time])) stops << start_stop if start_stop end vehicle = vrp.vehicles[route[:vehicle_type]] + # Reloads empty the vehicle: capacity must be checked between + # intermediate depots, not accumulated across the whole route. route_loads = Hash.new(0) - route[:trips].each.with_index { |trip, idx| - stops += - trip[:visits].filter_map{ |visit_index| - service = @service_index_map[visit_index] - next unless service - if filter_capacity && !visit_fits_capacity?(vehicle, service, route_loads) - next - end - - apply_visit_load!(vehicle, service, route_loads) if filter_capacity - read_visit(vrp, vehicle, visit_index) - } - next if idx == route[:trips].size - 1 - - stops << read_reload_depot_trip(vrp, vehicle, trip[:end_depot]) + Array(route[:activities]).each { |activity| + kind = activity[:type].to_s.downcase + if kind == 'depot' + route_loads = Hash.new(0) + reload_stop = read_reload_depot(vrp, vehicle, activity[:idx], activity) + stops << reload_stop if reload_stop + next + end + next unless %w[client pickup delivery].include?(kind) + + visit_index = activity[:idx] + service = @service_index_map[visit_index] + next unless service + if filter_capacity && !visit_fits_capacity?(vehicle, service, route_loads) + next + end + + apply_visit_load!(vehicle, service, route_loads) if filter_capacity + stops << read_visit(vrp, vehicle, visit_index, activity) } - if route[:end_depot] - end_stop = read_depot_end(vrp, vehicle) + unless route[:end_depot].nil? + end_stop = read_depot_end(vrp, vehicle, depot_schedule(route[:end_schedule], route[:end_time])) stops << end_stop if end_stop end + complete_pyvrp_route_times!(stops, vehicle) Models::Solution::Route.new( stops: stops, vehicle: vehicle, info: Models::Solution::Route::Info.new( - start_time: route[:start_time], - end_time: route[:end_time] + start_time: stops.first&.info&.begin_time || route[:start_time], + end_time: stops.last&.info&.end_time || stops.last&.info&.begin_time || route[:end_time] ) ) } @@ -129,20 +135,62 @@ def solve(vrp, _job = nil, _thread_proc = nil) log "Solution cost: #{result[:cost]} & unassigned: #{unassigneds.size}", level: :info - solution = + pyvrp_solution = Models::Solution.new( elapsed: elapsed_time, - solvers: [:pryvrp], + solvers: [:pyvrp], routes: routes, unassigned_stops: unassigneds ) - solution.parse(vrp) + pyvrp_solution.parse(vrp, preserve_solver_waiting_times: true) + end + + def self.seed_vrp_routes_from_solution(vrp, solution) + new.send(:seed_vrp_routes_from_solution, vrp, solution) end private - def read_visit(vrp, vehicle, visit_index) - read_activity(vrp, vehicle, visit_index) + def depot_schedule(schedule, fallback_time) + return schedule if schedule.is_a?(Hash) && !schedule[:start_time].nil? + + { start_time: fallback_time, end_time: fallback_time, wait_duration: 0 } + end + + def solver_schedule(activity) + start_time = activity && activity[:start_time] + return {} if start_time.nil? + + end_time = activity[:end_time] || start_time + { + begin_time: start_time, + waiting_time: activity[:wait_duration].to_i, + end_time: end_time, + departure_time: end_time + } + end + + def complete_pyvrp_route_times!(stops, vehicle) + stops.each do |stop| + info = stop.info + info.waiting_time = 0 if info.waiting_time.nil? + next unless info.begin_time + + if stop.is_a?(Models::Solution::StopDepot) + info.end_time ||= info.begin_time + info.departure_time ||= info.begin_time + next + end + + service_duration = + info.end_time ? info.end_time - info.begin_time : stop.activity.duration_on(vehicle) + info.end_time ||= info.begin_time + service_duration.to_i + info.departure_time ||= info.end_time + end + end + + def read_visit(vrp, vehicle, visit_index, activity = nil) + read_activity(vrp, vehicle, visit_index, activity) end def read_unassigned(vrp, visit_index) @@ -161,44 +209,53 @@ def read_break(step) Models::Solution::Stop.new(original_rest, info: Models::Solution::Stop::Info.new(times)) end - def read_depot_start(_vrp, vehicle) + def read_depot_start(_vrp, vehicle, schedule = nil) point = vehicle&.start_point return nil if point.nil? route_data = {} @previous = point - Models::Solution::StopDepot.new(point, info: Models::Solution::Stop::Info.new(route_data)) + Models::Solution::StopDepot.new( + point, + info: Models::Solution::Stop::Info.new(route_data.merge(solver_schedule(schedule))) + ) end - def read_depot_end(vrp, vehicle) + def read_depot_end(vrp, vehicle, schedule = nil) point = vehicle&.end_point return nil if point.nil? route_data = compute_route_data(vrp, vehicle, point) @previous = point - Models::Solution::StopDepot.new(point, info: Models::Solution::Stop::Info.new(route_data)) + Models::Solution::StopDepot.new( + point, + info: Models::Solution::Stop::Info.new(route_data.merge(solver_schedule(schedule))) + ) end - def read_reload_depot_trip(vrp, vehicle, reload_depot_index) - reload_depot = @reload_depots[@depots.size - reload_depot_index] + def read_reload_depot(vrp, vehicle, reload_depot_index, activity = nil) + reload_depot = @reload_depots[reload_depot_index - @depots.size] return nil if reload_depot.nil? route_data = compute_route_data(vrp, vehicle, reload_depot.point) @previous = reload_depot.point - Models::Solution::Stop.new(reload_depot, info: Models::Solution::Stop::Info.new(route_data), loads: nil) + Models::Solution::Stop.new( + reload_depot, + info: Models::Solution::Stop::Info.new(route_data.merge(solver_schedule(activity))), + loads: nil + ) end - def read_activity(vrp, vehicle, visit_index) + def read_activity(vrp, vehicle, visit_index, activity = nil) service = @service_index_map[visit_index] @service_hash.delete(service.id) point = service.activity.point route_data = compute_route_data(vrp, vehicle, point) - # begin_time = act_step['arrival'] && (act_step['arrival'] + act_step['waiting_time'] + act_step['setup']) - times = route_data + times = route_data.merge(solver_schedule(activity)) job_data = Models::Solution::Stop.new(service, info: Models::Solution::Stop::Info.new(times), loads: nil) @previous = point job_data @@ -216,6 +273,94 @@ def compute_route_data(vrp, vehicle, point) } end + # Bounded substitutes for MAX_INT64 so PenaltyManager magnitudes stay comparable. + def max_matrix_cell(vrp, dimension) + vrp.matrices.filter_map{ |matrix| matrix.send(dimension) }.flat_map(&:flatten).compact.max + end + + def time_horizon(vrp) + tw_ends = [] + vrp.vehicles.each{ |vehicle| + tw_ends << vehicle.timewindow.end if vehicle.timewindow&.end + tw_ends << vehicle.duration if vehicle.duration + } + vrp.services.each{ |service| + service.activity.timewindows.each{ |tw| tw_ends << tw.end if tw.end } + } + vrp.reload_depots.each{ |depot| + depot.timewindows.each{ |tw| tw_ends << tw.end if tw.end } + } + return [tw_ends.max, 1].max if tw_ends.any? + + matrix_bound = max_matrix_cell(vrp, :time) + service_time = + vrp.services.sum{ |service| + service.activity.duration.to_i + service.activity.setup_duration.to_i + } + computed = matrix_bound && ((matrix_bound * (vrp.services.size + 2)) + service_time) + [computed || 86_400, 1].max + end + + def distance_horizon(vrp) + vehicle_max = vrp.vehicles.map(&:distance).compact.max + matrix_bound = max_matrix_cell(vrp, :distance) || max_matrix_cell(vrp, :time) + # n_services hops is 10^8–10^9 m on large instances. A per-vehicle hop + # count still leaves slack for unbalanced *and* random infeasible routes + # (tight hop caps make excess_distance the first PenaltyManager term to + # saturate on road matrices in metres). + hops = [(vrp.services.size / [vrp.vehicles.size, 1].max) + 2, 50].max + computed = [ + vehicle_max, + matrix_bound && (matrix_bound * hops) + ].compact.max + [computed || 1, 1].max + end + + def unit_demand_totals(vrp) + totals = Hash.new(0) + vrp.services.each{ |service| + pickup, delivery = scaled_pickup_delivery(service) + (pickup.keys | delivery.keys).each{ |unit_id| + totals[unit_id] += pickup[unit_id] + delivery[unit_id] + } + } + totals + end + + def unbounded_unit_capacity(unit_id) + demand = @unit_demand_totals[unit_id].to_i + demand.positive? ? demand : 1 + end + + # Load dimensions for skills that actually restrict assignment. Universal + # tags (every vehicle has them) only inflate PenaltyManager. + def skill_dimension_index(vrp) + vehicle_sets = vrp.vehicles.map{ |vehicle| Array(vehicle.skills&.first).to_set } + service_skills = vrp.services.flat_map(&:skills).uniq + names = + service_skills.select{ |skill| + vehicle_sets.any?{ |set| !set.include?(skill) } + } + names.each_with_index.to_h + end + + def skill_demand_quantity + CUSTOM_QUANTITY_BIGNUM.round + end + + def skill_vehicle_capacity(vrp) + [vrp.services.size, 1].max * skill_demand_quantity + end + + def service_horizon_past_vehicles?(vrp) + max_service_end = + vrp.services.flat_map{ |service| + service.activity.timewindows.filter_map(&:end) + }.max + max_vehicle_end = vrp.vehicles.filter_map{ |vehicle| vehicle.timewindow&.end }.max + max_service_end && max_vehicle_end && max_service_end > max_vehicle_end + end + def collect_skills(object, vrp_skills) return [] unless vrp_skills.any? @@ -236,23 +381,34 @@ def pyvrp_problem(vrp) # 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) + @time_horizon = time_horizon(vrp) + @service_horizon_past_vehicles = service_horizon_past_vehicles?(vrp) + @distance_horizon = distance_horizon(vrp) + @unit_demand_totals = unit_demand_totals(vrp) + prepare_exclusion_costs(vrp) + log "PyVRP horizons time=#{@time_horizon} distance=#{@distance_horizon}", level: :info + locations = build_locations(vrp) depots = build_depots(vrp) - vrp.vehicles.map(&:skills).flatten.uniq.each_with_index{ |skill, index| @skills_index_hash[skill] = index } + @skills_index_hash = skill_dimension_index(vrp) used_matrices = vrp.vehicles.map(&:matrix_id).uniq matrices = used_matrices.map { |id| vrp.matrices.find { |m| m.id == id } } distance_matrices = matrices.map(&:distance).compact - duration_matrices = matrices.map(&:time).compact - expand_matrices(vrp, distance_matrices, duration_matrices) + duration_matrices = matrices.map { |matrix| matrix.time&.map(&:itself) }.compact + apply_setup_to_duration_matrices(vrp, duration_matrices) distance_matrices = duration_matrices if distance_matrices.empty? @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) + log "PyVRP locations=#{locations.size} clients=#{clients.size} groups=#{groups.size} " \ + "skill_dims=#{@skills_index_hash.size} unit_dims=#{vrp.units.size} ", + level: :info vehicles = build_vehicles(vrp) routes = build_routes(vrp) { + locations: locations, depots: depots, clients: clients, vehicle_types: vehicles, @@ -263,50 +419,61 @@ def pyvrp_problem(vrp) }.delete_if { |_, v| v.nil? || v.empty? } end - def expand_matrices(vrp, distance_matrices, duration_matrices) - additive_setups = Array.new(@depots.size, 0) + def build_locations(vrp) + point_by_matrix_index = + vrp.points.filter_map{ |point| + next if point.matrix_index.nil? + + [point.matrix_index, point] + }.to_h + matrix_indices = point_by_matrix_index.keys.sort + @matrix_indices = matrix_indices + @location_by_matrix_index = matrix_indices.each_with_index.to_h + @location_by_point_id = {} + vrp.points.each{ |point| + next if point.matrix_index.nil? + + @location_by_point_id[point.id] = @location_by_matrix_index[point.matrix_index] + } + matrix_indices.map{ |matrix_index| + point = point_by_matrix_index[matrix_index] + { name: point.id.to_s } + } + end - reload_depot_points = - vrp.reload_depots.map(&:point) - additive_setups += Array.new(reload_depot_points.size, 0) - client_points = - vrp.services.flat_map{ |service| - points = - (service.activity.timewindows.empty? ? [nil] : service.activity.timewindows).map{ |_tw| - service.activity.point - } - points.each{ |_p| additive_setups << service.activity.setup_duration.to_i } - points - } + def location_index_for(point) + return nil unless point - all_points = (@depots + reload_depot_points + client_points) + @location_by_point_id[point.id] + end - distance_matrices.map! do |matrix| - matrix = - Array.new(all_points.size) { |i| - Array.new(all_points.size) { |j| - distance(matrix, all_points[i], all_points[j]) - } - } - end + def setup_duration_by_matrix_index(vrp) + setups = {} + vrp.services.each{ |service| + matrix_index = service.activity.point&.matrix_index + next if matrix_index.nil? - duration_matrices.map! do |matrix| - dist = nil - matrix = - Array.new(all_points.size) { |i| - Array.new(all_points.size) { |j| - dist = distance(matrix, all_points[i], all_points[j]) - dist += additive_setups[j] if i != j && all_points[i]&.matrix_index != all_points[j]&.matrix_index - dist - } - } - end + setups[matrix_index] = service.activity.setup_duration.to_i + } + setups end - def distance(matrix, point1, point2) - return 0 if point1.nil? || point2.nil? - - matrix[point1.matrix_index][point2.matrix_index] + def apply_setup_to_duration_matrices(vrp, duration_matrices) + setups = setup_duration_by_matrix_index(vrp) + location_count = @matrix_indices.size + duration_matrices.map!{ |matrix| + Array.new(location_count){ |from_loc| + from_matrix_index = @matrix_indices[from_loc] + Array.new(location_count){ |to_loc| + to_matrix_index = @matrix_indices[to_loc] + travel = matrix[from_matrix_index][to_matrix_index] + if from_matrix_index != to_matrix_index && setups[to_matrix_index].to_i.positive? + travel += setups[to_matrix_index].to_i + end + travel + } + } + } end def build_vehicles(vrp) @@ -314,15 +481,21 @@ def build_vehicles(vrp) all_units = vrp.units.index_by(&:id) vrp.vehicles.map { |veh| - capacity_hash = all_units.map{ |id, _unit| [id, MAX_INT64] }.to_h + capacity_hash = all_units.map{ |id, _unit| [id, unbounded_unit_capacity(id)] }.to_h veh.capacities.each do |capacity| capacity_hash[capacity.unit_id] = - (capacity.limit && (capacity.limit * CUSTOM_QUANTITY_BIGNUM).round || MAX_INT_UNITS) + if capacity.limit + (capacity.limit * CUSTOM_QUANTITY_BIGNUM).round + else + unbounded_unit_capacity(capacity.unit_id) + end end capacity_skills = Array.new(@skills_index_hash.size, 0) - veh.skills.first.each do |skill| - capacity_skills[@skills_index_hash[skill]] = vrp.services.size + Array(veh.skills&.first).each do |skill| + next unless @skills_index_hash.key?(skill) + + capacity_skills[@skills_index_hash[skill]] = skill_vehicle_capacity(vrp) end { @@ -331,9 +504,9 @@ def build_vehicles(vrp) start_depot: @vehicle_start_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, - shift_duration: veh.duration || MAX_INT64, - max_distance: veh.distance || MAX_INT64, + tw_late: veh.timewindow&.end || @time_horizon, + shift_duration: veh.duration || @time_horizon, + max_distance: veh.distance || @distance_horizon, unit_distance_cost: veh.cost_distance_multiplier.to_i, unit_duration_cost: veh.cost_time_multiplier.to_i, profile: used_matrices.index(veh.matrix_id), @@ -350,12 +523,10 @@ def build_clients_and_groups(vrp) client_list = [] groups = [] service_to_client_indices = {} - depot_size = @service_index_map.size vrp.services.each do |service| activity = service.activity point = activity.point - location = point.location delivery_hash = all_units.map { |id, _| [id, 0] }.to_h pickup_hash = all_units.map { |id, _| [id, 0] }.to_h @@ -375,13 +546,15 @@ def build_clients_and_groups(vrp) quantity_skills = Array.new(@skills_index_hash.size, 0) service.skills.each do |skill| - quantity_skills[@skills_index_hash[skill]] = 1 + next unless @skills_index_hash.key?(skill) + + quantity_skills[@skills_index_hash[skill]] = skill_demand_quantity end null_quantity_skills = Array.new(@skills_index_hash.size, 0) timewindows = if activity.timewindows.empty? - [Models::Timewindow.new(start: 0, end: MAX_INT64)] + [Models::Timewindow.new(start: 0, end: @time_horizon)] else activity.timewindows end @@ -389,16 +562,15 @@ def build_clients_and_groups(vrp) client_index = @service_index_map.size @service_index_map << service client_list << { - x: location&.lon || 0, - y: location&.lat || 0, + location: location_index_for(point), delivery: delivery_hash.values + null_quantity_skills, pickup: pickup_hash.values + quantity_skills, service_duration: activity.duration.to_i, tw_early: tw.start || 0, - tw_late: tw.end || MAX_INT64, + tw_late: tw.end || @time_horizon, release_time: 0, prize: client_prize(service), - required: mandatory_client?(service), + required: mandatory_service?(service) && timewindows.size <= 1, name: "#{service.id}_tw#{tw_idx}" } service_to_client_indices[service.id] ||= [] @@ -409,21 +581,98 @@ def build_clients_and_groups(vrp) service_to_client_indices.each do |_service_id, indices| next unless indices.size > 1 - indices.each { |idx| client_list[idx - depot_size][:group] = groups.size } - groups << { clients: indices, required: false } + service = @service_index_map[indices.first] + indices.each { |idx| client_list[idx][:group] = groups.size } + groups << { clients: indices, required: mandatory_service?(service) } end [client_list, groups] end - def mandatory_client?(service) - service.exclusion_cost.nil? && service.activity.timewindows.size <= 1 + def mandatory_service?(service) + service.priority == 0 end def client_prize(service) - return service.exclusion_cost.round if service.exclusion_cost + exclusion_cost_for(service)&.round || 0 + end + + def exclusion_cost_for(service) + return service.exclusion_cost if service.exclusion_cost + return if mandatory_service?(service) - mandatory_client?(service) ? 0 : (MAX_PENALTY / (service.priority + 1)).round + @exclusion_costs.fetch(service.id) + end + + def prepare_exclusion_costs(vrp) + @exclusion_costs = {} + soft_services = + vrp.services.select{ |service| + !mandatory_service?(service) && service.exclusion_cost.nil? + } + mandatory_count = vrp.services.count{ |service| mandatory_service?(service) } + log( + "PyVRP client requirement: #{mandatory_count} mandatory, #{soft_services.size} optional", + level: :info + ) + return if soft_services.empty? + + max_fixed = [vrp.vehicles.map(&:cost_fixed).max.to_f, 1.0].max + unit_time_cost = [vrp.vehicles.map(&:cost_time_multiplier).max.to_f, 1.0].max + unit_distance_cost = [vrp.vehicles.map(&:cost_distance_multiplier).max.to_f, 1.0].max + avg_service_duration = + vrp.services.sum{ |service| service.activity.duration.to_i } / + [vrp.services.size, 1].max.to_f + avg_travel = average_depot_travel_seconds(vrp) + max_roundtrip = max_depot_roundtrip_seconds(vrp) + marginal_travel_cost = unit_time_cost * (2 * avg_travel + avg_service_duration) + roundtrip_cost = (unit_time_cost + unit_distance_cost) * max_roundtrip + density = soft_services.size.to_f / [vrp.vehicles.size, 1].max + sqrt_density = Math.sqrt(density) + base_prize = [ + max_fixed * 4 * density, + max_fixed * 4 * sqrt_density, + roundtrip_cost * 2, + roundtrip_cost * density, + marginal_travel_cost * 2 + ].max.ceil + priority_four_floor = (max_fixed * 4 * density).ceil + + soft_services.each do |service| + priority_factor = 2**(4 - service.priority.clamp(0, 8)) + timewindow_count = [service.activity.timewindows.size, 1].max + prize = (priority_factor * base_prize).ceil + prize = [prize, priority_four_floor].max if service.priority >= 4 + if timewindow_count > 1 + group_prize = 500_000 * timewindow_count + prize = [prize, group_prize].max + end + @exclusion_costs[service.id] = prize + end + end + + def max_depot_roundtrip_seconds(vrp) + vehicle = vrp.vehicles.first + depot = vehicle&.start_point + matrix = vrp.matrices.find{ |entry| entry.id == vehicle&.matrix_id } || vrp.matrices.first + return 0 unless depot&.matrix_index && matrix&.time + + row = matrix.time[depot.matrix_index] + return 0 unless row&.any? + + 2 * row.compact.max.to_f + end + + def average_depot_travel_seconds(vrp) + vehicle = vrp.vehicles.first + depot = vehicle&.start_point + matrix = vrp.matrices.find{ |entry| entry.id == vehicle&.matrix_id } || vrp.matrices.first + return 0 unless depot&.matrix_index && matrix&.time + + row = matrix.time[depot.matrix_index] + return 0 unless row&.any? + + row.compact.sum.to_f / row.size end def scaled_capacity_limits(vehicle) @@ -476,6 +725,45 @@ def apply_visit_load!(vehicle, service, route_loads) end end + def seed_vrp_routes_from_solution(vrp, solution) + vrp.routes = + solution.routes.filter_map{ |route| + service_ids = route.stops.filter_map(&:service_id) + next if service_ids.empty? + + vehicle = route.vehicle + missions = split_missions_with_reloads(vehicle, service_ids, vrp) + Models::Route.new(vehicle: vehicle, missions: missions) + } + end + + def split_missions_with_reloads(vehicle, service_ids, vrp) + services_by_id = vrp.services.index_by(&:id) + reload_depot = vehicle.reload_depots.first + max_reloads = vehicle.maximum_reloads.to_i + missions = [] + route_loads = Hash.new(0) + reloads_used = 0 + + service_ids.each{ |service_id| + service = services_by_id[service_id] + next unless service + + needs_reload = + reload_depot && + reloads_used < max_reloads && + !visit_fits_capacity?(vehicle, service, route_loads) + if needs_reload + missions << reload_depot + route_loads = Hash.new(0) + reloads_used += 1 + end + missions << service + apply_visit_load!(vehicle, service, route_loads) + } + missions + end + # Open routes have no end_point: omit end_depot and let PyVRP apply its default. def optional_end_depot_hash(vehicle_id) end_depot = @vehicle_end_point_index_hash[vehicle_id] @@ -558,10 +846,9 @@ def build_depots(vrp) @depot_points_standard_index_hash.map { |point_id, index| depots[index] = { - x: @point_hash[point_id]&.location&.lon || 0, - y: @point_hash[point_id]&.location&.lat || 0, + location: location_index_for(@point_hash[point_id]), tw_early: 0, - tw_late: MAX_INT64, + tw_late: @time_horizon, name: "#{point_id}_standard" || '_null_store' } } @@ -569,8 +856,7 @@ def build_depots(vrp) tw_start_to_index.each do |timewindow_start, point_index| depots[point_index] = { - x: @point_hash[point_id]&.location&.lon || 0, - y: @point_hash[point_id]&.location&.lat || 0, + location: location_index_for(@point_hash[point_id]), tw_early: timewindow_start || 0, tw_late: timewindow_start, name: "#{point_id}_#{timewindow_start}_force_start" || '_null_store' @@ -580,10 +866,9 @@ def build_depots(vrp) @depot_points_force_end_by_timewindow_end_index_hash.each do |point_id, tw_end_to_index| tw_end_to_index.each do |timewindow_end, point_index| depots[point_index] = { - x: @point_hash[point_id]&.location&.lon || 0, - y: @point_hash[point_id]&.location&.lat || 0, + location: location_index_for(@point_hash[point_id]), tw_early: timewindow_end || 0, - tw_late: timewindow_end || MAX_INT64, + tw_late: timewindow_end || @time_horizon, name: "#{point_id}_#{timewindow_end}_force_end" || '_null_store' } end @@ -598,10 +883,9 @@ def build_depots(vrp) @reload_depot_hash[depot.id] = depots.size depots << { - x: depot.point&.location&.lon || 0, - y: depot.point&.location&.lat || 0, + location: location_index_for(depot.point), tw_early: depot.timewindows.first&.start || 0, - tw_late: depot.timewindows.first&.end || MAX_INT64, + tw_late: depot.timewindows.first&.end || @time_horizon, service_duration: depot.duration.to_i, name: "reload_#{depot&.id&.to_s || 'null_store'}" } @@ -613,7 +897,6 @@ def build_depots(vrp) level: :warn ) end - @service_index_map += depots.map{ nil } depots end @@ -625,36 +908,32 @@ def build_routes(vrp) vehicle_type = vrp.vehicles.find_index{ |v| v.id == route.vehicle.id } { - visits: build_trips(vrp, route, vehicle_type), + activities: build_route_activities(route), vehicle_type: vehicle_type } }.compact end - def build_trips(vrp, route, vehicle_type) - trips = [] - vehicle = vrp.vehicles[vehicle_type] - current_trip = { - visits: [], - vehicle_type: vehicle_type, - start_depot: @vehicle_start_point_index_hash[vehicle.id] - }.merge(optional_end_depot_hash(vehicle.id)) + def build_route_activities(route) + activities = [] 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 } + visit_index = @service_index_map.find_index{ |service| service && service.id == mission.id } + activities << { type: 'client', idx: visit_index } if visit_index elsif mission.is_a?(Models::ReloadDepot) - reload_depot = @reload_depot_hash[mission.id] - current_trip[:end_depot] = reload_depot - trips << current_trip - current_trip = { - visits: [], - vehicle_type: vehicle_type, - start_depot: reload_depot - }.merge(optional_end_depot_hash(vehicle.id)) + reload_index = @reload_depot_hash[mission.id] + activities << { type: 'depot', idx: reload_index } if reload_index end end - trips << current_trip - trips + activities + end + + def pyvrp_python + env_python = ENV['PYVRP_PYTHON'] + return env_python if env_python && !env_python.empty? + + venv_python = '/opt/pyenv/bin/python3' + File.executable?(venv_python) ? venv_python : 'python3' end def run_pyvrp(problem, timeout = nil) @@ -665,7 +944,7 @@ def run_pyvrp(problem, timeout = nil) output = Tempfile.new('optimize-pyvrp-output', @tmp_dir) output.close - cmd = "python3 wrappers/pyvrp_wrapper.py #{input.path} #{output.path} #{timeout}" + cmd = "#{pyvrp_python} wrappers/pyvrp_wrapper.py #{input.path} #{output.path} #{timeout}" log cmd stdin, stdout_and_stderr, @thread = Open3.popen2e(cmd) diff --git a/wrappers/pyvrp_wrapper.py b/wrappers/pyvrp_wrapper.py index 49f65348..2d3f977b 100644 --- a/wrappers/pyvrp_wrapper.py +++ b/wrappers/pyvrp_wrapper.py @@ -1,21 +1,88 @@ import json -import math import sys import numpy as np -from pyvrp import Model, ProblemData, Client, Depot, VehicleType, ClientGroup, SolveParams, PenaltyParams, solve, Solution, Route, Trip +from pyvrp import ( + Activity, + ActivityType, + ProblemData, + Client, + Depot, + Location, + VehicleType, + ClientGroup, + SolveParams, + PenaltyParams, + solve, + Solution, + Route, +) +from pyvrp.search import ( + OPERATORS, + NeighbourhoodParams, + RelocateAlternative, + RelocateWithDepot, + RemoveAdjacentDepot, + ReplaceGroup, +) from pyvrp.stop import MaxRuntime +def _normalize_client_groups(data: dict): + """ + Rebuild membership from Client.group + """ + clients = data.get("clients") or [] + groups = data.get("groups") or [] + if not groups: + return + + by_group = {} + for idx, client in enumerate(clients): + group_idx = client.get("group") + if group_idx is None: + continue + by_group.setdefault(group_idx, []).append(idx) + + n_clients = len(clients) + n_depots = len(data.get("depots") or []) + for group_idx, group in enumerate(groups): + members = by_group.get(group_idx) + if members: + group["clients"] = members + continue + listed = list(group.get("clients") or []) + if any(idx >= n_clients for idx in listed) and n_depots: + listed = [idx - n_depots for idx in listed] + group["clients"] = listed + for idx in listed: + if 0 <= idx < n_clients: + clients[idx]["group"] = group_idx + def _problem_data_from_dict(cls, data: dict): """ Creates a :class:`~pyvrp._pyvrp.ProblemData` instance from a dictionary. """ - clients = [Client(**client) for client in data["clients"]] - depots = [Depot(**depot) for depot in data["depots"]] - vehicle_types = [VehicleType(**vt) for vt in data["vehicle_types"]] + _normalize_client_groups(data) + if data.get("locations"): + locations = [_location_from_dict(loc) for loc in data["locations"]] + clients = [Client(**_without(client, "x", "y")) for client in data["clients"]] + depots = [Depot(**_without(depot, "x", "y")) for depot in data["depots"]] + else: + locations = [] + depots = [] + for depot in data["depots"]: + kwargs, loc_idx = _entity_with_location(depot, locations) + depots.append(Depot(location=loc_idx, **kwargs)) + clients = [] + for client in data["clients"]: + kwargs, loc_idx = _entity_with_location(client, locations) + clients.append(Client(location=loc_idx, **kwargs)) + + vehicle_types = [VehicleType(**_drop_nones(vt)) for vt in data["vehicle_types"]] distance_matrices = [np.array(mat) for mat in data["distance_matrices"]] duration_matrices = [np.array(mat) for mat in data["duration_matrices"]] groups = [ClientGroup(**group) for group in data.get("groups", [])] return ProblemData( + locations=locations, clients=clients, depots=depots, vehicle_types=vehicle_types, @@ -24,30 +91,130 @@ def _problem_data_from_dict(cls, data: dict): groups=groups, ) -def _route_from_dict(route_dict: dict, data: ProblemData): +def _without(payload: dict, *keys): + return {key: value for key, value in payload.items() if key not in keys} + +def _location_from_dict(payload: dict): + return Location( + x=payload.get("x", 0), + y=payload.get("y", 0), + name=payload.get("name", ""), + ) + +def _drop_nones(payload: dict): + return {key: value for key, value in payload.items() if value is not None} + +def _entity_with_location(payload: dict, locations: list): + kwargs = dict(payload) + kwargs.pop("x", None) + kwargs.pop("y", None) + if "location" in kwargs: + loc_idx = kwargs.pop("location") + else: + loc_idx = len(locations) + locations.append(_location_from_dict({"name": kwargs.get("name", "")})) + return kwargs, loc_idx + +def _activity_idx(activity): + idx = activity.idx + return idx() if callable(idx) else idx + +def _activities_from_route_dict(route_dict: dict): """ - Creates a :class:`~pyvrp._pyvrp.Route` instance from a dictionary. + Build the inner activity list for Route(). Start and end depots are owned + by VehicleType. Only clients and intermediate reload depots go here. """ - trips = [] - for trip_dict in route_dict.get("visits", []): - trip_kwargs = { - "visits": trip_dict.get("visits", []), - "vehicle_type": trip_dict.get("vehicle_type", 0), - "start_depot": trip_dict.get("start_depot", 0), - } - if trip_dict.get("end_depot") is not None: - trip_kwargs["end_depot"] = trip_dict["end_depot"] - trip = Trip(data, **trip_kwargs) - trips.append(trip) + activities = [] + for item in route_dict.get("activities") or []: + kind = str(item["type"]).upper() + idx = item["idx"] + if kind == "DEPOT": + activities.append(Activity(ActivityType.DEPOT, idx)) + elif kind in ("CLIENT", "PICKUP", "DELIVERY"): + activities.append(Activity(ActivityType.CLIENT, idx)) + return activities +def _route_from_dict(route_dict: dict, data: ProblemData): + activities = _activities_from_route_dict(route_dict) + if not activities: + return None return Route( data, - visits=trips, - vehicle_type=route_dict.get("vehicle_type", 0) + activities=activities, + vehicle_type=route_dict.get("vehicle_type", 0), ) +def _inner_route_activities(route): + """ + Route iteration includes VehicleType start/end depots. optimizer-api + already adds those from start_depot/end_depot; only clients and + intermediate reload depots belong in the activity list. + """ + activities = list(route) + if activities and activities[0].is_depot(): + activities = activities[1:] + if activities and activities[-1].is_depot(): + activities = activities[:-1] + return activities + +def _finite_int(value): + if value is None: + return None + try: + if np.isinf(value) or np.isnan(value): + return None + except TypeError: + pass + return int(value) + +def _schedule_fields(activity): + if not hasattr(activity, "start_time"): + return {} + start_time = _finite_int(activity.start_time) + if start_time is None: + return {} + end_time = _finite_int(activity.end_time) + if end_time is None: + end_time = start_time + return { + "start_time": start_time, + "end_time": end_time, + "wait_duration": _finite_int(activity.wait_duration) or 0, + "duration": _finite_int(activity.duration) or 0, + } + +def _activity_to_dict(activity): + if activity.is_depot(): + kind = "depot" + elif activity.is_client(): + kind = "client" + else: + kind = str(getattr(activity.type, "name", activity.type)).lower() + payload = {"type": kind, "idx": _activity_idx(activity)} + payload.update(_schedule_fields(activity)) + return payload + +def _route_to_dict(route): + activities = list(route) + start_depot_activity = activities[0] if activities and activities[0].is_depot() else None + end_depot_activity = activities[-1] if len(activities) > 1 and activities[-1].is_depot() else None + return { + "vehicle_type": route.vehicle_type(), + "activities": [_activity_to_dict(activity) for activity in _inner_route_activities(route)], + "start_depot": route.start_depot(), + "end_depot": route.end_depot(), + "start_time": _finite_int(route.start_time()), + "end_time": _finite_int(route.end_time()), + "start_schedule": _schedule_fields(start_depot_activity) or None, + "end_schedule": _schedule_fields(end_depot_activity) or None, + } + def _solution_from_dict(cls, json_data: dict, data: ProblemData): - routes = [_route_from_dict(route, data) for route in json_data.get("routes", [])] + routes = [] + for route in json_data.get("routes", []): + built = _route_from_dict(route, data) + if built is not None: + routes.append(built) if not routes: return None return Solution( @@ -59,31 +226,112 @@ def _solution_from_dict(cls, json_data: dict, data: ProblemData): setattr(ProblemData, "from_dict", classmethod(_problem_data_from_dict)) setattr(Solution, "from_dict", classmethod(_solution_from_dict)) +INITIAL_PENALTY = 10.0 + + +class RoadPenaltyParams(PenaltyParams): + """ + 0.14 `midpoint_penalties` starts at (min+max)/2. Raising max_penalty + therefore also raises the *initial* penalty, so the search saturates + in a few updates (PenaltyBoundWarning at ~10s on C530). Keep a high + cap but start near the historic HGS value. + """ + + def midpoint_penalties(self, data): + start = INITIAL_PENALTY + return ([start] * data.num_load_dimensions, start, start) + + +def granular_num_neighbours(n_clients): + """Scale Vidal-style granular neighbourhood with instance size.""" + if n_clients >= 2000: + return 150 + if n_clients >= 200: + return 100 + return 50 + +def build_solve_params(n_clients): + """ + ILS defaults plus a larger granular neighbourhood. + + PyVRP 0.14 dropped route operators (SwapStar / SwapRoutes). Intensification + is the default OPERATORS set, which includes SWAP-style Relocate/Swap plus + RelocateAlternative / ReplaceGroup (multi-TW) and RelocateWithDepot / + RemoveAdjacentDepot (reload depots). + + Default max_penalty (1e5) saturates on large VRPTW (PenaltyBoundWarning) + while the search is still infeasible. Raise the cap on medium+ instances, + but start penalties at INITIAL_PENALTY — 0.14's midpoint is max/2. + Stay well below 1e8: PyVRP warns that a too-large cap overflows native ints. + """ + max_penalty = 1_000_000.0 if n_clients >= 200 else 100_000.0 + penalty_params = RoadPenaltyParams( + target_feasible=0.5, + max_penalty=max_penalty, + ) + neighbourhood = NeighbourhoodParams( + num_neighbours=granular_num_neighbours(n_clients), + weight_wait_time=0.2, + ) + required = { + RelocateAlternative, + ReplaceGroup, + RelocateWithDepot, + RemoveAdjacentDepot, + } + operators = list(OPERATORS) + for operator in required: + if operator not in operators: + operators.append(operator) + return SolveParams( + penalty=penalty_params, + neighbourhood=neighbourhood, + operators=operators, + ) + def main(input_path, output_path, timeout=None): # Load problem data from JSON with open(input_path, "r") as f: json_data = json.loads(f.read()) data = ProblemData.from_dict(json_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()) - # Closest power of two for the number of clients (rounded to nearest). - num_clients = len(clients) - closest_power_two_exponent = 0 if num_clients <= 0 else round(math.log(num_clients, 3)) - min_penalty = 10 ** (1 + closest_power_two_exponent) - penalty_params = PenaltyParams(target_feasible=0.8, min_penalty=min_penalty, max_penalty=1e10) - solve_params = SolveParams(penalty=penalty_params) - - result = solve( - data, - stop=MaxRuntime(int(timeout)), - params=solve_params, - display=True, - initial_solution=initial_solution, + try: + initial_solution = Solution.from_dict(json_data, data) + except Exception as exc: + print(f"PyVRP ignoring initial solution: {exc}", flush=True) + initial_solution = None + if initial_solution is not None: + print(f"PyVRP initial solution routes={len(initial_solution.routes())}", flush=True) + + n_clients = data.num_clients + n_groups = data.num_groups + solve_params = build_solve_params(n_clients) + print( + "PyVRP PenaltyParams " + f"min_penalty={solve_params.penalty.min_penalty} " + f"max_penalty={solve_params.penalty.max_penalty} " + f"initial_penalty={INITIAL_PENALTY} " + f"target_feasible={solve_params.penalty.target_feasible}", + flush=True, + ) + print( + "PyVRP search " + f"clients={n_clients} groups={n_groups} " + f"num_neighbours={solve_params.neighbourhood.num_neighbours} " + f"operators={[op.__name__ for op in solve_params.operators]}", + flush=True, ) + solve_kwargs = { + "stop": MaxRuntime(int(timeout)), + "params": solve_params, + "display": True, + } + if initial_solution is not None: + solve_kwargs["initial_solution"] = initial_solution + + result = solve(data, **solve_kwargs) + best_solution = result.best solution = { "runtime": getattr(result, "run_time", None), @@ -91,25 +339,7 @@ def main(input_path, output_path, timeout=None): "cost": result.cost() if result.cost() != np.inf else -1, "feasible": best_solution.is_feasible(), "complete": best_solution.is_complete(), - "routes": [ - { - "vehicle_type": route.vehicle_type(), - "trips": [ - { - "visits": trip.visits(), - "start_depot": trip.start_depot(), - "end_depot": trip.end_depot(), - "release_time": trip.release_time() - } - for trip in route.trips() - ], - "start_depot": route.start_depot(), - "end_depot": route.end_depot(), - "start_time": route.start_time(), - "end_time": route.end_time() - } - for route in best_solution.routes() - ] + "routes": [_route_to_dict(route) for route in best_solution.routes()], } with open(output_path, "w") as f: From f4a60d506f1ce6f54c97d24ed9207fc45bd92f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Tue, 8 Sep 2026 13:46:30 +0200 Subject: [PATCH 3/4] Introduce RegulatoryRests (as preprocess/postprocess) --- api/v01/entities/vrp_input.rb | 2 + core/components/vehicle.rb | 2 + core/strategies/orchestration.rb | 22 ++ docs/Rest.md | 19 +- lib/interpreters/regulatory_rest.rb | 312 ++++++++++++++++++ models/concerns/validate_data.rb | 18 +- models/rest.rb | 1 + test/lib/interpreters/regulatory_rest_test.rb | 309 +++++++++++++++++ wrappers/ortools.rb | 4 +- wrappers/vroom.rb | 4 +- wrappers/wrapper.rb | 2 +- 11 files changed, 688 insertions(+), 7 deletions(-) create mode 100644 lib/interpreters/regulatory_rest.rb create mode 100644 test/lib/interpreters/regulatory_rest_test.rb diff --git a/api/v01/entities/vrp_input.rb b/api/v01/entities/vrp_input.rb index 869c99bb..c7e115fb 100644 --- a/api/v01/entities/vrp_input.rb +++ b/api/v01/entities/vrp_input.rb @@ -347,8 +347,10 @@ module VrpMissions optional(:timewindows, type: Array, desc: 'Time slot while the rest may begin. At most one timewindow per rest is supported.') do use :vrp_request_timewindow end + optional(:lapse, type: Integer, values: ->(v) { v.positive? }, desc: 'Repeating rest after this many seconds of work (travel + service). Exclusive with timewindows. Not available with periodic heuristic.', coerce_with: ->(value) { ScheduleType.type_cast(value) }) optional(:late_multiplier, type: Float, desc: 'Late multiplier applied for this rest') optional(:exclusion_cost, type: Float, desc: 'Cost induced by non affectation of this rest') + mutually_exclusive :timewindows, :lapse end params :vrp_request_service do diff --git a/core/components/vehicle.rb b/core/components/vehicle.rb index 14c03b25..8c8972d1 100644 --- a/core/components/vehicle.rb +++ b/core/components/vehicle.rb @@ -4,6 +4,8 @@ module Vehicle def adjust_vehicles_duration(vrp) vrp.vehicles.select{ |v| v.duration? && !v.rests.empty? }.each{ |v| v.rests.each{ |r| + next if Interpreters::RegulatoryRest.lapse_rest?(r) + v.duration += r.duration } } diff --git a/core/strategies/orchestration.rb b/core/strategies/orchestration.rb index d5f1560b..b4c245fa 100644 --- a/core/strategies/orchestration.rb +++ b/core/strategies/orchestration.rb @@ -131,6 +131,8 @@ def solve(service_vrp, job = nil, block = nil) tic = Time.now optim_solution = nil + periodic_heuristic_flag = false + regulatory_rest = Interpreters::RegulatoryRest.new unfeasible_services = {} @@ -177,6 +179,22 @@ def solve(service_vrp, job = nil, block = nil) services_to_reinject << vrp.services.slice!(index) end } + if services_to_reinject.any? + excluded_ids = services_to_reinject.map(&:id) + id_summary = + if excluded_ids.size <= 20 + excluded_ids.join(', ') + else + "#{excluded_ids.first(5).join(', ')}, ... (+#{excluded_ids.size - 5} more)" + end + log "Excluded #{excluded_ids.size} infeasible service(s) before #{service} solve " \ + "(#{vrp.services.size} remaining): #{id_summary}", + level: :info + else + log "Infeasibility check: no services excluded before #{service} solve " \ + "(#{vrp.services.size} services)", + level: :info + end # vrp.periodic_heuristic check the first_solution_stategy which may change right after periodic heuristic periodic_heuristic_flag = vrp.periodic_heuristic? @@ -193,6 +211,7 @@ def solve(service_vrp, job = nil, block = nil) end end end + regulatory_rest.apply!(vrp) if vrp.configuration.resolution.solver && (!periodic_heuristic_flag || vrp.services.size < 200) if vrp.configuration.preprocessing.cluster_threshold.to_f.positive? block&.call(nil, nil, nil, @@ -286,6 +305,7 @@ def solve(service_vrp, job = nil, block = nil) optim_solution.configuration.csv = vrp.configuration.restitution.csv optim_solution.configuration.geometry = vrp.configuration.restitution.geometry optim_solution.unassigned_stops += unfeasible_services.values.flatten + regulatory_rest.patch_solution!(vrp, optim_solution) Cleanse.cleanse(vrp, optim_solution) optim_solution.parse(vrp) if vrp.configuration.preprocessing.first_solution_strategy @@ -297,6 +317,8 @@ def solve(service_vrp, job = nil, block = nil) log "<-- optim_wrap::solve elapsed: #{(Time.now - tic).round(2)}sec", level: :debug optim_solution + ensure + regulatory_rest&.rewind!(vrp) end def build_independent_vrps(vrp, skill_sets, vehicle_indices_by_skills, skill_service_ids) diff --git a/docs/Rest.md b/docs/Rest.md index 049517b0..e7338193 100644 --- a/docs/Rest.md +++ b/docs/Rest.md @@ -1,6 +1,11 @@ # Rest -Inform about the drivers obligations to have some rest within a route +Inform about the drivers obligations to have some rest within a route. + +A rest with `timewindows` is placed by the solver in that slot. + +A rest with `lapse` is a repeating pause of `duration` seconds every `lapse` seconds of work (travel + service). +In the current implementation, travel and service times are inflated by `duration / lapse` for the solver (rounded up). Customer timewindow **starts** are delayed by the same rate from tour start, capped at `duration` (one pause); **ends** and vehicle amplitude stay unchanged. Discrete pauses are then reinserted: after the visit if the lapse is reached during service, before the next drive if travel would complete it (unless that misses the next stop's timewindow). Solvers never receive these rests. ```json { @@ -35,3 +40,15 @@ Inform about the drivers obligations to have some rest within a route }] } ``` + +Repeating regulatory pause (45 minutes every 6 hours of work): + +```json +{ + "rests": [{ + "id": "Break-regulatory", + "duration": 2700, + "lapse": 21600 + }] +} +``` diff --git a/lib/interpreters/regulatory_rest.rb b/lib/interpreters/regulatory_rest.rb new file mode 100644 index 00000000..f50b8e9e --- /dev/null +++ b/lib/interpreters/regulatory_rest.rb @@ -0,0 +1,312 @@ +# Copyright © Cartoway, 2026 +# +# This file is part of Cartoway Optimizer. +# +# Cartoway Optimizer 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. +# +# You should have received a copy of the GNU Affero General Public License +# along with Cartoway Optimizer. If not, see: +# +# + +module Interpreters + # Inflate travel/service by duration/lapse, then reinsert discrete pauses + # after the visit (if the lapse is reached during service) or before travel + # (if the drive would complete it, and the next stop still fits its timewindow). + class RegulatoryRest + UNREACHABLE = (2**31) - 1 + + def self.applicable?(vrp) + !vrp.nil? && !vrp.schedule? && lapse_rests(vrp).any? + end + + def self.lapse_rests(vrp) + vrp.vehicles.flat_map(&:rests).select{ |rest| lapse_rest?(rest) }.uniq + end + + def self.lapse_rest?(rest) + rest&.lapse.to_i.positive? && rest.duration.to_i.positive? + end + + def self.solver_rests(vehicle) + vehicle.rests.reject{ |rest| lapse_rest?(rest) } + end + + def apply!(vrp) + return false unless self.class.applicable?(vrp) + return true if vrp[:regulatory_rest_snapshot] + + rests = self.class.lapse_rests(vrp) + rates = rests.map{ |rest| rest.duration.to_f / rest.lapse }.uniq + if rates.size > 1 + raise OptimizerWrapper::UnsupportedProblemError.new( + 'Regulatory rests must share the same duration/lapse ratio' + ) + end + + rate = rates.first + tour_start = vrp.vehicles.map{ |vehicle| vehicle.timewindow&.start }.compact.min || 0 + snapshot!(vrp) + inflate_matrices!(vrp, rate) + inflate_durations!(vrp, rate) + delay_timewindow_starts!(vrp, rate, tour_start, rests.map(&:duration).min) + strip_lapse_rests!(vrp) + log "RegulatoryRest: inflate rate=#{rate.round(4)} tour_start=#{tour_start}", level: :info + true + end + + def rewind!(vrp) + snapshot = vrp && vrp[:regulatory_rest_snapshot] + return unless snapshot + + snapshot[:matrices].each{ |matrix, original_time| + matrix.time = original_time.map(&:dup) + matrix.clear_flatten_cache! + } + snapshot[:durations].each{ |activity, (duration, setup_duration)| + activity.duration = duration + activity.setup_duration = setup_duration if activity.respond_to?(:setup_duration=) + } + snapshot[:tw_starts].each{ |timewindow, tw_start| timewindow.start = tw_start } + snapshot[:vehicle_rests].each{ |vehicle, rests| vehicle.rests = rests } + vrp.rests = snapshot[:vrp_rests] + vrp[:regulatory_rest_snapshot] = nil + end + + def patch_solution!(vrp, solution) + rewind!(vrp) if vrp[:regulatory_rest_snapshot] + return unless solution + + rest_by_vehicle = vrp.vehicles.filter_map{ |vehicle| + rest = vehicle.rests.find{ |candidate| self.class.lapse_rest?(candidate) } + [vehicle.id, rest] if rest + }.to_h + return if rest_by_vehicle.empty? + + solution.routes.each{ |route| + rest = rest_by_vehicle[route.vehicle&.id] + next unless rest && route.stops.any? + + rebuild_route_with_pauses!(vrp, route, rest) + } + end + + private + + def snapshot!(vrp) + durations = {} + each_activity(vrp){ |activity| + durations[activity] = [activity.duration, activity.respond_to?(:setup_duration) ? activity.setup_duration : 0] + } + + tw_starts = {} + each_activity(vrp){ |activity| + activity.timewindows.each{ |timewindow| tw_starts[timewindow] = timewindow.start } + } + + vrp[:regulatory_rest_snapshot] = { + matrices: vrp.matrices.select(&:time).map{ |matrix| [matrix, matrix.time.map(&:dup)] }.to_h, + durations: durations, + tw_starts: tw_starts, + vehicle_rests: vrp.vehicles.map{ |vehicle| [vehicle, vehicle.rests.dup] }.to_h, + vrp_rests: vrp.rests.dup, + } + end + + def inflate_matrices!(vrp, rate) + vrp.matrices.each{ |matrix| + next unless matrix.time + + matrix.time.each_with_index{ |row, i| + row.each_with_index{ |value, j| + next if i == j || value.nil? || value >= UNREACHABLE + + row[j] = inflate(value, rate) + } + } + matrix.clear_flatten_cache! + } + end + + def inflate_durations!(vrp, rate) + each_activity(vrp){ |activity| + activity.duration = inflate(activity.duration, rate) + next unless activity.respond_to?(:setup_duration) && activity.setup_duration.to_i.positive? + + activity.setup_duration = inflate(activity.setup_duration, rate) + } + end + + def strip_lapse_rests!(vrp) + vrp.vehicles.each{ |vehicle| vehicle.rests = self.class.solver_rests(vehicle) } + vrp.rests = vrp.rests.reject{ |rest| self.class.lapse_rest?(rest) } + end + + def each_activity(vrp, &block) + vrp.services.each{ |service| + [service.activity, *service.activities.to_a].compact.each(&block) + } + vrp.reload_depots.each(&block) + end + + def inflate(value, rate) + (value.to_f * (1 + rate)).ceil + end + + def delay_timewindow_starts!(vrp, rate, tour_start, cap) + each_activity(vrp){ |activity| + activity.timewindows.each{ |timewindow| + next if timewindow.start.nil? + + delayed = timewindow.start + start_delay(timewindow.start, tour_start, rate, cap) + next if timewindow.end && delayed >= timewindow.end + + timewindow.start = delayed + } + } + end + + def start_delay(tw_start, tour_start, rate, cap) + extra = (tw_start - tour_start) * rate + extra = 0 if extra.negative? + [extra.ceil, cap].min + end + + def rebuild_route_with_pauses!(vrp, route, rest) + matrix = vrp.matrices.find{ |mat| mat.id == route.vehicle.matrix_id } + vehicle = route.vehicle + lapse = rest.lapse + pause_duration = rest.duration + work = 0 + pause_index = 0 + previous_index = nil + previous_point_id = nil + clock = route.info.start_time || vehicle.timewindow&.start.to_i + new_stops = [] + + route.stops.reject{ |stop| stop.type == :rest }.each{ |stop| + restore_stop_durations!(stop) + current_index = stop.activity.point&.matrix_index + travel = travel_time(matrix, previous_index, current_index) + setup = setup_time(stop, vehicle, previous_point_id) + service = service_time(stop, vehicle) + + if work.positive? && work + travel >= lapse && pause_fits_before_stop?(stop, clock, travel, pause_duration) + pause_index += 1 + new_stops << build_rest_stop(rest, pause_index, clock, pause_duration) + clock += pause_duration + work = 0 + end + + clock += travel + waiting = waiting_time(stop, clock) + clock += waiting + begin_time = clock + clock += setup + service + + stop.info.travel_time = travel + stop.info.waiting_time = waiting + stop.info.begin_time = begin_time + stop.info.end_time = clock + stop.info.departure_time = clock + new_stops << stop + + work += travel + setup + service + if work >= lapse + pause_index += 1 + new_stops << build_rest_stop(rest, pause_index, clock, pause_duration) + clock += pause_duration + work = 0 + end + + previous_index = current_index if current_index + previous_point_id = stop.activity.point_id if stop.activity.point_id + } + + route.stops.replace(new_stops) + route.info.end_time = clock if new_stops.any? + end + + def travel_time(matrix, previous_index, current_index) + return 0 unless matrix&.time && previous_index && current_index + + matrix.time[previous_index][current_index].to_i + end + + def setup_time(stop, vehicle, previous_point_id) + return 0 if stop.type == :depot || previous_point_id.nil? + return 0 if stop.activity.point_id == previous_point_id + + activity = mission_activity(stop) + activity ? activity.setup_duration_on(vehicle).to_i : 0 + end + + def service_time(stop, vehicle) + return 0 if stop.type == :depot + + activity = mission_activity(stop) + return stop.activity.duration.to_i unless activity + + activity.duration_on(vehicle).to_i + end + + def waiting_time(stop, arrival) + timewindows = mission_activity(stop)&.timewindows.to_a + return 0 if timewindows.empty? + + earliest_start = timewindows.find{ |tw| (tw.end || 2**32) > arrival }&.start || 0 + [earliest_start - arrival, 0].max + end + + def pause_fits_before_stop?(stop, clock, travel, pause_duration) + begin_time = clock + pause_duration + travel + begin_time += waiting_time(stop, begin_time) + timewindows = mission_activity(stop)&.timewindows.to_a + return true if timewindows.empty? + + timewindows.any?{ |tw| + (tw.start.nil? || begin_time >= tw.start) && (tw.end.nil? || begin_time <= tw.end) + } + end + + def mission_activity(stop) + mission = stop.mission + return stop.activity unless mission + return mission if mission.is_a?(Models::Activity) || mission.is_a?(Models::ReloadDepot) + return mission.activity if mission.respond_to?(:activity) && mission.activity + return mission.activities[stop.alternative.to_i] if mission.respond_to?(:activities) && mission.activities.any? + + stop.activity + end + + def restore_stop_durations!(stop) + activity = mission_activity(stop) + return unless activity && stop.activity && activity != stop.activity + + stop.activity.duration = activity.duration + return unless stop.activity.respond_to?(:setup_duration=) && activity.respond_to?(:setup_duration) + + stop.activity.setup_duration = activity.setup_duration + end + + def build_rest_stop(rest, pause_index, begin_time, duration) + rest_copy = Models::Rest.new( + id: "#{rest.id}##{pause_index}", + original_id: rest.original_id || rest.id, + duration: duration + ) + Models::Solution::Stop.new( + rest_copy, + info: Models::Solution::Stop::Info.new( + begin_time: begin_time, + end_time: begin_time + duration, + departure_time: begin_time + duration, + travel_time: 0 + ) + ) + end + end +end diff --git a/models/concerns/validate_data.rb b/models/concerns/validate_data.rb index b4bca491..0f14ff49 100644 --- a/models/concerns/validate_data.rb +++ b/models/concerns/validate_data.rb @@ -115,7 +115,23 @@ def check_rests used_rest_ids.uniq.each{ |rest_id| corresponding = @hash[:rests].find{ |r| r[:id] == rest_id } - next unless corresponding && corresponding[:timewindows].to_a.size > 1 + next unless corresponding + + lapse = corresponding[:lapse].to_i + if lapse.positive? + if corresponding[:duration].to_i <= 0 + raise OptimizerWrapper::DiscordantProblemError.new( + 'Rests with lapse require a strictly positive duration' + ) + end + if corresponding[:duration].to_i >= lapse + raise OptimizerWrapper::DiscordantProblemError.new( + 'Rest duration must be smaller than lapse' + ) + end + end + + next unless corresponding[:timewindows].to_a.size > 1 raise OptimizerWrapper::UnsupportedProblemError.new('Rests can only have one timewindow') } diff --git a/models/rest.rb b/models/rest.rb index 490569d7..a94914a5 100644 --- a/models/rest.rb +++ b/models/rest.rb @@ -22,6 +22,7 @@ class Rest < Activity field :id field :original_id field :duration, default: 0 + field :lapse, default: nil field :late_multiplier, default: 0, vrp_result: :hide field :exclusion_cost, default: nil, vrp_result: :hide diff --git a/test/lib/interpreters/regulatory_rest_test.rb b/test/lib/interpreters/regulatory_rest_test.rb new file mode 100644 index 00000000..88cd8edc --- /dev/null +++ b/test/lib/interpreters/regulatory_rest_test.rb @@ -0,0 +1,309 @@ +require './test/test_helper' + +class Interpreters::RegulatoryRestTest < IsolatedTest + def regulatory_problem + { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 3600, 3600, 3600], + [3600, 0, 3600, 3600], + [3600, 3600, 0, 3600], + [3600, 3600, 3600, 0] + ] + }], + points: [ + { id: 'point_0', matrix_index: 0 }, + { id: 'point_1', matrix_index: 1 }, + { id: 'point_2', matrix_index: 2 }, + { id: 'point_3', matrix_index: 3 } + ], + rests: [{ + id: 'reg_rest', + duration: 2700, + lapse: 21600 + }], + vehicles: [{ + id: 'vehicle_0', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + rest_ids: ['reg_rest'], + timewindow: { start: 0, end: 43200 } + }], + services: [{ + id: 'service_1', + activity: { + point_id: 'point_1', + duration: 3600, + timewindows: [{ start: 21600, end: 28800 }] + } + }, { + id: 'service_2', + activity: { point_id: 'point_2', duration: 3600 } + }, { + id: 'service_3', + activity: { point_id: 'point_3', duration: 3600 } + }], + configuration: { + resolution: { duration: 100 }, + restitution: { intermediate_solutions: false } + } + } + end + + def pause_problem + { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 30, 30, 30], + [30, 0, 30, 30], + [30, 30, 0, 30], + [30, 30, 30, 0] + ] + }], + points: [ + { id: 'point_0', matrix_index: 0 }, + { id: 'point_1', matrix_index: 1 }, + { id: 'point_2', matrix_index: 2 }, + { id: 'point_3', matrix_index: 3 } + ], + rests: [{ + id: 'reg_rest', + duration: 10, + lapse: 100 + }], + vehicles: [{ + id: 'vehicle_0', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + rest_ids: ['reg_rest'], + timewindow: { start: 0, end: 1000 } + }], + services: [{ + id: 'service_1', + activity: { point_id: 'point_1', duration: 80 } + }, { + id: 'service_2', + activity: { point_id: 'point_2', duration: 80 } + }, { + id: 'service_3', + activity: { point_id: 'point_3', duration: 80 } + }], + configuration: { + resolution: { duration: 100 }, + restitution: { intermediate_solutions: false } + } + } + end + + def sequential_solution(vrp) + vehicle = vrp.vehicles.first + info = -> { Models::Solution::Stop::Info.new(begin_time: 0, end_time: 0, departure_time: 0) } + stops = [Models::Solution::StopDepot.new(vehicle.start_point, info: info.call)] + vrp.services.each{ |service| stops << Models::Solution::Stop.new(service, info: info.call) } + stops << Models::Solution::StopDepot.new(vehicle.end_point, info: info.call) + Models::Solution.new( + routes: [ + Models::Solution::Route.new( + vehicle: vehicle, + stops: stops, + info: Models::Solution::Route::Info.new(start_time: 0, end_time: 0) + ) + ] + ) + end + + def uniform_time_matrix(value) + [ + [0, value, value, value], + [value, 0, value, value], + [value, value, 0, value], + [value, value, value, 0] + ] + end + + def patched_stops(problem) + vrp = TestHelper.create(problem) + solution = sequential_solution(vrp) + Interpreters::RegulatoryRest.new.patch_solution!(vrp, solution) + [vrp, solution.routes.first.stops] + end + + def test_not_applicable_without_lapse + problem = regulatory_problem + problem[:rests].first.delete(:lapse) + vrp = TestHelper.create(problem) + + refute Interpreters::RegulatoryRest.applicable?(vrp) + end + + def test_inflates_durations_and_delays_timewindow_starts_only + vrp = TestHelper.create(regulatory_problem) + interpreter = Interpreters::RegulatoryRest.new + + assert interpreter.apply!(vrp) + + # 45min every 6h → rate 0.125, so 1h of work becomes 1h07m30s (not +15min) + assert_equal 4050, vrp.services.find{ |s| s.id == 'service_1' }.activity.duration + assert_equal 4050, vrp.matrices.first.time[0][1] + # start 21600 from tour_start 0 → +2700; end stays 28800 + assert_equal 24300, vrp.services.find{ |s| s.id == 'service_1' }.activity.timewindows.first.start + assert_equal 28800, vrp.services.find{ |s| s.id == 'service_1' }.activity.timewindows.first.end + assert_equal 43200, vrp.vehicles.first.timewindow.end + assert_empty vrp.vehicles.first.rests + refute_includes OptimizerWrapper.config[:services][:pyvrp].inapplicable_solve?(vrp), :assert_no_rest + end + + def test_inflate_ceils_fractional_durations_and_start_delay + problem = pause_problem + problem[:rests].first[:lapse] = 120 + problem[:services].first[:activity][:timewindows] = [{ start: 100, end: 200 }] + vrp = TestHelper.create(problem) + + Interpreters::RegulatoryRest.new.apply!(vrp) + + # 80 * 13/12 = 86.66… → 87; 30 * 13/12 = 32.5 → 33 + assert_equal 87, vrp.services.first.activity.duration + assert_equal 33, vrp.matrices.first.time[0][1] + tw = vrp.services.first.activity.timewindows.first + # extra 100/12 = 8.33… → start 109; end unchanged + assert_equal 109, tw.start + assert_equal 200, tw.end + end + + def test_timewindow_start_delay_is_capped_at_pause_duration + problem = pause_problem + problem[:services].last[:activity][:timewindows] = [{ start: 500, end: 800 }] + vrp = TestHelper.create(problem) + + Interpreters::RegulatoryRest.new.apply!(vrp) + + # Linear extra would be 500 * 0.1 = 50; cap at duration 10. + tw = vrp.services.find{ |s| s.id == 'service_3' }.activity.timewindows.first + assert_equal 510, tw.start + assert_equal 800, tw.end + end + + def test_rewind_restores_original_problem + vrp = TestHelper.create(regulatory_problem) + interpreter = Interpreters::RegulatoryRest.new + interpreter.apply!(vrp) + interpreter.rewind!(vrp) + + assert_equal 3600, vrp.services.find{ |s| s.id == 'service_1' }.activity.duration + assert_equal 3600, vrp.matrices.first.time[0][1] + assert_equal 21600, vrp.services.find{ |s| s.id == 'service_1' }.activity.timewindows.first.start + assert_equal 28800, vrp.services.find{ |s| s.id == 'service_1' }.activity.timewindows.first.end + assert_equal 43200, vrp.vehicles.first.timewindow.end + assert_equal 1, vrp.vehicles.first.rests.size + assert_equal 21600, vrp.vehicles.first.rests.first.lapse + end + + def test_patch_inserts_repeatable_pauses_from_accumulated_work + _vrp, stops = patched_stops(pause_problem) + + rest_stops = stops.select{ |stop| stop.type == :rest } + assert_equal 3, rest_stops.size, 'A pause is due after each 100s of work (three services)' + assert(rest_stops.all?{ |stop| stop.activity.duration == 10 }) + assert_equal [:depot, :service, :rest, :service, :rest, :service, :rest, :depot], stops.map(&:type) + end + + def test_patch_inserts_pause_after_service_when_lapse_exceeded_during_service + problem = pause_problem + problem[:matrices].first[:time] = uniform_time_matrix(10) + problem[:services].each{ |service| service[:activity][:duration] = 40 } + problem[:services][1][:activity][:timewindows] = [{ start: 0, end: 1000 }] + + _vrp, stops = patched_stops(problem) + + # Travel does not complete the lapse, service does → rest after S2 even if the TW would allow a pause before. + assert_equal [:depot, :service, :service, :rest, :service, :depot], stops.map(&:type) + end + + def test_patch_inserts_pause_before_travel_that_would_exceed_lapse + problem = pause_problem + problem[:matrices].first[:time] = uniform_time_matrix(10) + problem[:services].each{ |service| service[:activity][:duration] = 85 } + + _vrp, stops = patched_stops(problem) + + # After S1 work is 95s; the next 10s drive would complete the lapse → rest before travelling. + assert_equal [:depot, :service, :rest, :service, :rest, :service, :rest, :depot], stops.map(&:type) + end + + def test_patch_inserts_pause_after_stop_when_before_would_miss_timewindow + problem = pause_problem + problem[:rests].first[:duration] = 20 + problem[:matrices].first[:time] = uniform_time_matrix(10) + problem[:services].each{ |service| service[:activity][:duration] = 85 } + problem[:services][1][:activity][:timewindows] = [{ start: 105, end: 110 }] + + _vrp, stops = patched_stops(problem) + + assert_equal [:depot, :service, :service, :rest, :service, :rest, :depot], stops.map(&:type) + assert_equal 105, stops.find{ |stop| stop.service_id == 'service_2' }.info.begin_time + end + + def test_lapse_rests_do_not_skip_any_solver + vrp = TestHelper.create(regulatory_problem) + + assert_empty Interpreters::RegulatoryRest.solver_rests(vrp.vehicles.first) + %i[pyvrp vroom ortools].each{ |solver| + refute_includes OptimizerWrapper.config[:services][solver].inapplicable_solve?(vrp), :assert_no_rest, + "#{solver} must not skip because of a regulatory (lapse) rest" + } + end + + def test_pyvrp_still_rejects_classic_rests + problem = regulatory_problem + problem[:rests].first.delete(:lapse) + problem[:rests].first[:duration] = 600 + vrp = TestHelper.create(problem) + + assert_includes OptimizerWrapper.config[:services][:pyvrp].inapplicable_solve?(vrp), :assert_no_rest + end + + def test_apply_then_patch_restores_durations_and_inserts_pauses + vrp = TestHelper.create(pause_problem) + interpreter = Interpreters::RegulatoryRest.new + interpreter.apply!(vrp) + + assert_equal 88, vrp.services.first.activity.duration + + solution = sequential_solution(vrp) + interpreter.patch_solution!(vrp, solution) + + assert_equal 80, vrp.services.first.activity.duration + assert_equal 30, vrp.matrices.first.time[0][1] + assert_equal 1, vrp.vehicles.first.rests.size + assert_equal(3, solution.routes.first.stops.count{ |stop| stop.type == :rest }) + end + + def test_rejects_heterogeneous_inflation_rates + problem = regulatory_problem + problem[:rests] << { id: 'other_rest', duration: 600, lapse: 3600 } + problem[:vehicles] << { + id: 'vehicle_1', + matrix_id: 'matrix_0', + start_point_id: 'point_0', + rest_ids: ['other_rest'] + } + vrp = TestHelper.create(problem) + + assert_raises OptimizerWrapper::UnsupportedProblemError do + Interpreters::RegulatoryRest.new.apply!(vrp) + end + end + + def test_rejects_lapse_not_greater_than_duration + problem = regulatory_problem + problem[:rests].first[:duration] = 21600 + + assert_raises OptimizerWrapper::DiscordantProblemError do + TestHelper.create(problem) + end + end +end diff --git a/wrappers/ortools.rb b/wrappers/ortools.rb index a509c984..c714fcd7 100644 --- a/wrappers/ortools.rb +++ b/wrappers/ortools.rb @@ -319,7 +319,7 @@ def build_problem_vehicles(vrp, total_quantities, matrix_index_by_id) end: vehicle.timewindow&.end || 2147483647, maximum_lateness: vehicle.timewindow&.maximum_lateness || 0, ), - rests: vehicle.rests.collect{ |rest| + rests: Interpreters::RegulatoryRest.solver_rests(vehicle).collect{ |rest| OrtoolsVrp::Rest.new( time_window: if rest.timewindows.any? @@ -525,7 +525,7 @@ def build_unassigned(problem_services, problem_rests) def build_solution(vrp, content) problem_services = vrp.services.map{ |service| [service.id, service] }.to_h problem_rests = vrp.vehicles.map{ |vehicle| - [vehicle.id, vehicle.rests.map{ |rest| [rest.id, rest] }.to_h] + [vehicle.id, Interpreters::RegulatoryRest.solver_rests(vehicle).map{ |rest| [rest.id, rest] }.to_h] }.to_h routes = build_routes(vrp, problem_services, problem_rests, content.routes) Models::Solution.new( diff --git a/wrappers/vroom.rb b/wrappers/vroom.rb index ffb63951..44924584 100644 --- a/wrappers/vroom.rb +++ b/wrappers/vroom.rb @@ -148,7 +148,7 @@ def rest_equivalence(vrp) rest_index = 0 @rest_hash = {} vrp.vehicles.each{ |vehicle| - vehicle.rests.each{ |rest| + Interpreters::RegulatoryRest.solver_rests(vehicle).each{ |rest| @rest_hash["#{vehicle.id}_#{rest.id}"] = { index: rest_index, vehicle: vehicle.id, @@ -422,7 +422,7 @@ def collect_vehicles(vrp, vrp_skills, vrp_units) time_window: [vehicle.timewindow&.start || 0, vehicle.timewindow&.end || 2**30], # VROOM expects a default skill skills: collect_skills(vehicle, vrp_skills), - breaks: vehicle.rests.map{ |rest| + breaks: Interpreters::RegulatoryRest.solver_rests(vehicle).map{ |rest| rest_index = @rest_hash["#{vehicle.id}_#{rest.id}"][:index] { id: rest_index, diff --git a/wrappers/wrapper.rb b/wrappers/wrapper.rb index 4457d094..3e2e82e7 100644 --- a/wrappers/wrapper.rb +++ b/wrappers/wrapper.rb @@ -484,7 +484,7 @@ def assert_no_complex_setup_durations(vrp) end def assert_no_rest(vrp) - vrp.vehicles.none?{ |vehicle| vehicle.rests.any? } + vrp.vehicles.none?{ |vehicle| Interpreters::RegulatoryRest.solver_rests(vehicle).any? } end def solve_synchronous?(_vrp) From efe36874b52213476612b18a29223d83a22b9d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Tue, 8 Sep 2026 13:47:51 +0200 Subject: [PATCH 4/4] Fix computed times with vroom --- wrappers/vroom.rb | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/wrappers/vroom.rb b/wrappers/vroom.rb index 44924584..5e3c82f0 100644 --- a/wrappers/vroom.rb +++ b/wrappers/vroom.rb @@ -109,6 +109,7 @@ def solve_vroom(vrp, job, timeout: nil) stops = route['steps'].map{ |step| read_step(vrp, vehicle, step) }.compact + complete_vroom_route_times!(stops, vehicle) initial_loads = route['steps'].first['load']&.map&.with_index{ |load, l_index| Models::Solution::Load.new(quantity: Models::Quantity.new(unit: vrp.units[l_index]), current: load) @@ -119,7 +120,7 @@ def solve_vroom(vrp, job, timeout: nil) vehicle: vehicle, info: Models::Solution::Route::Info.new( start_time: stops.first.info.begin_time, - end_time: stops.last.info.begin_time + stops.last.activity.duration + end_time: stops.last.info.end_time || stops.last.info.begin_time ) ) } @@ -135,11 +136,36 @@ def solve_vroom(vrp, job, timeout: nil) routes: routes, unassigned_stops: unassigneds ) - solution.parse(vrp) + solution.parse(vrp, preserve_solver_waiting_times: true) end private + # Fill missing end/departure times so route totals match VROOM waiting/travel/service breakdown. + def complete_vroom_route_times!(stops, vehicle) + stops.each do |stop| + info = stop.info + info.waiting_time = 0 if info.waiting_time.nil? + + next unless info.begin_time + + if stop.is_a?(Models::Solution::StopDepot) + info.end_time ||= info.begin_time + info.departure_time ||= info.begin_time + next + end + + service_duration = + if stop.type == :rest + stop.activity.duration + else + info.end_time ? info.end_time - info.begin_time : stop.activity.duration_on(vehicle) + end + info.end_time ||= info.begin_time + service_duration + info.departure_time ||= info.end_time + end + end + def job_priority_for(service) (100 * (8 - service.priority).to_f / 8).to_i end @@ -198,7 +224,8 @@ def read_depot(vrp, vehicle, step) @previous = point times = { - begin_time: step['arrival'] + begin_time: step['arrival'], + waiting_time: step['waiting_time'] || 0 }.merge(route_data) if step['type'] == 'end' Models::Solution::StopDepot.new(vehicle.end_point, info: Models::Solution::Stop::Info.new(times))