or-tools 0.1.4 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -661,6 +661,11 @@ void Init_ext()
661
661
  "_solution_integer_value",
662
662
  *[](Object self, CpSolverResponse& response, operations_research::sat::IntVar& x) {
663
663
  return SolutionIntegerValue(response, x);
664
+ })
665
+ .define_method(
666
+ "_solution_boolean_value",
667
+ *[](Object self, CpSolverResponse& response, operations_research::sat::BoolVar& x) {
668
+ return SolutionBooleanValue(response, x);
664
669
  });
665
670
 
666
671
  define_class_under<CpSolverResponse>(rb_mORTools, "CpSolverResponse")
@@ -1,6 +1,6 @@
1
1
  require "mkmf-rice"
2
2
 
3
- abort "Missing stdc++" unless have_library("stdc++")
3
+ raise "Missing stdc++" unless have_library("stdc++")
4
4
 
5
5
  $CXXFLAGS << " -std=c++11 -DUSE_CBC"
6
6
 
@@ -8,73 +8,31 @@ $CXXFLAGS << " -std=c++11 -DUSE_CBC"
8
8
  $CXXFLAGS << " -Wno-sign-compare -Wno-shorten-64-to-32 -Wno-ignored-qualifiers"
9
9
 
10
10
  inc, lib = dir_config("or-tools")
11
-
12
- inc ||= "/usr/local/include"
13
- lib ||= "/usr/local/lib"
11
+ if inc || lib
12
+ inc ||= "/usr/local/include"
13
+ lib ||= "/usr/local/lib"
14
+ rpath = lib
15
+ else
16
+ # download
17
+ require_relative "vendor"
18
+
19
+ inc = "#{$vendor_path}/include"
20
+ lib = "#{$vendor_path}/lib"
21
+
22
+ # make rpath relative
23
+ # use double dollar sign and single quotes to escape properly
24
+ rpath_prefix = RbConfig::CONFIG["host_os"] =~ /darwin/ ? "@loader_path" : "$$ORIGIN"
25
+ rpath = "'#{rpath_prefix}/../../tmp/or-tools/lib'"
26
+ end
14
27
 
15
28
  $INCFLAGS << " -I#{inc}"
16
29
 
17
- $LDFLAGS << " -Wl,-rpath,#{lib}"
30
+ $LDFLAGS << " -Wl,-rpath,#{rpath}"
18
31
  $LDFLAGS << " -L#{lib}"
19
- $LDFLAGS << " -lortools"
32
+ raise "OR-Tools not found" unless have_library("ortools")
20
33
 
21
- %w(
22
- absl_city
23
- absl_time_zone
24
- absl_spinlock_wait
25
- absl_log_severity
26
- absl_failure_signal_handler
27
- absl_bad_optional_access
28
- absl_hash
29
- absl_raw_logging_internal
30
- absl_random_internal_pool_urbg
31
- absl_base
32
- absl_bad_any_cast_impl
33
- absl_periodic_sampler
34
- absl_random_distributions
35
- absl_flags_usage_internal
36
- absl_random_seed_sequences
37
- absl_throw_delegate
38
- absl_flags_handle
39
- absl_dynamic_annotations
40
- absl_debugging_internal
41
- absl_strings
42
- absl_flags
43
- absl_malloc_internal
44
- absl_str_format_internal
45
- absl_flags_usage
46
- absl_strings_internal
47
- absl_flags_program_name
48
- absl_flags_registry
49
- absl_int128
50
- absl_scoped_set_env
51
- absl_raw_hash_set
52
- absl_random_internal_seed_material
53
- absl_symbolize
54
- absl_random_internal_randen_slow
55
- absl_graphcycles_internal
56
- absl_exponential_biased
57
- absl_random_internal_randen_hwaes_impl
58
- absl_bad_variant_access
59
- absl_stacktrace
60
- absl_random_internal_randen_hwaes
61
- absl_flags_parse
62
- absl_random_internal_randen
63
- absl_random_internal_distribution_test_util
64
- absl_time
65
- absl_flags_config
66
- absl_synchronization
67
- absl_hashtablez_sampler
68
- absl_demangle_internal
69
- absl_leak_check
70
- absl_flags_marshalling
71
- absl_leak_check_disable
72
- absl_examine_stack
73
- absl_flags_internal
74
- absl_random_seed_gen_exception
75
- absl_civil_time
76
- ).each do |lib|
77
- $LDFLAGS << " -l#{lib}"
34
+ Dir["#{lib}/libabsl_*.a"].each do |lib|
35
+ $LDFLAGS << " #{lib}"
78
36
  end
79
37
 
80
38
  create_makefile("or_tools/ext")
@@ -0,0 +1,98 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "net/http"
4
+ require "tmpdir"
5
+
6
+ version = "7.8.7959"
7
+
8
+ if RbConfig::CONFIG["host_os"] =~ /darwin/i
9
+ filename = "or-tools_MacOsX-10.15.6_v#{version}.tar.gz"
10
+ checksum = "8d1d92105e962fab2fd7bde5843b711334a9f246dc22c51d41281898dbc1f69d"
11
+ else
12
+ os = %x[lsb_release -is].chomp rescue nil
13
+ os_version = %x[lsb_release -rs].chomp rescue nil
14
+ if os == "Ubuntu" && os_version == "20.04"
15
+ filename = "or-tools_ubuntu-20.04_v#{version}.tar.gz"
16
+ checksum = "40018cd573305cec76e12ff87e84d4ec18ce0823f265e4d75625bf3aefdea7c9"
17
+ elsif os == "Ubuntu" && os_version == "18.04"
18
+ filename = "or-tools_ubuntu-18.04_v#{version}.tar.gz"
19
+ checksum = "6e53e1a7b82b65b928617b9dce663778c5200de9366ad948948597dc763b1943"
20
+ elsif os == "Ubuntu" && os_version == "16.04"
21
+ filename = "or-tools_ubuntu-16.04_v#{version}.tar.gz"
22
+ checksum = "182cd4e2a1d2f29a9a81f2f90cb3e05cf1c0731c1a1e94c471d79a74dc09fff0"
23
+ elsif os == "Debian" && os_version == "10"
24
+ filename = "or-tools_debian-10_v#{version}.tar.gz "
25
+ checksum = "87dd294237095a7fea466c47789802b0a66c664fa1f79e6bbb2cd36323b409ca"
26
+ elsif os == "CentOS" && os_version == "8"
27
+ filename = "or-tools_centos-8_v#{version}.tar.gz"
28
+ checksum = "4e61bf2994fc767da1b4fd073f554c944d2c811609df4bc1afa6a2d876c05bb4"
29
+ else
30
+ # there is a binary download for Windows
31
+ # however, it's compiled with Visual Studio rather than MinGW (which RubyInstaller uses)
32
+ raise <<~MSG
33
+ Binary installation not available for this platform.
34
+
35
+ Build the OR-Tools C++ library from source, then run:
36
+ bundle config build.or-tools --with-or-tools-dir=/path/to/or-tools
37
+
38
+ MSG
39
+ end
40
+ end
41
+
42
+ short_version = version.split(".").first(2).join(".")
43
+ url = "https://github.com/google/or-tools/releases/download/v#{short_version}/#{filename}"
44
+
45
+ $stdout.sync = true
46
+
47
+ def download_file(url, download_path)
48
+ uri = URI(url)
49
+ location = nil
50
+
51
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
52
+ request = Net::HTTP::Get.new(uri)
53
+ http.request(request) do |response|
54
+ case response
55
+ when Net::HTTPRedirection
56
+ location = response["location"]
57
+ when Net::HTTPSuccess
58
+ i = 0
59
+ File.open(download_path, "wb") do |f|
60
+ response.read_body do |chunk|
61
+ f.write(chunk)
62
+
63
+ # print progress
64
+ putc "." if i % 50 == 0
65
+ i += 1
66
+ end
67
+ end
68
+ puts # newline
69
+ else
70
+ raise "Bad response"
71
+ end
72
+ end
73
+ end
74
+
75
+ # outside of Net::HTTP block to close previous connection
76
+ download_file(location, download_path) if location
77
+ end
78
+
79
+ # download
80
+ download_path = "#{Dir.tmpdir}/#{filename}"
81
+ unless File.exist?(download_path)
82
+ puts "Downloading #{url}..."
83
+ download_file(url, download_path)
84
+ end
85
+
86
+ # check integrity - do this regardless of if just downloaded
87
+ download_checksum = Digest::SHA256.file(download_path).hexdigest
88
+ raise "Bad checksum: #{download_checksum}" if download_checksum != checksum
89
+
90
+ # extract - can't use Gem::Package#extract_tar_gz from RubyGems
91
+ # since it limits filenames to 100 characters (doesn't support UStar format)
92
+ path = File.expand_path("../../tmp/or-tools", __dir__)
93
+ FileUtils.mkdir_p(path)
94
+ tar_args = Gem.win_platform? ? ["--force-local"] : []
95
+ system "tar", "zxf", download_path, "-C", path, "--strip-components=1", *tar_args
96
+
97
+ # export
98
+ $vendor_path = path
@@ -17,6 +17,12 @@ require "or_tools/sat_int_var"
17
17
  require "or_tools/solver"
18
18
  require "or_tools/version"
19
19
 
20
+ # higher level interfaces
21
+ require "or_tools/basic_scheduler"
22
+ require "or_tools/seating"
23
+ require "or_tools/sudoku"
24
+ require "or_tools/tsp"
25
+
20
26
  module ORTools
21
27
  class Error < StandardError; end
22
28
  end
@@ -0,0 +1,86 @@
1
+ module ORTools
2
+ class BasicScheduler
3
+ attr_reader :assignments, :assigned_hours
4
+
5
+ # for time blocks (shifts and availability)
6
+ # could also use time range (starts_at..ends_at) or starts_at + duration
7
+ # keep current format for now for flexibility
8
+ def initialize(people:, shifts:)
9
+ @shifts = shifts
10
+
11
+ model = ORTools::CpModel.new
12
+
13
+ # create variables
14
+ # a person must be available for the entire shift to be considered for it
15
+ vars = []
16
+ shifts.each_with_index do |shift, i|
17
+ people.each_with_index do |person, j|
18
+ if person[:availability].any? { |a| a[:starts_at] <= shift[:starts_at] && a[:ends_at] >= shift[:ends_at] }
19
+ vars << {shift: i, person: j, var: model.new_bool_var("{shift: #{i}, person: #{j}}")}
20
+ end
21
+ end
22
+ end
23
+
24
+ vars_by_shift = vars.group_by { |v| v[:shift] }
25
+ vars_by_person = vars.group_by { |v| v[:person] }
26
+
27
+ # one person per shift
28
+ vars_by_shift.each do |j, vs|
29
+ model.add(model.sum(vs.map { |v| v[:var] }) <= 1)
30
+ end
31
+
32
+ # one shift per day per person
33
+ # in future, may also want to add option to ensure assigned shifts are N hours apart
34
+ vars_by_person.each do |j, vs|
35
+ vs.group_by { |v| shift_dates[v[:shift]] }.each do |_, vs2|
36
+ model.add(model.sum(vs2.map { |v| v[:var] }) <= 1)
37
+ end
38
+ end
39
+
40
+ # max hours per person
41
+ # use seconds since model needs integers
42
+ vars_by_person.each do |j, vs|
43
+ max_hours = people[j][:max_hours]
44
+ if max_hours
45
+ model.add(model.sum(vs.map { |v| v[:var] * shift_duration[v[:shift]] }) <= max_hours * 3600)
46
+ end
47
+ end
48
+
49
+ # maximize hours assigned
50
+ # could also include distance from max hours
51
+ model.maximize(model.sum(vars.map { |v| v[:var] * shift_duration[v[:shift]] }))
52
+
53
+ # solve
54
+ solver = ORTools::CpSolver.new
55
+ status = solver.solve(model)
56
+ raise Error, "No solution found" unless [:feasible, :optimal].include?(status)
57
+
58
+ # read solution
59
+ @assignments = []
60
+ vars.each do |v|
61
+ if solver.value(v[:var])
62
+ @assignments << {
63
+ person: v[:person],
64
+ shift: v[:shift]
65
+ }
66
+ end
67
+ end
68
+ # can calculate manually if objective changes
69
+ @assigned_hours = solver.objective_value / 3600.0
70
+ end
71
+
72
+ def total_hours
73
+ @total_hours ||= shift_duration.sum / 3600.0
74
+ end
75
+
76
+ private
77
+
78
+ def shift_duration
79
+ @shift_duration ||= @shifts.map { |s| (s[:ends_at] - s[:starts_at]).round }
80
+ end
81
+
82
+ def shift_dates
83
+ @shift_dates ||= @shifts.map { |s| s[:starts_at].to_date }
84
+ end
85
+ end
86
+ end
@@ -12,7 +12,11 @@ module ORTools
12
12
  end
13
13
 
14
14
  def value(var)
15
- _solution_integer_value(@response, var)
15
+ if var.is_a?(BoolVar)
16
+ _solution_boolean_value(@response, var)
17
+ else
18
+ _solution_integer_value(@response, var)
19
+ end
16
20
  end
17
21
 
18
22
  def solve_with_solution_callback(model, observer)
@@ -0,0 +1,115 @@
1
+ module ORTools
2
+ class Seating
3
+ attr_reader :assignments, :people, :total_weight
4
+
5
+ def initialize(connections:, tables:, min_connections: 1)
6
+ @people = connections.flat_map { |c| c[:people] }.uniq
7
+
8
+ @connections_for = {}
9
+ @people.each do |person|
10
+ @connections_for[person] = {}
11
+ end
12
+ connections.each do |c|
13
+ c[:people].each_with_index do |person, i|
14
+ others = c[:people].dup
15
+ others.delete_at(i)
16
+ others.each do |other|
17
+ @connections_for[person][other] ||= 0
18
+ @connections_for[person][other] += c[:weight]
19
+ end
20
+ end
21
+ end
22
+
23
+ model = ORTools::CpModel.new
24
+ all_tables = tables.size.times.to_a
25
+
26
+ # decision variables
27
+ seats = {}
28
+ all_tables.each do |t|
29
+ people.each do |g|
30
+ seats[[t, g]] = model.new_bool_var("guest %s seats on table %i" % [g, t])
31
+ end
32
+ end
33
+
34
+ pairs = people.combination(2)
35
+
36
+ colocated = {}
37
+ pairs.each do |g1, g2|
38
+ colocated[[g1, g2]] = model.new_bool_var("guest %s seats with guest %s" % [g1, g2])
39
+ end
40
+
41
+ same_table = {}
42
+ pairs.each do |g1, g2|
43
+ all_tables.each do |t|
44
+ same_table[[g1, g2, t]] = model.new_bool_var("guest %s seats with guest %s on table %i" % [g1, g2, t])
45
+ end
46
+ end
47
+
48
+ # objective
49
+ objective = []
50
+ pairs.each do |g1, g2|
51
+ weight = @connections_for[g1][g2]
52
+ objective << colocated[[g1, g2]] * weight if weight
53
+ end
54
+ model.maximize(model.sum(objective))
55
+
56
+ # everybody seats at one table
57
+ people.each do |g|
58
+ model.add(model.sum(all_tables.map { |t| seats[[t, g]] }) == 1)
59
+ end
60
+
61
+ # tables have a max capacity
62
+ all_tables.each do |t|
63
+ model.add(model.sum(@people.map { |g| seats[[t, g]] }) <= tables[t])
64
+ end
65
+
66
+ # link colocated with seats
67
+ pairs.each do |g1, g2|
68
+ all_tables.each do |t|
69
+ # link same_table and seats
70
+ model.add_bool_or([seats[[t, g1]].not, seats[[t, g2]].not, same_table[[g1, g2, t]]])
71
+ model.add_implication(same_table[[g1, g2, t]], seats[[t, g1]])
72
+ model.add_implication(same_table[[g1, g2, t]], seats[[t, g2]])
73
+ end
74
+
75
+ # link colocated and same_table
76
+ model.add(model.sum(all_tables.map { |t| same_table[[g1, g2, t]] }) == colocated[[g1, g2]])
77
+ end
78
+
79
+ # min known neighbors rule
80
+ same_table_by_person = Hash.new { |hash, key| hash[key] = [] }
81
+ same_table.each do |(g1, g2, t), v|
82
+ next unless @connections_for[g1][g2]
83
+ same_table_by_person[g1] << v
84
+ same_table_by_person[g2] << v
85
+ end
86
+ same_table_by_person.each do |_, vars|
87
+ model.add(model.sum(vars) >= min_connections)
88
+ end
89
+
90
+ # solve
91
+ solver = ORTools::CpSolver.new
92
+ status = solver.solve(model)
93
+ raise Error, "No solution found" unless [:feasible, :optimal].include?(status)
94
+
95
+ # read solution
96
+ @assignments = {}
97
+ seats.each do |k, v|
98
+ if solver.value(v)
99
+ @assignments[k[1]] = k[0]
100
+ end
101
+ end
102
+ @total_weight = solver.objective_value
103
+ end
104
+
105
+ def assigned_tables
106
+ assignments.group_by { |_, v| v }.map { |k, v| [k, v.map(&:first)] }.sort_by(&:first).map(&:last)
107
+ end
108
+
109
+ def connections_for(person, same_table: false)
110
+ result = @connections_for[person]
111
+ result = result.select { |k, _| @assignments[k] == @assignments[person] } if same_table
112
+ result
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,132 @@
1
+ module ORTools
2
+ class Sudoku
3
+ attr_reader :solution
4
+
5
+ def initialize(initial_grid, x: false, magic_square: false, anti_knight: false, anti_king: false, non_consecutive: false)
6
+ raise ArgumentError, "Grid must be 9x9" unless initial_grid.size == 9 && initial_grid.all? { |r| r.size == 9 }
7
+ raise ArgumentError, "Grid must contain values between 0 and 9" unless initial_grid.flatten(1).all? { |v| (0..9).include?(v) }
8
+
9
+ model = ORTools::CpModel.new
10
+
11
+ cell_size = 3
12
+ line_size = cell_size**2
13
+ line = (0...line_size).to_a
14
+ cell = (0...cell_size).to_a
15
+
16
+ grid = {}
17
+ line.each do |i|
18
+ line.each do |j|
19
+ grid[[i, j]] = model.new_int_var(1, line_size, "grid %i %i" % [i, j])
20
+ end
21
+ end
22
+
23
+ line.each do |i|
24
+ model.add_all_different(line.map { |j| grid[[i, j]] })
25
+ end
26
+
27
+ line.each do |j|
28
+ model.add_all_different(line.map { |i| grid[[i, j]] })
29
+ end
30
+
31
+ cell.each do |i|
32
+ cell.each do |j|
33
+ one_cell = []
34
+ cell.each do |di|
35
+ cell.each do |dj|
36
+ one_cell << grid[[i * cell_size + di, j * cell_size + dj]]
37
+ end
38
+ end
39
+ model.add_all_different(one_cell)
40
+ end
41
+ end
42
+
43
+ line.each do |i|
44
+ line.each do |j|
45
+ if initial_grid[i][j] != 0
46
+ model.add(grid[[i, j]] == initial_grid[i][j])
47
+ end
48
+ end
49
+ end
50
+
51
+ if x
52
+ model.add_all_different(9.times.map { |i| grid[[i, i]] })
53
+ model.add_all_different(9.times.map { |i| grid[[i, 8 - i]] })
54
+ end
55
+
56
+ if magic_square
57
+ magic_sums = []
58
+ 3.times do |i|
59
+ magic_sums << model.sum(3.times.map { |j| grid[[3 + i, 3 + j]] })
60
+ magic_sums << model.sum(3.times.map { |j| grid[[3 + j, 3 + i]] })
61
+ end
62
+
63
+ magic_sums << model.sum(3.times.map { |i| grid[[3 + i, 3 + i]] })
64
+ magic_sums << model.sum(3.times.map { |i| grid[[3 + i, 5 - i]] })
65
+
66
+ first_sum = magic_sums.shift
67
+ magic_sums.each do |magic_sum|
68
+ model.add(magic_sum == first_sum)
69
+ end
70
+ end
71
+
72
+ if anti_knight
73
+ # add anti-knights rule
74
+ # for each square, add squares that cannot be feasible
75
+ moves = [[1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2]]
76
+ 9.times do |i|
77
+ 9.times do |j|
78
+ moves.each do |mi, mj|
79
+ square = grid[[i + mi, j + mj]]
80
+ if square
81
+ model.add(grid[[i, j]] != square)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ if anti_king
89
+ # add anti-king rule
90
+ # for each square, add squares that cannot be feasible
91
+ moves = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]]
92
+ 9.times do |i|
93
+ 9.times do |j|
94
+ moves.each do |mi, mj|
95
+ square = grid[[i + mi, j + mj]]
96
+ if square
97
+ model.add(grid[[i, j]] != square)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ if non_consecutive
105
+ # add non-consecutive rule
106
+ # for each square, add squares that cannot be feasible
107
+ moves = [[1, 0], [0, 1], [-1, 0], [0, -1]]
108
+ 9.times do |i|
109
+ 9.times do |j|
110
+ moves.each do |mi, mj|
111
+ square = grid[[i + mi, j + mj]]
112
+ if square
113
+ model.add(grid[[i, j]] + 1 != square)
114
+ model.add(grid[[i, j]] - 1 != square)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ solver = ORTools::CpSolver.new
122
+ status = solver.solve(model)
123
+ raise Error, "No solution found" unless [:feasible, :optimal].include?(status)
124
+
125
+ solution = []
126
+ line.each do |i|
127
+ solution << line.map { |j| solver.value(grid[[i, j]]) }
128
+ end
129
+ @solution = solution
130
+ end
131
+ end
132
+ end