From bc0b0874553ed42d983e4d0f0084ff0602de163b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Tue, 22 Sep 2026 14:01:09 +0200 Subject: [PATCH 01/12] Isolate vrp store per threads --- Gemfile | 16 ++- Gemfile.lock | 151 ++++++++++++---------- api/root.rb | 13 +- api/v01/api.rb | 29 +++-- api/v01/vrp.rb | 2 +- lib/cache_manager.rb | 11 +- models/base.rb | 76 +++++++++-- test/models/base_thread_isolation_test.rb | 72 +++++++++++ test/test_helper.rb | 1 + 9 files changed, 271 insertions(+), 100 deletions(-) create mode 100644 test/models/base_thread_isolation_test.rb diff --git a/Gemfile b/Gemfile index d17577d3c..52d8fa969 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source 'https://rubygems.org' -ruby '>= 3' +ruby '>= 3.2' gem 'require_all' @@ -12,19 +12,21 @@ gem 'rake' gem 'thin' # API -gem 'grape' +# ~> 3.2: ParamScopeTracker (thread-safe validation); keeps mustermann 3.x (sinatra/resque) +# grape >= 3.2 needs Ruby >= 3.2 (matches Docker/CI) +gem 'grape', '~> 3.2.0' gem 'grape-entity' -gem 'grape_logging' -gem 'grape-swagger' +gem 'grape_logging', '>= 3.0' +gem 'grape-swagger', '~> 2.1', '>= 2.1.3' # 2.1.3+ allows grape < 4; 2.2 needs Ruby >= 3.1 OK gem 'grape-swagger-entity' # Models -gem 'actionpack', require: 'action_dispatch' +gem 'actionpack', '>= 7.2', require: 'action_dispatch' # waiting for the following PRs to get merged and "released!" # https://github.com/zilkey/active_hash/pull/231 and https://github.com/zilkey/active_hash/pull/233 gem 'active_hash' -gem 'activemodel' -gem 'activesupport', require: 'active_support' +gem 'activemodel', '>= 7.2' +gem 'activesupport', '>= 7.2', require: 'active_support' gem 'google-protobuf', '>=3', require: 'google/protobuf' gem 'oj' diff --git a/Gemfile.lock b/Gemfile.lock index 64e420e91..6b18ddc50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,28 +24,38 @@ GIT GEM remote: https://rubygems.org/ specs: - actionpack (7.0.8) - actionview (= 7.0.8) - activesupport (= 7.0.8) - rack (~> 2.0, >= 2.2.4) + actionpack (7.2.1.2) + actionview (= 7.2.1.2) + activesupport (= 7.2.1.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.2) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (7.0.8) - activesupport (= 7.0.8) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actionview (7.2.1.2) + activesupport (= 7.2.1.2) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) active_hash (4.0.0) activesupport (>= 6.1.0) - activemodel (7.0.8) - activesupport (= 7.0.8) - activesupport (7.0.8) - concurrent-ruby (~> 1.0, >= 1.0.2) + activemodel (7.2.1.2) + activesupport (= 7.2.1.2) + activesupport (7.2.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ai4r (1.13) @@ -57,66 +67,74 @@ GEM benchmark (0.2.1) benchmark-ips (2.12.0) bigdecimal (4.1.2) - builder (3.2.4) + builder (3.3.0) byebug (11.1.3) charlock_holmes (0.7.9) chunky_png (1.4.0) color-generator (0.0.4) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) crack (0.4.5) rexml - crass (1.0.6) + crass (1.0.7) daemons (1.4.1) diff-lcs (1.5.0) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) dotenv (3.1.7) - dry-core (1.0.1) + drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) concurrent-ruby (~> 1.0) + logger zeitwerk (~> 2.6) - dry-inflector (1.0.0) - dry-logic (1.5.0) + dry-inflector (1.3.1) + dry-logic (1.6.0) + bigdecimal concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-types (1.7.1) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) e2mmap (0.1.0) - erubi (1.12.0) + erubi (1.13.1) eventmachine (1.2.7) geojson2image (0.2.2) chunky_png (~> 1.3) oj (~> 3.6) google-protobuf (3.24.3-x86_64-linux) - grape (1.8.0) - activesupport (>= 5) - builder + grape (3.2.1) + activesupport (>= 7.2) + dry-configurable dry-types (>= 1.1) - mustermann-grape (~> 1.0.0) - rack (>= 1.3.0) - rack-accept - grape-entity (1.0.0) + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk + grape-entity (1.1.0) activesupport (>= 3.0.0) - multi_json (>= 1.3.2) - grape-swagger (1.6.1) - grape (~> 1.3) - grape-swagger-entity (0.5.2) - grape-entity (>= 0.6.0) - grape-swagger (>= 1.2.0) - grape_logging (1.8.4) - grape + grape-swagger (2.2.0) + grape (>= 2.1, < 5.0) + grape-swagger-entity (0.7.1) + grape-entity (~> 1) + grape-swagger (~> 2) + grape_logging (3.0.1) + grape (>= 2.4.0) rack + zeitwerk hashdiff (1.0.1) http-accept (1.7.0) http-cookie (1.0.5) domain_name (~> 0.5) http_accept_language (2.1.1) - i18n (1.14.8) + i18n (1.15.2) concurrent-ruby (~> 1.0) jaro_winkler (1.5.6) json (2.7.1) @@ -125,13 +143,14 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.3) - loofah (2.21.3) + logger (1.7.0) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mime-types (3.5.1) mime-types-data (~> 3.2015) mime-types-data (3.2023.0808) - minitest (5.26.1) + minitest (5.27.0) minitest-around (0.5.0) minitest (~> 5.0) minitest-bisect (1.7.0) @@ -150,14 +169,13 @@ GEM minitest (~> 5.16) minitest-stub_any_instance (1.0.3) mono_logger (1.1.2) - multi_json (1.15.0) - mustermann (3.0.0) - ruby2_keywords (~> 0.0.1) - mustermann-grape (1.0.2) + multi_json (1.21.2) + mustermann (3.1.1) + mustermann-grape (1.1.0) mustermann (>= 1.0.0) netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.15.4-x86_64-linux) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) oj (3.17.3) bigdecimal (>= 3.0) @@ -172,10 +190,8 @@ GEM public_suffix (5.0.3) puma (6.3.1) nio4r (~> 2.0) - racc (1.7.3) - rack (2.2.8) - rack-accept (0.4.5) - rack (>= 0.4) + racc (1.8.1) + rack (2.2.24) rack-contrib (2.3.0) rack (~> 2.0) rack-cors (2.0.1) @@ -184,15 +200,17 @@ GEM rack (~> 2.2, >= 2.2.4) rack-server-pages (0.1.0) rack - rack-test (2.1.0) + rack-session (1.0.2) + rack (< 3) + rack-test (2.2.0) rack (>= 1.3) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) - loofah (~> 2.21) - nokogiri (~> 1.14) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.4.2) rake-compiler-dock (1.12.0) @@ -248,7 +266,7 @@ GEM rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) + securerandom (0.4.1) sentry-resque (5.18.2) resque (>= 1.24) sentry-ruby (~> 5.18.2) @@ -293,6 +311,7 @@ GEM unf_ext unf_ext (0.0.8.2) unicode-display_width (2.5.0) + useragent (0.16.11) vegas (0.1.11) rack (>= 1.0.0) webmock (3.19.1) @@ -300,16 +319,16 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) yard (0.9.34) - zeitwerk (2.6.11) + zeitwerk (2.8.3) PLATFORMS x86_64-linux DEPENDENCIES - actionpack + actionpack (>= 7.2) active_hash - activemodel - activesupport + activemodel (>= 7.2) + activesupport (>= 7.2) ai4r balanced_vrp_clustering! benchmark-ips @@ -317,11 +336,11 @@ DEPENDENCIES charlock_holmes dotenv google-protobuf (>= 3) - grape + grape (~> 3.2.0) grape-entity - grape-swagger + grape-swagger (~> 2.1, >= 2.1.3) grape-swagger-entity - grape_logging + grape_logging (>= 3.0) http_accept_language i18n minitest @@ -359,7 +378,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.0.6p216 + ruby 3.2.2p53 BUNDLED WITH 2.4.19 diff --git a/api/root.rb b/api/root.rb index 2690d8787..61e2d48e6 100644 --- a/api/root.rb +++ b/api/root.rb @@ -22,12 +22,21 @@ module Api class Root < Grape::API mount ApiV01 + # Omit VRP body from access logs (payloads are huge; dumps go to dump_vrp_dir when enabled) + VRP_LOG_PARAM_FILTER = %w[ + vrp points vehicles services matrices shipments routes relations + units rests zones subtours quantities capacities timewindows configuration + ].freeze + logger.formatter = GrapeLogging::Formatters::Default.new - use GrapeLogging::Middleware::RequestLogger, logger: logger + use GrapeLogging::Middleware::RequestLogger, + logger: logger, + include: [GrapeLogging::Loggers::FilterParameters.new(VRP_LOG_PARAM_FILTER)] + + format :json desc 'Ping hook. Responds by "pong".' get '/ping' do - content_type 'application/json' 'pong' end end diff --git a/api/v01/api.rb b/api/v01/api.rb index 71422abf0..24315956d 100644 --- a/api/v01/api.rb +++ b/api/v01/api.rb @@ -169,24 +169,25 @@ def split_key(key) e.is_a?(OptimizerWrapper::UnsupportedProblemError) || e.is_a?(OptimizerWrapper::UnsupportedRouterModeError) response[:message] += ' ' + e.data.map { |service| service.join(', ') }.join(' | ') if e.is_a?(OptimizerWrapper::UnsupportedProblemError) - rack_response(format_message(response, e.backtrace), 400) + error!(response, 400, {}, e.backtrace) elsif e.is_a?(Grape::Exceptions::MethodNotAllowed) - rack_response(format_message(response, e.backtrace), 405) + error!(response, 405, {}, e.backtrace) elsif e.is_a?(QuotaExceededError) - headers = { 'Content-Type' => content_type, - 'X-RateLimit-Limit' => e.data[:limit], - 'X-RateLimit-Remaining' => e.data[:remaining], - 'X-RateLimit-Reset' => if e.data[:reset] == :daily - count_time.to_date.next_day - elsif e.data[:reset] == :monthly - count_time.to_date.next_month - elsif e.data[:reset] == :yearly - count_time.to_date.next_year - end.to_time.to_i } - rack_response(format_message(response, nil), 429, headers) + headers = { + 'X-RateLimit-Limit' => e.data[:limit], + 'X-RateLimit-Remaining' => e.data[:remaining], + 'X-RateLimit-Reset' => if e.data[:reset] == :daily + count_time.to_date.next_day + elsif e.data[:reset] == :monthly + count_time.to_date.next_month + elsif e.data[:reset] == :yearly + count_time.to_date.next_year + end.to_time.to_i + } + error!(response, 429, headers) else Sentry.capture_exception(e) - rack_response(format_message(response, e.backtrace), 500) + error!(response, 500, {}, e.backtrace) end end diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index 4080999c9..60d279323 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -58,7 +58,7 @@ def self.call(object, _env) end class Vrp < APIBase - include Grape::Extensions::Hash::ParamBuilder + build_with :hash parser :csv, CSVParser diff --git a/lib/cache_manager.rb b/lib/cache_manager.rb index 35e2e7457..5256668e7 100644 --- a/lib/cache_manager.rb +++ b/lib/cache_manager.rb @@ -28,7 +28,7 @@ def initialize(cache) end def read(name, _options = nil) - filtered_name = name.to_s.parameterize(separator: '') + filtered_name = sanitize_filename(name) if File.exist?(File.join(@cache, filtered_name) + '.gz') # Gzip dumps Zlib::GzipReader.open(File.join(@cache, filtered_name) + '.gz', &:read) elsif File.exist?(File.join(@cache, filtered_name)) # Zlib (and uncompressed) dumps @@ -49,7 +49,7 @@ def write(name, value, mode: 'w', gz: true) raise CacheError.new('Stored value is not a String') if !value.is_a? String if gz - File.open(File.join(@cache, name.to_s.parameterize(separator: '')) + '.gz', mode) do |f| + File.open(File.join(@cache, sanitize_filename(name)) + '.gz', mode) do |f| gz = Zlib::GzipWriter.new(f) if value.bytesize < @data_bytesize_limit_in_mb.megabytes gz.write value @@ -74,4 +74,11 @@ def cleanup(options = nil) raise CacheError.new("Got error \"#{e}\" attempting to clean cache.") end end + + private + + # ActiveSupport 7.2+ refuses to parameterize ASCII-8BIT strings + def sanitize_filename(name) + name.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).parameterize(separator: '') + end end diff --git a/models/base.rb b/models/base.rb index 385618faa..467812091 100644 --- a/models/base.rb +++ b/models/base.rb @@ -52,6 +52,29 @@ def initialize(hash) end class << self + # Thread-local ActiveHash store. Child Thread.new does not inherit the parent + # store — keep VRP work on the request/worker thread, or copy Thread.current[:models_active_hash]. + def thread_store + stores = Thread.current[:models_active_hash] ||= {} + stores[name] ||= { records: [], record_index: {}, max_id: 0, dirty: false } + end + + def records + thread_store[:records] + end + + def record_index + thread_store[:record_index] + end + + def dirty + thread_store[:dirty] + end + + def dirty=(value) + thread_store[:dirty] = value + end + def create(attributes = {}) record = new(attributes) validate_unique_id(record) if dirty @@ -60,27 +83,62 @@ def create(attributes = {}) record end - # Override active_hash to improve performances + def all(options = {}) + relation = ActiveHash::Relation.new(self, records) + relation = relation.where!(options[:conditions]) if options[:conditions] + relation + end + + # Override active_hash to improve performances (+ thread-local store). + # ActiveHash::Base#insert uses next_id which scans all records (O(n)); + # keep a running max_id instead. def insert(record) - @records ||= [] record_id(record) validate_unique_id(record) if dirty mark_dirty - add_to_record_index({ record.id.to_s => @records.length }) - @records << record + add_to_record_index({ record.id.to_s => records.length }) + records << record + end + + def delete_all + mark_dirty + reset_record_index + thread_store[:records] = [] + thread_store[:max_id] = 0 + end + + # Override active_hash next_id (avoids scanning all records) + def next_id + thread_store[:max_id] = thread_store[:max_id].to_i.succ end - # Override active_hash to improve performances + # Override active_hash to improve performances (+ thread-local store) def record_id(record) - # sets record[:id] to @max_id+1 if it doesn't exist + # sets record[:id] to max_id+1 if it doesn't exist if record[:id]&.is_a?(Numeric) - @max_id = [@max_id || 0, record[:id].ceil].max + thread_store[:max_id] = [thread_store[:max_id] || 0, record[:id].ceil].max else - record[:id] ||= (@max_id = @max_id.to_i.succ) + record[:id] ||= next_id end end + def reset_record_index + record_index.clear + end + + def add_to_record_index(entry) + record_index.merge!(entry) + end + + def mark_dirty + self.dirty = true + end + + def mark_clean + self.dirty = false + end + def json_fields @json_fields ||= [] end @@ -92,6 +150,8 @@ def vrp_result_fields def types @types ||= {} end + + private :record_index, :reset_record_index, :add_to_record_index, :mark_dirty, :mark_clean, :thread_store end def to_hash diff --git a/test/models/base_thread_isolation_test.rb b/test/models/base_thread_isolation_test.rb new file mode 100644 index 000000000..65f73b9f1 --- /dev/null +++ b/test/models/base_thread_isolation_test.rb @@ -0,0 +1,72 @@ +# Copyright © Cartoway, 2026 +# +require './test/test_helper' + +module Models + class BaseThreadIsolationTest < Minitest::Test + def test_concurrent_create_and_delete_all_are_thread_local + ready = Queue.new + proceed = Queue.new + deleted = Queue.new + results = Queue.new + + threads = + Array.new(2){ |i| + Thread.new{ + Models.delete_all + point = Models::Point.create(id: "thread_#{i}_point", matrix_index: i) + assert_equal 1, Models::Point.all.size + assert_equal point.id, Models::Point.find("thread_#{i}_point").id + + ready << i + proceed.pop + + if i.zero? + Models.delete_all + assert_empty Models::Point.all + deleted << true + else + deleted.pop + assert_equal 1, Models::Point.all.size + assert_equal "thread_#{i}_point", Models::Point.first.id + end + results << [i, Models::Point.all.map(&:id)] + } + } + + 2.times{ ready.pop } + 2.times{ proceed << true } + threads.each(&:join) + + by_thread = Array.new(2){ results.pop }.to_h + assert_empty by_thread[0] + assert_equal ['thread_1_point'], by_thread[1] + ensure + Models.delete_all + end + + def test_concurrent_numeric_ids_do_not_collide_across_threads + errors = Queue.new + created = Queue.new + + threads = + Array.new(2){ + Thread.new{ + Models.delete_all + begin + 20.times{ |n| Models::Point.create(id: n + 1, matrix_index: n) } + created << Models::Point.all.map(&:id).sort + rescue StandardError => e + errors << e + end + } + } + threads.each(&:join) + + assert_equal 0, errors.size, ->{ Array.new(errors.size){ errors.pop }.inspect } + 2.times{ assert_equal((1..20).to_a, created.pop) } + ensure + Models.delete_all + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c4a23d8f1..81b734dce 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -109,6 +109,7 @@ def self.coerce(vrp) vrp[:rests]&.each{ |rest| rest[:duration] = ScheduleType.type_cast(rest[:duration] || 0) + rest[:lapse] = ScheduleType.type_cast(rest[:lapse]) if rest[:lapse] } vrp[:vehicles].each{ |vehicle| From 2714679449efc0daf95ff9639b19c53416db1526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Tue, 22 Sep 2026 15:54:16 +0200 Subject: [PATCH 02/12] Fix preload digest --- environment.rb | 5 +++++ test/digest_preload_test.rb | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 test/digest_preload_test.rb diff --git a/environment.rb b/environment.rb index 36852351d..8c54074b0 100644 --- a/environment.rb +++ b/environment.rb @@ -35,6 +35,11 @@ # gems from standard library require 'csv' require 'date' +# Preload Digest implementations: Digest::MD5/SHA* use const_missing and race under +# multi-threaded Puma ("Digest::Base cannot be directly inherited in Ruby"). +require 'digest/md5' +require 'digest/sha1' +require 'digest/sha2' require 'fileutils' require 'json' require 'logger' diff --git a/test/digest_preload_test.rb b/test/digest_preload_test.rb new file mode 100644 index 000000000..92a8c773b --- /dev/null +++ b/test/digest_preload_test.rb @@ -0,0 +1,32 @@ +# Copyright © Cartoway, 2026 +# +require './test/test_helper' + +class DigestPreloadTest < Minitest::Test + def test_digest_md5_is_preloaded_at_boot + assert Digest.const_defined?(:MD5, false), + 'Digest::MD5 must be required at boot to avoid Puma thread races' + end + + def test_concurrent_md5_hexdigest + errors = Queue.new + results = Queue.new + + threads = + Array.new(8){ + Thread.new{ + begin + 100.times{ |i| + results << Digest::MD5.hexdigest("payload-#{i}-#{Thread.current.object_id}") + } + rescue StandardError => e + errors << e + end + } + } + threads.each(&:join) + + assert_equal 0, errors.size, ->{ Array.new(errors.size){ errors.pop }.inspect } + assert_equal 800, results.size + end +end From 69b2b6856596503d874699d9ca353ed8e4668cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Tue, 22 Sep 2026 15:54:44 +0200 Subject: [PATCH 03/12] Reduce time consumption in Vrp create --- models/base.rb | 96 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 33 deletions(-) diff --git a/models/base.rb b/models/base.rb index 467812091..37ee8aef9 100644 --- a/models/base.rb +++ b/models/base.rb @@ -30,25 +30,16 @@ class Base < ActiveHash::Base include ActiveHash::Associations + # Only Array/Hash/Set defaults need per-instance isolation (shared mutable state). + MUTABLE_DEFAULT_TYPES = [Array, Hash, Set].freeze + def initialize(hash) - super(hash.each_with_object({}){ |(k, v), memo| - memo[k.to_sym] = convert(k, v) - }) - - # Make sure default values are not the same object for all - self.attributes.each{ |k, v| - # If the key doesn't exist in the hash and its relevant substructures then it must be a default value - next if hash.key?(k) || - !v.duplicable? || - ["#{k}_id", "#{ActiveSupport::Inflector.singularize(k.to_s)}_ids"].any?{ |key| - hash.key?(key.to_sym) - } || - hash[:configuration] && [:preprocessing, :restitution, :schedule, :resolution].any?{ |symbol| - hash[:configuration][symbol]&.key?(k[symbol.size + 1..-1]&.to_sym) - } - - self[k] = v.dup # dup to make sure they are different objects - } + provided = + hash.each_with_object({}){ |(k, v), memo| + memo[k.to_sym] = convert(k, v) + } + super(provided) + isolate_mutable_defaults!(provided) end class << self @@ -151,6 +142,21 @@ def types @types ||= {} end + # Precomputed once per class: mutable default key => companion keys that mean "already set". + # Avoids ActiveSupport::Inflector.singularize on every Models::Base#initialize (hot path). + def mutable_default_keys + @mutable_default_keys ||= + (default_attributes || {}).each_with_object({}){ |(k, v), memo| + next unless MUTABLE_DEFAULT_TYPES.any?{ |type| v.is_a?(type) } + + memo[k] = [:"#{k}_id", :"#{ActiveSupport::Inflector.singularize(k)}_ids"] + } + end + + def clear_mutable_default_keys_cache + @mutable_default_keys = nil + end + private :record_index, :reset_record_index, :add_to_record_index, :mark_dirty, :mark_clean, :thread_store end @@ -210,6 +216,7 @@ def self.field(name, options = {}) if options[:type] types[name] = options[:type] end + clear_mutable_default_keys_cache super(name, options) end @@ -222,6 +229,7 @@ def self.has_many(name, options = {}) # rubocop: disable Naming/PredicateName, S add_default_value(name, options[:default] || []) add_default_value(ids_function_name, options[:default] || []) + clear_mutable_default_keys_cache case options[:as_json] when :ids @@ -251,18 +259,19 @@ def self.has_many(name, options = {}) # rubocop: disable Naming/PredicateName, S self[name] ||= self.class.default_attributes[name] end + assoc_class = nil redefine_method("#{name}=") do |vals| - c = class_from_string(options[:class_name]) + assoc_class ||= class_from_string(options[:class_name]) self[name] = vals&.collect{ |val| - if c == Symbol + if assoc_class == Symbol val&.to_sym - elsif val.is_a?(c) + elsif val.is_a?(assoc_class) val elsif !val.empty? - c.create(val) + assoc_class.create(val) end }&.compact || [] - self[ids_function_name] = self[name]&.map(&:id) if c.module_parent == Models + self[ids_function_name] = self[name]&.map(&:id) if assoc_class.module_parent == Models self[name] end @@ -273,8 +282,8 @@ def self.has_many(name, options = {}) # rubocop: disable Naming/PredicateName, S end redefine_method("#{ids_function_name}=") do |vals| - c = class_from_string(options[:class_name]) - self[name] = vals && vals.split(',').flat_map{ |val_id| c.find(val_id) } + assoc_class ||= class_from_string(options[:class_name]) + self[name] = vals && vals.split(',').flat_map{ |val_id| assoc_class.find(val_id) } self[ids_function_name] = self[name]&.map(&:id) end end @@ -284,6 +293,7 @@ def self.belongs_to(name, options = {}) field_names << name id_function_name = "#{name}_id".to_sym + clear_mutable_default_keys_cache case options[:as_json] when :id @@ -309,10 +319,11 @@ def self.belongs_to(name, options = {}) self[name] end + assoc_class = nil redefine_method("#{name}=") do |val| - c = class_from_string(options[:class_name]) - self[name] = val&.is_a?(Hash) ? c.create(val) : val - self[id_function_name] = self[name]&.id if c.module_parent == Models + assoc_class ||= class_from_string(options[:class_name]) + self[name] = val&.is_a?(Hash) ? assoc_class.create(val) : val + self[id_function_name] = self[name]&.id if assoc_class.module_parent == Models self[name] end @@ -323,8 +334,8 @@ def self.belongs_to(name, options = {}) end redefine_method("#{id_function_name}=") do |val_id| - c = class_from_string(options[:class_name]) - self[name] = val_id && c.find(val_id) + assoc_class ||= class_from_string(options[:class_name]) + self[name] = val_id && assoc_class.find(val_id) self[id_function_name] = self[name]&.id end end @@ -332,8 +343,29 @@ def self.belongs_to(name, options = {}) private + def isolate_mutable_defaults!(provided) + configuration = provided[:configuration] + self.class.mutable_default_keys.each{ |k, companions| + next if provided.key?(k) + next if companions.any?{ |ck| provided.key?(ck) } + next if configuration && config_provides_key?(configuration, k) + + @attributes[k] = self.class.default_attributes[k].dup + } + end + + def config_provides_key?(configuration, key) + %i[preprocessing restitution schedule resolution].any?{ |section| + nested = key[section.size + 1..]&.to_sym + nested && configuration[section]&.key?(nested) + } + end + def convert(key, value) - convert_type(self.class.types[key].to_s, value) + type = self.class.types[key] + return value unless type + + convert_type(type.to_s, value) end def convert_type(type, value) @@ -342,8 +374,6 @@ def convert_type(type, value) end case type - when '' - value when 'Symbol' value&.to_sym when 'Date' From 4e808b8f61b11a87dc8f2a3dc12fce779a414cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 28 Sep 2026 09:51:36 +0200 Subject: [PATCH 04/12] Add Rosomaxa as a solver --- Dockerfile | 7 + README.md | 1 + config/access.rb | 1 + config/environments/development.rb | 16 +- config/environments/production.rb | 16 +- config/environments/test.rb | 12 + lib/interpreters/compute_several_solutions.rb | 2 +- models/types/solver_type.rb | 2 +- test/wrappers/rosomaxa_test.rb | 222 ++++++++ wrappers/rosomaxa.rb | 484 ++++++++++++++++++ 10 files changed, 759 insertions(+), 4 deletions(-) create mode 100644 test/wrappers/rosomaxa_test.rb create mode 100644 wrappers/rosomaxa.rb diff --git a/Dockerfile b/Dockerfile index 46d481bdc..9a459d0dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,12 @@ +# syntax=docker/dockerfile:1.4 +# Rosomaxa CLI is a musl binary. Pin matches the published GitHub/crates.io release. +ARG ROSOMAXA_VERSION=1.25.0 +FROM ghcr.io/reinterpretcat/vrp/vrp-cli:${ROSOMAXA_VERSION} AS rosomaxa-cli + FROM ghcr.io/cartoway/optimizer-ortools:master +COPY --from=rosomaxa-cli /solver/vrp-cli /usr/local/bin/vrp-cli + ARG BUNDLE_WITHOUT="test development" # Install Vroom ARG VROOM_RELEASE=v1.14.0 diff --git a/README.md b/README.md index 8c955195a..861e30f3f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Run an optimizer REST API depending of many constraints for a Vehicle Routing Pr This project use some solver engines: * [Vroom v1.14.0](https://github.com/VROOM-Project/vroom/releases/tag/v1.14.0) * [Optimizer-ortools v1.17.1](https://github.com/cartoroute/optimizer-ortools) & [OR-Tools v7.8](https://github.com/google/or-tools/releases/tag/v7.8) (use the version corresponding to your system operator, not source code). +* [vrp-cli 1.25.0](https://github.com/reinterpretcat/vrp/releases/tag/v1.25.0) (Rosomaxa, Apache-2.0, separate process). Service `:rosomaxa`, appended after the existing solvers. MVP covers matrix problems with single-activity services, a vehicle start, capacities and time windows. Relations, rests, reloads and shipments are skipped. ## API diff --git a/config/access.rb b/config/access.rb index 69c1937f2..4cbc59e4e 100644 --- a/config/access.rb +++ b/config/access.rb @@ -26,5 +26,6 @@ module OptimizerWrapper 'solvers' => { profile: :solvers }, 'vroom' => { profile: :vroom }, 'ortools' => { profile: :ortools }, + 'rosomaxa' => { profile: :rosomaxa }, } end diff --git a/config/environments/development.rb b/config/environments/development.rb index 8492d5ed1..eec020bf1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -19,6 +19,7 @@ require './wrappers/demo' require './wrappers/ortools' require './wrappers/pyvrp' +require './wrappers/rosomaxa' require './wrappers/vroom' require './lib/cache_manager' @@ -38,6 +39,10 @@ module OptimizerWrapper 'LD_LIBRARY_PATH=../or-tools/dependencies/install/lib/:../or-tools/lib/ ../optimizer-ortools/tsp_simple'.freeze ORTOOLS = Wrappers::Ortools.new(tmp_dir: TMP_DIR, exec_ortools: ORTOOLS_EXEC) PYVRP = Wrappers::PyVRP.new(tmp_dir: TMP_DIR) + ROSOMAXA = Wrappers::Rosomaxa.new( + tmp_dir: TMP_DIR, + exec_rosomaxa: ENV['ROSOMAXA_PATH'] || '/usr/local/bin/vrp-cli' + ) VROOM = Wrappers::Vroom.new(tmp_dir: TMP_DIR, threads: 1, exec_vroom: '/usr/local/bin/vroom') PARAMS_LIMIT = { points: 100000, vehicles: 1000 }.freeze @@ -61,13 +66,22 @@ module OptimizerWrapper demo: DEMO, ortools: ORTOOLS, pyvrp: PYVRP, + rosomaxa: ROSOMAXA, vroom: VROOM, }, profiles: { demo: { queue: 'DEFAULT', services: { - vrp: [:vroom, :ortools, :pyvrp] + vrp: [:vroom, :ortools, :pyvrp, :rosomaxa] + }, + params_limit: PARAMS_LIMIT, + quotas: QUOTAS, # Only taken into account if REDIS_COUNT + }, + rosomaxa: { + queue: 'DEFAULT', + services: { + vrp: [:rosomaxa] }, params_limit: PARAMS_LIMIT, quotas: QUOTAS, # Only taken into account if REDIS_COUNT diff --git a/config/environments/production.rb b/config/environments/production.rb index 4ff5e4e36..452f452f0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -19,6 +19,7 @@ require './wrappers/demo' require './wrappers/ortools' require './wrappers/pyvrp' +require './wrappers/rosomaxa' require './wrappers/vroom' require './lib/cache_manager' @@ -39,6 +40,10 @@ module OptimizerWrapper 'LD_LIBRARY_PATH=../or-tools/dependencies/install/lib/:../or-tools/lib/ ../optimizer-ortools/tsp_simple'.freeze ORTOOLS = Wrappers::Ortools.new(tmp_dir: TMP_DIR, exec_ortools: ORTOOLS_EXEC, threads: 4) PYVRP = Wrappers::PyVRP.new(tmp_dir: TMP_DIR) + ROSOMAXA = Wrappers::Rosomaxa.new( + tmp_dir: TMP_DIR, + exec_rosomaxa: ENV['ROSOMAXA_PATH'] || '/usr/local/bin/vrp-cli' + ) VROOM = Wrappers::Vroom.new(tmp_dir: TMP_DIR, threads: 1, exec_vroom: '/usr/local/bin/vroom') PARAMS_LIMIT = { points: 100000, vehicles: 1000 }.freeze @@ -62,13 +67,22 @@ module OptimizerWrapper demo: DEMO, ortools: ORTOOLS, pyvrp: PYVRP, + rosomaxa: ROSOMAXA, vroom: VROOM, }, profiles: { demo: { queue: 'DEFAULT', services: { - vrp: [:vroom, :ortools, :pyvrp] + vrp: [:vroom, :ortools, :pyvrp, :rosomaxa] + }, + params_limit: PARAMS_LIMIT, + quotas: QUOTAS, # Only taken into account if REDIS_COUNT + }, + rosomaxa: { + queue: 'DEFAULT', + services: { + vrp: [:rosomaxa] }, params_limit: PARAMS_LIMIT, quotas: QUOTAS, # Only taken into account if REDIS_COUNT diff --git a/config/environments/test.rb b/config/environments/test.rb index 65037fa48..3fc73a4bb 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -19,6 +19,7 @@ require './wrappers/demo' require './wrappers/ortools' require './wrappers/pyvrp' +require './wrappers/rosomaxa' require './wrappers/vroom' require './lib/cache_manager' @@ -42,6 +43,10 @@ module OptimizerWrapper ORTOOLS = Wrappers::Ortools.new(tmp_dir: TMP_DIR, exec_ortools: ORTOOLS_EXEC) VROOM = Wrappers::Vroom.new(tmp_dir: TMP_DIR, threads: 1, exec_vroom: ENV['VROOM_PATH'] || '/usr/local/bin/vroom') PYVRP = Wrappers::PyVRP.new(tmp_dir: TMP_DIR) + ROSOMAXA = Wrappers::Rosomaxa.new( + tmp_dir: TMP_DIR, + exec_rosomaxa: ENV['ROSOMAXA_PATH'] || '/usr/local/bin/vrp-cli' + ) PARAMS_LIMIT = { points: 150, vehicles: 10 }.freeze QUOTAS = [{ daily: 100000, monthly: 1000000, yearly: 10000000 }].freeze # Only taken into account if REDIS_COUNT @@ -65,6 +70,7 @@ module OptimizerWrapper ortools: ORTOOLS, vroom: VROOM, pyvrp: PYVRP, + rosomaxa: ROSOMAXA, }, profiles: { demo: { @@ -98,6 +104,12 @@ module OptimizerWrapper vrp: [:pyvrp] }, }, + rosomaxa: { + queue: 'DEFAULT', + services: { + vrp: [:rosomaxa] + }, + }, vroom: { queue: 'DEFAULT', services: { diff --git a/lib/interpreters/compute_several_solutions.rb b/lib/interpreters/compute_several_solutions.rb index e3e40eb90..3deaebdb6 100644 --- a/lib/interpreters/compute_several_solutions.rb +++ b/lib/interpreters/compute_several_solutions.rb @@ -213,7 +213,7 @@ def self.custom_heuristics(service, vrp, block = nil, service_vrp: nil) preprocessing_fss = vrp.configuration.preprocessing.first_solution_strategy - return service_vrp if service == :vroom || service == :pyvrp || + return service_vrp if service == :vroom || service == :pyvrp || service == :rosomaxa || preprocessing_fss.empty? || preprocessing_fss.include?('periodic') || (preprocessing_fss.size == 1 && !self_selection?(preprocessing_fss)) diff --git a/models/types/solver_type.rb b/models/types/solver_type.rb index 74e27a2c7..a47159a21 100644 --- a/models/types/solver_type.rb +++ b/models/types/solver_type.rb @@ -1,5 +1,5 @@ class SolverType - ALL_SOLVERS = %i[demo ortools pyvrp vroom].freeze + ALL_SOLVERS = %i[demo ortools pyvrp rosomaxa vroom].freeze def self.type_cast(value) value = value.split(',') if value.is_a?(String) diff --git a/test/wrappers/rosomaxa_test.rb b/test/wrappers/rosomaxa_test.rb new file mode 100644 index 000000000..cc80c4f45 --- /dev/null +++ b/test/wrappers/rosomaxa_test.rb @@ -0,0 +1,222 @@ +require './test/test_helper' + +class Wrappers::RosomaxaTest < Minitest::Test + def setup + @rosomaxa = OptimizerWrapper.config[:services][:rosomaxa] + @minimal_problem = { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 1], + [1, 0] + ], + distance: [ + [0, 1], + [1, 0] + ] + }], + points: [{ + id: 'point_0', + matrix_index: 0 + }, { + id: 'point_1', + matrix_index: 1 + }], + vehicles: [{ + id: 'vehicle_0', + start_point_id: 'point_0', + matrix_id: 'matrix_0' + }], + services: [{ + id: 'service_0', + activity: { + point_id: 'point_0' + } + }, { + id: 'service_1', + activity: { + point_id: 'point_1' + } + }], + configuration: { + resolution: { + duration: 1000 + } + } + } + @problem = { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 655, 1948, 5231, 2971], + [603, 0, 1692, 4977, 2715], + [1861, 1636, 0, 6143, 1532], + [5184, 4951, 6221, 0, 7244], + [2982, 2758, 1652, 7264, 0], + ], + distance: [ + [0, 655, 1948, 5231, 2971], + [603, 0, 1692, 4977, 2715], + [1861, 1636, 0, 6143, 1532], + [5184, 4951, 6221, 0, 7244], + [2982, 2758, 1652, 7264, 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 + }, { + id: 'point_4', + matrix_index: 4 + }], + vehicles: [{ + id: 'vehicle_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + matrix_id: 'matrix_0' + }], + services: [{ + id: 'service_1', + activity: { + point_id: 'point_1' + } + }, { + id: 'service_2', + activity: { + point_id: 'point_2' + } + }, { + id: 'service_3', + activity: { + point_id: 'point_3' + } + }, { + id: 'service_4', + activity: { + point_id: 'point_4' + } + }], + configuration: { + resolution: { + duration: 1000 + } + } + } + end + + def test_minimal_problem + vrp = TestHelper.create(@minimal_problem) + solution = @rosomaxa.solve(vrp) + + assert solution + assert_includes solution.solvers, :rosomaxa + assert_equal 1, solution.routes.size + assert_equal @minimal_problem[:services].size + 1, solution.routes.first.stops.size + assert_equal @minimal_problem[:services].collect{ |service| service[:id] }.sort, + solution.routes.first.stops.filter_map(&:service_id).sort + end + + def test_loop_problem + vrp = TestHelper.create(@problem) + solution = @rosomaxa.solve(vrp) + + assert solution + assert_includes solution.solvers, :rosomaxa + assert_equal 1, solution.routes.size + assert_equal @problem[:services].size + 2, solution.routes.first.stops.size + assert_equal @problem[:services].collect{ |service| service[:id] }.sort, + solution.routes.first.stops.filter_map(&:service_id).sort + end + + def test_capacity_leaves_one_service_unassigned + problem = { + matrices: [{ + id: 'matrix_0', + time: [ + [0, 10, 10], + [10, 0, 10], + [10, 10, 0] + ], + distance: [ + [0, 10, 10], + [10, 0, 10], + [10, 10, 0] + ] + }], + points: [{ + id: 'point_0', + matrix_index: 0 + }, { + id: 'point_1', + matrix_index: 1 + }, { + id: 'point_2', + matrix_index: 2 + }], + units: [{ id: 'kg' }], + vehicles: [{ + id: 'vehicle_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + matrix_id: 'matrix_0', + capacities: [{ unit_id: 'kg', limit: 1 }] + }], + services: [{ + id: 'service_1', + quantities: [{ unit_id: 'kg', delivery: 1 }], + activity: { point_id: 'point_1' } + }, { + id: 'service_2', + quantities: [{ unit_id: 'kg', delivery: 1 }], + activity: { point_id: 'point_2' } + }], + configuration: { + resolution: { + duration: 1000 + } + } + } + vrp = TestHelper.create(problem) + solution = @rosomaxa.solve(vrp) + + assert solution + assert_includes solution.solvers, :rosomaxa + assigned = solution.routes.flat_map{ |route| route.stops.filter_map(&:service_id) } + assert_equal 1, assigned.size + assert_equal 1, solution.unassigned_stops.count(&:service_id) + end + + def test_relation_is_inapplicable + vrp = TestHelper.create( + @minimal_problem.merge( + relations: [{ + type: :order, + linked_ids: ['service_0', 'service_1'] + }] + ) + ) + + assert_includes @rosomaxa.inapplicable_solve?(vrp), :assert_no_relations + end + + def test_vehicle_without_start_is_inapplicable + problem = @minimal_problem.dup + problem[:vehicles] = [{ + id: 'vehicle_0', + end_point_id: 'point_0', + matrix_id: 'matrix_0' + }] + vrp = TestHelper.create(problem) + + assert_includes @rosomaxa.inapplicable_solve?(vrp), :assert_vehicles_have_start + end +end diff --git a/wrappers/rosomaxa.rb b/wrappers/rosomaxa.rb new file mode 100644 index 000000000..64fabd533 --- /dev/null +++ b/wrappers/rosomaxa.rb @@ -0,0 +1,484 @@ +require './wrappers/wrapper' + +module Wrappers + # reinterpretcat/vrp CLI (pragmatic JSON). Times are RFC3339 offsets from 1970-01-01. + # API resolution.duration is milliseconds; --max-time is seconds. + class Rosomaxa < Wrapper + QUANTITY_SCALE = 1000 + MAX_I32 = (2**31) - 2 + EPOCH = Time.utc(1970, 1, 1) + + def initialize(hash = {}) + super(hash) + @exec_rosomaxa = hash[:exec_rosomaxa] || ENV['ROSOMAXA_PATH'] || '/usr/local/bin/vrp-cli' + end + + def solver_constraints + super + [ + :assert_vehicles_objective, + + :assert_correctness_matrices_vehicles_and_points_definition, + :assert_matrix_indices, + :assert_no_evaluation, + :assert_no_partitions, + :assert_no_relations, + :assert_no_subtours, + :assert_points_same_definition, + + :assert_no_ride_constraint, + :assert_no_service_duration_modifiers, + :assert_vehicles_no_alternative_skills, + :assert_vehicles_no_force_start, + :assert_vehicles_no_initial_load, + :assert_vehicles_no_late_multiplier, + :assert_vehicles_no_overload_multiplier, + :assert_vehicles_have_start, + :assert_no_overall_duration, + :assert_no_value_matrix, + :assert_no_rest, + :assert_vehicles_no_reload_depots, + + :assert_no_activity_with_position, + :assert_no_empty_or_fill, + :assert_services_no_late_multiplier, + :assert_services_no_setup_duration, + :assert_no_complex_setup_durations, + :assert_only_one_visit, + :assert_no_mixed_service_quantity, + :assert_no_exclusion_cost, + + :assert_no_first_solution_strategy, + :assert_no_free_approach_or_return, + :assert_no_planning_heuristic, + :assert_resolution_duration, + :assert_solver, + ] + end + + def solve(vrp, _job = nil, _thread_proc = nil) + if vrp.vehicles.empty? || vrp.points.empty? || vrp.services.empty? + return vrp.empty_solution(:rosomaxa) + end + + tic = Time.now + problem, matrices = pragmatic_problem(vrp) + timeout = [1, vrp.configuration.resolution.duration.to_f / 1000].max.to_i + result = run_rosomaxa(problem, matrices, timeout) + elapsed = (Time.now - tic) * 1000 + + build_solution(vrp, result, elapsed) + end + + private + + def assert_matrix_indices(vrp) + vrp.points.all?(&:matrix_index) && + vrp.vehicles.all?(&:matrix_id) && + vrp.matrices.any?{ |matrix| matrix_table(matrix, :time) || matrix_table(matrix, :distance) } + end + + def assert_vehicles_have_start(vrp) + vrp.vehicles.all?(&:start_point) + end + + def assert_no_mixed_service_quantity(vrp) + vrp.services.none?{ |service| + pickup, delivery = split_quantities(service) + pickup.values.any?(&:positive?) && delivery.values.any?(&:positive?) + } + end + + def pragmatic_problem(vrp) + prepare_locations!(vrp) + @unit_ids = active_unit_ids(vrp) + @demand_totals = demand_totals(vrp, @unit_ids) + @horizon = time_horizon(vrp) + matrices = build_matrices(vrp) + problem = { + plan: { + jobs: vrp.services.map{ |service| build_job(service) } + }, + fleet: { + vehicles: vrp.vehicles.map{ |vehicle| build_vehicle(vehicle) }, + profiles: matrices.map{ |matrix| { name: matrix[:profile] } } + } + } + [problem, matrices] + end + + def prepare_locations!(vrp) + @matrix_indices = vrp.points.map(&:matrix_index).uniq.sort + @location_by_matrix_index = @matrix_indices.each_with_index.to_h + end + + def location_index(point) + @location_by_matrix_index[point.matrix_index] + end + + def active_unit_ids(vrp) + ids = vrp.units.map(&:id) + return [] if ids.empty? + + used = + vrp.services.any?{ |service| + pickup, delivery = split_quantities(service) + (pickup.values + delivery.values).any?(&:positive?) + } + used ? ids : [] + end + + def demand_totals(vrp, unit_ids) + totals = Hash.new(0) + vrp.services.each{ |service| + pickup, delivery = split_quantities(service) + unit_ids.each{ |unit_id| totals[unit_id] += pickup[unit_id] + delivery[unit_id] } + } + totals + end + + def build_matrices(vrp) + vrp.vehicles.map(&:matrix_id).uniq.map{ |matrix_id| + matrix = vrp.matrices.find{ |item| item.id == matrix_id } + unless matrix + raise OptimizerWrapper::UnsupportedProblemError.new("Rosomaxa - missing matrix #{matrix_id}") + end + + time = matrix_table(matrix, :time) + distance = matrix_table(matrix, :distance) + time ||= distance + distance ||= time + unless time + raise OptimizerWrapper::UnsupportedProblemError.new("Rosomaxa - matrix #{matrix_id} has no time or distance") + end + + { + profile: matrix_id.to_s, + travelTimes: flat_cells(time, @matrix_indices), + distances: flat_cells(distance, @matrix_indices) + } + } + end + + def matrix_table(matrix, dimension) + table = matrix.send(dimension) + table if table.is_a?(Array) && table.any? + end + + def flat_cells(table, indices) + indices.flat_map{ |from| + row = table[from] || [] + indices.map{ |to| row[to].to_i } + } + end + + def build_vehicle(vehicle) + payload = { + typeId: vehicle.id.to_s, + vehicleIds: [vehicle.id.to_s], + profile: { matrix: vehicle.matrix_id.to_s }, + costs: { + fixed: vehicle.cost_fixed.to_f, + distance: vehicle.cost_distance_multiplier.to_f, + time: vehicle.cost_time_multiplier.to_f + }, + shifts: [shift_for(vehicle)], + capacity: capacity_vector(vehicle) + } + skill_names = Array(vehicle.skills&.first).map(&:to_s) + payload[:skills] = skill_names if skill_names.any? + limits = {} + limits[:maxDistance] = vehicle.distance.to_f if vehicle.distance + limits[:maxDuration] = vehicle.duration.to_f if vehicle.duration + payload[:limits] = limits if limits.any? + payload + end + + def shift_for(vehicle) + start_seconds = vehicle.timewindow&.start || 0 + shift = { + start: { + earliest: rfc3339(start_seconds), + location: { index: location_index(vehicle.start_point) } + } + } + return shift unless vehicle.end_point + + latest = vehicle.timewindow&.end + if vehicle.duration + duration_latest = start_seconds + vehicle.duration + latest = latest ? [latest, duration_latest].min : duration_latest + end + latest ||= start_seconds + @horizon + shift[:end] = { + latest: rfc3339(latest), + location: { index: location_index(vehicle.end_point) } + } + shift + end + + def capacity_vector(vehicle) + return [0] if @unit_ids.empty? + + @unit_ids.map{ |unit_id| + capacity = vehicle.capacities.find{ |cap| cap.unit_id == unit_id } + if capacity&.limit + scale(capacity.limit) + else + [@demand_totals[unit_id].to_i, 1].max + end + } + end + + def build_job(service) + pickup, delivery = split_quantities(service) + place = { + location: { index: location_index(service.activity.point) }, + duration: service.activity.duration.to_f + } + times = time_windows(service) + place[:times] = times if times.any? + task = { places: [place] } + job = { id: service.id.to_s } + if @unit_ids.any? && delivery.values.any?(&:positive?) + task[:demand] = demand_vector(delivery) + job[:deliveries] = [task] + elsif @unit_ids.any? && pickup.values.any?(&:positive?) + task[:demand] = demand_vector(pickup) + job[:pickups] = [task] + else + job[:services] = [task] + end + skill_names = service.skills.map(&:to_s) + job[:skills] = { allOf: skill_names } if skill_names.any? + job + end + + def demand_vector(by_unit) + @unit_ids.map{ |unit_id| by_unit[unit_id].to_i } + end + + def time_windows(service) + return [] unless service.activity + + service.activity.timewindows.filter_map{ |tw| + next if tw.start.nil? && tw.end.nil? + + [rfc3339(tw.start || 0), rfc3339(tw.end || @horizon)] + } + end + + def split_quantities(service) + pickup = Hash.new(0) + delivery = Hash.new(0) + service.quantities.each{ |quantity| + unit_id = quantity.unit_id + delivery[unit_id] = scale(quantity.delivery) if quantity.delivery + pickup[unit_id] = scale(quantity.pickup) if quantity.pickup + next if quantity.value.to_f.zero? + + if quantity.value < 0 + delivery[unit_id] = scale(quantity.value.abs) + else + pickup[unit_id] = scale(quantity.value) + end + } + [pickup, delivery] + end + + def scale(value) + (value.to_f * QUANTITY_SCALE).round.clamp(0, MAX_I32) + end + + def time_horizon(vrp) + ends = [] + vrp.vehicles.each{ |vehicle| + ends << vehicle.timewindow.end if vehicle.timewindow&.end + ends << (vehicle.timewindow&.start || 0) + vehicle.duration if vehicle.duration + } + vrp.services.each{ |service| + service.activity&.timewindows&.each{ |tw| ends << tw.end if tw.end } + } + [ends.max || 86_400, 1].max + end + + def rfc3339(seconds) + (EPOCH + seconds.to_i).iso8601 + end + + def parse_clock(value) + return nil if value.nil? || value == '' + + Time.iso8601(value).to_i + end + + def run_rosomaxa(problem, matrices, timeout) + input = nil + output = nil + matrix_files = [] + input = Tempfile.new('optimize-rosomaxa-problem', @tmp_dir) + input.write(problem.to_json) + input.close + + matrices.each{ |matrix| + file = Tempfile.new('optimize-rosomaxa-matrix', @tmp_dir) + file.write(matrix.to_json) + file.close + matrix_files << file + } + + output = Tempfile.new('optimize-rosomaxa-output', @tmp_dir) + output.close + + matrix_args = matrix_files.map{ |file| "-m '#{file.path}'" }.join(' ') + cmd = "#{@exec_rosomaxa} solve pragmatic '#{input.path}' #{matrix_args} --max-time=#{timeout} -o '#{output.path}'" + log cmd + _stdout, stderr, status = Open3.capture3(cmd) + unless status.success? + raise OptimizerWrapper::UnsupportedProblemError.new("Rosomaxa - #{stderr}") + end + + JSON.parse(File.read(output.path), symbolize_names: true) + ensure + input&.unlink + output&.unlink + matrix_files&.each(&:unlink) + end + + def build_solution(vrp, result, elapsed) + services_by_id = vrp.services.index_by{ |service| service.id.to_s } + vehicles_by_id = vrp.vehicles.index_by{ |vehicle| vehicle.id.to_s } + assigned = {} + + routes = + Array(result[:tours]).filter_map{ |tour| + vehicle = vehicles_by_id[tour[:vehicleId]] + next unless vehicle + + @previous = nil + stops = [] + Array(tour[:stops]).each{ |raw_stop| + arrival = parse_clock(raw_stop.dig(:time, :arrival)) + departure = parse_clock(raw_stop.dig(:time, :departure)) + Array(raw_stop[:activities]).each_with_index{ |activity, index| + case activity[:type].to_s + when 'departure' + depot = read_depot(vrp, vehicle, vehicle.start_point, arrival, departure) + stops << depot if depot + when 'arrival' + depot = read_depot(vrp, vehicle, vehicle.end_point, arrival, departure) + stops << depot if depot + when 'delivery', 'pickup', 'service', 'replacement' + service = services_by_id[activity[:jobId]] + next unless service + next if assigned[service.id] + + assigned[service.id] = true + stops << read_visit(vrp, vehicle, service, activity, arrival, departure, index.zero?) + end + } + } + next if stops.empty? + + Models::Solution::Route.new( + stops: stops, + vehicle: vehicle, + info: Models::Solution::Route::Info.new( + start_time: stops.first&.info&.begin_time, + end_time: stops.last&.info&.end_time || stops.last&.info&.begin_time + ) + ) + } + + unassigned = [] + Array(result[:unassigned]).each{ |job| + service = services_by_id[job[:jobId]] + next unless service + next if assigned[service.id] + + assigned[service.id] = true + unassigned << read_unassigned(service) + } + vrp.services.each{ |service| + next if assigned[service.id] + + unassigned << read_unassigned(service) + } + + log "Solution cost: #{result.dig(:statistic, :cost)} & unassigned: #{unassigned.size}", level: :info + + solution = + Models::Solution.new( + elapsed: elapsed, + solvers: [:rosomaxa], + routes: routes, + unassigned_stops: unassigned + ) + solution.parse(vrp, preserve_solver_waiting_times: true) + end + + def read_depot(vrp, vehicle, point, arrival, departure) + return nil unless point + + route_data = compute_route_data(vrp, vehicle, point) + @previous = point + Models::Solution::StopDepot.new( + point, + info: Models::Solution::Stop::Info.new( + route_data.merge( + begin_time: arrival, + end_time: departure, + departure_time: departure, + waiting_time: 0 + ) + ) + ) + end + + def read_visit(vrp, vehicle, service, activity, arrival, departure, first_activity) + point = service.activity.point + route_data = + if first_activity + compute_route_data(vrp, vehicle, point) + else + { travel_time: 0, travel_distance: 0, travel_value: 0 } + end + begin_time = activity.dig(:time, :start) ? parse_clock(activity[:time][:start]) : arrival + end_time = activity.dig(:time, :end) ? parse_clock(activity[:time][:end]) : departure + waiting = first_activity && begin_time && arrival ? [begin_time - arrival, 0].max : 0 + @previous = point + Models::Solution::Stop.new( + service, + info: Models::Solution::Stop::Info.new( + route_data.merge( + begin_time: begin_time, + end_time: end_time, + departure_time: end_time, + waiting_time: waiting + ) + ) + ) + end + + def read_unassigned(service) + Models::Solution::Stop.new( + service, + info: Models::Solution::Stop::Info.new( + travel_time: 0, + travel_distance: 0, + travel_value: 0 + ) + ) + end + + def compute_route_data(vrp, vehicle, point) + return { travel_time: 0, travel_distance: 0, travel_value: 0 } unless @previous && point&.matrix_index + + matrix = vrp.matrices.find{ |item| item.id == vehicle.matrix_id } if vehicle + { + travel_time: matrix&.time ? matrix.time[@previous.matrix_index][point.matrix_index] : 0, + travel_distance: matrix&.distance ? matrix.distance[@previous.matrix_index][point.matrix_index] : 0, + travel_value: matrix&.value ? matrix.value[@previous.matrix_index][point.matrix_index] : 0 + } + end + end +end From 5464bf6988e66c26fd6a60c03e705e571eec5c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 28 Sep 2026 10:13:21 +0200 Subject: [PATCH 05/12] Extend Rosomaxa with shipments, reloads, setup_duration and lunch breaks. --- README.md | 2 +- test/wrappers/rosomaxa_test.rb | 163 +++++++++++++++++++++- wrappers/rosomaxa.rb | 241 +++++++++++++++++++++++++++++---- 3 files changed, 379 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 861e30f3f..3dc3cbaee 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Run an optimizer REST API depending of many constraints for a Vehicle Routing Pr This project use some solver engines: * [Vroom v1.14.0](https://github.com/VROOM-Project/vroom/releases/tag/v1.14.0) * [Optimizer-ortools v1.17.1](https://github.com/cartoroute/optimizer-ortools) & [OR-Tools v7.8](https://github.com/google/or-tools/releases/tag/v7.8) (use the version corresponding to your system operator, not source code). -* [vrp-cli 1.25.0](https://github.com/reinterpretcat/vrp/releases/tag/v1.25.0) (Rosomaxa, Apache-2.0, separate process). Service `:rosomaxa`, appended after the existing solvers. MVP covers matrix problems with single-activity services, a vehicle start, capacities and time windows. Relations, rests, reloads and shipments are skipped. +* [vrp-cli 1.25.0](https://github.com/reinterpretcat/vrp/releases/tag/v1.25.0) (Rosomaxa, Apache-2.0, separate process). Service `:rosomaxa`, appended after the existing solvers. Covers matrix problems with single-activity services, a vehicle start, an optional end (open route), capacities, time windows, simple shipments, reload depots and lunch breaks. A missing start depot, regulatory rests, and relations other than simple shipments are skipped. `maximum_reloads` is not enforced. ## API diff --git a/test/wrappers/rosomaxa_test.rb b/test/wrappers/rosomaxa_test.rb index cc80c4f45..42e72b301 100644 --- a/test/wrappers/rosomaxa_test.rb +++ b/test/wrappers/rosomaxa_test.rb @@ -205,7 +205,168 @@ def test_relation_is_inapplicable ) ) - assert_includes @rosomaxa.inapplicable_solve?(vrp), :assert_no_relations + assert_includes @rosomaxa.inapplicable_solve?(vrp), :assert_no_relations_except_simple_shipments + end + + def test_shipment_pickup_before_delivery + problem = { + matrices: [{ + id: 'matrix_0', + time: [[0, 10, 10], [10, 0, 10], [10, 10, 0]], + distance: [[0, 10, 10], [10, 0, 10], [10, 10, 0]] + }], + points: [ + { id: 'point_0', matrix_index: 0 }, + { id: 'point_1', matrix_index: 1 }, + { id: 'point_2', matrix_index: 2 } + ], + units: [{ id: 'kg' }], + vehicles: [{ + id: 'vehicle_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + matrix_id: 'matrix_0', + capacities: [{ unit_id: 'kg', limit: 5 }] + }], + services: [{ + id: 'pickup_1', + quantities: [{ unit_id: 'kg', value: 1 }], + activity: { point_id: 'point_1' } + }, { + id: 'delivery_1', + quantities: [{ unit_id: 'kg', value: -1 }], + activity: { point_id: 'point_2' } + }], + relations: [{ + id: 'ship_1', + type: :shipment, + linked_ids: ['pickup_1', 'delivery_1'] + }], + configuration: { resolution: { duration: 1000 } } + } + vrp = TestHelper.create(problem) + assert_empty @rosomaxa.inapplicable_solve?(vrp) + + solution = @rosomaxa.solve(vrp) + ids = solution.routes.first.stops.filter_map(&:service_id) + assert_equal %w[pickup_1 delivery_1], ids + end + + def test_reload_serves_both_deliveries + problem = { + matrices: [{ + id: 'matrix_0', + time: [[0, 10, 10], [10, 0, 10], [10, 10, 0]], + distance: [[0, 10, 10], [10, 0, 10], [10, 10, 0]] + }], + points: [ + { id: 'point_0', matrix_index: 0 }, + { id: 'point_1', matrix_index: 1 }, + { id: 'point_2', matrix_index: 2 } + ], + units: [{ id: 'kg' }], + reload_depots: [{ id: 'reload_1', point_id: 'point_0', duration: 1 }], + vehicles: [{ + id: 'vehicle_0', + start_point_id: 'point_0', + end_point_id: 'point_0', + matrix_id: 'matrix_0', + capacities: [{ unit_id: 'kg', limit: 1 }], + reload_depot_ids: ['reload_1'], + maximum_reloads: 2 + }], + services: [{ + id: 'service_1', + quantities: [{ unit_id: 'kg', delivery: 1 }], + activity: { point_id: 'point_1' } + }, { + id: 'service_2', + quantities: [{ unit_id: 'kg', delivery: 1 }], + activity: { point_id: 'point_2' } + }], + configuration: { resolution: { duration: 1000 } } + } + vrp = TestHelper.create(problem) + solution = @rosomaxa.solve(vrp) + + assigned = solution.routes.flat_map{ |route| route.stops.filter_map(&:service_id) } + assert_equal %w[service_1 service_2], assigned.sort + assert(solution.routes.first.stops.any?{ |stop| stop.type == :reload_depot }) + end + + def test_lunch_break_is_scheduled + problem = @minimal_problem.dup + problem[:rests] = [{ + id: 'lunch', + duration: 5, + timewindows: [{ start: 0, end: 3600 }] + }] + problem[:vehicles] = [{ + id: 'vehicle_0', + start_point_id: 'point_0', + matrix_id: 'matrix_0', + rest_ids: ['lunch'], + timewindow: { start: 0, end: 3600 } + }] + vrp = TestHelper.create(problem) + assert_empty @rosomaxa.inapplicable_solve?(vrp) + + solution = @rosomaxa.solve(vrp) + assert(solution.routes.first.stops.any?{ |stop| stop.type == :rest && stop.rest_id == 'lunch' }) + end + + def test_regulatory_rest_is_inapplicable + problem = @minimal_problem.dup + problem[:rests] = [{ id: 'reg_rest', duration: 10, lapse: 100 }] + problem[:vehicles] = [{ + id: 'vehicle_0', + start_point_id: 'point_0', + matrix_id: 'matrix_0', + rest_ids: ['reg_rest'] + }] + vrp = TestHelper.create(problem) + + assert_includes @rosomaxa.inapplicable_solve?(vrp), :assert_only_simple_rests + end + + def test_setup_duration_is_added_to_incoming_travel + problem = @minimal_problem.dup + problem[:services] = [{ + id: 'service_0', + activity: { point_id: 'point_0' } + }, { + id: 'service_1', + activity: { point_id: 'point_1', setup_duration: 7 } + }] + problem[:vehicles] = [{ + id: 'vehicle_0', + start_point_id: 'point_0', + matrix_id: 'matrix_0', + coef_setup: 2 + }] + vrp = TestHelper.create(problem) + assert_empty @rosomaxa.inapplicable_solve?(vrp) + + _problem, matrices = @rosomaxa.send(:pragmatic_problem, vrp) + assert_equal [0, 15, 1, 0], matrices.first[:travelTimes] + + solution = @rosomaxa.solve(vrp) + visit = solution.routes.first.stops.find{ |stop| stop.service_id == 'service_1' } + assert visit.info.begin_time >= 15 + end + + def test_different_setup_durations_on_one_point_are_inapplicable + problem = @minimal_problem.dup + problem[:services] = [{ + id: 'service_a', + activity: { point_id: 'point_1', setup_duration: 5 } + }, { + id: 'service_b', + activity: { point_id: 'point_1', setup_duration: 9 } + }] + vrp = TestHelper.create(problem) + + assert_includes @rosomaxa.inapplicable_solve?(vrp), :assert_no_complex_setup_durations end def test_vehicle_without_start_is_inapplicable diff --git a/wrappers/rosomaxa.rb b/wrappers/rosomaxa.rb index 64fabd533..7d239dc5a 100644 --- a/wrappers/rosomaxa.rb +++ b/wrappers/rosomaxa.rb @@ -1,8 +1,6 @@ require './wrappers/wrapper' module Wrappers - # reinterpretcat/vrp CLI (pragmatic JSON). Times are RFC3339 offsets from 1970-01-01. - # API resolution.duration is milliseconds; --max-time is seconds. class Rosomaxa < Wrapper QUANTITY_SCALE = 1000 MAX_I32 = (2**31) - 2 @@ -21,7 +19,7 @@ def solver_constraints :assert_matrix_indices, :assert_no_evaluation, :assert_no_partitions, - :assert_no_relations, + :assert_no_relations_except_simple_shipments, :assert_no_subtours, :assert_points_same_definition, @@ -35,13 +33,12 @@ def solver_constraints :assert_vehicles_have_start, :assert_no_overall_duration, :assert_no_value_matrix, - :assert_no_rest, - :assert_vehicles_no_reload_depots, + :assert_only_simple_rests, + :assert_reload_depots_indexed, :assert_no_activity_with_position, :assert_no_empty_or_fill, :assert_services_no_late_multiplier, - :assert_services_no_setup_duration, :assert_no_complex_setup_durations, :assert_only_one_visit, :assert_no_mixed_service_quantity, @@ -81,6 +78,25 @@ def assert_vehicles_have_start(vrp) vrp.vehicles.all?(&:start_point) end + def assert_only_simple_rests(vrp) + vrp.vehicles.all?{ |vehicle| + vehicle.rests.all?{ |rest| + !Interpreters::RegulatoryRest.lapse_rest?(rest) && + rest.timewindows.size <= 1 && + !rest.late_multiplier.to_f.positive? && + rest.exclusion_cost.nil? + } + } + end + + def assert_reload_depots_indexed(vrp) + vrp.reload_depots.all?{ |depot| + depot.point&.matrix_index && + depot.exclusion_cost.nil? && + !depot.late_multiplier.to_f.positive? + } + end + def assert_no_mixed_service_quantity(vrp) vrp.services.none?{ |service| pickup, delivery = split_quantities(service) @@ -96,7 +112,7 @@ def pragmatic_problem(vrp) matrices = build_matrices(vrp) problem = { plan: { - jobs: vrp.services.map{ |service| build_job(service) } + jobs: build_jobs(vrp) }, fleet: { vehicles: vrp.vehicles.map{ |vehicle| build_vehicle(vehicle) }, @@ -107,8 +123,14 @@ def pragmatic_problem(vrp) end def prepare_locations!(vrp) - @matrix_indices = vrp.points.map(&:matrix_index).uniq.sort + indices = vrp.points.map(&:matrix_index) + indices += vrp.reload_depots.map{ |depot| depot.point&.matrix_index } + @matrix_indices = indices.compact.uniq.sort @location_by_matrix_index = @matrix_indices.each_with_index.to_h + @services_by_tag = {} + @services_by_job_id = {} + @rests_by_tag = {} + @reloads_by_tag = {} end def location_index(point) @@ -151,9 +173,11 @@ def build_matrices(vrp) raise OptimizerWrapper::UnsupportedProblemError.new("Rosomaxa - matrix #{matrix_id} has no time or distance") end + travel_times = flat_cells(time, @matrix_indices) + add_setup!(travel_times, setups_for(vrp, matrix_id)) { profile: matrix_id.to_s, - travelTimes: flat_cells(time, @matrix_indices), + travelTimes: travel_times, distances: flat_cells(distance, @matrix_indices) } } @@ -171,6 +195,33 @@ def flat_cells(table, indices) } end + def setups_for(vrp, matrix_id) + vehicle = vrp.vehicles.find{ |item| item.matrix_id == matrix_id } + setups = {} + vrp.services.each{ |service| + point = service.activity&.point + next unless point&.matrix_index + + setups[point.matrix_index] = service.activity.setup_duration_on(vehicle).to_i + } + setups + end + + def add_setup!(flat, setups) + count = @matrix_indices.size + @matrix_indices.each_with_index{ |to_index, to_loc| + extra = setups[to_index].to_i + next unless extra.positive? + + count.times{ |from_loc| + next if from_loc == to_loc + + flat[(from_loc * count) + to_loc] += extra + } + } + flat + end + def build_vehicle(vehicle) payload = { typeId: vehicle.id.to_s, @@ -201,6 +252,10 @@ def shift_for(vehicle) location: { index: location_index(vehicle.start_point) } } } + breaks = lunch_breaks(vehicle) + shift[:breaks] = breaks if breaks.any? + reloads = reload_places(vehicle) + shift[:reloads] = reloads if reloads.any? return shift unless vehicle.end_point latest = vehicle.timewindow&.end @@ -229,15 +284,84 @@ def capacity_vector(vehicle) } end + def reload_places(vehicle) + return [] if vehicle.maximum_reloads == 0 + + vehicle.reload_depots.filter_map{ |depot| + next unless depot.point + + @reloads_by_tag[depot.id.to_s] = depot + place = { + location: { index: location_index(depot.point) }, + duration: depot.duration.to_f, + tag: depot.id.to_s + } + times = windows_for(depot.timewindows) + place[:times] = times if times.any? + place + } + end + + def lunch_breaks(vehicle) + Interpreters::RegulatoryRest.solver_rests(vehicle).map{ |rest| + @rests_by_tag[rest.id.to_s] = rest + tw = rest.timewindows.first + earliest = tw&.start || vehicle.timewindow&.start || 0 + latest = tw&.end || vehicle.timewindow&.end || (earliest + @horizon) + { + time: [rfc3339(earliest), rfc3339(latest)], + places: [{ duration: rest.duration.to_f, tag: rest.id.to_s }] + } + } + end + + def build_jobs(vrp) + shipped = {} + jobs = [] + vrp.relations.each{ |relation| + next unless relation.type == :shipment && relation.linked_services.size == 2 + + pickup, delivery = relation.linked_services + shipped[pickup.id] = true + shipped[delivery.id] = true + jobs << shipment_job(relation, pickup, delivery) + } + vrp.services.each{ |service| + next if shipped[service.id] + + jobs << build_job(service) + } + jobs + end + + def shipment_job(relation, pickup, delivery) + job_id = relation.id.to_s + job_id = "shipment_#{pickup.id}_#{delivery.id}" if job_id.empty? + @services_by_job_id[job_id] = [pickup, delivery] + @services_by_tag[pickup.id.to_s] = pickup + @services_by_tag[delivery.id.to_s] = delivery + pickup_qty, = split_quantities(pickup) + _, delivery_qty = split_quantities(delivery) + job = { + id: job_id, + pickups: [{ + places: [place_for(pickup.activity, tag: pickup.id)], + demand: demand_vector(pickup_qty) + }], + deliveries: [{ + places: [place_for(delivery.activity, tag: delivery.id)], + demand: demand_vector(delivery_qty) + }] + } + skill_names = (pickup.skills + delivery.skills).map(&:to_s).uniq + job[:skills] = { allOf: skill_names } if skill_names.any? + job + end + def build_job(service) + @services_by_job_id[service.id.to_s] = [service] pickup, delivery = split_quantities(service) - place = { - location: { index: location_index(service.activity.point) }, - duration: service.activity.duration.to_f - } - times = time_windows(service) - place[:times] = times if times.any? - task = { places: [place] } + task = { places: [place_for(service.activity)] } job = { id: service.id.to_s } if @unit_ids.any? && delivery.values.any?(&:positive?) task[:demand] = demand_vector(delivery) @@ -257,10 +381,19 @@ def demand_vector(by_unit) @unit_ids.map{ |unit_id| by_unit[unit_id].to_i } end - def time_windows(service) - return [] unless service.activity + def place_for(activity, tag: nil) + place = { + location: { index: location_index(activity.point) }, + duration: activity.duration.to_f + } + times = windows_for(activity.timewindows) + place[:times] = times if times.any? + place[:tag] = tag.to_s if tag + place + end - service.activity.timewindows.filter_map{ |tw| + def windows_for(timewindows) + timewindows.filter_map{ |tw| next if tw.start.nil? && tw.end.nil? [rfc3339(tw.start || 0), rfc3339(tw.end || @horizon)] @@ -368,12 +501,29 @@ def build_solution(vrp, result, elapsed) depot = read_depot(vrp, vehicle, vehicle.end_point, arrival, departure) stops << depot if depot when 'delivery', 'pickup', 'service', 'replacement' - service = services_by_id[activity[:jobId]] + service = service_for_activity(activity, services_by_id) next unless service next if assigned[service.id] assigned[service.id] = true stops << read_visit(vrp, vehicle, service, activity, arrival, departure, index.zero?) + when 'reload' + depot = @reloads_by_tag[activity[:jobTag].to_s] + next unless depot + + stops << read_timed_stop( + vrp, vehicle, depot, depot.point, activity, + { arrival: arrival, departure: departure, travel: index.zero? } + ) + when 'break' + rest = @rests_by_tag[activity[:jobTag].to_s] + next unless rest + + point = index.zero? ? point_at(vrp, raw_stop[:location]) : nil + stops << read_timed_stop( + vrp, vehicle, rest, point, activity, + { arrival: arrival, departure: departure, travel: !point.nil? } + ) end } } @@ -391,12 +541,13 @@ def build_solution(vrp, result, elapsed) unassigned = [] Array(result[:unassigned]).each{ |job| - service = services_by_id[job[:jobId]] - next unless service - next if assigned[service.id] + services = @services_by_job_id[job[:jobId]] || Array(services_by_id[job[:jobId]]) + services.each{ |service| + next if assigned[service.id] - assigned[service.id] = true - unassigned << read_unassigned(service) + assigned[service.id] = true + unassigned << read_unassigned(service) + } } vrp.services.each{ |service| next if assigned[service.id] @@ -434,6 +585,46 @@ def read_depot(vrp, vehicle, point, arrival, departure) ) end + def service_for_activity(activity, services_by_id) + tagged = @services_by_tag[activity[:jobTag].to_s] if activity[:jobTag] + tagged || services_by_id[activity[:jobId]] + end + + def point_at(vrp, location) + index = location.is_a?(Hash) ? location[:index] : nil + return nil if index.nil? + + origin = @matrix_indices[index] + vrp.points.find{ |point| point.matrix_index == origin } + end + + def read_timed_stop(vrp, vehicle, mission, point, activity, timing) + arrival = timing[:arrival] + departure = timing[:departure] + count_travel = timing[:travel] + route_data = + if count_travel && point + compute_route_data(vrp, vehicle, point) + else + { travel_time: 0, travel_distance: 0, travel_value: 0 } + end + @previous = point if count_travel && point + begin_time = activity.dig(:time, :start) ? parse_clock(activity[:time][:start]) : arrival + end_time = activity.dig(:time, :end) ? parse_clock(activity[:time][:end]) : departure + waiting = count_travel && begin_time && arrival ? [begin_time - arrival, 0].max : 0 + Models::Solution::Stop.new( + mission, + info: Models::Solution::Stop::Info.new( + route_data.merge( + begin_time: begin_time, + end_time: end_time, + departure_time: end_time, + waiting_time: waiting + ) + ) + ) + end + def read_visit(vrp, vehicle, service, activity, arrival, departure, first_activity) point = service.activity.point route_data = From f83442808505827685201223b98d5c8dea0bbe06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 5 Oct 2026 09:41:27 +0200 Subject: [PATCH 06/12] Drop the HEALTHCHECK start interval the dockerfile 1.4 frontend rejects. --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9a459d0dd..ccf161a87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -101,7 +101,6 @@ ADD . /srv/app EXPOSE 80 HEALTHCHECK \ - --start-interval=1s \ --start-period=30s \ --interval=30s \ --timeout=20s \ From 419814fa9eb8338b4777ba58202ed7eb762cab93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 5 Oct 2026 10:04:39 +0200 Subject: [PATCH 07/12] Fix test --- test/real_cases_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/real_cases_test.rb b/test/real_cases_test.rb index 5f391f73d..03ff2f154 100644 --- a/test/real_cases_test.rb +++ b/test/real_cases_test.rb @@ -147,7 +147,7 @@ def test_ortools_one_route_with_rest_and_waiting_time assert_equal 1, solutions[0].routes.size # Check total travel time - assert_operator solutions[0].routes.sum{ |r| r[:total_travel_time] }, :<=, 5394, 'Too long travel time' + assert_operator solutions[0].routes.sum{ |r| r.info.total_travel_time }, :<=, 5394, 'Too long travel time' # Check stops assert_equal check_vrp_services_size + 2 + 1, solutions[0].routes[0].stops.size # Check elapsed time From a344e25f19e7bba516a4e6f3af23c14a621d7a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 5 Oct 2026 13:51:48 +0200 Subject: [PATCH 08/12] Add jobs comparison UI with retention and resolution metadata. --- api/v01/entities/vrp_result.rb | 2 + api/v01/vrp.rb | 6 +- core/services/job_service.rb | 55 ++- core/strategies/orchestration.rb | 5 + lib/cache_manager.rb | 5 +- lib/heuristics/dichotomous_approach.rb | 2 + lib/interpreters/multi_trip.rb | 61 +--- lib/interpreters/split_clustering.rb | 6 +- models/resolution_context.rb | 7 + models/solution/solution.rb | 15 + models/vrp.rb | 9 + optimizer_wrapper.rb | 1 + public/css/style.css | 179 +++++++++- public/index.html.erb | 21 +- public/js/compare.js | 405 +++++++++++++++++++++++ public/js/jobManager.js | 182 ++++++++-- public/js/translations.js | 74 ++++- test/api/v01/helpers/request_helper.rb | 9 +- test/api/v01/vrp_test.rb | 68 +++- test/models/interpreter_tracking_test.rb | 31 ++ test/public/compare_stats_test.js | 74 +++++ test/public/job_filters_test.js | 36 ++ 22 files changed, 1128 insertions(+), 125 deletions(-) create mode 100644 public/js/compare.js create mode 100644 test/models/interpreter_tracking_test.rb create mode 100644 test/public/compare_stats_test.js create mode 100644 test/public/job_filters_test.js diff --git a/api/v01/entities/vrp_result.rb b/api/v01/entities/vrp_result.rb index 709dc78c0..1f68350f7 100644 --- a/api/v01/entities/vrp_result.rb +++ b/api/v01/entities/vrp_result.rb @@ -114,8 +114,10 @@ class VrpResultSolutionSkippedSolver < Grape::Entity end class VrpResultSolution < Grape::Entity + expose :name, documentation: { type: String, desc: 'Name of the source VRP problem' } expose :heuristic_synthesis, documentation: { is_array: true, desc: 'When first_solution_strategies are provided, sum up of tryied heuristics and their performance.' } expose :solvers, documentation: { is_array: true, type: String, desc: 'Solvers used to perform the optimization' } + expose :interpreters, documentation: { is_array: true, type: String, desc: 'Interpreters used during the resolution (split, dichotomous, periodic, …)' } expose :skipped_services, using: VrpResultSolutionSkippedSolver, documentation: { is_array: true, desc: 'Solvers which were not able to solve the problem or sub problems' } expose :cost, documentation: { type: Float, desc: 'The actual cost of the solution considering all costs' } expose :cost_details, using: VRPResultDetailedCosts, documentation: { desc: 'The detail of the different costs which impact the solution' } diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index 60d279323..33143b17f 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -114,6 +114,7 @@ class Vrp < APIBase Sentry.set_user(api_key: params[:api_key]) # Filtered in sentry if user_context vrp_params = d_params[:points] ? d_params : d_params[:vrp] + vrp_params[:name] = Models::Vrp.normalize_name(vrp_params[:name]) if vrp_params[:name] APIBase.dump_vrp_dir.write([key_print, vrp_params[:name] || 'no_vrp_name', checksum].compact.join('_'), d_params.to_json) if OptimizerWrapper.config[:dump][:vrp] Sentry.set_extras(vrp_name: vrp_params[:name]) @@ -224,7 +225,8 @@ class Vrp < APIBase if job&.completed? # job can still be nil if we have the solution from the dump APIBase.dump_vrp_dir.write([id, params[:api_key], 'solution'].join('_'), Oj.dump(result_object)) if stored_result.nil? && OptimizerWrapper.config[:dump][:solution] - Core::Services::JobService.job_remove(params[:api_key], id) + # Keep completed jobs for a few hours so the UI list / compare stay usable after retrieval + Core::Services::JobService.job_remove(params[:api_key], id) if Core::Services::JobService.retention_expired?(job) end status 200 @@ -252,7 +254,7 @@ class Vrp < APIBase desc 'List vrp jobs', { nickname: 'get_job_list', success: VrpJobsList, - detail: 'List running or queued jobs.' + detail: 'List queued, working and recently completed jobs.' } get do status 200 diff --git a/core/services/job_service.rb b/core/services/job_service.rb index b872f0e16..90ee604a0 100644 --- a/core/services/job_service.rb +++ b/core/services/job_service.rb @@ -1,20 +1,30 @@ module Core module Services module JobService + # Keep completed jobs listable/retrievable for a few hours after resolution ends. + COMPLETED_RETENTION = (ENV['OPTIM_JOB_RETENTION_HOURS'] || 4).to_i.hours + def job_list(api_key) - (OptimizerWrapper::JobList.get(api_key) || []).collect{ |e| - if job = Resque::Plugins::Status::Hash.get(e) # rubocop: disable Lint/AssignmentInCondition - { - time: job.time, - uuid: job.uuid, - status: job.status, - avancement: job.message, - checksum: job.options && job.options['checksum'] - } - else + (OptimizerWrapper::JobList.get(api_key) || []).filter_map{ |e| + job = Resque::Plugins::Status::Hash.get(e) + if job.nil? OptimizerWrapper::Result.remove(api_key, e) + next + end + if retention_expired?(job) + job_remove(api_key, e) + next end - }.compact + + { + time: job.time, + uuid: job.uuid, + status: job.status, + avancement: job.message, + checksum: job.options && job.options['checksum'], + name: job.options && job.options['vrp_name'] + } + } end def job_kill(_api_key, id) @@ -29,7 +39,28 @@ def job_remove(api_key, id) Resque::Plugins::Status::Hash.remove(id) end end - module_function :job_list, :job_kill, :job_remove + + def retention_expired?(job) + return false unless job&.completed? + + finished_at = completed_at(job) + return false unless finished_at + + Time.now - finished_at >= COMPLETED_RETENTION + end + + def completed_at(job) + message = job.message.to_s + if (match = message.match(/\ACompleted at (.+)\z/)) + Time.parse(match[1]) + else + job.time + end + rescue ArgumentError, TypeError + job.time + end + + module_function :job_list, :job_kill, :job_remove, :retention_expired?, :completed_at end end end diff --git a/core/strategies/orchestration.rb b/core/strategies/orchestration.rb index b4c245fa3..1296873bf 100644 --- a/core/strategies/orchestration.rb +++ b/core/strategies/orchestration.rb @@ -115,6 +115,7 @@ def define_process(service_vrp, job = nil, &block) log "<-- define_process levels (dicho: #{dicho_level}, split: #{split_level}) "\ "elapsed: #{(Time.now - tic).round(2)} sec" solution.configuration.deprecated_headers = vrp.configuration.restitution.use_deprecated_csv_headers + solution.absorb_interpreters!(service_vrp) solution end @@ -137,6 +138,7 @@ def solve(service_vrp, job = nil, block = nil) unfeasible_services = {} if !vrp.subtours.empty? + service_vrp.mark_interpreter!(:multimodal) multi_modal = Interpreters::MultiModal.new(vrp, service) optim_solution = multi_modal.multimodal_routes elsif vrp.vehicles.empty? || vrp.services.empty? @@ -198,6 +200,7 @@ def solve(service_vrp, job = nil, block = nil) # vrp.periodic_heuristic check the first_solution_stategy which may change right after periodic heuristic periodic_heuristic_flag = vrp.periodic_heuristic? + service_vrp.mark_interpreter!(:periodic) if periodic_heuristic_flag # TODO: refactor with dedicated class if vrp.schedule? periodic = Interpreters::PeriodicVisits.new(vrp) @@ -214,6 +217,7 @@ def solve(service_vrp, job = nil, block = nil) 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? + service_vrp.mark_interpreter!(:clique_cluster) block&.call(nil, nil, nil, 'process clique clustering : threshold '\ "(#{vrp.configuration.preprocessing.cluster_threshold.to_f}) ", @@ -315,6 +319,7 @@ def solve(service_vrp, job = nil, block = nil) optim_solution = vrp.empty_solution(service, unfeasible_services.values) end + optim_solution&.absorb_interpreters!(service_vrp) log "<-- optim_wrap::solve elapsed: #{(Time.now - tic).round(2)}sec", level: :debug optim_solution ensure diff --git a/lib/cache_manager.rb b/lib/cache_manager.rb index 5256668e7..e4855943d 100644 --- a/lib/cache_manager.rb +++ b/lib/cache_manager.rb @@ -77,8 +77,9 @@ def cleanup(options = nil) private - # ActiveSupport 7.2+ refuses to parameterize ASCII-8BIT strings + # ActiveSupport 7.2+ refuses to parameterize ASCII-8BIT strings. + # Keep '_' so VRP names stay segmentable (cart_c352_lundipierre_…). def sanitize_filename(name) - name.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).parameterize(separator: '') + name.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).parameterize(separator: '_') end end diff --git a/lib/heuristics/dichotomous_approach.rb b/lib/heuristics/dichotomous_approach.rb index 5cf0a8626..9450e74ff 100644 --- a/lib/heuristics/dichotomous_approach.rb +++ b/lib/heuristics/dichotomous_approach.rb @@ -166,6 +166,7 @@ def self.feasible_vrp(solution, service_vrp) def self.dichotomous_heuristic(service_vrp, job = nil, &block) solution = nil if dichotomous_candidate?(service_vrp) + service_vrp.mark_interpreter!(:dichotomous) vrp = service_vrp.vrp level = service_vrp.dicho_level dicho_data = service_vrp.dicho_data @@ -197,6 +198,7 @@ def self.dichotomous_heuristic(service_vrp, job = nil, &block) else service_vrp.vrp.configuration.resolution.init_duration = nil end + solution&.absorb_interpreters!(service_vrp) solution end diff --git a/lib/interpreters/multi_trip.rb b/lib/interpreters/multi_trip.rb index c255f9012..68205e4cf 100644 --- a/lib/interpreters/multi_trip.rb +++ b/lib/interpreters/multi_trip.rb @@ -6,63 +6,16 @@ def self.presolve(service_vrp, job = nil, &block) new.presolve(service_vrp, job, &block) end - def presolvable?(service_vrp) - service_vrp[:service] == :pyvrp && service_vrp[:skipped_services].any?{ |skipped_service| - skipped_service[:service] == :vroom && - skipped_service[:reasons].all?{ |reason| reason == :assert_vehicles_no_reload_depots } - } + def presolvable?(_service_vrp) + # Disabled: PyVRP handles reloads natively. Re-enable VROOM seeding here later if needed. + false end - def resolvable?(solution) - solution.routes.any?(&:under_used?) - end - - def presolve(service_vrp, job = nil, &block) - if presolvable?(service_vrp) - service_vrp.vrp.configuration.resolution.solver = :vroom - service_vrp[:service] = :vroom - - solution = Core::Strategies::Orchestration.solve(service_vrp, job, &block) - - # extract routes below 80% of work_time of the vehicle to build a new service_vrp - if resolvable?(solution) - under_used_routes = solution.routes.select(&:under_used?) - log "Under used routes: #{under_used_routes.size}" - solution.routes -= under_used_routes - sub_service_ids = - under_used_routes.map{ |route| route.stops.map(&:service_id) }.flatten + - solution.unassigned_stops.map(&:service_id) - sub_services = - service_vrp.vrp.services.select{ |service| sub_service_ids.include?(service.id) } - solution.unassigned_stops = [] + def presolve(service_vrp, _job = nil, &_block) + return nil unless presolvable?(service_vrp) - vehicles = under_used_routes.map(&:vehicle) - reload_depots = vehicles.flat_map(&:reload_depots).uniq - points = - vehicles.map(&:start_point) + - vehicles.map(&:end_point) + - reload_depots.map(&:point) + - sub_services.map{ |service| service.activity.point } - sub_vrp_hash = { - points: points.uniq.map(&:as_json), - vehicles: vehicles.map(&:as_json), - services: sub_services.map(&:as_json), - # relations: [], # PyVRP does not hand relations - units: service_vrp.vrp.units.map(&:as_json), - reload_depots: reload_depots.map(&:as_json), - configuration: service_vrp.vrp.configuration.as_json - } - sub_vrp_hash[:vehicles].each{ |vehicle| vehicle.delete(:matrix_id) } - sub_vrp_hash[:points].each{ |point| point.delete(:matrix_index) } - sub_vrp_hash[:configuration][:resolution][:duration] -= solution.elapsed - new_service_vrp = - Models::ResolutionContext.new(service: :pyvrp, vrp: Models::Vrp.create(sub_vrp_hash, check: false)) - sub_solution = Core::Strategies::Orchestration.solve(new_service_vrp, job, &block) - solution.routes += sub_solution.routes - solution.unassigned_stops = sub_solution.unassigned_stops - end - solution - end + service_vrp.mark_interpreter!(:multi_trip) + nil end end end diff --git a/lib/interpreters/split_clustering.rb b/lib/interpreters/split_clustering.rb index b6be16dd3..195c522e5 100644 --- a/lib/interpreters/split_clustering.rb +++ b/lib/interpreters/split_clustering.rb @@ -31,6 +31,7 @@ def self.split_clusters(service_vrp, job = nil, &block) vrp = service_vrp.vrp if vrp.configuration.preprocessing.partitions&.any? + service_vrp.mark_interpreter!(:split) splited_service_vrps = generate_split_vrps(service_vrp, job, block) if OptimizerWrapper.config[:debug][:output_clusters] && splited_service_vrps.size > 1 OutputHelper::Clustering.generate_files(splited_service_vrps, @@ -71,9 +72,10 @@ def self.split_clusters(service_vrp, job = nil, &block) vrp.services = splited_service_vrps.flat_map{ |sv| sv.vrp.services } vrp.relations = splited_service_vrps.flat_map{ |sv| sv.vrp.relations } - split_solutions.reduce(&:+) # return + split_solutions.reduce(&:+)&.absorb_interpreters!(service_vrp) # return elsif split_solve_candidate?(service_vrp) - split_solve(service_vrp, &block) # return + service_vrp.mark_interpreter!(:split) + split_solve(service_vrp, &block)&.absorb_interpreters!(service_vrp) # return else service_vrp.dicho_level ||= 0 nil # return diff --git a/models/resolution_context.rb b/models/resolution_context.rb index b0a584da2..6557dd72f 100644 --- a/models/resolution_context.rb +++ b/models/resolution_context.rb @@ -18,9 +18,16 @@ class ResolutionContext < Base field :original_duration_ms, default: nil # Set once at dicho root after self_selection; propagated to sub-problems (empty string = use supplied routes) field :selected_first_solution_strategy, default: nil + # Interpreters marked as soon as they are eligible for this resolution context + field :interpreters, default: [] has_many :skipped_services, class_name: 'Models::SkippedService' belongs_to :vrp, class_name: 'Models::Vrp' + def mark_interpreter!(name) + self.interpreters |= [name.to_s] + self + end + # Migrate old hash format to ResolutionContext objects def self.migrate_from_hash(hash_data) if hash_data.is_a?(Hash) diff --git a/models/solution/solution.rb b/models/solution/solution.rb index 96032bd9f..4fd3ae9f3 100644 --- a/models/solution/solution.rb +++ b/models/solution/solution.rb @@ -25,6 +25,19 @@ class Solution < Base field :heuristic_synthesis, default: [] field :iterations field :solvers, default: [] + field :interpreters, default: [] + + def mark_interpreter!(name) + self.interpreters |= [name.to_s] + self + end + + def absorb_interpreters!(service_vrp) + return self unless service_vrp + + self.interpreters |= Array(service_vrp.interpreters) + self + end has_many :routes, class_name: 'Models::Solution::Route' has_many :skipped_services, class_name: 'Models::SkippedService' @@ -87,9 +100,11 @@ def count_used_routes def +(other) solution = Solution.new({}) + solution.name = self.name || other.name solution.elapsed = self.elapsed + other.elapsed solution.heuristic_synthesis = self.heuristic_synthesis + other.heuristic_synthesis solution.solvers = self.solvers + other.solvers + solution.interpreters = (self.interpreters + other.interpreters).uniq solution.routes = self.routes + other.routes solution.skipped_services = self.skipped_services + other.skipped_services solution.unassigned_stops = self.unassigned_stops + other.unassigned_stops diff --git a/models/vrp.rb b/models/vrp.rb index a191a438a..e643a56a5 100644 --- a/models/vrp.rb +++ b/models/vrp.rb @@ -33,6 +33,13 @@ class Vrp < Base field :name, default: nil field :router, default: OptimizerWrapper.router(OptimizerWrapper.config[:router][:api_key]), as_json: :none + # Keep '_' as segment separator (filterable); turn spaces/punctuation into '_'. + def self.normalize_name(name) + return if name.nil? + + name.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).parameterize(separator: '_') + end + has_many :matrices, class_name: 'Models::Matrix' has_many :points, class_name: 'Models::Point' has_many :units, class_name: 'Models::Unit' @@ -68,6 +75,7 @@ def self.create(hash, options = {}) self.ensure_retrocompatibility(hash) self.filter(hash) if options[:check] # TODO : add filters.rb here vrp.check_consistency(hash) if options[:check] # TODO: this check can be directly done on the hash without concern + hash[:name] = normalize_name(hash[:name]) if hash[:name] [:name, :matrices, :units, :points, :rests, :zones, :capacities, :quantities, :timewindows, :reload_depots, :vehicles, :services, :relations, :subtours, :routes, :configuration].each{ |key| vrp.send("#{key}=", hash[key]) if hash[key] @@ -102,6 +110,7 @@ def expand_vehicles_for_consistent_empty_result def empty_solution(solver, unassigned_with_reason = [], already_expanded = true) self.vehicles = expand_vehicles_for_consistent_empty_result if self.schedule? && !already_expanded solution = Models::Solution.new( + name: self.name, solvers: [solver], routes: self.vehicles.map{ |v| self.empty_route(v) }, unassigned_stops: (unassigned_visits(unassigned_with_reason) + diff --git a/optimizer_wrapper.rb b/optimizer_wrapper.rb index 632e51950..b8763eee7 100644 --- a/optimizer_wrapper.rb +++ b/optimizer_wrapper.rb @@ -81,6 +81,7 @@ def self.wrapper_vrp(api_key, profile, vrp, checksum, job_id = nil) job_id = Job.enqueue_to(profile[:queue], Job, services_vrps: Base64.encode64(Marshal.dump(services_vrps)), api_key: api_key, checksum: checksum, + vrp_name: vrp.name, pids: []) JobList.add(api_key, job_id) { diff --git a/public/css/style.css b/public/css/style.css index 81417340b..0830c5189 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2,6 +2,12 @@ body { font-family: verdana, arial, helvetica, sans-serif; font-size: 0.9em; } +body:not([data-page=result]) { + font-family: "Segoe UI", system-ui, sans-serif; + background: #f4f6f8; + color: #1f2933; + margin: 0; +} h1 { margin: 5px; font-variant: small-caps; @@ -71,16 +77,179 @@ form #optim-options input { min-width: 300px; } -#jobs-list{ - height: 155px; +#optim-list-status { + border: 0; + background: transparent; + padding: 0; +} +#optim-list-legend { + font-size: 1.05rem; + font-weight: 650; + padding: 0 0 8px; +} +.jobs-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 0 0 10px; +} +.jobs-filters-label { + color: #52606d; + font-size: 0.85rem; +} +.jobs-filter-select { + border: 1px solid #d0d7e2; + border-radius: 8px; + padding: 6px 8px; + font: inherit; + background: #fff; + min-width: 8rem; +} +#jobs-list { + max-height: 320px; width: 100%; box-sizing: border-box; - overflow-y: scroll; - display: inline-block; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.job-card { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + background: #fff; + border: 1px solid #e3e8ee; + border-radius: 10px; + padding: 10px 12px; + line-height: 1.4; +} +.job-card-main, .job-card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; } .job_title { - font-family: Courier New, Courier, Lucida Sans Typewriter, Lucida Typewriter, monospace; + font-family: ui-monospace, monospace; + font-size: 0.82rem; + background: #f4f6f8; + padding: 2px 6px; + border-radius: 4px; +} +.status-pill { + font-size: 0.75rem; + border-radius: 999px; + padding: 2px 8px; + background: #e8eef5; + color: #345; +} +.status-completed { background: #e5f6ec; color: #146c36; } +.status-working { background: #fff4d6; color: #8a5a00; } +.status-failed, .status-killed { background: #fde8e8; color: #9b1c1c; } +.job-action { + border: 1px solid #d0d7e2; + background: #fff; + border-radius: 6px; + padding: 4px 8px; + font: inherit; + color: #1f4b99; + text-decoration: none; + cursor: pointer; +} +.job-action-danger { color: #9b1c1c; } +.compare { + margin: 16px 8px 24px; + background: #fff; + border: 1px solid #e3e8ee; + border-radius: 12px; + padding: 16px 18px 8px; +} +.compare-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} +.compare-header h2 { + margin: 0 0 4px; + font-size: 1.15rem; + font-weight: 650; +} +.compare-header p { + margin: 0; + color: #62707e; + font-size: 0.85rem; +} +#compare-add { + display: flex; + gap: 8px; + border: 0; + margin: 0; } +#compare-job-id { + width: 220px; + border: 1px solid #d0d7e2; + border-radius: 8px; + padding: 8px 10px; + font: inherit; +} +#compare-submit { + border: 0; + background: #1f4b99; + color: #fff; + border-radius: 8px; + padding: 8px 14px; + font: inherit; + cursor: pointer; +} +.compare-scroll { overflow-x: auto; margin-top: 12px; } +.compare-table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + min-width: 640px; +} +.compare-table th, .compare-table td { + border-bottom: 1px solid #eef1f4; + padding: 10px 12px; + text-align: left; + vertical-align: top; + font-weight: 400; +} +.compare-table tbody th { + color: #52606d; + width: 180px; + white-space: nowrap; +} +.compare-plan, .metric-cell.is-reference { + box-shadow: inset 0 0 0 1px #c5ced8; +} +.compare-plan.is-reference { + box-shadow: inset 0 0 0 1.5px #1f2933; + border-radius: 8px 8px 0 0; +} +.plan-head { position: relative; min-width: 180px; padding-right: 18px; } +.plan-title { font-weight: 650; word-break: break-all; } +.plan-when { color: #62707e; font-size: 0.75rem; margin-top: 2px; } +.plan-close { + position: absolute; + top: 0; + right: 0; + border: 0; + background: transparent; + font-size: 1.1rem; + cursor: pointer; + color: #62707e; +} +.plan-ref { display: block; margin-top: 8px; font-size: 0.8rem; color: #52606d; } +.metric { font-variant-numeric: tabular-nums; } +.delta { font-size: 0.78rem; color: #9aa5b1; min-height: 1em; } +.tone-worse .metric, .tone-worse .delta { color: #c0392b; } +.tone-better .metric, .tone-better .delta { color: #1e7f4f; } +.tone-diff .metric { color: #8a5a00; } .error { font-weight: bold; diff --git a/public/index.html.erb b/public/index.html.erb index 92a710923..707f6b86d 100644 --- a/public/index.html.erb +++ b/public/index.html.erb @@ -14,10 +14,26 @@
+
+
+
+
+

Comparaison des résolutions

+

Choisissez la référence, puis comparez les écarts colorés sous chaque indicateur.

+
+
+ + + +
+
+ +
+
@@ -36,14 +52,15 @@ - + - + + diff --git a/public/js/compare.js b/public/js/compare.js new file mode 100644 index 000000000..a58f5d0c6 --- /dev/null +++ b/public/js/compare.js @@ -0,0 +1,405 @@ +var VISIT_TYPES = { service: true, pickup: true, delivery: true }; + +var COMPARE_ROWS = [ + { key: 'solvers', kind: 'text' }, + { key: 'interpreters', kind: 'text' }, + { key: 'heuristics', kind: 'text' }, + { key: 'elapsed', kind: 'elapsed_ms', higherIsWorse: true }, + { key: 'iterations', kind: 'count' }, + { key: 'unassigned', kind: 'count', higherIsWorse: true }, + { key: 'unassigned_reasons', kind: 'text' }, + { key: 'distance', kind: 'km', higherIsWorse: true }, + { key: 'tour', kind: 'duration', higherIsWorse: true }, + { key: 'work', kind: 'duration', higherIsWorse: true }, + { key: 'stops', kind: 'count', higherIsWorse: true }, + { key: 'visits', kind: 'duration', higherIsWorse: true }, + { key: 'pauses', kind: 'duration', higherIsWorse: true }, + { key: 'driving', kind: 'duration', higherIsWorse: true }, + { key: 'waiting', kind: 'duration', higherIsWorse: true }, + { key: 'vehicles', kind: 'count', higherIsWorse: true }, + { key: 'cost', kind: 'money', higherIsWorse: true } +]; + +function compareField(object, name) { + if (!object || object[name] == null || object[name] === '') return null; + var value = Number(object[name]); + return isNaN(value) ? null : value; +} + +function activityDuration(activity) { + var detail = activity.detail || {}; + if (detail.duration != null && detail.duration !== '') return Number(detail.duration) || 0; + if (activity.end_time != null && activity.begin_time != null) { + return Math.max(0, Number(activity.end_time) - Number(activity.begin_time)); + } + return 0; +} + +function isVisit(activity) { + if (VISIT_TYPES[activity.type]) return true; + return !!(activity.service_id || activity.pickup_shipment_id || activity.delivery_shipment_id); +} + +function isPause(activity) { + return activity.type === 'rest' || !!activity.rest_id; +} + +function listField(value) { + if (Array.isArray(value)) { + var parts = value.filter(function (item) { return item != null && item !== ''; }); + return parts.length ? parts.join(', ') : null; + } + if (value == null || value === '') return null; + return String(value); +} + +function heuristicNames(synthesis) { + if (!Array.isArray(synthesis) || !synthesis.length) return null; + var names = synthesis.map(function (item) { + if (!item || typeof item !== 'object') return item; + return item.heuristic || item.name || item.strategy || null; + }).filter(Boolean); + return names.length ? names.join(', ') : null; +} + +function groupUnassignedReasons(solution) { + var unassigned = (solution && (solution.unassigned || solution.unassigned_stops)) || []; + if (!unassigned.length) return { count: 0, reasons: null }; + + var counts = {}; + unassigned.forEach(function (stop) { + var reason = (stop && stop.reason) || 'unknown'; + counts[reason] = (counts[reason] || 0) + 1; + }); + + var reasons = Object.keys(counts).sort(function (a, b) { + return counts[b] - counts[a] || a.localeCompare(b); + }).map(function (reason) { + return reason + ' (' + counts[reason] + ')'; + }).join(', '); + + return { count: unassigned.length, reasons: reasons }; +} + +function compareStats(solution) { + var routes = (solution && solution.routes) || []; + var visits = 0; + var visitDuration = 0; + var pauseDuration = 0; + var vehicles = 0; + var tourFromClock = 0; + var clockComplete = routes.length > 0; + + routes.forEach(function (route) { + var activities = route.activities || route.stops || []; + var routeVisits = 0; + activities.forEach(function (activity) { + if (isPause(activity)) { + pauseDuration += activityDuration(activity); + return; + } + if (!isVisit(activity)) return; + routeVisits += 1; + visitDuration += activityDuration(activity); + }); + visits += routeVisits; + if (routeVisits > 0) vehicles += 1; + if (route.start_time == null || route.end_time == null) clockComplete = false; + else tourFromClock += Number(route.end_time) - Number(route.start_time); + }); + + var driving = compareField(solution, 'total_travel_time'); + var cost = compareField(solution, 'cost'); + var unassigned = groupUnassignedReasons(solution); + + return { + solvers: listField(solution && solution.solvers), + interpreters: listField(solution && solution.interpreters), + heuristics: heuristicNames(solution && solution.heuristic_synthesis), + elapsed: compareField(solution, 'elapsed'), + iterations: compareField(solution, 'iterations'), + unassigned: unassigned.count, + unassigned_reasons: unassigned.reasons, + distance: compareField(solution, 'total_distance'), + tour: clockComplete ? tourFromClock : compareField(solution, 'total_time'), + work: (driving || 0) + visitDuration, + stops: visits, + visits: visitDuration, + pauses: pauseDuration, + driving: driving, + waiting: compareField(solution, 'total_waiting_time'), + vehicles: vehicles, + cost: cost + }; +} + +function formatDuration(seconds) { + if (seconds == null || isNaN(seconds)) return '\u2014'; + var sign = seconds < 0 ? '-' : ''; + var abs = Math.abs(Math.round(seconds)); + var hours = Math.floor(abs / 3600); + var minutes = Math.floor((abs % 3600) / 60); + var hh = hours < 10 ? '0' + hours : String(hours); + var mm = minutes < 10 ? '0' + minutes : String(minutes); + return sign + hh + ':' + mm; +} + +function formatNumber(value, digits) { + return new Intl.NumberFormat('fr-FR', { + useGrouping: false, + minimumFractionDigits: digits, + maximumFractionDigits: digits + }).format(value); +} + +function formatKm(meters) { + if (meters == null || isNaN(meters)) return '\u2014'; + var km = meters / 1000; + var digits = Math.abs(km - Math.round(km)) < 0.05 ? 0 : 1; + return formatNumber(km, digits) + ' km'; +} + +function formatMoney(value) { + if (value == null || isNaN(value)) return '\u2014'; + return formatNumber(value, 2); +} + +function formatCount(value) { + if (value == null || isNaN(value)) return '\u2014'; + return String(value); +} + +function formatElapsedMs(ms) { + if (ms == null || isNaN(ms)) return '\u2014'; + var seconds = ms / 1000; + if (Math.abs(seconds) < 60) return formatNumber(seconds, 1) + ' s'; + return formatDuration(seconds); +} + +function formatStat(kind, value) { + if (kind === 'text') return value == null || value === '' ? '\u2014' : String(value); + if (kind === 'km') return formatKm(value); + if (kind === 'duration') return formatDuration(value); + if (kind === 'elapsed_ms') return formatElapsedMs(value); + if (kind === 'money') return formatMoney(value); + return formatCount(value); +} + +function formatDelta(kind, delta) { + if (delta == null || delta === 0) return '\u2014'; + var sign = delta > 0 ? '+' : '-'; + var abs = Math.abs(delta); + if (kind === 'duration') return sign + formatDuration(abs); + if (kind === 'elapsed_ms') return sign + formatElapsedMs(abs); + if (kind === 'km') return sign + formatKm(abs); + if (kind === 'money') return sign + formatMoney(abs); + return sign + formatCount(abs); +} + +function deltaTone(row, value, reference) { + if (value == null || reference == null || value === reference) return 'same'; + if (row.kind === 'text' || row.higherIsWorse == null) return 'diff'; + var worse = row.higherIsWorse ? value > reference : value < reference; + return worse ? 'worse' : 'better'; +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function (char) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]; + }); +} + +function compareLabel(key) { + if (typeof i18next !== 'undefined') return i18next.t('compare_' + key); + return key; +} + +var planCompareState = { + plans: [], + referenceId: null, + knownJobs: [] +}; + +function planById(jobId) { + for (var i = 0; i < planCompareState.plans.length; i++) { + if (planCompareState.plans[i].id === jobId) return planCompareState.plans[i]; + } + return null; +} + +function renderCompare() { + var wrap = document.getElementById('compare-table-wrap'); + if (!wrap) return; + if (planCompareState.plans.length === 0) { + wrap.innerHTML = ''; + return; + } + + var reference = planById(planCompareState.referenceId) || planCompareState.plans[0]; + planCompareState.referenceId = reference.id; + + var head = '' + escapeHtml(compareLabel('indicator')) + ''; + planCompareState.plans.forEach(function (plan) { + var checked = plan.id === planCompareState.referenceId ? ' checked' : ''; + head += '' + + '
' + + '' + + '
' + escapeHtml(plan.label) + '
' + + (plan.when ? '
' + escapeHtml(plan.when) + '
' : '') + + '' + + '
'; + }); + + var body = COMPARE_ROWS.map(function (row) { + var cells = '' + escapeHtml(compareLabel(row.key)) + ''; + planCompareState.plans.forEach(function (plan) { + var value = plan.stats ? plan.stats[row.key] : null; + var refValue = reference.stats ? reference.stats[row.key] : null; + var isReference = plan.id === reference.id; + var tone = isReference ? 'same' : deltaTone(row, value, refValue); + var delta = '\u2014'; + if (!isReference && row.kind !== 'text' && value != null && refValue != null && value !== refValue) { + delta = formatDelta(row.kind, value - refValue); + } + cells += '' + + '
' + escapeHtml(formatStat(row.kind, value)) + '
' + + (row.kind === 'text' ? '' : '
' + escapeHtml(delta) + '
') + + ''; + }); + return '' + cells + ''; + }).join(''); + + wrap.innerHTML = '
' + + head + '' + body + '
'; +} + +function showCompareError(message) { + var node = document.getElementById('compare-error'); + if (!node) return; + if (!message) { + node.hidden = true; + node.textContent = ''; + return; + } + node.hidden = false; + node.textContent = message; +} + +function addPlan(jobId, meta) { + jobId = (jobId || '').trim(); + if (!jobId) return; + if (planById(jobId)) { + renderCompare(); + return; + } + showCompareError(''); + var plan = { + id: jobId, + label: jobId, + when: meta && meta.when, + stats: null + }; + planCompareState.plans.push(plan); + if (!planCompareState.referenceId) planCompareState.referenceId = jobId; + renderCompare(); + + $.ajax({ + url: '/0.1/vrp/jobs/' + encodeURIComponent(jobId) + '.json', + type: 'get', + dataType: 'json', + data: { api_key: getParams()['api_key'] } + }).done(function (data) { + var solution = data.solutions && data.solutions[0]; + if (!solution) { + planCompareState.plans = planCompareState.plans.filter(function (item) { return item.id !== jobId; }); + if (planCompareState.referenceId === jobId) { + planCompareState.referenceId = planCompareState.plans[0] && planCompareState.plans[0].id; + } + showCompareError(compareLabel('no_solution')); + renderCompare(); + return; + } + plan.stats = compareStats(solution); + if (solution.name) plan.label = solution.name; + if (data.job && data.job.avancement && !plan.when) plan.when = data.job.avancement; + renderCompare(); + }).fail(function (jqXHR) { + planCompareState.plans = planCompareState.plans.filter(function (item) { return item.id !== jobId; }); + if (planCompareState.referenceId === jobId) { + planCompareState.referenceId = planCompareState.plans[0] && planCompareState.plans[0].id; + } + var message = jqXHR.responseJSON && jqXHR.responseJSON.message; + showCompareError(message || i18next.t('compare_missing', { id: jobId })); + renderCompare(); + }); +} + +function syncJobs(jobs) { + planCompareState.knownJobs = jobs || []; + var list = document.getElementById('compare-job-ids'); + if (!list) return; + list.innerHTML = ''; + planCompareState.knownJobs.forEach(function (job) { + if (job.status !== 'completed') return; + var option = document.createElement('option'); + option.value = job.uuid; + list.appendChild(option); + }); +} + +function initCompare() { + var form = document.getElementById('compare-add'); + if (!form) return; + document.getElementById('compare-title').textContent = compareLabel('title'); + document.getElementById('compare-help').textContent = compareLabel('help'); + document.getElementById('compare-job-id').placeholder = compareLabel('add_placeholder'); + document.getElementById('compare-submit').textContent = compareLabel('add'); + + form.addEventListener('submit', function (event) { + event.preventDefault(); + var input = document.getElementById('compare-job-id'); + addPlan(input.value); + input.value = ''; + }); + + document.getElementById('compare-table-wrap').addEventListener('click', function (event) { + var removeId = event.target.getAttribute('data-remove'); + if (!removeId) return; + planCompareState.plans = planCompareState.plans.filter(function (plan) { return plan.id !== removeId; }); + if (planCompareState.referenceId === removeId) { + planCompareState.referenceId = planCompareState.plans[0] && planCompareState.plans[0].id; + } + renderCompare(); + }); + + document.getElementById('compare-table-wrap').addEventListener('change', function (event) { + var referenceId = event.target.getAttribute('data-reference'); + if (!referenceId) return; + planCompareState.referenceId = referenceId; + renderCompare(); + }); +} + +if (typeof window !== 'undefined') { + window.planCompare = { + add: addPlan, + syncJobs: syncJobs + }; + if (typeof $ !== 'undefined') $(initCompare); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + compareStats: compareStats, + groupUnassignedReasons: groupUnassignedReasons, + formatDuration: formatDuration, + formatElapsedMs: formatElapsedMs, + formatKm: formatKm, + formatMoney: formatMoney, + formatDelta: formatDelta, + deltaTone: deltaTone + }; +} diff --git a/public/js/jobManager.js b/public/js/jobManager.js index 7aef741ec..b2c7fc365 100644 --- a/public/js/jobManager.js +++ b/public/js/jobManager.js @@ -10,18 +10,60 @@ function buildDownloadLink(jobId, state) { var url = "/0.1/vrp/jobs/" + jobId + extension + '?api_key=' + getParams()['api_key']; - return ' ' + msg + ''; } function buildResultLink(jobId) { - return '' + i18next.t('show_result') + '' + return '' + i18next.t('show_result') + '' +} + +function escapeJobHtml(value) { + return String(value).replace(/[&<>"']/g, function (char) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]; + }); +} + +function nameSegments(name) { + return String(name || '').split('_').filter(function (segment) { return segment.length > 0; }); +} + +function jobsMatchingPrefixes(jobs, prefixes) { + prefixes = prefixes || []; + return (jobs || []).filter(function (job) { + var segments = nameSegments(job.name); + for (var i = 0; i < prefixes.length; i++) { + if (segments[i] !== prefixes[i]) return false; + } + return true; + }); +} + +function nextPrefixOptions(jobs, prefixes) { + var depth = (prefixes || []).length; + var seen = {}; + var options = []; + jobsMatchingPrefixes(jobs, prefixes).forEach(function (job) { + var segment = nameSegments(job.name)[depth]; + if (!segment || seen[segment]) return; + seen[segment] = true; + options.push(segment); + }); + return options.sort(); +} + +function jobsSignature(jobs) { + return (jobs || []).map(function (job) { + return [job.uuid, job.status, job.name || '', job.avancement || ''].join('|'); + }).join(';'); } var jobsManager = { jobs: [], + filterPrefixes: [], htmlElements: { builder: function (jobs) { + $('#jobs-list').empty(); $(jobs).each(function () { currentJob = this; @@ -29,7 +71,7 @@ var jobsManager = { var startTime = (new Date(currentJob.time)).toLocaleString('fr-FR'); var completedDate = '' - if (currentJob.status === 'completed') { + if (currentJob.status === 'completed' && currentJob.avancement) { var splitedDate = currentJob.avancement .replace("Completed at ", '') .split(' '); @@ -38,31 +80,111 @@ var jobsManager = { } + var jobId = escapeJobHtml(currentJob.uuid); + var status = escapeJobHtml(currentJob.status); + var displayName = escapeJobHtml(currentJob.name || currentJob.uuid); + var compareBtn = currentJob.status === 'completed' + ? '' + : ''; var jobDOM = - '
' - + '' + startTime + ' : ' - + '' + 'Job N° ' + currentJob.uuid + ' ' - + '' - + ' (Status: ' + currentJob.status + completedDate + ')' - + (donwloadBtn ? buildDownloadLink(currentJob.uuid, currentJob.status) : '') - + (currentJob.status === 'completed' ? ' - ' + buildResultLink(currentJob.uuid) : '') - + '
'; + + '
' + + ''; $('#jobs-list').append(jobDOM); }); - $('#jobs-list button').on('click', function () { + $('#jobs-list button').off('click').on('click', function () { jobsManager.roleDispatcher(this); }); } }, + pruneFilterPrefixes: function () { + while (jobsManager.filterPrefixes.length > 0) { + var prefixes = jobsManager.filterPrefixes.slice(0, -1); + var options = nextPrefixOptions(jobsManager.jobs, prefixes); + var selected = jobsManager.filterPrefixes[jobsManager.filterPrefixes.length - 1]; + if (options.indexOf(selected) !== -1) break; + jobsManager.filterPrefixes.pop(); + } + }, + renderFilters: function () { + var root = document.getElementById('jobs-filters'); + if (!root) return; + + jobsManager.pruneFilterPrefixes(); + var firstOptions = nextPrefixOptions(jobsManager.jobs, []); + if (firstOptions.length === 0) { + root.hidden = true; + root.innerHTML = ''; + return; + } + + root.hidden = false; + var html = '' + escapeJobHtml(i18next.t('jobs_filter_label')) + ''; + var depth = 0; + while (true) { + var prefixes = jobsManager.filterPrefixes.slice(0, depth); + var options = nextPrefixOptions(jobsManager.jobs, prefixes); + if (options.length === 0) break; + + var selected = jobsManager.filterPrefixes[depth] || ''; + html += ''; + + if (!selected) break; + depth += 1; + } + + if (jobsManager.filterPrefixes.length > 0) { + html += ''; + } + + root.innerHTML = html; + $(root).find('select').on('change', function () { + var selectedDepth = Number(this.getAttribute('data-depth')); + var value = this.value; + jobsManager.filterPrefixes = jobsManager.filterPrefixes.slice(0, selectedDepth); + if (value) jobsManager.filterPrefixes.push(value); + jobsManager.render(); + }); + $(root).find('[data-role="reset-filters"]').on('click', function () { + jobsManager.filterPrefixes = []; + jobsManager.render(); + }); + }, + render: function () { + jobsManager.renderFilters(); + jobsManager.htmlElements.builder(jobsMatchingPrefixes(jobsManager.jobs, jobsManager.filterPrefixes)); + }, roleDispatcher: function (object) { switch ($(object).data('role')) { case 'focus': //actually in building, create to apply different behavior to the button object restartJob, actually not set. #TODO break; + case 'compare': + if (window.planCompare) window.planCompare.add($(object).val()); + break; case 'delete': if (window.confirm(i18next.t('delete_confirm'))) { this.ajaxDeleteJob($(object).val()); @@ -71,6 +193,7 @@ var jobsManager = { } }, ajaxGetJobs: function (timeinterval) { + $('#optim-list-legend').text(i18next.t('current_jobs')); var ajaxload = function () { if (!requestPendingAllJobs) { requestPendingAllJobs = true; @@ -82,6 +205,7 @@ var jobsManager = { complete: function () { requestPendingAllJobs = false; } }).done(function (data) { jobsManager.shouldUpdate(data); + if (window.planCompare) window.planCompare.syncJobs(data); }).fail(function (jqXHR, textStatus, errorThrown) { if (jqXHR.status !== 500) { clearInterval(window.AjaxGetRequestInterval); @@ -110,29 +234,15 @@ var jobsManager = { }, }).done(function (data) { if (debug) { console.log("the uuid has been deleted from the jobs queue & the DB"); } - $('button[data-role="delete"][value="' + uuid + '"]').fadeOut(500, function () { $(this).closest('.job').remove(); }); + jobsManager.jobs = jobsManager.jobs.filter(function (job) { return job.uuid !== uuid; }); + jobsManager.render(); }); }, shouldUpdate: function (data) { - // erase list if no job running - if (data.length === 0 && jobsManager.jobs.length !== 0) { - $('#jobs-list').empty(); - } - //check if chagements occurs in the data api. #TODO, update if more params are needed. - $(data).each(function (index, object) { - if (jobsManager.jobs.length > 0) { - if (object.status != jobsManager.jobs[index].status || jobsManager.jobs.length != data.length) { - jobsManager.jobs = data; - $('#jobs-list').empty(); - jobsManager.htmlElements.builder(jobsManager.jobs); - } - } - else { - jobsManager.jobs = data; - $('#jobs-list').empty(); - jobsManager.htmlElements.builder(jobsManager.jobs); - } - }); + data = data || []; + if (jobsSignature(data) === jobsSignature(jobsManager.jobs)) return; + jobsManager.jobs = data; + jobsManager.render(); }, checkJobStatus: function (options, cb) { var nbError = 0; @@ -238,3 +348,11 @@ function downloadButton(jobId, content) { document.body.appendChild(a); a.click(); } + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + nameSegments: nameSegments, + jobsMatchingPrefixes: jobsMatchingPrefixes, + nextPrefixOptions: nextPrefixOptions + }; +} diff --git a/public/js/translations.js b/public/js/translations.js index ac4e2776f..24f5f4d59 100644 --- a/public/js/translations.js +++ b/public/js/translations.js @@ -26,6 +26,9 @@ var fr = { download_optim_error: 'Télécharger le rapport d\'erreur de l\'optimisation', download_optim: 'Télécharger le résultat de l\'optimisation', current_jobs: 'Vos optimisations', + jobs_filter_label: 'Filtrer par nom', + jobs_filter_all: 'Tous', + jobs_filter_reset: 'Réinitialiser', invalid_duration: 'Durée invalide : {{duration}}', missing_column: 'Colonne manquante ou donnée nulle : {{columnName}}', same_reference: 'Référence identique détectée : {{reference}}', @@ -34,7 +37,39 @@ var fr = { customers: 'clients', vehicles: 'véhicules', select2_hidden_title: 'Cacher', - select2_default_title: 'Défaut' + select2_default_title: 'Défaut', + status_queued: 'En attente', + status_working: 'En cours', + status_completed: 'Terminé', + status_failed: 'Échec', + status_killed: 'Arrêté', + compare_action: 'Comparer', + compare_title: 'Comparaison des résolutions', + compare_help: 'Choisissez la référence, puis comparez les écarts colorés sous chaque indicateur.', + compare_add_placeholder: 'Ajouter un VRP…', + compare_add: 'Ajouter', + compare_reference: 'Utiliser comme référence', + compare_indicator: 'Indicateur', + compare_remove: 'Retirer', + compare_missing: 'Identifiant inconnu\u202F: {{id}}', + compare_no_solution: 'Aucune solution pour ce job.', + compare_solvers: 'Solveur', + compare_interpreters: 'Interpreters', + compare_heuristics: 'Heuristiques', + compare_elapsed: 'Durée de résolution', + compare_iterations: 'Itérations', + compare_unassigned: 'Non assignés', + compare_unassigned_reasons: 'Raisons non assignés', + compare_distance: 'Distance', + compare_tour: 'Durée totale de tournée', + compare_work: 'Durée du travail', + compare_stops: 'Arrêts', + compare_visits: 'Durée des visites', + compare_pauses: 'Durée des pauses', + compare_driving: 'Temps de conduite', + compare_waiting: 'Temps d’attente', + compare_vehicles: 'Nombre de véhicules', + compare_cost: 'Coût' } }; @@ -66,6 +101,9 @@ var en = { download_optim_error: 'Download the optimization error report', download_optim: 'Download the result of the optimization', current_jobs: 'Your optimizations', + jobs_filter_label: 'Filter by name', + jobs_filter_all: 'All', + jobs_filter_reset: 'Reset', invalid_duration: 'Invalid duration: {{duration}}', missing_column: 'Missing column or null data: {{columnName}}', same_reference: 'Identical reference detected: {{reference}}', @@ -74,7 +112,39 @@ var en = { customers: 'customers', vehicles: 'vehicles', select2_hidden_title: 'Hide', - select2_default_title: 'Default' + select2_default_title: 'Default', + status_queued: 'Queued', + status_working: 'Running', + status_completed: 'Completed', + status_failed: 'Failed', + status_killed: 'Stopped', + compare_action: 'Compare', + compare_title: 'Resolution comparison', + compare_help: 'Pick a reference, then compare the colored gaps under each indicator.', + compare_add_placeholder: 'Add a VRP…', + compare_add: 'Add', + compare_reference: 'Use as reference', + compare_indicator: 'Indicator', + compare_remove: 'Remove', + compare_missing: 'Unknown id: {{id}}', + compare_no_solution: 'No solution for this job.', + compare_solvers: 'Solver', + compare_interpreters: 'Interpreters', + compare_heuristics: 'Heuristics', + compare_elapsed: 'Solve duration', + compare_iterations: 'Iterations', + compare_unassigned: 'Unassigned', + compare_unassigned_reasons: 'Unassigned reasons', + compare_distance: 'Distance', + compare_tour: 'Total route duration', + compare_work: 'Work duration', + compare_stops: 'Stops', + compare_visits: 'Visit duration', + compare_pauses: 'Break duration', + compare_driving: 'Driving time', + compare_waiting: 'Waiting time', + compare_vehicles: 'Vehicle count', + compare_cost: 'Cost' } }; diff --git a/test/api/v01/helpers/request_helper.rb b/test/api/v01/helpers/request_helper.rb index 267e9c107..c3b263b30 100644 --- a/test/api/v01/helpers/request_helper.rb +++ b/test/api/v01/helpers/request_helper.rb @@ -143,11 +143,16 @@ def delete_job(job_id, params) end def delete_completed_job(job_id, params) + # Completed jobs stay listable for a few hours after retrieval; cleanup may return 202 or 404. puts "#{job_id} #{Time.now} sending delete_completed" delete "0.1/vrp/jobs/#{job_id}.json", params - assert_equal 404, last_response.status, last_response.body + assert_includes [202, 404], last_response.status, last_response.body puts "#{job_id} #{Time.now} delete_completed done" - JSON.parse(last_response.body) + begin + JSON.parse(last_response.body) + rescue JSON::ParserError + nil + end end def asynchronously(options = {start_worker: false}) diff --git a/test/api/v01/vrp_test.rb b/test/api/v01/vrp_test.rb index 2c7cf2980..c9435f5f1 100644 --- a/test/api/v01/vrp_test.rb +++ b/test/api/v01/vrp_test.rb @@ -34,6 +34,30 @@ def test_submit_vrp_in_queue delete_job @job_id, api_key: 'demo' end + def test_returned_solution_keeps_vrp_name + vrp = VRP.toy + vrp[:name] = 'cart_c352_lundipierre_c98e06a2a5b2ee0f80ddf521f32cef2c' + + post '/0.1/vrp/submit', { api_key: 'demo', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json' + assert_equal 200, last_response.status, last_response.body + solution = JSON.parse(last_response.body)['solutions'][0] + assert_equal 'cart_c352_lundipierre_c98e06a2a5b2ee0f80ddf521f32cef2c', solution['name'] + assert_includes solution['solvers'], 'demo' + assert_kind_of Array, solution['interpreters'] + end + + def test_vrp_name_is_normalized_with_underscore_segments + assert_equal 'c352_lundi_pierre', Models::Vrp.normalize_name('c352 Lundi (Pierre)') + assert_equal 'cart_c352_lundipierre_abc', Models::Vrp.normalize_name('cart_c352_lundipierre_abc') + + vrp = VRP.toy + vrp[:name] = 'c352 Lundi (Pierre)' + + post '/0.1/vrp/submit', { api_key: 'demo', vrp: vrp }.to_json, 'CONTENT_TYPE' => 'application/json' + assert_equal 200, last_response.status, last_response.body + assert_equal 'c352_lundi_pierre', JSON.parse(last_response.body).dig('solutions', 0, 'name') + end + def test_dont_ignore_legitimate_skills OptimizerWrapper.stub( :define_main_process, @@ -176,12 +200,16 @@ def test_first_solution_strategy_param def test_list_vrp asynchronously do - @job_id = submit_vrp api_key: 'demo', vrp: VRP.toy + vrp = VRP.toy + vrp[:name] = 'cart_c352_lundipierre_c98e06a2a5b2ee0f80ddf521f32cef2c' + @job_id = submit_vrp api_key: 'demo', vrp: vrp end get '/0.1/vrp/jobs', api_key: 'demo' assert_equal 200, last_response.status, last_response.body - assert_includes JSON.parse(last_response.body).map{ |a| a['uuid'] }, @job_id + listed = JSON.parse(last_response.body).find{ |a| a['uuid'] == @job_id } + assert listed, last_response.body + assert_equal 'cart_c352_lundipierre_c98e06a2a5b2ee0f80ddf521f32cef2c', listed['name'] ensure delete_job @job_id, api_key: 'demo' end @@ -248,16 +276,36 @@ def test_get_completed_job_with_solution_dump OptimizerWrapper.config[:dump][:solution] = old_config_dump_solution end - def test_get_deletes_completed_job - # create a "completed" job with an unused uuid + def test_get_keeps_completed_job_until_retention_expires uuid = Resque::Plugins::Status::Hash.generate_uuid until !uuid.nil? && Resque::Plugins::Status::Hash.get(uuid).nil? - Resque::Plugins::Status::Hash.create(uuid, { 'status' => 'completed', 'options' => { 'api_key' => 'demo' } }) + OptimizerWrapper::JobList.add('demo', uuid) + Resque::Plugins::Status::Hash.create(uuid, { + 'status' => 'completed', + 'message' => "Completed at #{Time.now}", + 'options' => { 'api_key' => 'demo' } + }) + + get "/0.1/vrp/jobs/#{uuid}.json", api_key: 'demo' + assert_equal 200, last_response.status, last_response.body + assert Resque::Plugins::Status::Hash.get(uuid), 'Completed job should stay after retrieval' - wait_status uuid, 'completed', api_key: 'demo' + get '/0.1/vrp/jobs', api_key: 'demo' + assert_includes JSON.parse(last_response.body).map{ |a| a['uuid'] }, uuid + + Resque::Plugins::Status::Hash.set(uuid, { + 'status' => 'completed', + 'message' => "Completed at #{Time.now - Core::Services::JobService::COMPLETED_RETENTION - 1.minute}", + 'options' => { 'api_key' => 'demo' } + }) - delete_completed_job uuid, api_key: 'demo' + get "/0.1/vrp/jobs/#{uuid}.json", api_key: 'demo' + assert_nil Resque::Plugins::Status::Hash.get(uuid), 'Completed job should be removed after retention' + + get '/0.1/vrp/jobs', api_key: 'demo' + refute_includes JSON.parse(last_response.body).map{ |a| a['uuid'] }, uuid ensure - Resque::Plugins::Status::Hash.remove(uuid) + Resque::Plugins::Status::Hash.remove(uuid) if uuid + OptimizerWrapper::Result.remove('demo', uuid) if uuid end def test_block_call_under_clustering @@ -280,7 +328,7 @@ def test_block_call_under_clustering @job_id = submit_vrp(api_key: 'demo', vrp: vrp) response = wait_status @job_id, 'completed', api_key: 'demo' refute_empty response['solutions'].to_a, "Solution is missing from the response body: #{response}" - delete_completed_job @job_id, api_key: 'demo' + delete_job @job_id, api_key: 'demo' } end end @@ -306,7 +354,7 @@ def test_unfounded_avancement_message_change wait_status @job_id, 'completed', api_key: 'demo' output.flush end - delete_completed_job @job_id, api_key: 'demo' if @job_id + delete_job @job_id, api_key: 'demo' if @job_id lines_with_avancement = output.grep(/avancement/) ensure diff --git a/test/models/interpreter_tracking_test.rb b/test/models/interpreter_tracking_test.rb new file mode 100644 index 000000000..f14160541 --- /dev/null +++ b/test/models/interpreter_tracking_test.rb @@ -0,0 +1,31 @@ +require './test/test_helper' + +class InterpreterTrackingTest < Minitest::Test + def test_absorb_interpreters_from_resolution_context + service_vrp = Models::ResolutionContext.new(vrp: TestHelper.create(VRP.toy)) + service_vrp.mark_interpreter!(:dichotomous) + service_vrp.mark_interpreter!(:split) + + solution = Models::Solution.new({}) + solution.absorb_interpreters!(service_vrp) + + assert_equal %w[dichotomous split], solution.interpreters.sort + end + + def test_dichotomous_marks_interpreter_as_soon_as_eligible + service_vrp = Models::ResolutionContext.new(vrp: TestHelper.create(VRP.toy), service: :ortools) + solution = Models::Solution.new(solvers: [:ortools]) + + Interpreters::Dichotomous.stub(:dichotomous_candidate?, ->(_sv) { true }) do + Interpreters::Dichotomous.stub(:set_config, ->(_sv) {}) do + Interpreters::Dichotomous.stub(:ensure_time_budget!, ->(_sv) {}) do + Interpreters::Dichotomous.stub(:build_dicho_node_solution, ->(*) { solution }) do + result = Interpreters::Dichotomous.dichotomous_heuristic(service_vrp) + assert_includes service_vrp.interpreters, 'dichotomous' + assert_includes result.interpreters, 'dichotomous' + end + end + end + end + end +end diff --git a/test/public/compare_stats_test.js b/test/public/compare_stats_test.js new file mode 100644 index 000000000..6d1c8bb73 --- /dev/null +++ b/test/public/compare_stats_test.js @@ -0,0 +1,74 @@ +const assert = require('assert'); +const { + compareStats, groupUnassignedReasons, formatDuration, formatKm, formatDelta, formatElapsedMs, deltaTone +} = require('../../public/js/compare.js'); + +const solution = { + name: 'cart_c352_demo', + solvers: ['vroom'], + interpreters: ['split', 'dichotomous'], + heuristic_synthesis: [{ heuristic: 'global_cheapest_arc' }], + elapsed: 30919.055852, + iterations: 42, + unassigned: [ + { service_id: 'a', reason: 'No vehicle available for this service' }, + { service_id: 'b', reason: 'No vehicle available for this service' }, + { service_id: 'c', reason: 'Cannot be performed due to timewindows' } + ], + total_distance: 733000, + total_travel_time: 18 * 3600, + total_waiting_time: 0, + total_time: 18 * 3600 + 54 * 60, + cost: 2504.63, + routes: [{ + start_time: 0, + end_time: 18 * 3600 + 54 * 60, + activities: [ + { type: 'depot' }, + { type: 'service', detail: { duration: 9 * 60 } }, + { type: 'rest', detail: { duration: 45 * 60 } } + ] + }, { + start_time: 100, + end_time: 100, + activities: [{ type: 'service', service_id: 'a', detail: { duration: 0 } }] + }] +}; + +const stats = compareStats(solution); +assert.strictEqual(stats.distance, 733000); +assert.strictEqual(stats.driving, 18 * 3600); +assert.strictEqual(stats.waiting, 0); +assert.strictEqual(stats.visits, 9 * 60); +assert.strictEqual(stats.pauses, 45 * 60); +assert.strictEqual(stats.stops, 2); +assert.strictEqual(stats.vehicles, 2); +assert.strictEqual(stats.work, 18 * 3600 + 9 * 60); +assert.strictEqual(stats.tour, 18 * 3600 + 54 * 60); +assert.strictEqual(stats.cost, 2504.63); +assert.strictEqual(stats.solvers, 'vroom'); +assert.strictEqual(stats.interpreters, 'split, dichotomous'); +assert.strictEqual(stats.heuristics, 'global_cheapest_arc'); +assert.strictEqual(stats.elapsed, 30919.055852); +assert.strictEqual(stats.iterations, 42); +assert.strictEqual(stats.unassigned, 3); +assert.strictEqual( + stats.unassigned_reasons, + 'No vehicle available for this service (2), Cannot be performed due to timewindows (1)' +); +assert.deepStrictEqual(groupUnassignedReasons({ unassigned: [] }), { count: 0, reasons: null }); + +assert.strictEqual(formatElapsedMs(30919.055852), '30,9 s'); +assert.strictEqual(formatDuration(18 * 3600 + 54 * 60), '18:54'); +assert.strictEqual(formatDuration(45 * 60), '00:45'); +assert.strictEqual(formatKm(733000), '733 km'); +assert.strictEqual(formatKm(62900), '62,9 km'); +assert.strictEqual(formatDelta('km', 62900), '+62,9 km'); +assert.strictEqual(formatDelta('duration', 46 * 60), '+00:46'); +assert.strictEqual(formatDelta('duration', -46 * 60), '-00:46'); +assert.strictEqual(formatDelta('money', 0), '\u2014'); +assert.strictEqual(deltaTone({ higherIsWorse: true }, 796000, 733000), 'worse'); +assert.strictEqual(deltaTone({ higherIsWorse: true }, 10, 10), 'same'); +assert.strictEqual(deltaTone({ higherIsWorse: true }, 5, 10), 'better'); + +console.log('ok'); diff --git a/test/public/job_filters_test.js b/test/public/job_filters_test.js new file mode 100644 index 000000000..004843460 --- /dev/null +++ b/test/public/job_filters_test.js @@ -0,0 +1,36 @@ +const assert = require('assert'); +const { + nameSegments, jobsMatchingPrefixes, nextPrefixOptions +} = require('../../public/js/jobManager.js'); + +const name = 'cart_c352_lundipierre_c98e06a2a5b2ee0f80ddf521f32cef2c'; +assert.deepStrictEqual( + nameSegments(name), + ['cart', 'c352', 'lundipierre', 'c98e06a2a5b2ee0f80ddf521f32cef2c'] +); + +const jobs = [ + { uuid: '1', name: 'cart_c352_lundipierre_aaa' }, + { uuid: '2', name: 'cart_c352_mardipierre_bbb' }, + { uuid: '3', name: 'cart_c999_lundipierre_ccc' }, + { uuid: '4', name: 'bike_c352_lundipierre_ddd' }, + { uuid: '5', name: null } +]; + +assert.deepStrictEqual(nextPrefixOptions(jobs, []), ['bike', 'cart']); +assert.deepStrictEqual( + jobsMatchingPrefixes(jobs, ['cart']).map((job) => job.uuid), + ['1', '2', '3'] +); +assert.deepStrictEqual(nextPrefixOptions(jobs, ['cart']), ['c352', 'c999']); +assert.deepStrictEqual( + jobsMatchingPrefixes(jobs, ['cart', 'c352']).map((job) => job.uuid), + ['1', '2'] +); +assert.deepStrictEqual(nextPrefixOptions(jobs, ['cart', 'c352']), ['lundipierre', 'mardipierre']); +assert.deepStrictEqual( + jobsMatchingPrefixes(jobs, ['cart', 'c352', 'lundipierre']).map((job) => job.uuid), + ['1'] +); + +console.log('ok'); From ebe8da0778f84e24f7e51e1e26c7d3f81819e447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 5 Oct 2026 13:51:48 +0200 Subject: [PATCH 09/12] Allow downloading the dumped VRP from the jobs list. --- api/v01/vrp.rb | 31 +++++++++++++++++++++++++++++- core/services/job_service.rb | 37 +++++++++++++++++++++++++++++++++--- lib/cache_manager.rb | 5 +++++ optimizer_wrapper.rb | 1 + public/js/jobManager.js | 12 +++++++++++- public/js/translations.js | 2 ++ test/api/v01/vrp_test.rb | 30 +++++++++++++++++++++++++++++ 7 files changed, 113 insertions(+), 5 deletions(-) diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index 33143b17f..830a2e05a 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -115,7 +115,10 @@ class Vrp < APIBase vrp_params = d_params[:points] ? d_params : d_params[:vrp] vrp_params[:name] = Models::Vrp.normalize_name(vrp_params[:name]) if vrp_params[:name] - APIBase.dump_vrp_dir.write([key_print, vrp_params[:name] || 'no_vrp_name', checksum].compact.join('_'), d_params.to_json) if OptimizerWrapper.config[:dump][:vrp] + if OptimizerWrapper.config[:dump][:vrp] + dump_key = Core::Services::JobService.vrp_dump_key(api_key, vrp_params[:name], checksum) + APIBase.dump_vrp_dir.write(dump_key, d_params.to_json) + end Sentry.set_extras(vrp_name: vrp_params[:name]) @@ -198,6 +201,32 @@ class Vrp < APIBase end resource :jobs do + desc 'Download dumped VRP for a job', { + nickname: 'get_job_vrp', + failure: [ + { code: 404, message: 'Not Found', model: ::Api::V01::Status } + ], + detail: 'Download the VRP JSON dump associated with the job, when dump is enabled.' + } + params { + requires :id, type: String, desc: 'Job id returned by creating VRP problem.' + } + get ':id/vrp' do + id = params[:id] + job = Resque::Plugins::Status::Hash.get(id) + content = Core::Services::JobService.read_vrp_dump(params[:api_key], job) + if content.nil? + status 404 + error!({ message: "VRP dump for job id='#{id}' not found" }, 404) + end + + filename = "#{(job.options && job.options['vrp_name']) || id}.json" + header 'Content-Disposition', "attachment; filename=\"#{filename}\"" + content_type 'application/json' + env['api.format'] = :binary + body content + end + desc 'Fetch vrp job status', { nickname: 'get_job', success: VrpResult, diff --git a/core/services/job_service.rb b/core/services/job_service.rb index 90ee604a0..283559a76 100644 --- a/core/services/job_service.rb +++ b/core/services/job_service.rb @@ -16,13 +16,15 @@ def job_list(api_key) next end + options = job.options || {} { time: job.time, uuid: job.uuid, status: job.status, avancement: job.message, - checksum: job.options && job.options['checksum'], - name: job.options && job.options['vrp_name'] + checksum: options['checksum'], + name: options['vrp_name'], + vrp_dump: vrp_dump?(api_key, job) } } end @@ -40,6 +42,34 @@ def job_remove(api_key, id) end end + def vrp_dump_key(api_key, vrp_name, checksum) + return unless checksum + + key_print = api_key.to_s.rpartition('-')[0] + key_print = api_key.to_s[0..3] if key_print.empty? + [key_print, vrp_name.presence || 'no_vrp_name', checksum].compact.join('_') + end + + def read_vrp_dump(api_key, job) + return unless job&.dig('options', 'api_key') == api_key + + options = job.options || {} + key = options['vrp_dump_key'].presence || vrp_dump_key(api_key, options['vrp_name'], options['checksum']) + return unless key + + OptimizerWrapper.dump_vrp_dir.read(key) + end + + def vrp_dump?(api_key, job) + return false unless job&.dig('options', 'checksum') + + options = job.options || {} + key = options['vrp_dump_key'].presence || vrp_dump_key(api_key, options['vrp_name'], options['checksum']) + return false unless key + + OptimizerWrapper.dump_vrp_dir.exist?(key) + end + def retention_expired?(job) return false unless job&.completed? @@ -60,7 +90,8 @@ def completed_at(job) job.time end - module_function :job_list, :job_kill, :job_remove, :retention_expired?, :completed_at + module_function :job_list, :job_kill, :job_remove, :vrp_dump_key, :read_vrp_dump, :vrp_dump?, + :retention_expired?, :completed_at end end end diff --git a/lib/cache_manager.rb b/lib/cache_manager.rb index e4855943d..584bfd91c 100644 --- a/lib/cache_manager.rb +++ b/lib/cache_manager.rb @@ -27,6 +27,11 @@ def initialize(cache) FileUtils.mkdir_p(@cache) end + def exist?(name) + filtered_name = sanitize_filename(name) + File.exist?(File.join(@cache, filtered_name) + '.gz') || File.exist?(File.join(@cache, filtered_name)) + end + def read(name, _options = nil) filtered_name = sanitize_filename(name) if File.exist?(File.join(@cache, filtered_name) + '.gz') # Gzip dumps diff --git a/optimizer_wrapper.rb b/optimizer_wrapper.rb index b8763eee7..7793070d3 100644 --- a/optimizer_wrapper.rb +++ b/optimizer_wrapper.rb @@ -82,6 +82,7 @@ def self.wrapper_vrp(api_key, profile, vrp, checksum, job_id = nil) api_key: api_key, checksum: checksum, vrp_name: vrp.name, + vrp_dump_key: vrp_dump_key(api_key, vrp.name, checksum), pids: []) JobList.add(api_key, job_id) { diff --git a/public/js/jobManager.js b/public/js/jobManager.js index b2c7fc365..e3e51699d 100644 --- a/public/js/jobManager.js +++ b/public/js/jobManager.js @@ -18,6 +18,15 @@ function buildResultLink(jobId) { return '' + i18next.t('show_result') + '' } +function buildVrpDumpLink(job) { + if (!job || !job.vrp_dump) return ''; + var jobId = job.uuid; + var filename = (job.name || jobId) + '.json'; + var url = '/0.1/vrp/jobs/' + encodeURIComponent(jobId) + '/vrp?api_key=' + getParams()['api_key']; + return '' + + i18next.t('download_vrp') + ''; +} + function escapeJobHtml(value) { return String(value).replace(/[&<>"']/g, function (char) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]; @@ -54,7 +63,7 @@ function nextPrefixOptions(jobs, prefixes) { function jobsSignature(jobs) { return (jobs || []).map(function (job) { - return [job.uuid, job.status, job.name || '', job.avancement || ''].join('|'); + return [job.uuid, job.status, job.name || '', job.avancement || '', job.vrp_dump ? '1' : '0'].join('|'); }).join(';'); } @@ -96,6 +105,7 @@ var jobsManager = { + '' + '
' + compareBtn + + buildVrpDumpLink(currentJob) + (donwloadBtn ? buildDownloadLink(currentJob.uuid, currentJob.status) : '') + (currentJob.status === 'completed' ? buildResultLink(currentJob.uuid) : '') + ' +
+
-
+
-
+
- + - - - - + + + + diff --git a/public/js/index.js b/public/js/index.js index f134d242f..67e5ee9aa 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -35,7 +35,11 @@ postForm.on('submit', function (e) { } $('#send-files').attr('disabled', true); - $('#optim-infos').html('' + i18next.t('optimize_loading') + ' - '); + $('#optim-infos').html( + '' + i18next.t('optimize_loading') + '' + + '' + + '' + ); jobsManager.submit({ data: JSON.stringify(vrp), @@ -43,7 +47,11 @@ postForm.on('submit', function (e) { contentType: "application/json", }).done(function (submittedJob) { timer = displayTimer(); - $('#optim-infos').append(' '); + $('#optim-infos').append( + '' + + '' + ); $('#optim-kill').click(function (e) { if (window.confirm(i18next.t('delete_confirm'))) { jobsManager.delete($('#optim-job-uid').val()) @@ -81,10 +89,14 @@ postForm.on('submit', function (e) { else if (job.job.status == 'working') { if ($('#optim-status').html() != i18next.t('optimize_loading')) $('#optim-status').html(i18next.t('optimize_loading')); if (job.solutions && job.solutions[0]) { - if (!lastSolution) - $('#optim-infos').append(' - ' + i18next.t('display_solution') + ''); + if (!lastSolution) { + $('#optim-infos').append( + '' + + i18next.t('display_solution') + '' + ); + } lastSolution = job.solutions[0]; - $('#display-solution').click(function (e) { + $('#display-solution').off('click').click(function (e) { displaySolution(submittedJob.job.id, lastSolution); e.preventDefault(); return false; @@ -100,7 +112,7 @@ postForm.on('submit', function (e) { if (job.job.graph) { displayGraph(job.job.graph); } - displaySolution(submittedJob.job.id, job.solutions[0], { downloadButton: true }); + displaySolution(submittedJob.job.id, job.solutions[0], { downloadButton: true, readyForm: true }); } else if (job.job.status == 'failed' || job.job.status == 'killed') { if (debug) console.log('Job failed/killed: ' + JSON.stringify(job)); diff --git a/public/js/utils.js b/public/js/utils.js index 2be90ca41..86d070ff1 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -33,28 +33,45 @@ var displayTimer = function() { var displaySolution = function (jobId, solution, options) { var csv = "/0.1/vrp/jobs/" + jobId + '.csv' + '?api_key=' + getParams()['api_key']; + var summary = ''; if (typeof solution === 'string') { $('#result').html(solution); } else if (typeof solution !== 'string') { - $('#optim-infos').html('iterations: ' + solution.iterations + ' cost: ' + Math.round(solution.cost) + ' (time: ' + (solution.total_time && solution.total_time.toHHMMSS()) + ' distance: ' + Math.round(solution.total_distance / 1000) + ')'); + summary = 'iterations: ' + solution.iterations + + ' cost: ' + Math.round(solution.cost) + ' (time: ' + + (solution.total_time && solution.total_time.toHHMMSS()) + + ' distance: ' + Math.round(solution.total_distance / 1000) + ')'; $('#result').html(JSON.stringify(solution, null, 4)); } var jsonData = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(solution)); - $('#optim-infos').append(' - ' + i18next.t('download_json') + ''); - $('#optim-infos').append(' - ' + i18next.t('download_csv') + ''); - $('#optim-infos').append(' - ' + i18next.t('show_result') + '') + $('#optim-infos').html(summary + + '' + i18next.t('download_json') + '' + + '' + i18next.t('download_csv') + '' + + '' + i18next.t('show_result') + ''); if (options && options.downloadButton) { downloadButton(jobId, csv); } if (options && options.initForm) { initForm(); + } else if (options && options.readyForm) { + readyForm(); } }; -var initForm = function() { +// Re-enable submit without clearing the displayed solution / downloads. +var readyForm = function() { jobsManager.stopJobChecking(); + if (typeof timer !== 'undefined' && timer) { + clearInterval(timer); + timer = null; + } $('#send-files').attr('disabled', false); +}; + +var initForm = function() { + readyForm(); $('#optim-infos').html(''); }; From 32d2c62fa4f202476b3b4a01a1b5cf8fc1b24c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 5 Oct 2026 13:59:26 +0200 Subject: [PATCH 11/12] Remove is-reference box-shadow --- public/css/style.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index aedf5c932..87f6f36d5 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -313,9 +313,6 @@ form #optim-options input { width: 180px; white-space: nowrap; } -.compare-plan, .metric-cell.is-reference { - box-shadow: inset 0 0 0 1px #c5ced8; -} .compare-plan.is-reference { box-shadow: inset 0 0 0 1.5px #1f2933; border-radius: 8px 8px 0 0; @@ -447,4 +444,4 @@ body[data-page=result] { #select-container .select2-container { max-height: 200px; overflow-y: auto; -} \ No newline at end of file +} From d1e3561d8c74763fe78f85ecb9c676ce47e3324c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gw=C3=A9na=C3=ABl=20Rault?= Date: Mon, 5 Oct 2026 14:05:04 +0200 Subject: [PATCH 12/12] Use Buildx GHA cache so CI Docker builds are not always cold. --- .github/workflows/main.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d8e57d9f..dd65f46e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,6 +52,9 @@ jobs: if: github.event.pull_request.draft == false needs: rubocop runs-on: ubuntu-latest + permissions: + contents: read + actions: write # Buildx GHA cache read/write steps: - name: Checkout uses: actions/checkout@v4 @@ -62,9 +65,9 @@ jobs: mv /tmp/gist/* test/fixtures rm -rf /tmp/gist - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile @@ -74,12 +77,8 @@ jobs: VROOM_VERSION=${{ secrets.VROOM_VERSION }} tags: optimizer-api:latest outputs: type=docker,dest=/tmp/optimizer-api.tar - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new - - name: Move buildx cache - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache + cache-from: type=gha + cache-to: type=gha,mode=max - name: Upload Docker image artifact uses: actions/upload-artifact@v4 with: