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.
@@ -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