lpsolver 0.2.1 → 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 +38 -13
- data/ext/lpsolver/Makefile +2 -2
- 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 -410
- 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,105 +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
|
-
lp_file.unlink
|
|
268
|
-
opts_file.unlink
|
|
269
|
-
|
|
270
|
-
# HiGHS returns non-zero exit code for infeasible/unbounded problems,
|
|
271
|
-
# but still writes a valid solution file. Check for valid status instead.
|
|
272
|
-
solution_content = File.read(solution_file.path)
|
|
273
|
-
status_match = solution_content.match(/Model status\s*\n\s*(\S+)/i)
|
|
274
|
-
unless status_match
|
|
275
|
-
raise SolverError, "HiGHS solver failed:\n#{output}" unless $?.success?
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
@solution = parse_solution_file(solution_file.path)
|
|
279
|
-
solution_file.unlink
|
|
280
|
-
@solution
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
# Sets the optimization sense to minimization, sets the objective,
|
|
284
|
-
# and solves the model in a single call.
|
|
285
|
-
#
|
|
286
|
-
# This is a convenience method that combines #minimize, #set_objective,
|
|
287
|
-
# and #solve into one step.
|
|
288
|
-
#
|
|
289
|
-
# @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
|
|
290
|
-
# The objective function to minimize. Can be:
|
|
291
|
-
# - A LinearExpression: `x * 3 + y * 5`
|
|
292
|
-
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
293
|
-
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
294
|
-
# @return [Solution] The solution object containing variable values,
|
|
295
|
-
# objective value, and model status.
|
|
296
|
-
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
297
|
-
# @example
|
|
298
|
-
# solution = model.minimize!(x * 3 + y * 5)
|
|
299
|
-
# puts solution.objective_value # => optimal (minimum) value
|
|
300
|
-
# @example Quadratic minimization (QP)
|
|
301
|
-
# solution = model.minimize!(x * x + y * y)
|
|
302
|
-
def minimize!(objective)
|
|
303
|
-
@sense = :minimize
|
|
304
|
-
set_objective(objective)
|
|
305
|
-
solve
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# Sets the optimization sense to maximization, sets the objective,
|
|
309
|
-
# and solves the model in a single call.
|
|
310
|
-
#
|
|
311
|
-
# This is a convenience method that combines #maximize, #set_objective,
|
|
312
|
-
# and #solve into one step.
|
|
313
|
-
#
|
|
314
|
-
# @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
|
|
315
|
-
# The objective function to maximize. Can be:
|
|
316
|
-
# - A LinearExpression: `x * 3 + y * 5`
|
|
317
|
-
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
318
|
-
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
319
|
-
# @return [Solution] The solution object containing variable values,
|
|
320
|
-
# objective value, and model status.
|
|
321
|
-
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
322
|
-
# @example
|
|
323
|
-
# solution = model.maximize!(x * 3 + y * 5)
|
|
324
|
-
# puts solution.objective_value # => optimal (maximum) value
|
|
325
|
-
def maximize!(objective)
|
|
326
|
-
@sense = :maximize
|
|
327
|
-
set_objective(objective)
|
|
328
|
-
solve
|
|
329
|
-
end
|
|
190
|
+
public
|
|
330
191
|
|
|
331
192
|
# Converts the model to HiGHS LP format string.
|
|
332
193
|
#
|
|
333
|
-
#
|
|
334
|
-
# problem that HiGHS can read. It includes the objective function,
|
|
335
|
-
# constraints, variable bounds, and integer declarations.
|
|
194
|
+
# Delegates to LpGenerator for serialization.
|
|
336
195
|
#
|
|
337
196
|
# @return [String] The LP format content.
|
|
338
197
|
# @example
|
|
@@ -347,84 +206,7 @@ module LpSolver
|
|
|
347
206
|
# # 0 <= y <= +Inf
|
|
348
207
|
# # End
|
|
349
208
|
def to_lp
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
# Objective
|
|
353
|
-
lines << (@sense == :minimize ? 'Minimize' : 'Maximize')
|
|
354
|
-
|
|
355
|
-
obj_terms = @objective.map do |var_idx, coeff|
|
|
356
|
-
var_name = find_var_name(var_idx)
|
|
357
|
-
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
358
|
-
end.join(' + ')
|
|
359
|
-
|
|
360
|
-
if @quadratic_terms.any?
|
|
361
|
-
quad_parts = @quadratic_terms.map do |i1, i2, coeff|
|
|
362
|
-
n1 = sanitize_name(find_var_name(i1))
|
|
363
|
-
n2 = sanitize_name(find_var_name(i2))
|
|
364
|
-
if i1 == i2
|
|
365
|
-
"#{format_coeff(coeff)} #{n1} ^ 2"
|
|
366
|
-
else
|
|
367
|
-
"#{format_coeff(coeff)} #{n1} * #{n2}"
|
|
368
|
-
end
|
|
369
|
-
end.join(' + ')
|
|
370
|
-
lines << " obj: #{obj_terms} + [ #{quad_parts} ] / 2"
|
|
371
|
-
else
|
|
372
|
-
lines << " obj: #{obj_terms}"
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# Constraints
|
|
376
|
-
if @constraints.any?
|
|
377
|
-
lines << 'Subject To'
|
|
378
|
-
@constraints.each do |name, _idx|
|
|
379
|
-
data = @constraints_data[name]
|
|
380
|
-
terms = data[:expr].map do |var_idx, coeff|
|
|
381
|
-
var_name = find_var_name(var_idx)
|
|
382
|
-
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
383
|
-
end.join(' + ')
|
|
384
|
-
|
|
385
|
-
if data[:lb] == -Float::INFINITY && data[:ub] == Float::INFINITY
|
|
386
|
-
lines << " #{sanitize_name(name)}: #{terms} free"
|
|
387
|
-
elsif data[:lb] == -Float::INFINITY
|
|
388
|
-
lines << " #{sanitize_name(name)}: #{terms} <= #{format_bound(data[:ub])}"
|
|
389
|
-
elsif data[:ub] == Float::INFINITY
|
|
390
|
-
lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
|
|
391
|
-
elsif (data[:ub] - data[:lb]).abs < 1e-12
|
|
392
|
-
lines << " #{sanitize_name(name)}: #{terms} = #{format_bound(data[:lb])}"
|
|
393
|
-
else
|
|
394
|
-
lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
|
|
395
|
-
lines << " #{sanitize_name(name)}_ub: #{terms} <= #{format_bound(data[:ub])}"
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
# Bounds
|
|
401
|
-
if @var_bounds.any?
|
|
402
|
-
lines << 'Bounds'
|
|
403
|
-
@variables.each do |name, _var|
|
|
404
|
-
lb, ub = @var_bounds[name]
|
|
405
|
-
sname = sanitize_name(name)
|
|
406
|
-
|
|
407
|
-
if lb == ub
|
|
408
|
-
lines << " #{sname} = #{format_bound(lb)}"
|
|
409
|
-
elsif lb > -Float::INFINITY && ub < Float::INFINITY
|
|
410
|
-
lines << " #{lb} <= #{sname} <= #{format_bound(ub)}"
|
|
411
|
-
elsif lb > -Float::INFINITY
|
|
412
|
-
lines << " #{sname} >= #{format_bound(lb)}"
|
|
413
|
-
elsif ub < Float::INFINITY
|
|
414
|
-
lines << " #{sname} <= #{format_bound(ub)}"
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
# Integer variables
|
|
420
|
-
int_vars = @variables.select { |sym, _| @var_types[sym] == :integer }
|
|
421
|
-
if int_vars.any?
|
|
422
|
-
lines << 'Integers'
|
|
423
|
-
int_vars.each { |name, _| lines << " #{sanitize_name(name)}" }
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
lines << 'End'
|
|
427
|
-
lines.join("\n")
|
|
209
|
+
LpGenerator.new(self).generate
|
|
428
210
|
end
|
|
429
211
|
|
|
430
212
|
# Writes the model to an LP file.
|
|
@@ -437,28 +219,16 @@ module LpSolver
|
|
|
437
219
|
File.write(filename, to_lp)
|
|
438
220
|
end
|
|
439
221
|
|
|
440
|
-
#
|
|
222
|
+
# Sets the solver driver.
|
|
441
223
|
#
|
|
442
|
-
# @
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
# model.variables
|
|
446
|
-
# # => { :x => @x(0), :y => @y(1) }
|
|
447
|
-
def variables
|
|
448
|
-
@variables
|
|
224
|
+
# @param driver [Drivers::CliDriver, Drivers::NativeDriver] The driver to use for solving.
|
|
225
|
+
def driver=(driver)
|
|
226
|
+
@driver = driver
|
|
449
227
|
end
|
|
450
228
|
|
|
451
229
|
private
|
|
452
230
|
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
# @param idx [Integer] The internal variable index.
|
|
456
|
-
# @return [String] The variable name, or "v#{idx}" if not found.
|
|
457
|
-
def find_var_name(idx)
|
|
458
|
-
@variables.find { |_, var| var.index == idx }&.first || "v#{idx}"
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
# Normalizes bound values for LP format output.
|
|
231
|
+
# Normalizes bound values for internal storage.
|
|
462
232
|
#
|
|
463
233
|
# Converts special infinity values to their canonical forms.
|
|
464
234
|
#
|
|
@@ -467,82 +237,7 @@ module LpSolver
|
|
|
467
237
|
def normalize_bound(val)
|
|
468
238
|
return -Float::INFINITY if val == -Float::INFINITY || val == -1.0 / 0.0
|
|
469
239
|
return Float::INFINITY if val == Float::INFINITY || val == 1.0 / 0.0
|
|
470
|
-
|
|
471
240
|
val.to_f
|
|
472
241
|
end
|
|
473
|
-
|
|
474
|
-
# Formats a coefficient for LP output.
|
|
475
|
-
#
|
|
476
|
-
# Integers are output without decimal points for readability.
|
|
477
|
-
#
|
|
478
|
-
# @param value [Float] The coefficient value.
|
|
479
|
-
# @return [String] The formatted coefficient string.
|
|
480
|
-
def format_coeff(value)
|
|
481
|
-
if value == value.to_i && value.abs < 1e15
|
|
482
|
-
value.to_i.to_s
|
|
483
|
-
else
|
|
484
|
-
format('%.6g', value)
|
|
485
|
-
end
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
# Formats a bound value for LP output.
|
|
489
|
-
#
|
|
490
|
-
# @param value [Float] The bound value.
|
|
491
|
-
# @return [String] The formatted bound string (+Inf, -Inf, or numeric).
|
|
492
|
-
def format_bound(value)
|
|
493
|
-
return '+Inf' if value == Float::INFINITY
|
|
494
|
-
return '-Inf' if value == -Float::INFINITY
|
|
495
|
-
|
|
496
|
-
format_coeff(value)
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
# Sanitizes a name for LP format (no spaces, special characters).
|
|
500
|
-
#
|
|
501
|
-
# @param name [String, Symbol] The name to sanitize.
|
|
502
|
-
# @return [String] The sanitized name (max 32 characters).
|
|
503
|
-
def sanitize_name(name)
|
|
504
|
-
name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')[0, 32]
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
# Parses the HiGHS solution file.
|
|
508
|
-
#
|
|
509
|
-
# Extracts variable values, objective value, and model status from
|
|
510
|
-
# the solution file format produced by HiGHS.
|
|
511
|
-
#
|
|
512
|
-
# @param path [String] The path to the HiGHS solution file.
|
|
513
|
-
# @return [Solution] The parsed solution object.
|
|
514
|
-
def parse_solution_file(path)
|
|
515
|
-
content = File.read(path)
|
|
516
|
-
variables = {}
|
|
517
|
-
objective_value = 0.0
|
|
518
|
-
model_status = 'unknown'
|
|
519
|
-
iterations = 0
|
|
520
|
-
|
|
521
|
-
status_match = content.match(/Model status\s*\n\s*(\S+)/i)
|
|
522
|
-
model_status = status_match[1].downcase.gsub('_', ' ') if status_match
|
|
523
|
-
|
|
524
|
-
obj_match = content.match(/Objective\s+(\S+)/i)
|
|
525
|
-
objective_value = obj_match[1].to_f if obj_match
|
|
526
|
-
|
|
527
|
-
in_columns = false
|
|
528
|
-
content.each_line do |line|
|
|
529
|
-
if line =~ /# Columns/i
|
|
530
|
-
in_columns = true
|
|
531
|
-
next
|
|
532
|
-
end
|
|
533
|
-
next unless in_columns
|
|
534
|
-
break if line.strip.empty? || line.start_with?('#')
|
|
535
|
-
|
|
536
|
-
parts = line.strip.split(/\s+/, 2)
|
|
537
|
-
variables[parts[0]] = parts[1].to_f if parts.length == 2
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
Solution.new(
|
|
541
|
-
variables: variables,
|
|
542
|
-
objective_value: objective_value,
|
|
543
|
-
model_status: model_status,
|
|
544
|
-
iterations: iterations
|
|
545
|
-
)
|
|
546
|
-
end
|
|
547
242
|
end
|
|
548
243
|
end
|
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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lpsolver
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Siaw
|
|
@@ -1194,12 +1194,16 @@ files:
|
|
|
1194
1194
|
- ext/lpsolver/native.so
|
|
1195
1195
|
- lib/lpsolver.rb
|
|
1196
1196
|
- lib/lpsolver/constraint_spec.rb
|
|
1197
|
+
- lib/lpsolver/drivers.rb
|
|
1198
|
+
- lib/lpsolver/drivers/README.md
|
|
1199
|
+
- lib/lpsolver/drivers/cli_driver.rb
|
|
1200
|
+
- lib/lpsolver/drivers/native_driver.rb
|
|
1197
1201
|
- lib/lpsolver/exception.rb
|
|
1198
1202
|
- lib/lpsolver/highs
|
|
1199
1203
|
- lib/lpsolver/linear_expression.rb
|
|
1204
|
+
- lib/lpsolver/lp_generator.rb
|
|
1200
1205
|
- lib/lpsolver/model.rb
|
|
1201
1206
|
- lib/lpsolver/native.so
|
|
1202
|
-
- lib/lpsolver/native_model.rb
|
|
1203
1207
|
- lib/lpsolver/quadratic_expression.rb
|
|
1204
1208
|
- lib/lpsolver/solution.rb
|
|
1205
1209
|
- lib/lpsolver/variable.rb
|