quicopt 0.1.0 → 0.4.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/.yardopts +2 -1
- data/README.md +269 -48
- data/lib/quicopt/client.rb +219 -91
- data/lib/quicopt/indicators.rb +236 -0
- data/lib/quicopt/model.rb +463 -67
- data/lib/quicopt/modeler/v1/program_pb.rb +10 -1
- data/lib/quicopt/structured.rb +438 -0
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +173 -37
- data/lib/quicopt.rb +8 -7
- data/proto/quicopt/modeler/v1/program.proto +160 -6
- metadata +12 -7
data/lib/quicopt/model.rb
CHANGED
|
@@ -9,24 +9,28 @@
|
|
|
9
9
|
# expressions (+x + y <= 5+) builds a +Constraint+ — neither evaluates to a number
|
|
10
10
|
# or a boolean.
|
|
11
11
|
#
|
|
12
|
-
# Expressions are
|
|
12
|
+
# Expressions are collected as they are built: an +Expression+ is always a
|
|
13
13
|
# constant plus a map of linear terms plus a map of quadratic terms, never an
|
|
14
14
|
# unreduced operator tree. That is what makes the degree check exact — anything
|
|
15
15
|
# that would exceed degree 2 raises at the point of multiplication rather than
|
|
16
|
-
# reaching the service, per the "raise loudly, never approximate" rule the
|
|
16
|
+
# reaching the service, per the "raise loudly, never approximate" rule the other
|
|
17
17
|
# clients follow.
|
|
18
18
|
#
|
|
19
|
-
# Only flat (unindexed) variables exist here. The
|
|
20
|
-
# sets
|
|
21
|
-
#
|
|
19
|
+
# Only flat (unindexed) variables exist here. The message format also carries
|
|
20
|
+
# index sets and quantified constraints; the Ruby DSL deliberately does not — the
|
|
21
|
+
# Python and Julia clients reach those. It does carry permutations, partitions
|
|
22
|
+
# and lookup tables (+quicopt/structured+) and terms that switch
|
|
23
|
+
# (+quicopt/indicators+), which enter an +Expression+ as factors of degree 1,
|
|
24
|
+
# alongside the variables.
|
|
22
25
|
|
|
23
26
|
module Quicopt
|
|
24
27
|
# Base class of every error this gem raises.
|
|
25
28
|
class Error < StandardError; end
|
|
26
29
|
|
|
27
|
-
# An expression
|
|
28
|
-
# product of three variables, or a division by a variable.
|
|
29
|
-
# of construction so the offending term is still in the
|
|
30
|
+
# An expression Quicopt cannot express, because it is neither linear nor
|
|
31
|
+
# quadratic — a product of three variables, say, or a division by a variable.
|
|
32
|
+
# Raised at the point of construction so the offending term is still in the
|
|
33
|
+
# backtrace.
|
|
30
34
|
class UnsupportedExpression < Error; end
|
|
31
35
|
|
|
32
36
|
# Number and term rendering shared by the DSL's +to_s+ methods. Presentation
|
|
@@ -43,7 +47,7 @@ module Quicopt
|
|
|
43
47
|
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
44
48
|
end
|
|
45
49
|
|
|
46
|
-
# One term, dropping a unit coefficient (
|
|
50
|
+
# One term, dropping a unit coefficient (<tt>x</tt>, <tt>-x</tt>, <tt>3*x</tt>).
|
|
47
51
|
def term(coefficient, factors)
|
|
48
52
|
return factors if coefficient == 1.0
|
|
49
53
|
return "-#{factors}" if coefficient == -1.0
|
|
@@ -59,7 +63,7 @@ module Quicopt
|
|
|
59
63
|
# what lets +3 * x+ parse: Ruby hands the multiplication back to us rather than
|
|
60
64
|
# failing in +Integer#*+.
|
|
61
65
|
module Operators
|
|
62
|
-
# @return [Expression]
|
|
66
|
+
# @return [Expression] <tt>self + other</tt>
|
|
63
67
|
def +(other)
|
|
64
68
|
to_expr.plus(Expression.cast(other))
|
|
65
69
|
end
|
|
@@ -76,7 +80,7 @@ module Quicopt
|
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
# Division by a numeric constant only — dividing by an expression is not a
|
|
79
|
-
# polynomial and
|
|
83
|
+
# polynomial, and a Quicopt model is a polynomial.
|
|
80
84
|
#
|
|
81
85
|
# @return [Expression] +self / divisor+
|
|
82
86
|
# @raise [UnsupportedExpression] if +divisor+ is not a nonzero number
|
|
@@ -90,7 +94,7 @@ module Quicopt
|
|
|
90
94
|
to_expr.scale(1.0 / divisor)
|
|
91
95
|
end
|
|
92
96
|
|
|
93
|
-
# Integer powers 0, 1 and 2 only —
|
|
97
|
+
# Integer powers 0, 1 and 2 only — a Quicopt model is linear or quadratic.
|
|
94
98
|
#
|
|
95
99
|
# @return [Expression] +self ** exponent+
|
|
96
100
|
# @raise [UnsupportedExpression] on any other exponent
|
|
@@ -101,7 +105,8 @@ module Quicopt
|
|
|
101
105
|
when 2 then to_expr.times(to_expr)
|
|
102
106
|
else
|
|
103
107
|
raise UnsupportedExpression,
|
|
104
|
-
"exponent #{exponent.inspect} is
|
|
108
|
+
"exponent #{exponent.inspect} is not supported: a Quicopt model is " \
|
|
109
|
+
"linear or quadratic, so the exponent must be 0, 1 or 2"
|
|
105
110
|
end
|
|
106
111
|
end
|
|
107
112
|
|
|
@@ -125,12 +130,28 @@ module Quicopt
|
|
|
125
130
|
Constraint.build(self, other, :ge)
|
|
126
131
|
end
|
|
127
132
|
|
|
133
|
+
# A strict comparison, for +Quicopt.holds+ only: no constraint row can be
|
|
134
|
+
# strict, so +Model#add+ refuses it.
|
|
135
|
+
#
|
|
136
|
+
# @return [Constraint] the comparison +self < other+
|
|
137
|
+
def <(other)
|
|
138
|
+
Constraint.build(self, other, :lt)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A strict comparison, for +Quicopt.holds+ only (see +<+).
|
|
142
|
+
#
|
|
143
|
+
# @return [Constraint] the comparison +self > other+
|
|
144
|
+
def >(other)
|
|
145
|
+
Constraint.build(self, other, :gt)
|
|
146
|
+
end
|
|
147
|
+
|
|
128
148
|
# Builds an *equality row*, not a boolean — +x == 5+ is a constraint.
|
|
129
149
|
#
|
|
130
150
|
# A non-numeric, non-expression operand falls back to identity, so an
|
|
131
|
-
# incidental comparison (
|
|
132
|
-
# of raising. Hash and Set membership are unaffected
|
|
133
|
-
#
|
|
151
|
+
# incidental comparison (<tt>x == nil</tt>, <tt>x == "x"</tt>) still
|
|
152
|
+
# answers +false+ instead of raising. Hash and Set membership are unaffected
|
|
153
|
+
# either way: they key off <tt>hash</tt>/<tt>eql?</tt>, which +Variable+
|
|
154
|
+
# defines separately.
|
|
134
155
|
#
|
|
135
156
|
# @return [Constraint, Boolean]
|
|
136
157
|
def ==(other)
|
|
@@ -139,19 +160,20 @@ module Quicopt
|
|
|
139
160
|
Constraint.build(self, other, :eq)
|
|
140
161
|
end
|
|
141
162
|
|
|
142
|
-
# A
|
|
143
|
-
#
|
|
163
|
+
# A "not equal to" comparison, for +Quicopt.holds+ only: +Model#add+ refuses
|
|
164
|
+
# it as a row. Defined so that Ruby does not derive +!=+ from +==+ and
|
|
165
|
+
# silently answer +false+.
|
|
144
166
|
#
|
|
145
|
-
# @
|
|
167
|
+
# @return [Constraint, Boolean]
|
|
146
168
|
def !=(other)
|
|
147
169
|
return !equal?(other) unless Expression.castable?(other)
|
|
148
170
|
|
|
149
|
-
|
|
150
|
-
"!= is not a constraint the wire schema can carry; model it with a binary and a big-M row"
|
|
171
|
+
Constraint.build(self, other, :ne)
|
|
151
172
|
end
|
|
152
173
|
|
|
153
174
|
# Lets Ruby fold a numeric on the left into an expression, so +3 * x+ and
|
|
154
|
-
# +10 - x+ dispatch here instead of failing inside
|
|
175
|
+
# +10 - x+ dispatch here instead of failing inside
|
|
176
|
+
# <tt>Integer</tt>/<tt>Float</tt>.
|
|
155
177
|
#
|
|
156
178
|
# @param other [Numeric]
|
|
157
179
|
# @return [Array(Expression, Expression)]
|
|
@@ -162,17 +184,19 @@ module Quicopt
|
|
|
162
184
|
|
|
163
185
|
# A decision variable, owned by the +Model+ that created it.
|
|
164
186
|
#
|
|
165
|
-
# +index+ is the variable's position in its model; it also fixes the
|
|
166
|
-
#
|
|
187
|
+
# +index+ is the variable's position in its model; it also fixes the order of a
|
|
188
|
+
# quadratic term's two factors (see +order+), so +x * y+ and +y * x+ land on the
|
|
167
189
|
# same coefficient.
|
|
168
190
|
class Variable
|
|
169
191
|
include Operators
|
|
170
192
|
|
|
171
193
|
# @return [Model] the model that declared this variable
|
|
172
194
|
attr_reader :model
|
|
173
|
-
# @return [String] the
|
|
195
|
+
# @return [String] the variable's name; solutions come back keyed by it
|
|
174
196
|
attr_reader :name
|
|
175
|
-
# @return [Symbol] +:CONTINUOUS+, +:INTEGER
|
|
197
|
+
# @return [Symbol] +:CONTINUOUS+, +:INTEGER+, +:BINARY+, or one of the two
|
|
198
|
+
# domains that also allow 0: +:SEMICONTINUOUS+ (0 or any value within the
|
|
199
|
+
# bounds) and +:SEMIINTEGER+ (0 or a whole number within them)
|
|
176
200
|
attr_reader :domain
|
|
177
201
|
# @return [Float] the lower bound (may be +-Float::INFINITY+)
|
|
178
202
|
attr_reader :lower
|
|
@@ -199,6 +223,34 @@ module Quicopt
|
|
|
199
223
|
Expression.new(linear: { self => 1.0 })
|
|
200
224
|
end
|
|
201
225
|
|
|
226
|
+
# Whether the variable is in use: 1 when its value is not 0, else 0. Made
|
|
227
|
+
# for a variable declared with +semi: true+, which is 0 or within its
|
|
228
|
+
# bounds: a delivery of either nothing or 10 to 25 units, say. A fee per
|
|
229
|
+
# delivery is then
|
|
230
|
+
#
|
|
231
|
+
# fee * ship.active
|
|
232
|
+
#
|
|
233
|
+
# and a model that is otherwise linear stays one the service solves exactly.
|
|
234
|
+
#
|
|
235
|
+
# @return [Expression] a 0/1 expression
|
|
236
|
+
def active
|
|
237
|
+
Conditions.term(:indicator, to_expr)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Where this variable sorts among its model's terms: before every read of a
|
|
241
|
+
# permutation or lookup table, then by +index+.
|
|
242
|
+
#
|
|
243
|
+
# @api private
|
|
244
|
+
def order
|
|
245
|
+
[0, @index]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [Array<Variable>] this variable alone
|
|
249
|
+
# @api private
|
|
250
|
+
def variables
|
|
251
|
+
[self]
|
|
252
|
+
end
|
|
253
|
+
|
|
202
254
|
# Identity equality, kept separate from +==+ (which builds a constraint) so
|
|
203
255
|
# variables stay usable as Hash and Set keys.
|
|
204
256
|
def eql?(other)
|
|
@@ -210,7 +262,7 @@ module Quicopt
|
|
|
210
262
|
object_id.hash
|
|
211
263
|
end
|
|
212
264
|
|
|
213
|
-
# @return [String] the variable's
|
|
265
|
+
# @return [String] the variable's name
|
|
214
266
|
def to_s
|
|
215
267
|
@name
|
|
216
268
|
end
|
|
@@ -221,8 +273,12 @@ module Quicopt
|
|
|
221
273
|
end
|
|
222
274
|
end
|
|
223
275
|
|
|
224
|
-
# A
|
|
225
|
-
#
|
|
276
|
+
# A polynomial of degree ≤ 2 over a model's variables, always kept in collected
|
|
277
|
+
# form: <tt>constant + Σ cᵢ·xᵢ + Σ qᵢⱼ·xᵢxⱼ</tt>.
|
|
278
|
+
#
|
|
279
|
+
# A value read from a permutation (<tt>route.item_at(0)</tt>) or a lookup
|
|
280
|
+
# table (<tt>dist[i, j]</tt>) counts as one factor, like a variable:
|
|
281
|
+
# +2 * dist[i, j]+ is linear, +x * dist[i, j]+ quadratic.
|
|
226
282
|
#
|
|
227
283
|
# Instances are immutable — every operator returns a new expression — so an
|
|
228
284
|
# expression can be shared, reused and stored without aliasing surprises.
|
|
@@ -231,7 +287,8 @@ module Quicopt
|
|
|
231
287
|
|
|
232
288
|
# @return [Float] the constant term
|
|
233
289
|
attr_reader :constant
|
|
234
|
-
# @return [Hash{Variable=>Float}] linear coefficients, in insertion order
|
|
290
|
+
# @return [Hash{Variable=>Float}] linear coefficients, in insertion order.
|
|
291
|
+
# A read of a permutation or lookup table is a key here too.
|
|
235
292
|
attr_reader :linear
|
|
236
293
|
# @return [Hash{Array(Variable,Variable)=>Float}] quadratic coefficients,
|
|
237
294
|
# keyed by the factor pair in model order, in insertion order
|
|
@@ -280,7 +337,7 @@ module Quicopt
|
|
|
280
337
|
degree.zero?
|
|
281
338
|
end
|
|
282
339
|
|
|
283
|
-
# @return [Expression]
|
|
340
|
+
# @return [Expression] <tt>self + other</tt>
|
|
284
341
|
def plus(other)
|
|
285
342
|
Expression.new(constant: @constant + other.constant,
|
|
286
343
|
linear: Expression.merge(@linear, other.linear),
|
|
@@ -297,7 +354,7 @@ module Quicopt
|
|
|
297
354
|
quadratic: @quadratic.transform_values { |c| c * f })
|
|
298
355
|
end
|
|
299
356
|
|
|
300
|
-
# Multiply two expressions, staying
|
|
357
|
+
# Multiply two expressions, staying at degree 2 or below.
|
|
301
358
|
#
|
|
302
359
|
# A constant factor just scales; two linear factors expand into quadratic
|
|
303
360
|
# terms. Anything else would exceed degree 2 and raises instead.
|
|
@@ -310,7 +367,8 @@ module Quicopt
|
|
|
310
367
|
|
|
311
368
|
if degree > 1 || other.degree > 1
|
|
312
369
|
raise UnsupportedExpression,
|
|
313
|
-
"
|
|
370
|
+
"multiplying degree #{degree} by degree #{other.degree} gives a cubic or " \
|
|
371
|
+
"higher term; a Quicopt model is linear or quadratic"
|
|
314
372
|
end
|
|
315
373
|
|
|
316
374
|
quadratic = {}
|
|
@@ -326,21 +384,31 @@ module Quicopt
|
|
|
326
384
|
quadratic: Expression.prune(quadratic))
|
|
327
385
|
end
|
|
328
386
|
|
|
329
|
-
# Every variable this expression mentions, in first-use order
|
|
387
|
+
# Every variable this expression mentions, in first-use order — including
|
|
388
|
+
# those in the positions of a lookup table read.
|
|
330
389
|
#
|
|
331
390
|
# @return [Array<Variable>]
|
|
332
391
|
def variables
|
|
392
|
+
factors.flat_map(&:variables).uniq
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Every factor of every term — variables and reads of permutations and lookup
|
|
396
|
+
# tables — in first-use order.
|
|
397
|
+
#
|
|
398
|
+
# @return [Array<Variable, Placement, TableEntry, Condition, Extremum>]
|
|
399
|
+
# @api private
|
|
400
|
+
def factors
|
|
333
401
|
seen = @linear.keys
|
|
334
402
|
@quadratic.each_key { |a, b| seen |= [a, b] }
|
|
335
403
|
seen
|
|
336
404
|
end
|
|
337
405
|
|
|
338
|
-
# Renders the polynomial, e.g. +3
|
|
406
|
+
# Renders the polynomial, e.g. <tt>-4 + 3\*x + 2\*y</tt>.
|
|
339
407
|
def to_s
|
|
340
408
|
terms = []
|
|
341
409
|
terms << Format.number(@constant) unless @constant.zero?
|
|
342
|
-
@linear.each { |v, c| terms << Format.term(c, v.
|
|
343
|
-
@quadratic.each { |(a, b), c| terms << Format.term(c, "#{a
|
|
410
|
+
@linear.each { |v, c| terms << Format.term(c, v.to_s) }
|
|
411
|
+
@quadratic.each { |(a, b), c| terms << Format.term(c, "#{a}*#{b}") }
|
|
344
412
|
return "0" if terms.empty?
|
|
345
413
|
|
|
346
414
|
terms.join(" + ").gsub(" + -", " - ")
|
|
@@ -383,23 +451,29 @@ module Quicopt
|
|
|
383
451
|
#
|
|
384
452
|
# @api private
|
|
385
453
|
def self.pair(left, right)
|
|
386
|
-
(left.
|
|
454
|
+
((left.order <=> right.order) <= 0 ? [left, right] : [right, left]).freeze
|
|
387
455
|
end
|
|
388
456
|
end
|
|
389
457
|
|
|
390
458
|
# One constraint row: a residual expression compared against zero.
|
|
391
459
|
#
|
|
392
460
|
# +expr+ is +lhs - rhs+, so the row is +expr <= 0+, +expr >= 0+ or +expr == 0+.
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
461
|
+
# +Quicopt::Wire+ then splits that into the body (the variable terms) and the
|
|
462
|
+
# numeric +bound+ (the constant, moved to the other side), which is the form
|
|
463
|
+
# Quicopt sends.
|
|
464
|
+
#
|
|
465
|
+
# A comparison built with +<+, +>+ or +!=+ is not a row — +Model#add+ refuses it
|
|
466
|
+
# — but it can be turned into a 0/1 expression with +Quicopt.holds+.
|
|
396
467
|
class Constraint
|
|
397
468
|
# The comparison operator each sense renders as.
|
|
398
|
-
SENSES = { le: "<=", ge: ">=", eq: "==" }.freeze
|
|
469
|
+
SENSES = { le: "<=", ge: ">=", eq: "==", lt: "<", gt: ">", ne: "!=" }.freeze
|
|
470
|
+
# The senses a constraint row can have.
|
|
471
|
+
ROWS = %i[le ge eq].freeze
|
|
399
472
|
|
|
400
473
|
# @return [Expression] the residual +lhs - rhs+
|
|
401
474
|
attr_reader :expr
|
|
402
|
-
# @return [Symbol] +:le+, +:ge+ or +:eq+
|
|
475
|
+
# @return [Symbol] +:le+, +:ge+ or +:eq+ for a row; +:lt+, +:gt+ or +:ne+ for
|
|
476
|
+
# a comparison only +Quicopt.holds+ takes
|
|
403
477
|
attr_reader :sense
|
|
404
478
|
|
|
405
479
|
# Build a row from the two sides of a comparison.
|
|
@@ -448,8 +522,8 @@ module Quicopt
|
|
|
448
522
|
# m.maximize(3 * x + 2 * y)
|
|
449
523
|
# m.add(x + y <= 5)
|
|
450
524
|
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
525
|
+
# You build the model; +to_program+ / +encode+ turn it into the message the
|
|
526
|
+
# service reads, and +Quicopt::Client#solve+ sends it.
|
|
453
527
|
class Model
|
|
454
528
|
# @return [Array<Variable>] the declared variables, in declaration order
|
|
455
529
|
attr_reader :variables
|
|
@@ -459,10 +533,25 @@ module Quicopt
|
|
|
459
533
|
attr_reader :objective
|
|
460
534
|
# @return [String] +"min"+ or +"max"+
|
|
461
535
|
attr_reader :sense
|
|
536
|
+
# @return [Array<Permutation>] the declared permutations, in declaration order
|
|
537
|
+
attr_reader :permutations
|
|
538
|
+
# @return [Array<LookupTable>] the declared lookup tables, in declaration order
|
|
539
|
+
attr_reader :tables
|
|
540
|
+
# @return [Array<Partition>] the declared partitions, in declaration order
|
|
541
|
+
attr_reader :partitions
|
|
542
|
+
# @return [Array<Block>] the declared blocks, in declaration order
|
|
543
|
+
attr_reader :blocks
|
|
462
544
|
|
|
463
545
|
def initialize
|
|
464
546
|
@variables = []
|
|
465
547
|
@by_name = {}
|
|
548
|
+
@permutations = []
|
|
549
|
+
@partitions = []
|
|
550
|
+
@tables = []
|
|
551
|
+
@blocks = []
|
|
552
|
+
@structured = {}
|
|
553
|
+
@interned = {}
|
|
554
|
+
@reads = 0
|
|
466
555
|
@constraints = []
|
|
467
556
|
@objective = Expression.new
|
|
468
557
|
@sense = "min"
|
|
@@ -470,27 +559,46 @@ module Quicopt
|
|
|
470
559
|
|
|
471
560
|
# Declare a continuous variable.
|
|
472
561
|
#
|
|
562
|
+
# With +semi: true+ the variable is 0 or within its bounds, never in between:
|
|
563
|
+
# a shipment of either nothing or 2.5 to 8 tonnes. Such a variable is called
|
|
564
|
+
# semicontinuous. Its bounds must then be finite, and when 0 lies within them
|
|
565
|
+
# it is an ordinary variable.
|
|
566
|
+
#
|
|
473
567
|
# @param lower [Numeric, nil] the lower bound; +nil+ means unbounded below
|
|
474
568
|
# @param upper [Numeric, nil] the upper bound; +nil+ means unbounded above
|
|
475
|
-
# @param name [String, nil] the
|
|
569
|
+
# @param name [String, nil] the variable's name; auto-generated when omitted
|
|
476
570
|
# @param start [Numeric, nil] the starting point; defaults to 0 clamped into
|
|
477
|
-
# the bounds, matching the
|
|
571
|
+
# the bounds, matching the Python and Julia clients (to 0 with +semi+)
|
|
572
|
+
# @param semi [Boolean] whether the variable may also be 0
|
|
478
573
|
# @return [Variable]
|
|
479
|
-
|
|
480
|
-
|
|
574
|
+
# @raise [ArgumentError] with +semi+, if a bound is missing or infinite
|
|
575
|
+
def num_var(lower = nil, upper = nil, name = nil, start: nil, semi: false)
|
|
576
|
+
declare(semi ? :SEMICONTINUOUS : :CONTINUOUS, lower, upper, name, start)
|
|
481
577
|
end
|
|
482
578
|
|
|
483
579
|
# Declare an integer variable.
|
|
484
580
|
#
|
|
485
581
|
# Bounds of exactly +[0, 1]+ declare a +:BINARY+ variable rather than an
|
|
486
|
-
# integer one — the same
|
|
487
|
-
#
|
|
582
|
+
# integer one — the same rule the Python and Julia clients apply, so the same
|
|
583
|
+
# model means the same thing whichever language wrote it.
|
|
584
|
+
#
|
|
585
|
+
# With +semi: true+ the variable is 0 or a whole number within its bounds,
|
|
586
|
+
# never in between: a delivery of either nothing or 10 to 25 units. Such a
|
|
587
|
+
# variable is called semi-integer. Its bounds must then be finite.
|
|
488
588
|
#
|
|
589
|
+
# ship = m.int_var(10, 25, "ship", semi: true) # none, or 10 to 25 units
|
|
590
|
+
# m.minimize(2 * ship + 30 * ship.active) # 2 per unit, 30 per delivery
|
|
591
|
+
#
|
|
592
|
+
# @param semi [Boolean] whether the variable may also be 0
|
|
489
593
|
# @return [Variable]
|
|
490
|
-
|
|
594
|
+
# @raise [ArgumentError] with +semi+, if a bound is missing or infinite
|
|
595
|
+
def int_var(lower = nil, upper = nil, name = nil, start: nil, semi: false)
|
|
491
596
|
lo = coerce_bound(lower, -Float::INFINITY)
|
|
492
597
|
hi = coerce_bound(upper, Float::INFINITY)
|
|
493
|
-
domain =
|
|
598
|
+
domain = if semi then :SEMIINTEGER
|
|
599
|
+
elsif lo.zero? && hi == 1.0 then :BINARY
|
|
600
|
+
else :INTEGER
|
|
601
|
+
end
|
|
494
602
|
declare(domain, lo, hi, name, start)
|
|
495
603
|
end
|
|
496
604
|
|
|
@@ -501,13 +609,209 @@ module Quicopt
|
|
|
501
609
|
declare(:BINARY, 0.0, 1.0, name, start)
|
|
502
610
|
end
|
|
503
611
|
|
|
504
|
-
# Look a variable up by
|
|
612
|
+
# Look a variable up by name.
|
|
505
613
|
#
|
|
506
614
|
# @return [Variable, nil]
|
|
507
615
|
def variable(name)
|
|
508
616
|
@by_name[name.to_s]
|
|
509
617
|
end
|
|
510
618
|
|
|
619
|
+
# Declare an ordering as a decision: +n_items+ items placed in as many
|
|
620
|
+
# slots, one item per slot, both numbered from 0. For a route the items are
|
|
621
|
+
# the stops and the slots are the visits, so slot 0 holds the first stop
|
|
622
|
+
# visited; for a machine the items are the jobs and the slots the positions
|
|
623
|
+
# in its queue.
|
|
624
|
+
#
|
|
625
|
+
# Five stops along a road; find the shortest route through all of them that
|
|
626
|
+
# visits stop 3 before stop 0:
|
|
627
|
+
#
|
|
628
|
+
# position = [0, 3, 1, 4, 2] # km along the road, stops 0 to 4
|
|
629
|
+
# m = Quicopt::Model.new
|
|
630
|
+
# dist = m.lookup_table("dist", position.map { |a| position.map { |b| (a - b).abs } })
|
|
631
|
+
# route = m.permutation("route", 5)
|
|
632
|
+
# route.precede(3, 0) # stop 3 is visited before stop 0
|
|
633
|
+
# m.minimize((0...4).sum { |k| dist[route.item_at(k), route.item_at(k + 1)] })
|
|
634
|
+
#
|
|
635
|
+
# result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
636
|
+
# result.objective # 4.0
|
|
637
|
+
# result.structures["route"].item_at # [3, 1, 4, 2, 0]: the stops in the order visited
|
|
638
|
+
#
|
|
639
|
+
# A model with a permutation is solved by a search, so its result has the
|
|
640
|
+
# status +"heuristic"+.
|
|
641
|
+
#
|
|
642
|
+
# @param name [String, Symbol] identifies the permutation in the result, as
|
|
643
|
+
# +result.structures[name]+; shares one namespace with the model's variables
|
|
644
|
+
# and lookup tables
|
|
645
|
+
# @param n_items [Integer] the number of items, at least 2
|
|
646
|
+
# @param start [Array<Integer>, nil] the arrangement the service's search
|
|
647
|
+
# starts from: +start[i]+ is the slot of item +i+, so it holds each of
|
|
648
|
+
# +0...n_items+ once. Left out, item +i+ starts in slot +i+.
|
|
649
|
+
# @return [Permutation]
|
|
650
|
+
# @raise [TypeError] if +n_items+ is not an Integer
|
|
651
|
+
# @raise [ArgumentError] if +n_items+ is below 2, the name is taken, or
|
|
652
|
+
# +start+ is not an arrangement of +0...n_items+
|
|
653
|
+
def permutation(name, n_items, start: nil)
|
|
654
|
+
unless n_items.is_a?(Integer)
|
|
655
|
+
raise TypeError, "a permutation's number of items is a whole number, got #{n_items.inspect}"
|
|
656
|
+
end
|
|
657
|
+
raise ArgumentError, "a permutation has at least 2 items, got #{n_items}" if n_items < 2
|
|
658
|
+
|
|
659
|
+
nm = claim(name)
|
|
660
|
+
unless start.nil? || (start.is_a?(Array) && start.all?(Integer) && start.sort == (0...n_items).to_a)
|
|
661
|
+
raise ArgumentError, "permutation #{nm.inspect}: start must hold each of 0 to #{n_items - 1} once " \
|
|
662
|
+
"(start[i] is the slot item i starts in)"
|
|
663
|
+
end
|
|
664
|
+
permutation = Permutation.new(self, nm, n_items, start&.dup)
|
|
665
|
+
@permutations << permutation
|
|
666
|
+
@structured[nm] = permutation
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Declare an assignment of +n_items+ items to +n_parts+ parts as a decision:
|
|
670
|
+
# which team each person joins, which truck each parcel rides in. Items and
|
|
671
|
+
# parts are both numbered from 0, and a part may stay empty.
|
|
672
|
+
#
|
|
673
|
+
# Six players of strengths 7, 5, 4, 3, 2 and 1 form a team of four and a
|
|
674
|
+
# team of two, as evenly matched as possible:
|
|
675
|
+
#
|
|
676
|
+
# strength = [7, 5, 4, 3, 2, 1] # of players 0 to 5
|
|
677
|
+
# m = Quicopt::Model.new
|
|
678
|
+
# teams = m.partition("teams", 6, 2) # 6 players into teams 0 and 1
|
|
679
|
+
# total = (0...2).map { |t| (0...6).sum { |p| strength[p] * Quicopt.holds(teams.part_of(p) == t) } }
|
|
680
|
+
# m.add(teams.size_of(0) == 4) # team 0 has four players
|
|
681
|
+
# m.minimize(Quicopt.max(total[0], total[1]))
|
|
682
|
+
#
|
|
683
|
+
# result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
684
|
+
# result.objective # 11.0
|
|
685
|
+
# result.structures["teams"].part_of # [1, 0, 1, 0, 0, 0]: players 0 and 2 form team 1
|
|
686
|
+
#
|
|
687
|
+
# @param name [String, Symbol] identifies the partition in the result, as
|
|
688
|
+
# +result.structures[name]+; shares one namespace with the model's
|
|
689
|
+
# variables, permutations and lookup tables
|
|
690
|
+
# @param n_items [Integer] the number of items, at least 1
|
|
691
|
+
# @param n_parts [Integer] the number of parts, at least 2
|
|
692
|
+
# @param start [Array<Integer>, nil] the assignment the service's search
|
|
693
|
+
# starts from: +start[i]+ is the part of item +i+, one of +0...n_parts+.
|
|
694
|
+
# Left out, item +i+ starts in part +i % n_parts+.
|
|
695
|
+
# @return [Partition]
|
|
696
|
+
# @raise [TypeError] if +n_items+ or +n_parts+ is not an Integer
|
|
697
|
+
# @raise [ArgumentError] if +n_items+ is below 1, +n_parts+ below 2, the
|
|
698
|
+
# name is taken, or +start+ does not give each item a part
|
|
699
|
+
def partition(name, n_items, n_parts, start: nil)
|
|
700
|
+
unless n_items.is_a?(Integer)
|
|
701
|
+
raise TypeError, "a partition's number of items is a whole number, got #{n_items.inspect}"
|
|
702
|
+
end
|
|
703
|
+
unless n_parts.is_a?(Integer)
|
|
704
|
+
raise TypeError, "a partition's number of parts is a whole number, got #{n_parts.inspect}"
|
|
705
|
+
end
|
|
706
|
+
raise ArgumentError, "a partition has at least 1 item, got #{n_items}" if n_items < 1
|
|
707
|
+
raise ArgumentError, "a partition has at least 2 parts, got #{n_parts}" if n_parts < 2
|
|
708
|
+
|
|
709
|
+
nm = claim(name)
|
|
710
|
+
unless start.nil? || (start.is_a?(Array) && start.size == n_items &&
|
|
711
|
+
start.all? { |b| b.is_a?(Integer) && b >= 0 && b < n_parts })
|
|
712
|
+
raise ArgumentError, "partition #{nm.inspect}: start gives each of the #{n_items} items a part " \
|
|
713
|
+
"from 0 to #{n_parts - 1}"
|
|
714
|
+
end
|
|
715
|
+
partition = Partition.new(self, nm, n_items, n_parts, start&.dup)
|
|
716
|
+
@partitions << partition
|
|
717
|
+
@structured[nm] = partition
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Declare the variables of one subproblem as a block: one customer's
|
|
721
|
+
# supply, one project's staffing. The constraints over a block's own
|
|
722
|
+
# variables alone, such as that customer's demand, hold in every solution
|
|
723
|
+
# the service returns. A block changes nothing else about the model: the
|
|
724
|
+
# same solutions are feasible and the objective is the same.
|
|
725
|
+
#
|
|
726
|
+
# supply = (0...3).map { |w| m.int_var(10, 25, "ship_#{w}", semi: true) }
|
|
727
|
+
# m.block("customer", supply)
|
|
728
|
+
#
|
|
729
|
+
# @param name [String, Symbol] the block's name, distinct from every other
|
|
730
|
+
# block's
|
|
731
|
+
# @param vars [Array<Variable>] its variables, each with finite bounds and in
|
|
732
|
+
# no other block
|
|
733
|
+
# @return [Block]
|
|
734
|
+
# @raise [TypeError] if a member is not a variable
|
|
735
|
+
# @raise [ArgumentError] if the block is empty, the name is taken, a member
|
|
736
|
+
# belongs to another model or block or has an infinite bound
|
|
737
|
+
def block(name, vars)
|
|
738
|
+
raise TypeError, "a block's name is a String or a Symbol, got #{name.inspect}" unless name.is_a?(String) || name.is_a?(Symbol)
|
|
739
|
+
|
|
740
|
+
nm = name.to_s
|
|
741
|
+
raise ArgumentError, "a block's name cannot be empty" if nm.empty?
|
|
742
|
+
raise ArgumentError, "there is already a block named #{nm.inspect}" if @blocks.any? { |b| b.name == nm }
|
|
743
|
+
|
|
744
|
+
members = Array(vars)
|
|
745
|
+
raise ArgumentError, "block #{nm.inspect} has no variables" if members.empty?
|
|
746
|
+
|
|
747
|
+
members.each_with_index do |v, k|
|
|
748
|
+
raise TypeError, "block #{nm.inspect}: a member is a variable, got #{v.class}" unless v.is_a?(Variable)
|
|
749
|
+
raise ArgumentError, "variable #{v.name.inspect} belongs to a different Quicopt::Model" unless v.model.equal?(self)
|
|
750
|
+
raise ArgumentError, "variable #{v.name.inspect} is listed twice in block #{nm.inspect}" if members[0...k].any? { |u| u.equal?(v) }
|
|
751
|
+
if (owner = @blocks.find { |b| b.variables.any? { |u| u.equal?(v) } })
|
|
752
|
+
raise ArgumentError, "variable #{v.name.inspect} is already in block #{owner.name.inspect}"
|
|
753
|
+
end
|
|
754
|
+
unless v.lower.finite? && v.upper.finite?
|
|
755
|
+
raise ArgumentError, "variable #{v.name.inspect} has bounds [#{v.lower}, #{v.upper}]; " \
|
|
756
|
+
"a block's variables need finite bounds"
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
block = Block.new(nm, members.dup.freeze)
|
|
760
|
+
@blocks << block
|
|
761
|
+
block
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# Declare a table of data that can be indexed by decisions. A model sometimes
|
|
765
|
+
# needs a number from a table at a position that is not known yet: the distance
|
|
766
|
+
# between the first and the second stop of a route whose ordering the service
|
|
767
|
+
# is still choosing, or the price of an option an integer variable picks. A
|
|
768
|
+
# Ruby array cannot be indexed this way; the table returned here can.
|
|
769
|
+
#
|
|
770
|
+
# price = m.lookup_table("price", [3.0, 1.0, 4.0, 1.5])
|
|
771
|
+
# choice = m.int_var(0, 3, "choice")
|
|
772
|
+
# m.minimize(price[choice]) # picks option 1, at 1.0
|
|
773
|
+
#
|
|
774
|
+
# Indexing with whole numbers only gives the entry itself; indexing with an
|
|
775
|
+
# expression, such as +Permutation#item_at+ or an integer variable, gives an
|
|
776
|
+
# expression whose value is the entry at the positions the service chooses.
|
|
777
|
+
#
|
|
778
|
+
# The whole table is sent with the model, so a very large table makes the
|
|
779
|
+
# request large.
|
|
780
|
+
#
|
|
781
|
+
# @param name [String, Symbol] the table's name; shares one namespace with the
|
|
782
|
+
# model's variables and permutations
|
|
783
|
+
# @param values [Array<Numeric>, Array<Array<Numeric>>] a vector (indexed
|
|
784
|
+
# +table[i]+) or a matrix (<tt>table[i, j]</tt>, every row the same
|
|
785
|
+
# length) of numbers, none of them NaN
|
|
786
|
+
# @return [LookupTable]
|
|
787
|
+
# @raise [TypeError] if +values+ is neither a vector nor a matrix of numbers
|
|
788
|
+
# @raise [ArgumentError] if it is empty, ragged or holds NaN, or the name is
|
|
789
|
+
# taken
|
|
790
|
+
def lookup_table(name, values)
|
|
791
|
+
nm = claim(name)
|
|
792
|
+
data, shape = table_data(nm, values)
|
|
793
|
+
table = LookupTable.new(self, nm, data, shape)
|
|
794
|
+
@tables << table
|
|
795
|
+
@structured[nm] = table
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# The +order+ of the next read of a permutation, partition or lookup table,
|
|
799
|
+
# or of a term that switches: after every variable, then in the order they
|
|
800
|
+
# were first made.
|
|
801
|
+
#
|
|
802
|
+
# @api private
|
|
803
|
+
def next_order
|
|
804
|
+
[1, @reads += 1]
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# The term stored under +key+, built by the block on first use, so that
|
|
808
|
+
# equal terms are one object.
|
|
809
|
+
#
|
|
810
|
+
# @api private
|
|
811
|
+
def intern(key)
|
|
812
|
+
@interned[key] ||= yield
|
|
813
|
+
end
|
|
814
|
+
|
|
511
815
|
# Minimize +expr+.
|
|
512
816
|
#
|
|
513
817
|
# @return [Expression] the objective now set
|
|
@@ -528,24 +832,31 @@ module Quicopt
|
|
|
528
832
|
# @return [Constraint] the row just added
|
|
529
833
|
# @raise [TypeError] if handed something that is not a constraint — most
|
|
530
834
|
# often a bare expression, or a comparison Ruby already reduced to a boolean
|
|
835
|
+
# @raise [ArgumentError] for a comparison with +<+, +>+ or +!=+, which no row
|
|
836
|
+
# can express
|
|
531
837
|
def add(constraint)
|
|
532
838
|
unless constraint.is_a?(Constraint)
|
|
533
839
|
raise TypeError,
|
|
534
840
|
"add expects a constraint such as `x + y <= 5`, got #{constraint.class}"
|
|
535
841
|
end
|
|
842
|
+
unless Constraint::ROWS.include?(constraint.sense)
|
|
843
|
+
raise ArgumentError,
|
|
844
|
+
"#{constraint} is not a constraint Quicopt can hold: use <=, >= or ==, " \
|
|
845
|
+
"or turn the comparison into a 0/1 expression with Quicopt.holds"
|
|
846
|
+
end
|
|
536
847
|
check_ownership(constraint.expr)
|
|
537
848
|
@constraints << constraint
|
|
538
849
|
constraint
|
|
539
850
|
end
|
|
540
851
|
|
|
541
|
-
#
|
|
852
|
+
# Turn this model into the structured message the service reads.
|
|
542
853
|
#
|
|
543
854
|
# @return [Quicopt::Modeler::V1::Program]
|
|
544
855
|
def to_program
|
|
545
856
|
Wire.program(self)
|
|
546
857
|
end
|
|
547
858
|
|
|
548
|
-
#
|
|
859
|
+
# Turn this model into the message the service reads and encode it.
|
|
549
860
|
#
|
|
550
861
|
# @return [String] binary protobuf bytes
|
|
551
862
|
def encode
|
|
@@ -558,8 +869,18 @@ module Quicopt
|
|
|
558
869
|
lines << "subject to" unless @constraints.empty?
|
|
559
870
|
@constraints.each { |c| lines << " #{c}" }
|
|
560
871
|
@variables.each do |v|
|
|
561
|
-
|
|
872
|
+
range = "[#{Format.number(v.lower)}, #{Format.number(v.upper)}]"
|
|
873
|
+
range = "{0} ∪ #{range}" if %i[SEMICONTINUOUS SEMIINTEGER].include?(v.domain)
|
|
874
|
+
lines << " #{v.name} in #{v.domain} #{range}"
|
|
562
875
|
end
|
|
876
|
+
@permutations.each do |p|
|
|
877
|
+
rules = p.precedences.map { |a, b| "#{a} before #{b}" }
|
|
878
|
+
rules = rules.empty? ? "" : ", with #{rules.join(', ')}"
|
|
879
|
+
lines << " #{p.name} is an ordering of items 0 to #{p.n_items - 1}#{rules}"
|
|
880
|
+
end
|
|
881
|
+
@partitions.each { |p| lines << " #{p.name} puts items 0 to #{p.n_items - 1} in parts 0 to #{p.n_parts - 1}" }
|
|
882
|
+
@tables.each { |t| lines << " #{t.name} is a #{t.shape.join(' × ')} lookup table" }
|
|
883
|
+
@blocks.each { |b| lines << " #{b.name} is a block of #{b.variables.join(', ')}" }
|
|
563
884
|
lines.join("\n")
|
|
564
885
|
end
|
|
565
886
|
|
|
@@ -575,8 +896,15 @@ module Quicopt
|
|
|
575
896
|
hi = coerce_bound(upper, Float::INFINITY)
|
|
576
897
|
raise ArgumentError, "lower bound #{lo} exceeds upper bound #{hi}" if lo > hi
|
|
577
898
|
|
|
899
|
+
semi = %i[SEMICONTINUOUS SEMIINTEGER].include?(domain)
|
|
900
|
+
if semi && !(lo.finite? && hi.finite?)
|
|
901
|
+
raise ArgumentError, "a variable that is 0 or in [#{lo}, #{hi}] needs finite bounds"
|
|
902
|
+
end
|
|
903
|
+
start = 0.0 if semi && start.nil?
|
|
904
|
+
|
|
578
905
|
nm = name.nil? ? auto_name : name.to_s
|
|
579
906
|
raise ArgumentError, "duplicate variable name #{nm.inspect}" if @by_name.key?(nm)
|
|
907
|
+
raise ArgumentError, "the name #{nm.inspect} is already declared on this model" if @structured.key?(nm)
|
|
580
908
|
|
|
581
909
|
variable = Variable.new(self, nm, domain, lo, hi, coerce_start(start, lo, hi), @variables.size)
|
|
582
910
|
@variables << variable
|
|
@@ -591,8 +919,8 @@ module Quicopt
|
|
|
591
919
|
value.to_f
|
|
592
920
|
end
|
|
593
921
|
|
|
594
|
-
# 0 clamped into the bounds — finite for any bounds, matching the
|
|
595
|
-
#
|
|
922
|
+
# 0 clamped into the bounds — finite for any bounds, matching the Python and
|
|
923
|
+
# Julia clients so an unset start comes out the same everywhere.
|
|
596
924
|
def coerce_start(start, lower, upper)
|
|
597
925
|
return [[0.0, lower].max, upper].min if start.nil?
|
|
598
926
|
raise ArgumentError, "start must be a number, got #{start.class}" unless start.is_a?(Numeric)
|
|
@@ -601,9 +929,53 @@ module Quicopt
|
|
|
601
929
|
end
|
|
602
930
|
|
|
603
931
|
def auto_name
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
"x#{
|
|
932
|
+
number = @variables.size + 1
|
|
933
|
+
number += 1 while @by_name.key?("x#{number}") || @structured.key?("x#{number}")
|
|
934
|
+
"x#{number}"
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# +name+ as a String, checked to be free among the model's variables,
|
|
938
|
+
# permutations and lookup tables: on the wire one name must mean one thing.
|
|
939
|
+
def claim(name)
|
|
940
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
941
|
+
raise TypeError, "a name is a String or a Symbol, got #{name.inspect}"
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
nm = name.to_s
|
|
945
|
+
raise ArgumentError, "a name cannot be empty" if nm.empty?
|
|
946
|
+
if @by_name.key?(nm) || @structured.key?(nm)
|
|
947
|
+
raise ArgumentError, "the name #{nm.inspect} is already declared on this model"
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
nm
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# A table's entries as frozen Floats, and its shape.
|
|
954
|
+
def table_data(name, values)
|
|
955
|
+
raise TypeError, "lookup table #{name.inspect}: values is an Array, got #{values.class}" unless values.is_a?(Array)
|
|
956
|
+
raise ArgumentError, "lookup table #{name.inspect} has no entries" if values.empty?
|
|
957
|
+
|
|
958
|
+
if values.all?(Numeric)
|
|
959
|
+
data = values.map(&:to_f)
|
|
960
|
+
shape = [data.size]
|
|
961
|
+
flat = data
|
|
962
|
+
elsif values.all?(Array)
|
|
963
|
+
unless values.all? { |row| row.all?(Numeric) }
|
|
964
|
+
raise TypeError, "lookup table #{name.inspect}: a matrix holds numbers only"
|
|
965
|
+
end
|
|
966
|
+
if values.first.empty? || values.any? { |row| row.size != values.first.size }
|
|
967
|
+
raise ArgumentError, "lookup table #{name.inspect}: every row of a matrix has the same, " \
|
|
968
|
+
"nonzero number of entries"
|
|
969
|
+
end
|
|
970
|
+
data = values.map { |row| row.map(&:to_f).freeze }
|
|
971
|
+
shape = [data.size, data.first.size]
|
|
972
|
+
flat = data.flatten
|
|
973
|
+
else
|
|
974
|
+
raise TypeError, "lookup table #{name.inspect}: values is a vector or a matrix of numbers"
|
|
975
|
+
end
|
|
976
|
+
raise ArgumentError, "lookup table #{name.inspect} holds NaN" if flat.any?(&:nan?)
|
|
977
|
+
|
|
978
|
+
[data.freeze, shape]
|
|
607
979
|
end
|
|
608
980
|
|
|
609
981
|
def set_objective(expr, sense)
|
|
@@ -614,14 +986,38 @@ module Quicopt
|
|
|
614
986
|
objective
|
|
615
987
|
end
|
|
616
988
|
|
|
617
|
-
# Reject variables belonging to another model: their
|
|
618
|
-
# arbitrarily and their coefficients would silently merge
|
|
989
|
+
# Reject variables, permutations and tables belonging to another model: their
|
|
990
|
+
# names would collide arbitrarily and their coefficients would silently merge
|
|
991
|
+
# on the wire.
|
|
619
992
|
def check_ownership(expr)
|
|
620
|
-
expr.
|
|
621
|
-
next if
|
|
993
|
+
expr.factors.each do |f|
|
|
994
|
+
next if f.model.equal?(self)
|
|
622
995
|
|
|
623
|
-
|
|
996
|
+
what = f.is_a?(Variable) ? "variable #{f.name.inspect}" : f.to_s
|
|
997
|
+
raise ArgumentError, "#{what} belongs to a different Quicopt::Model"
|
|
624
998
|
end
|
|
625
999
|
end
|
|
626
1000
|
end
|
|
1001
|
+
|
|
1002
|
+
# The variables of one subproblem, declared with +Model#block+.
|
|
1003
|
+
class Block
|
|
1004
|
+
# @return [String] the block's name
|
|
1005
|
+
attr_reader :name
|
|
1006
|
+
# @return [Array<Variable>] its variables, in the order given
|
|
1007
|
+
attr_reader :variables
|
|
1008
|
+
|
|
1009
|
+
# Declared with +Model#block+, not built directly.
|
|
1010
|
+
#
|
|
1011
|
+
# @api private
|
|
1012
|
+
def initialize(name, variables)
|
|
1013
|
+
@name = name
|
|
1014
|
+
@variables = variables
|
|
1015
|
+
freeze
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# @return [String] name and members
|
|
1019
|
+
def inspect
|
|
1020
|
+
"#<Quicopt::Block #{@name}: #{@variables.join(', ')}>"
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
627
1023
|
end
|