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 1/2] 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 2/2] 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))