or-tools 0.1.2 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +1505 -423
- data/ext/or-tools/ext.cpp +280 -18
- data/ext/or-tools/extconf.rb +21 -63
- data/ext/or-tools/vendor.rb +95 -0
- data/lib/or-tools.rb +8 -0
- data/lib/or_tools/basic_scheduler.rb +86 -0
- data/lib/or_tools/bool_var.rb +9 -0
- data/lib/or_tools/cp_solver.rb +11 -2
- data/lib/or_tools/cp_solver_solution_callback.rb +12 -1
- data/lib/or_tools/ext.bundle +0 -0
- data/lib/or_tools/int_var.rb +5 -0
- data/lib/or_tools/linear_expr.rb +8 -0
- data/lib/or_tools/sat_int_var.rb +13 -0
- data/lib/or_tools/sat_linear_expr.rb +18 -2
- data/lib/or_tools/seating.rb +115 -0
- data/lib/or_tools/sudoku.rb +132 -0
- data/lib/or_tools/tsp.rb +60 -0
- data/lib/or_tools/version.rb +1 -1
- metadata +9 -2
@@ -0,0 +1,95 @@
|
|
1
|
+
require "digest"
|
2
|
+
require "fileutils"
|
3
|
+
require "net/http"
|
4
|
+
require "tmpdir"
|
5
|
+
|
6
|
+
version = "7.7.7810"
|
7
|
+
|
8
|
+
if RbConfig::CONFIG["host_os"] =~ /darwin/i
|
9
|
+
filename = "or-tools_MacOsX-10.15.5_v#{version}.tar.gz"
|
10
|
+
checksum = "764f290f6d916bc366913a37d93e6f83bd7969ad33515ccc1ca390f544d65721"
|
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 == "18.04"
|
15
|
+
filename = "or-tools_ubuntu-18.04_v#{version}.tar.gz"
|
16
|
+
checksum = "12bdac29144b077b3f9ba602f947e4b9b9ce63ed3df4e325cda1333827edbcf8"
|
17
|
+
elsif os == "Ubuntu" && os_version == "16.04"
|
18
|
+
filename = "or-tools_ubuntu-16.04_v#{version}.tar.gz"
|
19
|
+
checksum = "cc696d342b97aa6cf7c62b6ae2cae95dfc665f2483d147c4117fdba434b13a53"
|
20
|
+
elsif os == "Debian" && os_version == "10"
|
21
|
+
filename = "or-tools_debian-10_v#{version}.tar.gz "
|
22
|
+
checksum = "3dd0299e9ad8d12fe6d186bfd59e63080c8e9f3c6b0489af9900c389cf7e4224"
|
23
|
+
elsif os == "CentOS" && os_version == "8"
|
24
|
+
filename = "or-tools_centos-8_v#{version}.tar.gz"
|
25
|
+
checksum = "1f7d8bce56807c4283374e05024ffac8afd81ff99063217418d02d626cf03088"
|
26
|
+
else
|
27
|
+
# there is a binary download for Windows
|
28
|
+
# however, it's compiled with Visual Studio rather than MinGW (which RubyInstaller uses)
|
29
|
+
raise <<~MSG
|
30
|
+
Binary installation not available for this platform.
|
31
|
+
|
32
|
+
Build the OR-Tools C++ library from source, then run:
|
33
|
+
bundle config build.or-tools --with-or-tools-dir=/path/to/or-tools
|
34
|
+
|
35
|
+
MSG
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
short_version = version.split(".").first(2).join(".")
|
40
|
+
url = "https://github.com/google/or-tools/releases/download/v#{short_version}/#{filename}"
|
41
|
+
|
42
|
+
$stdout.sync = true
|
43
|
+
|
44
|
+
def download_file(url, download_path)
|
45
|
+
uri = URI(url)
|
46
|
+
location = nil
|
47
|
+
|
48
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
49
|
+
request = Net::HTTP::Get.new(uri)
|
50
|
+
http.request(request) do |response|
|
51
|
+
case response
|
52
|
+
when Net::HTTPRedirection
|
53
|
+
location = response["location"]
|
54
|
+
when Net::HTTPSuccess
|
55
|
+
i = 0
|
56
|
+
File.open(download_path, "wb") do |f|
|
57
|
+
response.read_body do |chunk|
|
58
|
+
f.write(chunk)
|
59
|
+
|
60
|
+
# print progress
|
61
|
+
putc "." if i % 50 == 0
|
62
|
+
i += 1
|
63
|
+
end
|
64
|
+
end
|
65
|
+
puts # newline
|
66
|
+
else
|
67
|
+
raise "Bad response"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# outside of Net::HTTP block to close previous connection
|
73
|
+
download_file(location, download_path) if location
|
74
|
+
end
|
75
|
+
|
76
|
+
# download
|
77
|
+
download_path = "#{Dir.tmpdir}/#{filename}"
|
78
|
+
unless File.exist?(download_path)
|
79
|
+
puts "Downloading #{url}..."
|
80
|
+
download_file(url, download_path)
|
81
|
+
end
|
82
|
+
|
83
|
+
# check integrity - do this regardless of if just downloaded
|
84
|
+
download_checksum = Digest::SHA256.file(download_path).hexdigest
|
85
|
+
raise "Bad checksum: #{download_checksum}" if download_checksum != checksum
|
86
|
+
|
87
|
+
# extract - can't use Gem::Package#extract_tar_gz from RubyGems
|
88
|
+
# since it limits filenames to 100 characters (doesn't support UStar format)
|
89
|
+
path = File.expand_path("../../tmp/or-tools", __dir__)
|
90
|
+
FileUtils.mkdir_p(path)
|
91
|
+
tar_args = Gem.win_platform? ? ["--force-local"] : []
|
92
|
+
system "tar", "zxf", download_path, "-C", path, "--strip-components=1", *tar_args
|
93
|
+
|
94
|
+
# export
|
95
|
+
$vendor_path = path
|
data/lib/or-tools.rb
CHANGED
@@ -4,9 +4,11 @@ require "or_tools/ext"
|
|
4
4
|
# modules
|
5
5
|
require "or_tools/comparison"
|
6
6
|
require "or_tools/comparison_operators"
|
7
|
+
require "or_tools/bool_var"
|
7
8
|
require "or_tools/cp_model"
|
8
9
|
require "or_tools/cp_solver"
|
9
10
|
require "or_tools/cp_solver_solution_callback"
|
11
|
+
require "or_tools/int_var"
|
10
12
|
require "or_tools/knapsack_solver"
|
11
13
|
require "or_tools/linear_expr"
|
12
14
|
require "or_tools/routing_model"
|
@@ -15,6 +17,12 @@ require "or_tools/sat_int_var"
|
|
15
17
|
require "or_tools/solver"
|
16
18
|
require "or_tools/version"
|
17
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
|
+
|
18
26
|
module ORTools
|
19
27
|
class Error < StandardError; end
|
20
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
|
data/lib/or_tools/cp_solver.rb
CHANGED
@@ -12,11 +12,20 @@ module ORTools
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def value(var)
|
15
|
-
|
15
|
+
if var.is_a?(BoolVar)
|
16
|
+
_solution_boolean_value(@response, var)
|
17
|
+
else
|
18
|
+
_solution_integer_value(@response, var)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def solve_with_solution_callback(model, observer)
|
23
|
+
@response = _solve_with_observer(model, observer, false)
|
24
|
+
@response.status
|
16
25
|
end
|
17
26
|
|
18
27
|
def search_for_all_solutions(model, observer)
|
19
|
-
@response = _solve_with_observer(model, observer)
|
28
|
+
@response = _solve_with_observer(model, observer, true)
|
20
29
|
@response.status
|
21
30
|
end
|
22
31
|
end
|
@@ -3,7 +3,18 @@ module ORTools
|
|
3
3
|
attr_writer :response
|
4
4
|
|
5
5
|
def value(expr)
|
6
|
-
|
6
|
+
case expr
|
7
|
+
when SatIntVar
|
8
|
+
@response.solution_integer_value(expr)
|
9
|
+
when BoolVar
|
10
|
+
@response.solution_boolean_value(expr)
|
11
|
+
else
|
12
|
+
raise "Unsupported type"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def objective_value
|
17
|
+
@response.objective_value
|
7
18
|
end
|
8
19
|
end
|
9
20
|
end
|
data/lib/or_tools/ext.bundle
CHANGED
Binary file
|
data/lib/or_tools/linear_expr.rb
CHANGED
data/lib/or_tools/sat_int_var.rb
CHANGED
@@ -5,5 +5,18 @@ module ORTools
|
|
5
5
|
def *(other)
|
6
6
|
SatLinearExpr.new([[self, other]])
|
7
7
|
end
|
8
|
+
|
9
|
+
def +(other)
|
10
|
+
SatLinearExpr.new([[self, 1], [other, 1]])
|
11
|
+
end
|
12
|
+
|
13
|
+
def -(other)
|
14
|
+
SatLinearExpr.new([[self, 1], [-other, 1]])
|
15
|
+
end
|
16
|
+
|
17
|
+
# for now
|
18
|
+
def inspect
|
19
|
+
name
|
20
|
+
end
|
8
21
|
end
|
9
22
|
end
|
@@ -16,8 +16,24 @@ module ORTools
|
|
16
16
|
add(other, -1)
|
17
17
|
end
|
18
18
|
|
19
|
+
def *(other)
|
20
|
+
if vars.size == 1
|
21
|
+
self.class.new([[vars[0][0], vars[0][1] * other]])
|
22
|
+
else
|
23
|
+
raise ArgumentError, "Multiplication not allowed here"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
19
27
|
def inspect
|
20
|
-
vars.map
|
28
|
+
vars.map do |v|
|
29
|
+
k = v[0]
|
30
|
+
k = k.respond_to?(:name) ? k.name : k.inspect
|
31
|
+
if v[1] == 1
|
32
|
+
k
|
33
|
+
else
|
34
|
+
"#{k} * #{v[1]}"
|
35
|
+
end
|
36
|
+
end.join(" + ").sub(" + -", " - ")
|
21
37
|
end
|
22
38
|
|
23
39
|
private
|
@@ -27,7 +43,7 @@ module ORTools
|
|
27
43
|
case other
|
28
44
|
when SatLinearExpr
|
29
45
|
other.vars
|
30
|
-
when BoolVar
|
46
|
+
when BoolVar, SatIntVar
|
31
47
|
[[other, 1]]
|
32
48
|
else
|
33
49
|
raise ArgumentError, "Unsupported type"
|
@@ -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
|