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.
@@ -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
@@ -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
@@ -0,0 +1,9 @@
1
+ module ORTools
2
+ class BoolVar
3
+ include ComparisonOperators
4
+
5
+ def *(other)
6
+ SatLinearExpr.new([[self, other]])
7
+ end
8
+ end
9
+ end
@@ -12,11 +12,20 @@ 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
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
- @response.solution_boolean_value(expr)
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
Binary file
@@ -0,0 +1,5 @@
1
+ module ORTools
2
+ class IntVar
3
+ include ComparisonOperators
4
+ end
5
+ end
@@ -8,6 +8,14 @@ module ORTools
8
8
  end
9
9
  end
10
10
 
11
+ def >=(other)
12
+ if other.is_a?(LinearExpr)
13
+ _gte_linear_expr(other)
14
+ else
15
+ _gte_double(other)
16
+ end
17
+ end
18
+
11
19
  def <=(other)
12
20
  if other.is_a?(LinearExpr)
13
21
  _lte_linear_expr(other)
@@ -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 { |v| v[0].is_a?(BoolVar) ? v[0].name : v[0].name + " * " + v[1] }.join(" + ")
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