lpsolver 0.2.0 → 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/README.md +72 -13
- data/ext/lpsolver/Makefile +8 -12
- data/ext/lpsolver/ext.o +0 -0
- data/ext/lpsolver/extconf.rb +11 -8
- data/ext/lpsolver/native.so +0 -0
- data/lib/lpsolver/drivers/README.md +91 -0
- data/lib/lpsolver/drivers/cli_driver.rb +123 -0
- data/lib/lpsolver/drivers/native_driver.rb +150 -0
- data/lib/lpsolver/drivers.rb +9 -0
- data/lib/lpsolver/lp_generator.rb +200 -0
- data/lib/lpsolver/model.rb +105 -406
- data/lib/lpsolver/native.so +0 -0
- data/lib/lpsolver/solution.rb +21 -1
- data/lib/lpsolver/version.rb +1 -1
- data/lib/lpsolver.rb +10 -4
- metadata +6 -2
- data/lib/lpsolver/native_model.rb +0 -261
data/lib/lpsolver/model.rb
CHANGED
|
@@ -1,93 +1,79 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'tempfile'
|
|
4
|
-
|
|
5
3
|
module LpSolver
|
|
6
4
|
# A high-level interface to HiGHS for building and solving LP/QP/MIP models.
|
|
7
5
|
#
|
|
8
6
|
# The Model class provides a Ruby DSL for defining variables, constraints,
|
|
9
|
-
# and objectives. Models are
|
|
10
|
-
# the HiGHS command-line interface.
|
|
11
|
-
#
|
|
12
|
-
# == Problem Types Supported
|
|
13
|
-
#
|
|
14
|
-
# * **Linear Programming (LP)**: Linear objective with linear constraints.
|
|
15
|
-
# Example: maximize profit given resource constraints.
|
|
16
|
-
#
|
|
17
|
-
# * **Quadratic Programming (QP)**: Quadratic objective (convex) with
|
|
18
|
-
# linear constraints. Example: minimize portfolio variance for a target return.
|
|
19
|
-
#
|
|
20
|
-
# * **Mixed Integer Programming (MIP)**: LP or QP with some or all variables
|
|
21
|
-
# restricted to integer values. Example: coin change problem with integer counts.
|
|
22
|
-
#
|
|
23
|
-
# == Usage
|
|
24
|
-
#
|
|
25
|
-
# The DSL uses Ruby operators to build expressions naturally:
|
|
7
|
+
# and objectives. Models are solved via a pluggable driver.
|
|
26
8
|
#
|
|
27
|
-
# model = LpSolver::Model.new
|
|
28
|
-
# x = model.add_variable(:x, lb: 0)
|
|
29
|
-
# y = model.add_variable(:y, lb: 0)
|
|
30
|
-
#
|
|
31
|
-
# # Constraints
|
|
32
|
-
# model.add_constraint(:budget, (x * 2 + y) <= 100)
|
|
33
|
-
# model.add_constraint(:demand, (x + y * 2) >= 50)
|
|
34
|
-
#
|
|
35
|
-
# # Objective
|
|
36
|
-
# model.minimize
|
|
37
|
-
# model.set_objective(x * 3 + y * 5)
|
|
38
|
-
#
|
|
39
|
-
# # Solve
|
|
40
|
-
# solution = model.solve
|
|
41
|
-
# puts solution[:x] # => optimal value for x
|
|
42
|
-
#
|
|
43
|
-
# @example Linear Programming
|
|
44
|
-
# model = LpSolver::Model.new
|
|
45
|
-
# x = model.add_variable(:x, lb: 0)
|
|
46
|
-
# y = model.add_variable(:y, lb: 0)
|
|
47
|
-
# model.add_constraint(:c1, (x + y) >= 4)
|
|
48
|
-
# model.minimize
|
|
49
|
-
# model.set_objective(x * 3 + y * 5)
|
|
50
|
-
# solution = model.solve
|
|
51
|
-
# solution.objective_value # => 12.0
|
|
52
|
-
#
|
|
53
|
-
# @example Quadratic Programming
|
|
54
|
-
# model = LpSolver::Model.new
|
|
55
|
-
# x = model.add_variable(:x, lb: 0)
|
|
56
|
-
# y = model.add_variable(:y, lb: 0)
|
|
57
|
-
# model.add_constraint(:c, (x + y) >= 2)
|
|
58
|
-
# model.minimize
|
|
59
|
-
# model.set_objective(x * x + y * y)
|
|
60
|
-
# solution = model.solve
|
|
61
|
-
# solution.objective_value # => 2.0 (at x=1, y=1)
|
|
62
|
-
#
|
|
63
|
-
# @example Mixed Integer Programming
|
|
64
|
-
# model = LpSolver::Model.new
|
|
65
|
-
# x = model.add_variable(:x, lb: 0, integer: true)
|
|
66
|
-
# y = model.add_variable(:y, lb: 0, integer: true)
|
|
67
|
-
# model.add_constraint(:c, (x + y) == 10)
|
|
68
|
-
# model.minimize
|
|
69
|
-
# model.set_objective(x * 2 + y * 3)
|
|
70
|
-
# solution = model.solve
|
|
71
|
-
# solution[:x] # => 10.0 (integer)
|
|
72
9
|
class Model
|
|
73
|
-
#
|
|
10
|
+
# @return [Hash{Symbol => Variable}] All variables defined in this model.
|
|
11
|
+
attr_reader :variables
|
|
12
|
+
# @return [Integer] The next available variable index.
|
|
13
|
+
attr_reader :var_counter
|
|
14
|
+
# @return [Symbol] The current optimization heading.
|
|
15
|
+
attr_reader :heading
|
|
16
|
+
# @return [Array<Hash>] Constraint data as an array of {name, lb, ub, expr}.
|
|
17
|
+
def constraints
|
|
18
|
+
@constraints_data.values
|
|
19
|
+
end
|
|
20
|
+
# @return [Hash{Symbol => Hash}] Maps constraint names to their data.
|
|
21
|
+
attr_reader :constraints_data
|
|
22
|
+
# @return [Hash{Symbol => Symbol}] Maps variable names to their types.
|
|
23
|
+
attr_reader :var_types
|
|
24
|
+
# @return [Hash{Integer => Float}] Maps variable indices to coefficients.
|
|
25
|
+
attr_reader :objective
|
|
26
|
+
# @return [Array<[Integer, Integer, Float]>] Quadratic term entries.
|
|
27
|
+
attr_reader :quadratic_terms
|
|
28
|
+
# @return [Hash{Symbol => Array<Float>}] Maps variable names to [lb, ub] bounds.
|
|
29
|
+
attr_reader :var_bounds
|
|
30
|
+
# @return [String] The model name.
|
|
31
|
+
attr_reader :name
|
|
32
|
+
# @return [Drivers::CliDriver, Drivers::NativeDriver] The configured solver driver.
|
|
33
|
+
attr_reader :driver
|
|
34
|
+
|
|
35
|
+
# Returns the default solver driver.
|
|
36
|
+
#
|
|
37
|
+
# Tries CliDriver first (if HiGHS binary is available), then falls back
|
|
38
|
+
# to NativeDriver (if native extension is compiled), then raises an error.
|
|
39
|
+
#
|
|
40
|
+
# @return [Drivers::CliDriver, Drivers::NativeDriver] The default driver.
|
|
41
|
+
# @raise [RuntimeError] If neither CLI nor native driver is available.
|
|
42
|
+
def self.default_driver
|
|
43
|
+
begin
|
|
44
|
+
Drivers::CliDriver.new
|
|
45
|
+
rescue
|
|
46
|
+
begin
|
|
47
|
+
Drivers::NativeDriver.new
|
|
48
|
+
rescue LoadError
|
|
49
|
+
raise 'No solver available. Install HiGHS or compile the native extension with: rake compile'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Solves the model using the configured driver, with automatic fallback.
|
|
74
55
|
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# 3. 'highs' on system PATH
|
|
56
|
+
# If the configured driver is CliDriver and the HiGHS binary is not found,
|
|
57
|
+
# automatically falls back to NativeDriver. If NativeDriver is also not
|
|
58
|
+
# available, raises the original error.
|
|
79
59
|
#
|
|
80
|
-
# @return [
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
60
|
+
# @return [Solution] The solution object.
|
|
61
|
+
# @raise [SolverError] If the solver encounters an error.
|
|
62
|
+
# @raise [LoadError] If no driver is available.
|
|
63
|
+
def solve
|
|
64
|
+
begin
|
|
65
|
+
@driver.solve(self)
|
|
66
|
+
rescue SolverError, LoadError => e
|
|
67
|
+
# If CLI driver fails and native driver is available, try it
|
|
68
|
+
if @driver.class.name.end_with?('CliDriver')
|
|
69
|
+
begin
|
|
70
|
+
@driver = Drivers::NativeDriver.new
|
|
71
|
+
@driver.solve(self)
|
|
72
|
+
rescue LoadError
|
|
73
|
+
raise e
|
|
74
|
+
end
|
|
89
75
|
else
|
|
90
|
-
|
|
76
|
+
raise e
|
|
91
77
|
end
|
|
92
78
|
end
|
|
93
79
|
end
|
|
@@ -102,37 +88,23 @@ module LpSolver
|
|
|
102
88
|
@constraints = {} # { symbol => index }
|
|
103
89
|
@var_counter = 0
|
|
104
90
|
@constr_counter = 0
|
|
105
|
-
@
|
|
91
|
+
@heading = :minimize
|
|
106
92
|
@solution = nil
|
|
107
93
|
@objective = {} # { var_index => coefficient }
|
|
108
94
|
@quadratic_terms = [] # [[var1_idx, var2_idx, coefficient], ...]
|
|
109
95
|
@var_types = {} # { symbol => :continuous | :integer }
|
|
110
96
|
@var_bounds = {} # { symbol => [lb, ub] }
|
|
111
97
|
@constraints_data = {} # { symbol => { lb:, ub:, expr: [[var_idx, coeff], ...] } }
|
|
98
|
+
@driver = self.class.default_driver
|
|
112
99
|
end
|
|
113
100
|
|
|
114
101
|
# Adds a variable to the model.
|
|
115
102
|
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
# @
|
|
121
|
-
# for identification in the LP format output and solution results.
|
|
122
|
-
# @param lb [Float] The lower bound for the variable (default: 0.0).
|
|
123
|
-
# Use -Float::INFINITY for no lower bound.
|
|
124
|
-
# @param ub [Float] The upper bound for the variable (default: Float::INFINITY).
|
|
125
|
-
# Use Float::INFINITY for no upper bound. Setting lb == ub fixes the variable.
|
|
126
|
-
# @param integer [Boolean] Whether the variable must take integer values
|
|
127
|
-
# (default: false). When true, the model becomes a MIP problem.
|
|
128
|
-
# @return [Variable] The variable object, which supports arithmetic and
|
|
129
|
-
# comparison operators for building expressions and constraints.
|
|
130
|
-
# @example Adding a continuous variable
|
|
131
|
-
# x = model.add_variable(:x, lb: 0)
|
|
132
|
-
# @example Adding an integer variable
|
|
133
|
-
# count = model.add_variable(:count, lb: 0, integer: true)
|
|
134
|
-
# @example Adding a fixed variable
|
|
135
|
-
# capacity = model.add_variable(:capacity, lb: 100, ub: 100)
|
|
103
|
+
# @param name [Symbol, String] The variable name.
|
|
104
|
+
# @param lb [Float] Lower bound (default: 0.0).
|
|
105
|
+
# @param ub [Float] Upper bound (default: +Inf).
|
|
106
|
+
# @param integer [Boolean] Whether the variable is integer (default: false).
|
|
107
|
+
# @return [Variable] The variable object.
|
|
136
108
|
def add_variable(name, lb: 0.0, ub: Float::INFINITY, integer: false)
|
|
137
109
|
name = name.to_sym
|
|
138
110
|
idx = @var_counter
|
|
@@ -146,26 +118,12 @@ module LpSolver
|
|
|
146
118
|
|
|
147
119
|
# Adds a constraint to the model.
|
|
148
120
|
#
|
|
149
|
-
#
|
|
150
|
-
# They can be specified using the DSL (comparison operators) or the
|
|
151
|
-
# legacy array format.
|
|
152
|
-
#
|
|
153
|
-
# @param name [Symbol, String] The name of the constraint.
|
|
121
|
+
# @param name [Symbol, String] The constraint name.
|
|
154
122
|
# @param expr [ConstraintSpec, Array<[Integer, Float]>] The constraint
|
|
155
|
-
# specification
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
# @param lb [Float] Lower bound for the constraint (used with array-style expr).
|
|
159
|
-
# Default: -Float::INFINITY.
|
|
160
|
-
# @param ub [Float] Upper bound for the constraint (used with array-style expr).
|
|
161
|
-
# Default: Float::INFINITY.
|
|
123
|
+
# specification (DSL expression or legacy array format).
|
|
124
|
+
# @param lb [Float] Lower bound (default: -Inf).
|
|
125
|
+
# @param ub [Float] Upper bound (default: +Inf).
|
|
162
126
|
# @return [Symbol] The constraint name.
|
|
163
|
-
# @example Using DSL comparison operators
|
|
164
|
-
# model.add_constraint(:budget, (x * 2 + y) <= 100)
|
|
165
|
-
# model.add_constraint(:demand, (x + y * 2) >= 50)
|
|
166
|
-
# model.add_constraint(:balance, (x + y) == 10)
|
|
167
|
-
# @example Using legacy array format
|
|
168
|
-
# model.add_constraint(:budget, [[x.index, 2], [y.index, 1]], ub: 100)
|
|
169
127
|
def add_constraint(name, expr, lb: -Float::INFINITY, ub: Float::INFINITY)
|
|
170
128
|
name = name.to_sym
|
|
171
129
|
|
|
@@ -189,39 +147,34 @@ module LpSolver
|
|
|
189
147
|
name
|
|
190
148
|
end
|
|
191
149
|
|
|
192
|
-
# Sets the
|
|
150
|
+
# Sets heading to minimize, sets the objective, and solves.
|
|
193
151
|
#
|
|
194
|
-
# @
|
|
195
|
-
# @
|
|
196
|
-
|
|
197
|
-
|
|
152
|
+
# @param objective [LinearExpression, QuadraticExpression, Hash{Integer => Float}] The objective.
|
|
153
|
+
# @return [Solution] The solution object.
|
|
154
|
+
# @raise [SolverError] If the solver encounters an error.
|
|
155
|
+
def minimize!(objective)
|
|
156
|
+
@heading = :minimize
|
|
157
|
+
set_objective_internal(objective)
|
|
158
|
+
solve
|
|
198
159
|
end
|
|
199
160
|
|
|
200
|
-
# Sets the
|
|
161
|
+
# Sets heading to maximize, sets the objective, and solves.
|
|
201
162
|
#
|
|
202
|
-
# @
|
|
203
|
-
# @
|
|
204
|
-
|
|
205
|
-
|
|
163
|
+
# @param objective [LinearExpression, QuadraticExpression, Hash{Integer => Float}] The objective.
|
|
164
|
+
# @return [Solution] The solution object.
|
|
165
|
+
# @raise [SolverError] If the solver encounters an error.
|
|
166
|
+
def maximize!(objective)
|
|
167
|
+
@heading = :maximize
|
|
168
|
+
set_objective_internal(objective)
|
|
169
|
+
solve
|
|
206
170
|
end
|
|
207
171
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
#
|
|
211
|
-
# It can be a linear expression (for LP), a quadratic expression
|
|
212
|
-
# (for QP), or a hash of coefficients (legacy format).
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# Sets the objective function internally.
|
|
213
175
|
#
|
|
214
|
-
# @param objective [LinearExpression, QuadraticExpression, Hash{
|
|
215
|
-
|
|
216
|
-
# - A LinearExpression: `x * 3 + y * 5`
|
|
217
|
-
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
218
|
-
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
219
|
-
# @return [void]
|
|
220
|
-
# @example Linear objective
|
|
221
|
-
# model.set_objective(x * 3 + y * 5)
|
|
222
|
-
# @example Quadratic objective (QP)
|
|
223
|
-
# model.set_objective(x * x + y * y)
|
|
224
|
-
def set_objective(objective)
|
|
176
|
+
# @param objective [LinearExpression, QuadraticExpression, Hash{Integer => Float}] The objective.
|
|
177
|
+
def set_objective_internal(objective)
|
|
225
178
|
if objective.is_a?(QuadraticExpression)
|
|
226
179
|
@objective = objective.linear_terms.transform_values(&:to_f)
|
|
227
180
|
@quadratic_terms = objective.hessian_entries
|
|
@@ -234,101 +187,11 @@ module LpSolver
|
|
|
234
187
|
end
|
|
235
188
|
end
|
|
236
189
|
|
|
237
|
-
|
|
238
|
-
#
|
|
239
|
-
# Serializes the model to HiGHS LP format, invokes the HiGHS solver,
|
|
240
|
-
# and parses the solution file to return a Solution object.
|
|
241
|
-
#
|
|
242
|
-
# @return [Solution] The solution object containing variable values,
|
|
243
|
-
# objective value, and model status.
|
|
244
|
-
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
245
|
-
# @example
|
|
246
|
-
# solution = model.solve
|
|
247
|
-
# solution[:x] # => optimal value for variable x
|
|
248
|
-
# solution.objective_value # => optimal objective value
|
|
249
|
-
# solution.feasible? # => true
|
|
250
|
-
def solve
|
|
251
|
-
lp_content = to_lp
|
|
252
|
-
lp_file = Tempfile.new(['model', '.lp'])
|
|
253
|
-
lp_file.write(lp_content)
|
|
254
|
-
lp_file.close
|
|
255
|
-
|
|
256
|
-
solution_file = Tempfile.new(['solution', '.sol'])
|
|
257
|
-
opts_file = Tempfile.new(['highs_opts', '.txt'])
|
|
258
|
-
opts_file.write("log_to_console = false\noutput_flag = false\n")
|
|
259
|
-
opts_file.close
|
|
260
|
-
|
|
261
|
-
cmd = "#{self.class::HIGHS_PATH} " \
|
|
262
|
-
"--model_file #{lp_file.path} " \
|
|
263
|
-
"--options_file #{opts_file.path} " \
|
|
264
|
-
"--solution_file #{solution_file.path}"
|
|
265
|
-
|
|
266
|
-
output = `#{cmd} 2>&1`
|
|
267
|
-
status = $?.success?
|
|
268
|
-
|
|
269
|
-
lp_file.unlink
|
|
270
|
-
opts_file.unlink
|
|
271
|
-
|
|
272
|
-
raise SolverError, "HiGHS solver failed:\n#{output}" unless status
|
|
273
|
-
|
|
274
|
-
@solution = parse_solution_file(solution_file.path)
|
|
275
|
-
solution_file.unlink
|
|
276
|
-
@solution
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
# Sets the optimization sense to minimization, sets the objective,
|
|
280
|
-
# and solves the model in a single call.
|
|
281
|
-
#
|
|
282
|
-
# This is a convenience method that combines #minimize, #set_objective,
|
|
283
|
-
# and #solve into one step.
|
|
284
|
-
#
|
|
285
|
-
# @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
|
|
286
|
-
# The objective function to minimize. Can be:
|
|
287
|
-
# - A LinearExpression: `x * 3 + y * 5`
|
|
288
|
-
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
289
|
-
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
290
|
-
# @return [Solution] The solution object containing variable values,
|
|
291
|
-
# objective value, and model status.
|
|
292
|
-
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
293
|
-
# @example
|
|
294
|
-
# solution = model.minimize!(x * 3 + y * 5)
|
|
295
|
-
# puts solution.objective_value # => optimal (minimum) value
|
|
296
|
-
# @example Quadratic minimization (QP)
|
|
297
|
-
# solution = model.minimize!(x * x + y * y)
|
|
298
|
-
def minimize!(objective)
|
|
299
|
-
@sense = :minimize
|
|
300
|
-
set_objective(objective)
|
|
301
|
-
solve
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# Sets the optimization sense to maximization, sets the objective,
|
|
305
|
-
# and solves the model in a single call.
|
|
306
|
-
#
|
|
307
|
-
# This is a convenience method that combines #maximize, #set_objective,
|
|
308
|
-
# and #solve into one step.
|
|
309
|
-
#
|
|
310
|
-
# @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
|
|
311
|
-
# The objective function to maximize. Can be:
|
|
312
|
-
# - A LinearExpression: `x * 3 + y * 5`
|
|
313
|
-
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
314
|
-
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
315
|
-
# @return [Solution] The solution object containing variable values,
|
|
316
|
-
# objective value, and model status.
|
|
317
|
-
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
318
|
-
# @example
|
|
319
|
-
# solution = model.maximize!(x * 3 + y * 5)
|
|
320
|
-
# puts solution.objective_value # => optimal (maximum) value
|
|
321
|
-
def maximize!(objective)
|
|
322
|
-
@sense = :maximize
|
|
323
|
-
set_objective(objective)
|
|
324
|
-
solve
|
|
325
|
-
end
|
|
190
|
+
public
|
|
326
191
|
|
|
327
192
|
# Converts the model to HiGHS LP format string.
|
|
328
193
|
#
|
|
329
|
-
#
|
|
330
|
-
# problem that HiGHS can read. It includes the objective function,
|
|
331
|
-
# constraints, variable bounds, and integer declarations.
|
|
194
|
+
# Delegates to LpGenerator for serialization.
|
|
332
195
|
#
|
|
333
196
|
# @return [String] The LP format content.
|
|
334
197
|
# @example
|
|
@@ -343,84 +206,7 @@ module LpSolver
|
|
|
343
206
|
# # 0 <= y <= +Inf
|
|
344
207
|
# # End
|
|
345
208
|
def to_lp
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
# Objective
|
|
349
|
-
lines << (@sense == :minimize ? 'Minimize' : 'Maximize')
|
|
350
|
-
|
|
351
|
-
obj_terms = @objective.map do |var_idx, coeff|
|
|
352
|
-
var_name = find_var_name(var_idx)
|
|
353
|
-
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
354
|
-
end.join(' + ')
|
|
355
|
-
|
|
356
|
-
if @quadratic_terms.any?
|
|
357
|
-
quad_parts = @quadratic_terms.map do |i1, i2, coeff|
|
|
358
|
-
n1 = sanitize_name(find_var_name(i1))
|
|
359
|
-
n2 = sanitize_name(find_var_name(i2))
|
|
360
|
-
if i1 == i2
|
|
361
|
-
"#{format_coeff(coeff)} #{n1} ^ 2"
|
|
362
|
-
else
|
|
363
|
-
"#{format_coeff(coeff)} #{n1} * #{n2}"
|
|
364
|
-
end
|
|
365
|
-
end.join(' + ')
|
|
366
|
-
lines << " obj: #{obj_terms} + [ #{quad_parts} ] / 2"
|
|
367
|
-
else
|
|
368
|
-
lines << " obj: #{obj_terms}"
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
# Constraints
|
|
372
|
-
if @constraints.any?
|
|
373
|
-
lines << 'Subject To'
|
|
374
|
-
@constraints.each do |name, _idx|
|
|
375
|
-
data = @constraints_data[name]
|
|
376
|
-
terms = data[:expr].map do |var_idx, coeff|
|
|
377
|
-
var_name = find_var_name(var_idx)
|
|
378
|
-
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
379
|
-
end.join(' + ')
|
|
380
|
-
|
|
381
|
-
if data[:lb] == -Float::INFINITY && data[:ub] == Float::INFINITY
|
|
382
|
-
lines << " #{sanitize_name(name)}: #{terms} free"
|
|
383
|
-
elsif data[:lb] == -Float::INFINITY
|
|
384
|
-
lines << " #{sanitize_name(name)}: #{terms} <= #{format_bound(data[:ub])}"
|
|
385
|
-
elsif data[:ub] == Float::INFINITY
|
|
386
|
-
lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
|
|
387
|
-
elsif (data[:ub] - data[:lb]).abs < 1e-12
|
|
388
|
-
lines << " #{sanitize_name(name)}: #{terms} = #{format_bound(data[:lb])}"
|
|
389
|
-
else
|
|
390
|
-
lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
|
|
391
|
-
lines << " #{sanitize_name(name)}_ub: #{terms} <= #{format_bound(data[:ub])}"
|
|
392
|
-
end
|
|
393
|
-
end
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
# Bounds
|
|
397
|
-
if @var_bounds.any?
|
|
398
|
-
lines << 'Bounds'
|
|
399
|
-
@variables.each do |name, _var|
|
|
400
|
-
lb, ub = @var_bounds[name]
|
|
401
|
-
sname = sanitize_name(name)
|
|
402
|
-
|
|
403
|
-
if lb == ub
|
|
404
|
-
lines << " #{sname} = #{format_bound(lb)}"
|
|
405
|
-
elsif lb > -Float::INFINITY && ub < Float::INFINITY
|
|
406
|
-
lines << " #{lb} <= #{sname} <= #{format_bound(ub)}"
|
|
407
|
-
elsif lb > -Float::INFINITY
|
|
408
|
-
lines << " #{sname} >= #{format_bound(lb)}"
|
|
409
|
-
elsif ub < Float::INFINITY
|
|
410
|
-
lines << " #{sname} <= #{format_bound(ub)}"
|
|
411
|
-
end
|
|
412
|
-
end
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
# Integer variables
|
|
416
|
-
int_vars = @variables.select { |sym, _| @var_types[sym] == :integer }
|
|
417
|
-
if int_vars.any?
|
|
418
|
-
lines << 'Integers'
|
|
419
|
-
int_vars.each { |name, _| lines << " #{sanitize_name(name)}" }
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
lines << 'End'
|
|
423
|
-
lines.join("\n")
|
|
209
|
+
LpGenerator.new(self).generate
|
|
424
210
|
end
|
|
425
211
|
|
|
426
212
|
# Writes the model to an LP file.
|
|
@@ -433,28 +219,16 @@ module LpSolver
|
|
|
433
219
|
File.write(filename, to_lp)
|
|
434
220
|
end
|
|
435
221
|
|
|
436
|
-
#
|
|
222
|
+
# Sets the solver driver.
|
|
437
223
|
#
|
|
438
|
-
# @
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
# model.variables
|
|
442
|
-
# # => { :x => @x(0), :y => @y(1) }
|
|
443
|
-
def variables
|
|
444
|
-
@variables
|
|
224
|
+
# @param driver [Drivers::CliDriver, Drivers::NativeDriver] The driver to use for solving.
|
|
225
|
+
def driver=(driver)
|
|
226
|
+
@driver = driver
|
|
445
227
|
end
|
|
446
228
|
|
|
447
229
|
private
|
|
448
230
|
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
# @param idx [Integer] The internal variable index.
|
|
452
|
-
# @return [String] The variable name, or "v#{idx}" if not found.
|
|
453
|
-
def find_var_name(idx)
|
|
454
|
-
@variables.find { |_, var| var.index == idx }&.first || "v#{idx}"
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
# Normalizes bound values for LP format output.
|
|
231
|
+
# Normalizes bound values for internal storage.
|
|
458
232
|
#
|
|
459
233
|
# Converts special infinity values to their canonical forms.
|
|
460
234
|
#
|
|
@@ -463,82 +237,7 @@ module LpSolver
|
|
|
463
237
|
def normalize_bound(val)
|
|
464
238
|
return -Float::INFINITY if val == -Float::INFINITY || val == -1.0 / 0.0
|
|
465
239
|
return Float::INFINITY if val == Float::INFINITY || val == 1.0 / 0.0
|
|
466
|
-
|
|
467
240
|
val.to_f
|
|
468
241
|
end
|
|
469
|
-
|
|
470
|
-
# Formats a coefficient for LP output.
|
|
471
|
-
#
|
|
472
|
-
# Integers are output without decimal points for readability.
|
|
473
|
-
#
|
|
474
|
-
# @param value [Float] The coefficient value.
|
|
475
|
-
# @return [String] The formatted coefficient string.
|
|
476
|
-
def format_coeff(value)
|
|
477
|
-
if value == value.to_i && value.abs < 1e15
|
|
478
|
-
value.to_i.to_s
|
|
479
|
-
else
|
|
480
|
-
format('%.6g', value)
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# Formats a bound value for LP output.
|
|
485
|
-
#
|
|
486
|
-
# @param value [Float] The bound value.
|
|
487
|
-
# @return [String] The formatted bound string (+Inf, -Inf, or numeric).
|
|
488
|
-
def format_bound(value)
|
|
489
|
-
return '+Inf' if value == Float::INFINITY
|
|
490
|
-
return '-Inf' if value == -Float::INFINITY
|
|
491
|
-
|
|
492
|
-
format_coeff(value)
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
# Sanitizes a name for LP format (no spaces, special characters).
|
|
496
|
-
#
|
|
497
|
-
# @param name [String, Symbol] The name to sanitize.
|
|
498
|
-
# @return [String] The sanitized name (max 32 characters).
|
|
499
|
-
def sanitize_name(name)
|
|
500
|
-
name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')[0, 32]
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
# Parses the HiGHS solution file.
|
|
504
|
-
#
|
|
505
|
-
# Extracts variable values, objective value, and model status from
|
|
506
|
-
# the solution file format produced by HiGHS.
|
|
507
|
-
#
|
|
508
|
-
# @param path [String] The path to the HiGHS solution file.
|
|
509
|
-
# @return [Solution] The parsed solution object.
|
|
510
|
-
def parse_solution_file(path)
|
|
511
|
-
content = File.read(path)
|
|
512
|
-
variables = {}
|
|
513
|
-
objective_value = 0.0
|
|
514
|
-
model_status = 'unknown'
|
|
515
|
-
iterations = 0
|
|
516
|
-
|
|
517
|
-
status_match = content.match(/Model status\s*\n\s*(\S+)/i)
|
|
518
|
-
model_status = status_match[1].downcase.gsub('_', ' ') if status_match
|
|
519
|
-
|
|
520
|
-
obj_match = content.match(/Objective\s+(\S+)/i)
|
|
521
|
-
objective_value = obj_match[1].to_f if obj_match
|
|
522
|
-
|
|
523
|
-
in_columns = false
|
|
524
|
-
content.each_line do |line|
|
|
525
|
-
if line =~ /# Columns/i
|
|
526
|
-
in_columns = true
|
|
527
|
-
next
|
|
528
|
-
end
|
|
529
|
-
next unless in_columns
|
|
530
|
-
break if line.strip.empty? || line.start_with?('#')
|
|
531
|
-
|
|
532
|
-
parts = line.strip.split(/\s+/, 2)
|
|
533
|
-
variables[parts[0]] = parts[1].to_f if parts.length == 2
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
Solution.new(
|
|
537
|
-
variables: variables,
|
|
538
|
-
objective_value: objective_value,
|
|
539
|
-
model_status: model_status,
|
|
540
|
-
iterations: iterations
|
|
541
|
-
)
|
|
542
|
-
end
|
|
543
242
|
end
|
|
544
243
|
end
|
data/lib/lpsolver/native.so
CHANGED
|
Binary file
|
data/lib/lpsolver/solution.rb
CHANGED
|
@@ -21,11 +21,15 @@ module LpSolver
|
|
|
21
21
|
# @return [Hash{String => Float}] Maps variable names to their optimal values.
|
|
22
22
|
# The keys are the variable names as strings (as produced by HiGHS),
|
|
23
23
|
# and the values are the optimal decision variable values.
|
|
24
|
+
# When the solution is infeasible, this hash is empty.
|
|
25
|
+
# Always check `infeasible?` or `unbounded?` before reading variable values.
|
|
24
26
|
attr_reader :variables
|
|
25
27
|
|
|
26
28
|
# @return [Float] The optimal objective function value.
|
|
27
29
|
# For minimization problems, this is the minimum value.
|
|
28
30
|
# For maximization problems, this is the maximum value.
|
|
31
|
+
# When the solution is infeasible, this returns `0.0`.
|
|
32
|
+
# Always check `infeasible?` or `unbounded?` before reading this value.
|
|
29
33
|
attr_reader :objective_value
|
|
30
34
|
|
|
31
35
|
# @return [String] The status of the model as reported by HiGHS.
|
|
@@ -40,6 +44,19 @@ module LpSolver
|
|
|
40
44
|
# This is a diagnostic metric; may be 0 for some solver types.
|
|
41
45
|
attr_reader :iterations
|
|
42
46
|
|
|
47
|
+
# Returns the model status as a Symbol.
|
|
48
|
+
#
|
|
49
|
+
# @return [Symbol] The solver status as a Ruby symbol:
|
|
50
|
+
# - :optimal — An optimal solution was found.
|
|
51
|
+
# - :infeasible — No feasible solution exists.
|
|
52
|
+
# - :unbounded — The objective can be improved without bound.
|
|
53
|
+
# - :unknown — The solver could not determine the status.
|
|
54
|
+
# @example
|
|
55
|
+
# solution.status # => :optimal
|
|
56
|
+
def status
|
|
57
|
+
@model_status.to_sym
|
|
58
|
+
end
|
|
59
|
+
|
|
43
60
|
# Creates a new Solution object.
|
|
44
61
|
#
|
|
45
62
|
# @param variables [Hash{String => Float}] Maps variable names to values.
|
|
@@ -57,12 +74,15 @@ module LpSolver
|
|
|
57
74
|
#
|
|
58
75
|
# @param name [Symbol, String, Variable] The variable name (Symbol, String,
|
|
59
76
|
# or Variable object).
|
|
60
|
-
# @return [Float] The optimal value of the variable
|
|
77
|
+
# @return [Float] The optimal value of the variable, or `nil` if the
|
|
78
|
+
# solution is infeasible or unbounded.
|
|
61
79
|
# @raise [KeyError] If the variable name is not found in the solution.
|
|
62
80
|
# @example
|
|
63
81
|
# solution[:x] # => 4.0 (by symbol)
|
|
64
82
|
# solution['x'] # => 4.0 (by string)
|
|
65
83
|
# solution[x] # => 4.0 (by Variable object)
|
|
84
|
+
# @note When the solution is infeasible, all variables are empty.
|
|
85
|
+
# Check `infeasible?` first before accessing variable values.
|
|
66
86
|
def [](name)
|
|
67
87
|
key = if name.is_a?(Variable)
|
|
68
88
|
name.name.to_s
|
data/lib/lpsolver/version.rb
CHANGED
data/lib/lpsolver.rb
CHANGED
|
@@ -15,10 +15,7 @@
|
|
|
15
15
|
# y = model.add_variable(:y, lb: 0)
|
|
16
16
|
#
|
|
17
17
|
# model.add_constraint(:budget, (x * 2 + y) <= 100)
|
|
18
|
-
# model.minimize
|
|
19
|
-
# model.set_objective(x * 3 + y * 5)
|
|
20
|
-
#
|
|
21
|
-
# solution = model.solve
|
|
18
|
+
# solution = model.minimize!(x * 3 + y * 5)
|
|
22
19
|
# puts solution.objective_value # => 12.0
|
|
23
20
|
#
|
|
24
21
|
# @see LpSolver::Model
|
|
@@ -39,3 +36,12 @@ require_relative 'lpsolver/quadratic_expression'
|
|
|
39
36
|
# Solvers and data classes
|
|
40
37
|
require_relative 'lpsolver/solution'
|
|
41
38
|
require_relative 'lpsolver/model'
|
|
39
|
+
require_relative 'lpsolver/lp_generator'
|
|
40
|
+
require_relative 'lpsolver/drivers'
|
|
41
|
+
|
|
42
|
+
# Native extension (optional — only available if compiled)
|
|
43
|
+
begin
|
|
44
|
+
require_relative 'lpsolver/native'
|
|
45
|
+
rescue LoadError
|
|
46
|
+
# Native extension not compiled — NativeDriver will raise LoadError at runtime
|
|
47
|
+
end
|