lpsolver 0.1.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 +7 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +345 -0
- data/exe/README.md +1 -0
- data/lib/lpsolver/constraint_spec.rb +96 -0
- data/lib/lpsolver/exception.rb +55 -0
- data/lib/lpsolver/linear_expression.rb +194 -0
- data/lib/lpsolver/model.rb +520 -0
- data/lib/lpsolver/quadratic_expression.rb +164 -0
- data/lib/lpsolver/solution.rb +123 -0
- data/lib/lpsolver/variable.rb +221 -0
- data/lib/lpsolver/version.rb +8 -0
- data/lib/lpsolver.rb +41 -0
- data/lpsolver.gemspec +29 -0
- metadata +65 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module LpSolver
|
|
6
|
+
# A high-level interface to HiGHS for building and solving LP/QP/MIP models.
|
|
7
|
+
#
|
|
8
|
+
# The Model class provides a Ruby DSL for defining variables, constraints,
|
|
9
|
+
# and objectives. Models are serialized to HiGHS LP format and solved via
|
|
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:
|
|
26
|
+
#
|
|
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
|
+
class Model
|
|
73
|
+
# The path to the HiGHS binary.
|
|
74
|
+
#
|
|
75
|
+
# Set via the HIGHS_PATH environment variable, or defaults to 'highs'
|
|
76
|
+
# on the system PATH. This is used to invoke the HiGHS solver via
|
|
77
|
+
# the command line.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] The path to the HiGHS executable.
|
|
80
|
+
HIGHS_PATH = ENV.fetch('HIGHS_PATH', 'highs')
|
|
81
|
+
|
|
82
|
+
# Creates a new empty LP/QP/MIP model.
|
|
83
|
+
#
|
|
84
|
+
# @param name [String] An optional name for this model, used for
|
|
85
|
+
# debugging and identification in logs. Defaults to 'untitled'.
|
|
86
|
+
def initialize(name = nil)
|
|
87
|
+
@name = name || 'untitled'
|
|
88
|
+
@variables = {} # { symbol => Variable }
|
|
89
|
+
@constraints = {} # { symbol => index }
|
|
90
|
+
@var_counter = 0
|
|
91
|
+
@constr_counter = 0
|
|
92
|
+
@sense = :minimize
|
|
93
|
+
@solution = nil
|
|
94
|
+
@objective = {} # { var_index => coefficient }
|
|
95
|
+
@quadratic_terms = [] # [[var1_idx, var2_idx, coefficient], ...]
|
|
96
|
+
@var_types = {} # { symbol => :continuous | :integer }
|
|
97
|
+
@var_bounds = {} # { symbol => [lb, ub] }
|
|
98
|
+
@constraints_data = {} # { symbol => { lb:, ub:, expr: [[var_idx, coeff], ...] } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Adds a variable to the model.
|
|
102
|
+
#
|
|
103
|
+
# Variables represent the decision quantities to be determined by the
|
|
104
|
+
# solver. Each variable is assigned a unique internal index and can be
|
|
105
|
+
# used in expressions via arithmetic operators.
|
|
106
|
+
#
|
|
107
|
+
# @param name [Symbol, String] The name of the variable. This is used
|
|
108
|
+
# for identification in the LP format output and solution results.
|
|
109
|
+
# @param lb [Float] The lower bound for the variable (default: 0.0).
|
|
110
|
+
# Use -Float::INFINITY for no lower bound.
|
|
111
|
+
# @param ub [Float] The upper bound for the variable (default: Float::INFINITY).
|
|
112
|
+
# Use Float::INFINITY for no upper bound. Setting lb == ub fixes the variable.
|
|
113
|
+
# @param integer [Boolean] Whether the variable must take integer values
|
|
114
|
+
# (default: false). When true, the model becomes a MIP problem.
|
|
115
|
+
# @return [Variable] The variable object, which supports arithmetic and
|
|
116
|
+
# comparison operators for building expressions and constraints.
|
|
117
|
+
# @example Adding a continuous variable
|
|
118
|
+
# x = model.add_variable(:x, lb: 0)
|
|
119
|
+
# @example Adding an integer variable
|
|
120
|
+
# count = model.add_variable(:count, lb: 0, integer: true)
|
|
121
|
+
# @example Adding a fixed variable
|
|
122
|
+
# capacity = model.add_variable(:capacity, lb: 100, ub: 100)
|
|
123
|
+
def add_variable(name, lb: 0.0, ub: Float::INFINITY, integer: false)
|
|
124
|
+
name = name.to_sym
|
|
125
|
+
idx = @var_counter
|
|
126
|
+
var = Variable.new(idx, name)
|
|
127
|
+
@variables[name] = var
|
|
128
|
+
@var_types[name] = integer ? :integer : :continuous
|
|
129
|
+
@var_bounds[name] = [normalize_bound(lb), normalize_bound(ub)]
|
|
130
|
+
@var_counter += 1
|
|
131
|
+
var
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Adds a constraint to the model.
|
|
135
|
+
#
|
|
136
|
+
# Constraints define the feasible region of the optimization problem.
|
|
137
|
+
# They can be specified using the DSL (comparison operators) or the
|
|
138
|
+
# legacy array format.
|
|
139
|
+
#
|
|
140
|
+
# @param name [Symbol, String] The name of the constraint.
|
|
141
|
+
# @param expr [ConstraintSpec, Array<[Integer, Float]>] The constraint
|
|
142
|
+
# specification. Can be either:
|
|
143
|
+
# - A ConstraintSpec from comparison operators: (x * 2 + y) <= 100
|
|
144
|
+
# - An array of [var_index, coefficient] pairs with explicit bounds
|
|
145
|
+
# @param lb [Float] Lower bound for the constraint (used with array-style expr).
|
|
146
|
+
# Default: -Float::INFINITY.
|
|
147
|
+
# @param ub [Float] Upper bound for the constraint (used with array-style expr).
|
|
148
|
+
# Default: Float::INFINITY.
|
|
149
|
+
# @return [Symbol] The constraint name.
|
|
150
|
+
# @example Using DSL comparison operators
|
|
151
|
+
# model.add_constraint(:budget, (x * 2 + y) <= 100)
|
|
152
|
+
# model.add_constraint(:demand, (x + y * 2) >= 50)
|
|
153
|
+
# model.add_constraint(:balance, (x + y) == 10)
|
|
154
|
+
# @example Using legacy array format
|
|
155
|
+
# model.add_constraint(:budget, [[x.index, 2], [y.index, 1]], ub: 100)
|
|
156
|
+
def add_constraint(name, expr, lb: -Float::INFINITY, ub: Float::INFINITY)
|
|
157
|
+
name = name.to_sym
|
|
158
|
+
|
|
159
|
+
if expr.is_a?(ConstraintSpec)
|
|
160
|
+
lb_val, ub_val = expr.bounds
|
|
161
|
+
data_expr = expr.expr
|
|
162
|
+
else
|
|
163
|
+
lb_val = lb
|
|
164
|
+
ub_val = ub
|
|
165
|
+
data_expr = expr
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
idx = @constr_counter
|
|
169
|
+
@constraints[name] = idx
|
|
170
|
+
@constraints_data[name] = {
|
|
171
|
+
lb: normalize_bound(lb_val),
|
|
172
|
+
ub: normalize_bound(ub_val),
|
|
173
|
+
expr: data_expr
|
|
174
|
+
}
|
|
175
|
+
@constr_counter += 1
|
|
176
|
+
name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Sets the optimization sense to minimization.
|
|
180
|
+
#
|
|
181
|
+
# @return [void]
|
|
182
|
+
# @see #maximize
|
|
183
|
+
def minimize
|
|
184
|
+
@sense = :minimize
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Sets the optimization sense to maximization.
|
|
188
|
+
#
|
|
189
|
+
# @return [void]
|
|
190
|
+
# @see #minimize
|
|
191
|
+
def maximize
|
|
192
|
+
@sense = :maximize
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Sets the objective function for the model.
|
|
196
|
+
#
|
|
197
|
+
# The objective function defines what the solver should optimize.
|
|
198
|
+
# It can be a linear expression (for LP), a quadratic expression
|
|
199
|
+
# (for QP), or a hash of coefficients (legacy format).
|
|
200
|
+
#
|
|
201
|
+
# @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
|
|
202
|
+
# The objective function. Can be:
|
|
203
|
+
# - A LinearExpression: `x * 3 + y * 5`
|
|
204
|
+
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
205
|
+
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
206
|
+
# @return [void]
|
|
207
|
+
# @example Linear objective
|
|
208
|
+
# model.set_objective(x * 3 + y * 5)
|
|
209
|
+
# @example Quadratic objective (QP)
|
|
210
|
+
# model.set_objective(x * x + y * y)
|
|
211
|
+
def set_objective(objective)
|
|
212
|
+
if objective.is_a?(QuadraticExpression)
|
|
213
|
+
@objective = objective.linear_terms.transform_values(&:to_f)
|
|
214
|
+
@quadratic_terms = objective.hessian_entries
|
|
215
|
+
elsif objective.is_a?(LinearExpression)
|
|
216
|
+
@objective = objective.terms.transform_values(&:to_f)
|
|
217
|
+
@quadratic_terms = []
|
|
218
|
+
else
|
|
219
|
+
@objective = objective.transform_values { |v| v.is_a?(Variable) ? 1.0 : v.to_f }
|
|
220
|
+
@quadratic_terms = []
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Solves the model and returns the solution.
|
|
225
|
+
#
|
|
226
|
+
# Serializes the model to HiGHS LP format, invokes the HiGHS solver,
|
|
227
|
+
# and parses the solution file to return a Solution object.
|
|
228
|
+
#
|
|
229
|
+
# @return [Solution] The solution object containing variable values,
|
|
230
|
+
# objective value, and model status.
|
|
231
|
+
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
232
|
+
# @example
|
|
233
|
+
# solution = model.solve
|
|
234
|
+
# solution[:x] # => optimal value for variable x
|
|
235
|
+
# solution.objective_value # => optimal objective value
|
|
236
|
+
# solution.feasible? # => true
|
|
237
|
+
def solve
|
|
238
|
+
lp_content = to_lp
|
|
239
|
+
lp_file = Tempfile.new(['model', '.lp'])
|
|
240
|
+
lp_file.write(lp_content)
|
|
241
|
+
lp_file.close
|
|
242
|
+
|
|
243
|
+
solution_file = Tempfile.new(['solution', '.sol'])
|
|
244
|
+
opts_file = Tempfile.new(['highs_opts', '.txt'])
|
|
245
|
+
opts_file.write("log_to_console = false\noutput_flag = false\n")
|
|
246
|
+
opts_file.close
|
|
247
|
+
|
|
248
|
+
cmd = "#{self.class::HIGHS_PATH} " \
|
|
249
|
+
"--model_file #{lp_file.path} " \
|
|
250
|
+
"--options_file #{opts_file.path} " \
|
|
251
|
+
"--solution_file #{solution_file.path}"
|
|
252
|
+
|
|
253
|
+
output = `#{cmd} 2>&1`
|
|
254
|
+
status = $?.success?
|
|
255
|
+
|
|
256
|
+
lp_file.unlink
|
|
257
|
+
opts_file.unlink
|
|
258
|
+
|
|
259
|
+
raise SolverError, "HiGHS solver failed:\n#{output}" unless status
|
|
260
|
+
|
|
261
|
+
@solution = parse_solution_file(solution_file.path)
|
|
262
|
+
solution_file.unlink
|
|
263
|
+
@solution
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Sets the optimization sense to minimization, sets the objective,
|
|
267
|
+
# and solves the model in a single call.
|
|
268
|
+
#
|
|
269
|
+
# This is a convenience method that combines #minimize, #set_objective,
|
|
270
|
+
# and #solve into one step.
|
|
271
|
+
#
|
|
272
|
+
# @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
|
|
273
|
+
# The objective function to minimize. Can be:
|
|
274
|
+
# - A LinearExpression: `x * 3 + y * 5`
|
|
275
|
+
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
276
|
+
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
277
|
+
# @return [Solution] The solution object containing variable values,
|
|
278
|
+
# objective value, and model status.
|
|
279
|
+
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
280
|
+
# @example
|
|
281
|
+
# solution = model.minimize!(x * 3 + y * 5)
|
|
282
|
+
# puts solution.objective_value # => optimal (minimum) value
|
|
283
|
+
# @example Quadratic minimization (QP)
|
|
284
|
+
# solution = model.minimize!(x * x + y * y)
|
|
285
|
+
def minimize!(objective)
|
|
286
|
+
@sense = :minimize
|
|
287
|
+
set_objective(objective)
|
|
288
|
+
solve
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Sets the optimization sense to maximization, sets the objective,
|
|
292
|
+
# and solves the model in a single call.
|
|
293
|
+
#
|
|
294
|
+
# This is a convenience method that combines #maximize, #set_objective,
|
|
295
|
+
# and #solve into one step.
|
|
296
|
+
#
|
|
297
|
+
# @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
|
|
298
|
+
# The objective function to maximize. Can be:
|
|
299
|
+
# - A LinearExpression: `x * 3 + y * 5`
|
|
300
|
+
# - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
|
|
301
|
+
# - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
|
|
302
|
+
# @return [Solution] The solution object containing variable values,
|
|
303
|
+
# objective value, and model status.
|
|
304
|
+
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
305
|
+
# @example
|
|
306
|
+
# solution = model.maximize!(x * 3 + y * 5)
|
|
307
|
+
# puts solution.objective_value # => optimal (maximum) value
|
|
308
|
+
def maximize!(objective)
|
|
309
|
+
@sense = :maximize
|
|
310
|
+
set_objective(objective)
|
|
311
|
+
solve
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Converts the model to HiGHS LP format string.
|
|
315
|
+
#
|
|
316
|
+
# The LP format is a text-based representation of the optimization
|
|
317
|
+
# problem that HiGHS can read. It includes the objective function,
|
|
318
|
+
# constraints, variable bounds, and integer declarations.
|
|
319
|
+
#
|
|
320
|
+
# @return [String] The LP format content.
|
|
321
|
+
# @example
|
|
322
|
+
# puts model.to_lp
|
|
323
|
+
# # Minimize
|
|
324
|
+
# # obj: 3 x + 5 y
|
|
325
|
+
# # Subject To
|
|
326
|
+
# # budget: 2 x + 1 y <= 100
|
|
327
|
+
# # demand: 1 x + 2 y >= 50
|
|
328
|
+
# # Bounds
|
|
329
|
+
# # 0 <= x <= +Inf
|
|
330
|
+
# # 0 <= y <= +Inf
|
|
331
|
+
# # End
|
|
332
|
+
def to_lp
|
|
333
|
+
lines = []
|
|
334
|
+
|
|
335
|
+
# Objective
|
|
336
|
+
lines << (@sense == :minimize ? 'Minimize' : 'Maximize')
|
|
337
|
+
|
|
338
|
+
obj_terms = @objective.map do |var_idx, coeff|
|
|
339
|
+
var_name = find_var_name(var_idx)
|
|
340
|
+
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
341
|
+
end.join(' + ')
|
|
342
|
+
|
|
343
|
+
if @quadratic_terms.any?
|
|
344
|
+
quad_parts = @quadratic_terms.map do |i1, i2, coeff|
|
|
345
|
+
n1 = sanitize_name(find_var_name(i1))
|
|
346
|
+
n2 = sanitize_name(find_var_name(i2))
|
|
347
|
+
if i1 == i2
|
|
348
|
+
"#{format_coeff(coeff)} #{n1} ^ 2"
|
|
349
|
+
else
|
|
350
|
+
"#{format_coeff(coeff)} #{n1} * #{n2}"
|
|
351
|
+
end
|
|
352
|
+
end.join(' + ')
|
|
353
|
+
lines << " obj: #{obj_terms} + [ #{quad_parts} ] / 2"
|
|
354
|
+
else
|
|
355
|
+
lines << " obj: #{obj_terms}"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Constraints
|
|
359
|
+
if @constraints.any?
|
|
360
|
+
lines << 'Subject To'
|
|
361
|
+
@constraints.each do |name, _idx|
|
|
362
|
+
data = @constraints_data[name]
|
|
363
|
+
terms = data[:expr].map do |var_idx, coeff|
|
|
364
|
+
var_name = find_var_name(var_idx)
|
|
365
|
+
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
366
|
+
end.join(' + ')
|
|
367
|
+
|
|
368
|
+
if data[:lb] == -Float::INFINITY && data[:ub] == Float::INFINITY
|
|
369
|
+
lines << " #{sanitize_name(name)}: #{terms} free"
|
|
370
|
+
elsif data[:lb] == -Float::INFINITY
|
|
371
|
+
lines << " #{sanitize_name(name)}: #{terms} <= #{format_bound(data[:ub])}"
|
|
372
|
+
elsif data[:ub] == Float::INFINITY
|
|
373
|
+
lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
|
|
374
|
+
elsif (data[:ub] - data[:lb]).abs < 1e-12
|
|
375
|
+
lines << " #{sanitize_name(name)}: #{terms} = #{format_bound(data[:lb])}"
|
|
376
|
+
else
|
|
377
|
+
lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
|
|
378
|
+
lines << " #{sanitize_name(name)}_ub: #{terms} <= #{format_bound(data[:ub])}"
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Bounds
|
|
384
|
+
if @var_bounds.any?
|
|
385
|
+
lines << 'Bounds'
|
|
386
|
+
@variables.each do |name, _var|
|
|
387
|
+
lb, ub = @var_bounds[name]
|
|
388
|
+
sname = sanitize_name(name)
|
|
389
|
+
|
|
390
|
+
if lb == ub
|
|
391
|
+
lines << " #{sname} = #{format_bound(lb)}"
|
|
392
|
+
elsif lb > -Float::INFINITY && ub < Float::INFINITY
|
|
393
|
+
lines << " #{lb} <= #{sname} <= #{format_bound(ub)}"
|
|
394
|
+
elsif lb > -Float::INFINITY
|
|
395
|
+
lines << " #{sname} >= #{format_bound(lb)}"
|
|
396
|
+
elsif ub < Float::INFINITY
|
|
397
|
+
lines << " #{sname} <= #{format_bound(ub)}"
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Integer variables
|
|
403
|
+
int_vars = @variables.select { |sym, _| @var_types[sym] == :integer }
|
|
404
|
+
if int_vars.any?
|
|
405
|
+
lines << 'Integers'
|
|
406
|
+
int_vars.each { |name, _| lines << " #{sanitize_name(name)}" }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
lines << 'End'
|
|
410
|
+
lines.join("\n")
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Writes the model to an LP file.
|
|
414
|
+
#
|
|
415
|
+
# @param filename [String] The output file path.
|
|
416
|
+
# @return [void]
|
|
417
|
+
# @example
|
|
418
|
+
# model.write_lp('my_model.lp')
|
|
419
|
+
def write_lp(filename)
|
|
420
|
+
File.write(filename, to_lp)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
private
|
|
424
|
+
|
|
425
|
+
# Looks up a variable name by its internal index.
|
|
426
|
+
#
|
|
427
|
+
# @param idx [Integer] The internal variable index.
|
|
428
|
+
# @return [String] The variable name, or "v#{idx}" if not found.
|
|
429
|
+
def find_var_name(idx)
|
|
430
|
+
@variables.find { |_, var| var.index == idx }&.first || "v#{idx}"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Normalizes bound values for LP format output.
|
|
434
|
+
#
|
|
435
|
+
# Converts special infinity values to their canonical forms.
|
|
436
|
+
#
|
|
437
|
+
# @param val [Float] The bound value to normalize.
|
|
438
|
+
# @return [Float] The normalized bound value.
|
|
439
|
+
def normalize_bound(val)
|
|
440
|
+
return -Float::INFINITY if val == -Float::INFINITY || val == -1.0 / 0.0
|
|
441
|
+
return Float::INFINITY if val == Float::INFINITY || val == 1.0 / 0.0
|
|
442
|
+
|
|
443
|
+
val.to_f
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Formats a coefficient for LP output.
|
|
447
|
+
#
|
|
448
|
+
# Integers are output without decimal points for readability.
|
|
449
|
+
#
|
|
450
|
+
# @param value [Float] The coefficient value.
|
|
451
|
+
# @return [String] The formatted coefficient string.
|
|
452
|
+
def format_coeff(value)
|
|
453
|
+
if value == value.to_i && value.abs < 1e15
|
|
454
|
+
value.to_i.to_s
|
|
455
|
+
else
|
|
456
|
+
format('%.6g', value)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Formats a bound value for LP output.
|
|
461
|
+
#
|
|
462
|
+
# @param value [Float] The bound value.
|
|
463
|
+
# @return [String] The formatted bound string (+Inf, -Inf, or numeric).
|
|
464
|
+
def format_bound(value)
|
|
465
|
+
return '+Inf' if value == Float::INFINITY
|
|
466
|
+
return '-Inf' if value == -Float::INFINITY
|
|
467
|
+
|
|
468
|
+
format_coeff(value)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Sanitizes a name for LP format (no spaces, special characters).
|
|
472
|
+
#
|
|
473
|
+
# @param name [String, Symbol] The name to sanitize.
|
|
474
|
+
# @return [String] The sanitized name (max 32 characters).
|
|
475
|
+
def sanitize_name(name)
|
|
476
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')[0, 32]
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Parses the HiGHS solution file.
|
|
480
|
+
#
|
|
481
|
+
# Extracts variable values, objective value, and model status from
|
|
482
|
+
# the solution file format produced by HiGHS.
|
|
483
|
+
#
|
|
484
|
+
# @param path [String] The path to the HiGHS solution file.
|
|
485
|
+
# @return [Solution] The parsed solution object.
|
|
486
|
+
def parse_solution_file(path)
|
|
487
|
+
content = File.read(path)
|
|
488
|
+
variables = {}
|
|
489
|
+
objective_value = 0.0
|
|
490
|
+
model_status = 'unknown'
|
|
491
|
+
iterations = 0
|
|
492
|
+
|
|
493
|
+
status_match = content.match(/Model status\s*\n\s*(\S+)/i)
|
|
494
|
+
model_status = status_match[1].downcase.gsub('_', ' ') if status_match
|
|
495
|
+
|
|
496
|
+
obj_match = content.match(/Objective\s+(\S+)/i)
|
|
497
|
+
objective_value = obj_match[1].to_f if obj_match
|
|
498
|
+
|
|
499
|
+
in_columns = false
|
|
500
|
+
content.each_line do |line|
|
|
501
|
+
if line =~ /# Columns/i
|
|
502
|
+
in_columns = true
|
|
503
|
+
next
|
|
504
|
+
end
|
|
505
|
+
next unless in_columns
|
|
506
|
+
break if line.strip.empty? || line.start_with?('#')
|
|
507
|
+
|
|
508
|
+
parts = line.strip.split(/\s+/, 2)
|
|
509
|
+
variables[parts[0]] = parts[1].to_f if parts.length == 2
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
Solution.new(
|
|
513
|
+
variables: variables,
|
|
514
|
+
objective_value: objective_value,
|
|
515
|
+
model_status: model_status,
|
|
516
|
+
iterations: iterations
|
|
517
|
+
)
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LpSolver
|
|
4
|
+
# Represents a quadratic expression: linear terms + quadratic terms.
|
|
5
|
+
#
|
|
6
|
+
# Quadratic expressions extend linear expressions by including second-order
|
|
7
|
+
# terms (products of two variables). They are used to model quadratic objectives
|
|
8
|
+
# in Quadratic Programming (QP) problems, such as portfolio variance,
|
|
9
|
+
# least-squares residuals, or convex penalty functions.
|
|
10
|
+
#
|
|
11
|
+
# A quadratic expression has the mathematical form:
|
|
12
|
+
# c₀ + Σᵢ cᵢ·xᵢ + ½ Σᵢⱼ Qᵢⱼ·xᵢ·xⱼ
|
|
13
|
+
#
|
|
14
|
+
# where c₀ is the constant, cᵢ are linear coefficients, and Q is the
|
|
15
|
+
# symmetric Hessian matrix of quadratic coefficients.
|
|
16
|
+
#
|
|
17
|
+
# @note The Hessian is stored in a normalized form where off-diagonal
|
|
18
|
+
# entries (i ≠ j) are stored once as [i, j, coefficient], and the
|
|
19
|
+
# LP format automatically applies the ½ factor.
|
|
20
|
+
#
|
|
21
|
+
# @example Building a quadratic expression
|
|
22
|
+
# x = model.add_variable(:x, lb: 0)
|
|
23
|
+
# y = model.add_variable(:y, lb: 0)
|
|
24
|
+
# quad = x * x + y * y + (x * y) * 2 # x² + 2xy + y² = (x+y)²
|
|
25
|
+
# quad.linear_terms # => {}
|
|
26
|
+
# quad.quadratic_terms # => [[0, 0, 1.0], [1, 1, 1.0], [0, 1, 2.0]]
|
|
27
|
+
#
|
|
28
|
+
# @example Using in a QP model
|
|
29
|
+
# model.minimize
|
|
30
|
+
# model.set_objective(x * x + y * y)
|
|
31
|
+
# solution = model.solve
|
|
32
|
+
class QuadraticExpression
|
|
33
|
+
# @return [Hash{Integer => Float}] Maps variable indices to linear coefficients.
|
|
34
|
+
# The keys are internal variable indices, and the values are the coefficients
|
|
35
|
+
# of the linear terms (e.g., 2x + 3y → {x_idx => 2.0, y_idx => 3.0}).
|
|
36
|
+
attr_reader :linear_terms
|
|
37
|
+
|
|
38
|
+
# @return [Array<[Integer, Integer, Float]>] Quadratic term pairs.
|
|
39
|
+
# Each element is [var1_index, var2_index, coefficient], representing
|
|
40
|
+
# coefficient * var1 * var2. For diagonal terms (var1 == var2), this
|
|
41
|
+
# represents coefficient * var1².
|
|
42
|
+
# @example
|
|
43
|
+
# (x * x).quadratic_terms # => [[0, 0, 1.0]]
|
|
44
|
+
# (x * y).quadratic_terms # => [[0, 1, 1.0]]
|
|
45
|
+
attr_reader :quadratic_terms
|
|
46
|
+
|
|
47
|
+
# Creates a new QuadraticExpression.
|
|
48
|
+
#
|
|
49
|
+
# @param linear_terms [Hash{Integer => Float}] Maps variable indices to linear coefficients.
|
|
50
|
+
# @param quadratic_terms [Array<[Integer, Integer, Float]>] Quadratic term pairs.
|
|
51
|
+
def initialize(linear_terms = {}, quadratic_terms = [])
|
|
52
|
+
@linear_terms = linear_terms
|
|
53
|
+
@quadratic_terms = quadratic_terms
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Adds another expression, variable, constant, or quadratic expression.
|
|
57
|
+
#
|
|
58
|
+
# @param other [QuadraticExpression, LinearExpression, Variable, Numeric] The operand to add.
|
|
59
|
+
# @return [QuadraticExpression] A new expression combining the operands.
|
|
60
|
+
# @example Adding two quadratic expressions
|
|
61
|
+
# (x * x) + (y * y) # => QuadraticExpression with [[0,0,1.0], [1,1,1.0]]
|
|
62
|
+
# @example Adding a linear expression
|
|
63
|
+
# (x * x) + (x * 2 + 1) # => QuadraticExpression with linear: {x => 2}, constant: 1
|
|
64
|
+
def +(other)
|
|
65
|
+
if other.is_a?(Variable)
|
|
66
|
+
new_linear = @linear_terms.dup
|
|
67
|
+
new_linear[other.index] = (new_linear[other.index] || 0) + 1
|
|
68
|
+
QuadraticExpression.new(new_linear, @quadratic_terms.dup)
|
|
69
|
+
elsif other.is_a?(LinearExpression)
|
|
70
|
+
new_linear = @linear_terms.dup
|
|
71
|
+
other.terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) + coeff }
|
|
72
|
+
QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, @quadratic_terms.dup)
|
|
73
|
+
elsif other.is_a?(QuadraticExpression)
|
|
74
|
+
new_linear = @linear_terms.dup
|
|
75
|
+
other.linear_terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) + coeff }
|
|
76
|
+
new_linear.reject! { |_, v| v.zero? }
|
|
77
|
+
QuadraticExpression.new(new_linear, @quadratic_terms + other.quadratic_terms)
|
|
78
|
+
else
|
|
79
|
+
new_linear = @linear_terms.dup
|
|
80
|
+
new_linear[0] = (new_linear[0] || 0) + other.to_f
|
|
81
|
+
QuadraticExpression.new(new_linear, @quadratic_terms.dup)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Subtracts another expression, variable, constant, or quadratic expression.
|
|
86
|
+
#
|
|
87
|
+
# @param other [QuadraticExpression, LinearExpression, Variable, Numeric] The operand to subtract.
|
|
88
|
+
# @return [QuadraticExpression] A new expression representing the difference.
|
|
89
|
+
# @example
|
|
90
|
+
# (x * x + y * y) - (x * y) # => QuadraticExpression with [[0,0,1.0], [1,1,1.0], [0,1,-1.0]]
|
|
91
|
+
def -(other)
|
|
92
|
+
if other.is_a?(Variable)
|
|
93
|
+
new_linear = @linear_terms.dup
|
|
94
|
+
new_linear[other.index] = (new_linear[other.index] || 0) - 1
|
|
95
|
+
QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, @quadratic_terms.dup)
|
|
96
|
+
elsif other.is_a?(LinearExpression)
|
|
97
|
+
new_linear = @linear_terms.dup
|
|
98
|
+
other.terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) - coeff }
|
|
99
|
+
QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, @quadratic_terms.dup)
|
|
100
|
+
elsif other.is_a?(QuadraticExpression)
|
|
101
|
+
new_linear = @linear_terms.dup
|
|
102
|
+
other.linear_terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) - coeff }
|
|
103
|
+
new_linear.reject! { |_, v| v.zero? }
|
|
104
|
+
neg_quad = other.quadratic_terms.map { |i1, i2, c| [i1, i2, -c] }
|
|
105
|
+
QuadraticExpression.new(new_linear, @quadratic_terms + neg_quad)
|
|
106
|
+
else
|
|
107
|
+
new_linear = @linear_terms.dup
|
|
108
|
+
new_linear[0] = (new_linear[0] || 0) - other.to_f
|
|
109
|
+
QuadraticExpression.new(new_linear, @quadratic_terms.dup)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Multiplies this expression by a scalar.
|
|
114
|
+
#
|
|
115
|
+
# Scales both linear coefficients and quadratic coefficients by the given factor.
|
|
116
|
+
#
|
|
117
|
+
# @param scalar [Numeric] The scalar multiplier.
|
|
118
|
+
# @return [QuadraticExpression] A new expression with all coefficients scaled.
|
|
119
|
+
# @example
|
|
120
|
+
# (x * x + y * y) * 2 # => QuadraticExpression with [[0,0,2.0], [1,1,2.0]]
|
|
121
|
+
# @param [Object] other
|
|
122
|
+
def *(other)
|
|
123
|
+
s = other.to_f
|
|
124
|
+
new_linear = @linear_terms.transform_values { |c| c * s }
|
|
125
|
+
new_linear.reject! { |_, v| v.zero? }
|
|
126
|
+
new_quad = @quadratic_terms.map { |i1, i2, c| [i1, i2, c * s] }
|
|
127
|
+
QuadraticExpression.new(new_linear, new_quad)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Returns a QuadraticExpression with all coefficients negated.
|
|
131
|
+
#
|
|
132
|
+
# @return [QuadraticExpression] A new expression with negated terms.
|
|
133
|
+
# @example
|
|
134
|
+
# -(x * x + y * y) # => QuadraticExpression with [[0,0,-1.0], [1,1,-1.0]]
|
|
135
|
+
def -@
|
|
136
|
+
QuadraticExpression.new(
|
|
137
|
+
@linear_terms.transform_values { |c| -c }.reject { |_, v| v.zero? },
|
|
138
|
+
@quadratic_terms.map { |i1, i2, c| [i1, i2, -c] }
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Converts quadratic terms to HiGHS Hessian entries.
|
|
143
|
+
#
|
|
144
|
+
# Combines symmetric entries (e.g., x*y and y*x) and multiplies by 2
|
|
145
|
+
# to account for the ½ factor in the HiGHS LP format:
|
|
146
|
+
# [ 2·Qᵢⱼ·xᵢ·xⱼ ] / 2 = Qᵢⱼ·xᵢ·xⱼ
|
|
147
|
+
#
|
|
148
|
+
# @return [Array<[Integer, Integer, Float]>] Hessian entries as [var1_idx, var2_idx, coefficient].
|
|
149
|
+
# Each coefficient represents the value that will be divided by 2 in the LP format.
|
|
150
|
+
# @example
|
|
151
|
+
# (x * x).hessian_entries # => [[0, 0, 2.0]] → LP: [2x²]/2 = x²
|
|
152
|
+
# (x * y + y * x).hessian_entries # => [[0, 1, 4.0]] → LP: [4xy]/2 = 2xy
|
|
153
|
+
def hessian_entries
|
|
154
|
+
# Normalize: combine symmetric entries
|
|
155
|
+
pairs = {}
|
|
156
|
+
@quadratic_terms.each do |i1, i2, c|
|
|
157
|
+
key = [i1, i2].sort
|
|
158
|
+
pairs[key] = (pairs[key] || 0) + c
|
|
159
|
+
end
|
|
160
|
+
# Multiply by 2 to account for the "/ 2" in the LP format
|
|
161
|
+
pairs.map { |key, c| [key[0], key[1], c * 2.0] }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|