or-tools 0.1.2 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|