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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LpSolver
|
|
4
|
+
# Generates HiGHS LP format strings from a model's data.
|
|
5
|
+
#
|
|
6
|
+
# This class handles all serialization logic for converting a model's
|
|
7
|
+
# variables, constraints, objectives, and bounds into the HiGHS LP format.
|
|
8
|
+
# It is used by both the CLI and native drivers.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# generator = LpSolver::LpGenerator.new(model)
|
|
12
|
+
# puts generator.generate
|
|
13
|
+
class LpGenerator
|
|
14
|
+
# Creates a new LP generator for the given model.
|
|
15
|
+
#
|
|
16
|
+
# @param model [Model] The model to serialize.
|
|
17
|
+
def initialize(model)
|
|
18
|
+
@model = model
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Generates the HiGHS LP format string.
|
|
22
|
+
#
|
|
23
|
+
# @return [String] The LP format content.
|
|
24
|
+
# @example
|
|
25
|
+
# puts generator.generate
|
|
26
|
+
# # Minimize
|
|
27
|
+
# # obj: 3 x + 5 y
|
|
28
|
+
# # Subject To
|
|
29
|
+
# # budget: 2 x + 1 y <= 100
|
|
30
|
+
# # demand: 1 x + 2 y >= 50
|
|
31
|
+
# # Bounds
|
|
32
|
+
# # 0 <= x <= +Inf
|
|
33
|
+
# # 0 <= y <= +Inf
|
|
34
|
+
# # End
|
|
35
|
+
def generate
|
|
36
|
+
lines = []
|
|
37
|
+
lines << (@model.heading == :minimize ? 'Minimize' : 'Maximize')
|
|
38
|
+
lines << generate_objective
|
|
39
|
+
lines.concat(generate_constraints) if @model.constraints.any?
|
|
40
|
+
lines.concat(generate_bounds) if @model.var_bounds.any?
|
|
41
|
+
lines.concat(generate_integers) if has_integer_variables?
|
|
42
|
+
lines << 'End'
|
|
43
|
+
lines.join("\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Generates the objective line.
|
|
49
|
+
#
|
|
50
|
+
# @return [String] The objective line (e.g., " obj: 3 x + 5 y").
|
|
51
|
+
def generate_objective
|
|
52
|
+
obj_terms = @model.objective.map do |var_idx, coeff|
|
|
53
|
+
var_name = find_var_name(var_idx)
|
|
54
|
+
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
55
|
+
end.join(' + ')
|
|
56
|
+
|
|
57
|
+
if @model.quadratic_terms.any?
|
|
58
|
+
quad_parts = @model.quadratic_terms.map do |i1, i2, coeff|
|
|
59
|
+
n1 = sanitize_name(find_var_name(i1))
|
|
60
|
+
n2 = sanitize_name(find_var_name(i2))
|
|
61
|
+
if i1 == i2
|
|
62
|
+
"#{format_coeff(coeff)} #{n1} ^ 2"
|
|
63
|
+
else
|
|
64
|
+
"#{format_coeff(coeff)} #{n1} * #{n2}"
|
|
65
|
+
end
|
|
66
|
+
end.join(' + ')
|
|
67
|
+
" obj: #{obj_terms} + [ #{quad_parts} ] / 2"
|
|
68
|
+
else
|
|
69
|
+
" obj: #{obj_terms}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Generates the constraints section.
|
|
74
|
+
#
|
|
75
|
+
# @return [Array<String>] Array of constraint lines.
|
|
76
|
+
def generate_constraints
|
|
77
|
+
lines = ['Subject To']
|
|
78
|
+
@model.constraints_data.each do |name, data|
|
|
79
|
+
terms = data[:expr].map do |var_idx, coeff|
|
|
80
|
+
var_name = find_var_name(var_idx)
|
|
81
|
+
"#{format_coeff(coeff)} #{sanitize_name(var_name)}"
|
|
82
|
+
end.join(' + ')
|
|
83
|
+
|
|
84
|
+
lines << format_constraint(name, terms, data[:lb], data[:ub])
|
|
85
|
+
end
|
|
86
|
+
lines
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Formats a single constraint line.
|
|
90
|
+
#
|
|
91
|
+
# @param name [Symbol] The constraint name.
|
|
92
|
+
# @param terms [String] The constraint terms (e.g., "2 x + 1 y").
|
|
93
|
+
# @param lb [Float] Lower bound.
|
|
94
|
+
# @param ub [Float] Upper bound.
|
|
95
|
+
# @return [String] The formatted constraint line.
|
|
96
|
+
def format_constraint(name, terms, lb, ub)
|
|
97
|
+
sname = sanitize_name(name)
|
|
98
|
+
if lb == -Float::INFINITY && ub == Float::INFINITY
|
|
99
|
+
" #{sname}: #{terms} free"
|
|
100
|
+
elsif lb == -Float::INFINITY
|
|
101
|
+
" #{sname}: #{terms} <= #{format_bound(ub)}"
|
|
102
|
+
elsif ub == Float::INFINITY
|
|
103
|
+
" #{sname}: #{terms} >= #{format_bound(lb)}"
|
|
104
|
+
elsif (ub - lb).abs < 1e-12
|
|
105
|
+
" #{sname}: #{terms} = #{format_bound(lb)}"
|
|
106
|
+
else
|
|
107
|
+
" #{sname}: #{terms} >= #{format_bound(lb)}\n #{sname}_ub: #{terms} <= #{format_bound(ub)}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Generates the bounds section.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<String>] Array of bound lines.
|
|
114
|
+
def generate_bounds
|
|
115
|
+
lines = ['Bounds']
|
|
116
|
+
@model.variables.each do |name, _var|
|
|
117
|
+
lb, ub = @model.var_bounds[name]
|
|
118
|
+
sname = sanitize_name(name)
|
|
119
|
+
|
|
120
|
+
if lb == ub
|
|
121
|
+
lines << " #{sname} = #{format_bound(lb)}"
|
|
122
|
+
elsif lb > -Float::INFINITY && ub < Float::INFINITY
|
|
123
|
+
lines << " #{lb} <= #{sname} <= #{format_bound(ub)}"
|
|
124
|
+
elsif lb > -Float::INFINITY
|
|
125
|
+
lines << " #{sname} >= #{format_bound(lb)}"
|
|
126
|
+
elsif ub < Float::INFINITY
|
|
127
|
+
lines << " #{sname} <= #{format_bound(ub)}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
lines
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Generates the integer variables section.
|
|
134
|
+
#
|
|
135
|
+
# @return [Array<String>] Array of integer declaration lines.
|
|
136
|
+
def generate_integers
|
|
137
|
+
int_vars = @model.variables.select { |sym, _| @model.var_types[sym] == :integer }
|
|
138
|
+
return [] unless int_vars.any?
|
|
139
|
+
|
|
140
|
+
lines = ['Integers']
|
|
141
|
+
int_vars.each { |name, _| lines << " #{sanitize_name(name)}" }
|
|
142
|
+
lines
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Looks up a variable name by its internal index.
|
|
146
|
+
#
|
|
147
|
+
# @param idx [Integer] The internal variable index.
|
|
148
|
+
# @return [String] The variable name, or "v#{idx}" if not found.
|
|
149
|
+
def find_var_name(idx)
|
|
150
|
+
@model.variables.find { |_, var| var.index == idx }&.first || "v#{idx}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Checks if the model has any integer variables.
|
|
154
|
+
#
|
|
155
|
+
# @return [Boolean] True if any variable has :integer type.
|
|
156
|
+
def has_integer_variables?
|
|
157
|
+
@model.var_types.values.any? { |t| t == :integer }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Normalizes bound values for LP format output.
|
|
161
|
+
#
|
|
162
|
+
# @param val [Float] The bound value to normalize.
|
|
163
|
+
# @return [Float] The normalized bound value.
|
|
164
|
+
def normalize_bound(val)
|
|
165
|
+
return -Float::INFINITY if val == -Float::INFINITY || val == -1.0 / 0.0
|
|
166
|
+
return Float::INFINITY if val == Float::INFINITY || val == 1.0 / 0.0
|
|
167
|
+
val.to_f
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Formats a coefficient for LP output.
|
|
171
|
+
#
|
|
172
|
+
# @param value [Float] The coefficient value.
|
|
173
|
+
# @return [String] The formatted coefficient string.
|
|
174
|
+
def format_coeff(value)
|
|
175
|
+
if value == value.to_i && value.abs < 1e15
|
|
176
|
+
value.to_i.to_s
|
|
177
|
+
else
|
|
178
|
+
format('%.6g', value)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Formats a bound value for LP output.
|
|
183
|
+
#
|
|
184
|
+
# @param value [Float] The bound value.
|
|
185
|
+
# @return [String] The formatted bound string (+Inf, -Inf, or numeric).
|
|
186
|
+
def format_bound(value)
|
|
187
|
+
return '+Inf' if value == Float::INFINITY
|
|
188
|
+
return '-Inf' if value == -Float::INFINITY
|
|
189
|
+
format_coeff(value)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Sanitizes a name for LP format (no spaces, special characters).
|
|
193
|
+
#
|
|
194
|
+
# @param name [String, Symbol] The name to sanitize.
|
|
195
|
+
# @return [String] The sanitized name (max 32 characters).
|
|
196
|
+
def sanitize_name(name)
|
|
197
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')[0, 32]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|