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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9ff84f9d0bcf181ac202f5f25cf5754e04319e1bf459ab830af40f8e91c42bc7
|
|
4
|
+
data.tar.gz: f51148b23deca2cd3070726c78fc505d07bc3d9c3594e386cfbba4048e68187c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 827cfb4a818544a8a90a47231ec46de845b973c4522f4e3496ce9c85d4b55f01fd9cfed0eaf206d93757db09208e61047b8106519b38285ce789940518cb8e11
|
|
7
|
+
data.tar.gz: 253710d8517bbbc6fd817cb15ad630b8d9949c89ea08f6c3a9b0f71d08e1abf627b2739ceca7f133904ddc310a5925073475a2cfdfcaac0c921db782cfa49820
|
data/Gemfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source 'https://rubygems.org'
|
|
4
|
+
|
|
5
|
+
# Specify your gem's dependencies in lpsolver.gemspec
|
|
6
|
+
gemspec
|
|
7
|
+
|
|
8
|
+
# Specify development dependencies here and not in the gemspec
|
|
9
|
+
gem 'rake'
|
|
10
|
+
gem 'rspec'
|
|
11
|
+
gem 'rubocop'
|
|
12
|
+
gem 'rubocop-rake'
|
|
13
|
+
gem 'rubocop-rspec'
|
|
14
|
+
gem 'rubocop-yard'
|
|
15
|
+
gem 'yard'
|
|
16
|
+
gem 'yard-rspec'
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 You
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# LpSolver
|
|
2
|
+
|
|
3
|
+
A Ruby gem for solving optimization problems using the [HiGHS](https://github.com/ERGO-Code/HiGHS) solver.
|
|
4
|
+
|
|
5
|
+
## What is this for?
|
|
6
|
+
|
|
7
|
+
Imagine you want to **maximize profit** or **minimize cost** while following certain rules (like a budget limit or a minimum requirement). This gem helps you find the best answer.
|
|
8
|
+
|
|
9
|
+
You describe your problem in simple math, and the solver finds the optimal solution.
|
|
10
|
+
|
|
11
|
+
### Real-world examples
|
|
12
|
+
|
|
13
|
+
- **Coin change**: You need exactly $9.99 in coins. Which combination uses the least weight?
|
|
14
|
+
- **Diet plan**: You need 2000 calories, 50g protein, and 100g carbs per day. What combination of foods costs the least?
|
|
15
|
+
- **Factory**: You have limited materials and labor. How many of each product should you make to maximize profit?
|
|
16
|
+
- **Investment**: You want to split money across stocks, bonds, and gold. How do you minimize risk while earning a target return?
|
|
17
|
+
|
|
18
|
+
## Linear Programming (LP)
|
|
19
|
+
|
|
20
|
+
**LP** is for problems where everything scales in a straight line. If making one widget earns $5, making two earns $10. There are no "bulk discounts" or "diminishing returns" — just simple multiplication.
|
|
21
|
+
|
|
22
|
+
**Use LP when:**
|
|
23
|
+
- Your goal is a simple sum: `total_cost = price_a * qty_a + price_b * qty_b`
|
|
24
|
+
- Your rules are simple: `total_cost <= budget`, `qty_a + qty_b >= 100`
|
|
25
|
+
|
|
26
|
+
### LP Example: Diet Problem
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require 'lpsolver'
|
|
30
|
+
|
|
31
|
+
model = LpSolver::Model.new
|
|
32
|
+
|
|
33
|
+
bread = model.add_variable(:bread, lb: 0) # how many slices
|
|
34
|
+
milk = model.add_variable(:milk, lb: 0) # how many liters
|
|
35
|
+
|
|
36
|
+
# Rules first
|
|
37
|
+
model.add_constraint(:calories, (bread * 80 + milk * 60) >= 2000)
|
|
38
|
+
model.add_constraint(:protein, (bread * 3 + milk * 3.2) >= 50)
|
|
39
|
+
|
|
40
|
+
# Then solve
|
|
41
|
+
solution = model.minimize!(bread * 0.50 + milk * 1.20)
|
|
42
|
+
|
|
43
|
+
puts solution.objective_value # minimum cost
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quadratic Programming (QP)
|
|
47
|
+
|
|
48
|
+
**QP** is like LP, but your goal can involve multiplying variables together. This is useful when the "cost" or "risk" depends on how things interact, not just their individual values.
|
|
49
|
+
|
|
50
|
+
The most common use: **minimizing variance** (risk) in a portfolio. If Stock A and Stock B tend to move together, the combined risk isn't just the sum of their individual risks — it also depends on how they correlate.
|
|
51
|
+
|
|
52
|
+
**Use QP when:**
|
|
53
|
+
- Your goal involves squares or products: `risk = x² + y² + 2xy`
|
|
54
|
+
- You need to minimize deviation: `(actual - target)²`
|
|
55
|
+
|
|
56
|
+
### QP Example: Portfolio Optimization
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
require 'lpsolver'
|
|
60
|
+
|
|
61
|
+
model = LpSolver::Model.new
|
|
62
|
+
|
|
63
|
+
tech = model.add_variable(:tech, lb: 0)
|
|
64
|
+
bonds = model.add_variable(:bonds, lb: 0)
|
|
65
|
+
gold = model.add_variable(:gold, lb: 0)
|
|
66
|
+
|
|
67
|
+
# Rules first
|
|
68
|
+
model.add_constraint(:all_money, (tech + bonds + gold) == 1)
|
|
69
|
+
model.add_constraint(:target_return, (tech * 0.15 + bonds * 0.05 + gold * 0.08) >= 0.10)
|
|
70
|
+
|
|
71
|
+
# Then solve — minimize portfolio variance (risk)
|
|
72
|
+
solution = model.minimize!(
|
|
73
|
+
tech * tech * 0.04 + # tech variance
|
|
74
|
+
bonds * bonds * 0.0025 + # bonds variance
|
|
75
|
+
gold * gold * 0.01 + # gold variance
|
|
76
|
+
(tech * bonds) * 0.002 + # tech-bonds interaction
|
|
77
|
+
(tech * gold) * (-0.004) # tech-gold interaction (negative = they move apart)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
puts "Std dev: #{(Math.sqrt(solution.objective_value) * 100).round(2)}%"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## LP vs QP: Quick Comparison
|
|
84
|
+
|
|
85
|
+
| | LP | QP |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| **Goal** | Simple sum: `2x + 3y` | Includes products: `x² + 2xy + y²` |
|
|
88
|
+
| **Best for** | Cost, profit, weight, time | Risk, variance, error, deviation |
|
|
89
|
+
| **Speed** | Very fast | Fast |
|
|
90
|
+
| **Example** | "Minimize shipping cost" | "Minimize investment risk" |
|
|
91
|
+
|
|
92
|
+
## Installation
|
|
93
|
+
|
|
94
|
+
Add to your Gemfile:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
gem 'lpsolver'
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Then:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
bundle install
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or run directly from source:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
ruby -Ilib examples/coin_purse.rb
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Prerequisites
|
|
113
|
+
|
|
114
|
+
HiGHS must be installed:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Ubuntu/Debian
|
|
118
|
+
sudo apt install highs
|
|
119
|
+
|
|
120
|
+
# macOS
|
|
121
|
+
brew install highs
|
|
122
|
+
|
|
123
|
+
# Or from source
|
|
124
|
+
git clone https://github.com/ERGO-Code/HiGHS.git
|
|
125
|
+
cd HiGHS && mkdir build && cd build
|
|
126
|
+
cmake .. -DCMAKE_BUILD_TYPE=Release
|
|
127
|
+
make -j$(nproc)
|
|
128
|
+
sudo make install
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Or set a custom path:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
export HIGHS_PATH=/path/to/highs
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Usage
|
|
138
|
+
|
|
139
|
+
### Simple Example (Operator DSL)
|
|
140
|
+
|
|
141
|
+
The recommended approach uses Ruby operators for natural, readable code. Build the model first, then call `minimize!` or `maximize!` at the end — it sets the objective, picks the direction, and solves in one call:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
require 'lpsolver'
|
|
145
|
+
|
|
146
|
+
model = LpSolver::Model.new
|
|
147
|
+
|
|
148
|
+
# 1. Add variables
|
|
149
|
+
x = model.add_variable(:x, lb: 0)
|
|
150
|
+
y = model.add_variable(:y, lb: 0)
|
|
151
|
+
|
|
152
|
+
# 2. Add constraints
|
|
153
|
+
model.add_constraint(:budget, (x * 2 + y) <= 100)
|
|
154
|
+
model.add_constraint(:demand, (x + y * 2) >= 50)
|
|
155
|
+
|
|
156
|
+
# 3. Solve
|
|
157
|
+
solution = model.minimize!(x * 3 + y * 5)
|
|
158
|
+
|
|
159
|
+
puts "Cost: $#{solution.objective_value}"
|
|
160
|
+
puts "x = #{solution[:x]}"
|
|
161
|
+
puts "y = #{solution[:y]}"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Integer (MIP) Example
|
|
165
|
+
|
|
166
|
+
Some problems require whole numbers only (you can't make half a car):
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
model = LpSolver::Model.new
|
|
170
|
+
|
|
171
|
+
# integer: true means the value must be a whole number
|
|
172
|
+
car = model.add_variable(:car, lb: 0, integer: true)
|
|
173
|
+
bike = model.add_variable(:bike, lb: 0, integer: true)
|
|
174
|
+
|
|
175
|
+
model.add_constraint(:vehicles, (car + bike) >= 10)
|
|
176
|
+
model.add_constraint(:wheels, (car * 4 + bike * 2) >= 24)
|
|
177
|
+
|
|
178
|
+
solution = model.minimize!(car * 30 + bike * 5) # minimize cost
|
|
179
|
+
puts solution
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Maximization
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
model = LpSolver::Model.new
|
|
186
|
+
x = model.add_variable(:x, lb: 0)
|
|
187
|
+
y = model.add_variable(:y, lb: 0)
|
|
188
|
+
|
|
189
|
+
model.add_constraint(:c1, (x + y) <= 10)
|
|
190
|
+
solution = model.maximize!(x * 3 + y * 5)
|
|
191
|
+
puts solution.objective_value # => 50.0
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Complex Expressions
|
|
195
|
+
|
|
196
|
+
You can chain operators with constants and unary minus:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
x = model.add_variable(:x, lb: 0)
|
|
200
|
+
y = model.add_variable(:y, lb: 0)
|
|
201
|
+
|
|
202
|
+
# Arithmetic
|
|
203
|
+
expr = x * 2 + y * 3 # LinearExpression
|
|
204
|
+
expr = x * 2 + y * 3 + 5 # add constant
|
|
205
|
+
expr = x * 2 - y * 3 - 5 # subtract
|
|
206
|
+
expr = -(x * 2 + y * 3) # negate
|
|
207
|
+
|
|
208
|
+
# Constraints
|
|
209
|
+
model.add_constraint(:c, (x * 2 + y * 3 + 5) <= 100)
|
|
210
|
+
model.add_constraint(:c, (x * 2 + y * 3 + 5) >= 100)
|
|
211
|
+
model.add_constraint(:c, (x * 2 + y * 3 + 5) == 100)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Export LP Format
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
model = LpSolver::Model.new
|
|
218
|
+
x = model.add_variable(:x, lb: 0)
|
|
219
|
+
y = model.add_variable(:y, lb: 0)
|
|
220
|
+
|
|
221
|
+
model.add_constraint(:c1, (x * 2 + y) <= 10)
|
|
222
|
+
model.set_objective(x + y)
|
|
223
|
+
|
|
224
|
+
# Print the LP file format
|
|
225
|
+
puts model.to_lp
|
|
226
|
+
|
|
227
|
+
# Or write to a file
|
|
228
|
+
model.write_lp('model.lp')
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## DSL Quick Reference
|
|
232
|
+
|
|
233
|
+
### Variable (from `model.add_variable`)
|
|
234
|
+
|
|
235
|
+
| Operator | Example | Result |
|
|
236
|
+
|----------|---------|--------|
|
|
237
|
+
| `*` | `x * 2` | Linear expression (2x) |
|
|
238
|
+
| `*` | `x * y` | Quadratic term (xy) |
|
|
239
|
+
| `+` | `x + y`, `x + 5` | Sum or constant offset |
|
|
240
|
+
| `-` | `x - y`, `x - 5` | Difference or negative constant |
|
|
241
|
+
| `-` | `-x` | Negated expression |
|
|
242
|
+
| `<=` | `x + y <= 10` | Upper bound constraint |
|
|
243
|
+
| `>=` | `x + y >= 5` | Lower bound constraint |
|
|
244
|
+
| `==` | `x + y == 10` | Exact equality constraint |
|
|
245
|
+
|
|
246
|
+
### LinearExpression (from arithmetic)
|
|
247
|
+
|
|
248
|
+
| Operator | Example | Result |
|
|
249
|
+
|----------|---------|--------|
|
|
250
|
+
| `*` | `expr * 2` | Scaled expression |
|
|
251
|
+
| `+` | `expr + y`, `expr + 5` | Combined expression |
|
|
252
|
+
| `-` | `expr - y`, `expr - 5` | Difference expression |
|
|
253
|
+
| `-` | `-expr` | Negated expression |
|
|
254
|
+
| `<=`, `>=`, `==` | `expr <= 10` | Constraint |
|
|
255
|
+
|
|
256
|
+
### QuadraticExpression (from `Variable * Variable`)
|
|
257
|
+
|
|
258
|
+
| Operator | Example | Result |
|
|
259
|
+
|----------|---------|--------|
|
|
260
|
+
| `*` | `quad * 2` | Scaled expression |
|
|
261
|
+
| `+` | `quad + expr`, `quad + 5` | Combined expression |
|
|
262
|
+
| `-` | `quad - expr`, `quad - 5` | Difference expression |
|
|
263
|
+
| `-` | `-quad` | Negated expression |
|
|
264
|
+
|
|
265
|
+
> **Note:** `Variable * Variable` creates a quadratic term. `Variable * Scalar` creates a linear term. To scale a quadratic term, use `(x * y) * 2` (not `2 * (x * y)`).
|
|
266
|
+
|
|
267
|
+
## API Reference
|
|
268
|
+
|
|
269
|
+
### `Model#add_variable(name, lb: 0, ub: Float::INFINITY, integer: false)`
|
|
270
|
+
Add a variable. Returns a `Variable` object.
|
|
271
|
+
- `name` — variable name (Symbol or String)
|
|
272
|
+
- `lb` — lower bound (default: 0)
|
|
273
|
+
- `ub` — upper bound (default: no limit)
|
|
274
|
+
- `integer` — set to `true` for whole-number-only variables
|
|
275
|
+
|
|
276
|
+
### `Model#add_constraint(name, expr, lb: -Float::INFINITY, ub: Float::INFINITY)`
|
|
277
|
+
Add a constraint. `expr` can be:
|
|
278
|
+
- A `ConstraintSpec` from comparison operators: `(x * 2 + y) <= 100`
|
|
279
|
+
- An array of `[variable_index, coefficient]` pairs (legacy format)
|
|
280
|
+
|
|
281
|
+
### `Model#minimize!(objective)`
|
|
282
|
+
Set the objective to minimize and solve in one call.
|
|
283
|
+
|
|
284
|
+
### `Model#maximize!(objective)`
|
|
285
|
+
Set the objective to maximize and solve in one call.
|
|
286
|
+
|
|
287
|
+
### `Model#minimize`
|
|
288
|
+
Set the optimization sense to minimization (legacy, use `minimize!` instead).
|
|
289
|
+
|
|
290
|
+
### `Model#maximize`
|
|
291
|
+
Set the optimization sense to maximization (legacy, use `maximize!` instead).
|
|
292
|
+
|
|
293
|
+
### `Model#set_objective(objective)`
|
|
294
|
+
Set the objective function without solving (legacy, use `minimize!`/`maximize!` instead).
|
|
295
|
+
- `objective` can be a `LinearExpression`, `QuadraticExpression`, or Hash.
|
|
296
|
+
|
|
297
|
+
### `Model#to_lp`
|
|
298
|
+
Returns the model as a HiGHS LP format string.
|
|
299
|
+
|
|
300
|
+
### `Model#write_lp(filename)`
|
|
301
|
+
Writes the model to an LP file.
|
|
302
|
+
|
|
303
|
+
### `Model#solve`
|
|
304
|
+
Solves the model without setting an objective (legacy).
|
|
305
|
+
|
|
306
|
+
### `Solution#[]`
|
|
307
|
+
Get a variable's value: `solution[:x]`
|
|
308
|
+
|
|
309
|
+
### `Solution#values_at(*names)`
|
|
310
|
+
Get multiple values: `solution.values_at(:x, :y)`
|
|
311
|
+
|
|
312
|
+
### `Solution#objective_value`
|
|
313
|
+
The optimal (best) objective value.
|
|
314
|
+
|
|
315
|
+
### `Solution#feasible?`
|
|
316
|
+
True if a valid solution was found.
|
|
317
|
+
|
|
318
|
+
### `Solution#infeasible?`
|
|
319
|
+
True if no solution satisfies all constraints.
|
|
320
|
+
|
|
321
|
+
### `Solution#unbounded?`
|
|
322
|
+
True if the objective can improve without limit.
|
|
323
|
+
|
|
324
|
+
## Examples
|
|
325
|
+
|
|
326
|
+
- `examples/coin_purse.rb` — Minimize coin weight for exactly $9.99 (MIP)
|
|
327
|
+
- `examples/diet_problem.rb` — Minimize food cost while meeting nutrition (LP)
|
|
328
|
+
- `examples/factory.rb` — Maximize factory profit (LP)
|
|
329
|
+
- `examples/portfolio.rb` — Minimize investment risk (QP)
|
|
330
|
+
|
|
331
|
+
## Supported Problem Types
|
|
332
|
+
|
|
333
|
+
| Type | Goal | Constraints | Whole Numbers? |
|
|
334
|
+
|------|------|-------------|----------------|
|
|
335
|
+
| LP | Linear sum | Linear | No |
|
|
336
|
+
| QP | Quadratic (squares/products) | Linear | No |
|
|
337
|
+
| MIP | Linear | Linear | Yes |
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
[MIT License](https://opensource.org/licenses/MIT)
|
|
342
|
+
|
|
343
|
+
## Code of Conduct
|
|
344
|
+
|
|
345
|
+
See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
data/exe/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Executables go here
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LpSolver
|
|
4
|
+
# Represents a constraint specification derived from comparing an expression with a value.
|
|
5
|
+
#
|
|
6
|
+
# ConstraintSpec objects are created when comparison operators (<=, >=, ==) are
|
|
7
|
+
# applied to LinearExpression or Variable objects. They encode the constraint's
|
|
8
|
+
# operator type, variable coefficients, and bounds.
|
|
9
|
+
#
|
|
10
|
+
# A constraint specification has the form:
|
|
11
|
+
# operator: sum(coeff_i * x_i) + constant_offset compared to rhs
|
|
12
|
+
#
|
|
13
|
+
# The bounds are computed by rearranging the expression to isolate the
|
|
14
|
+
# variables on the left-hand side:
|
|
15
|
+
# sum(coeff_i * x_i) <= (rhs - constant_offset) for <= constraints
|
|
16
|
+
# sum(coeff_i * x_i) >= (rhs - constant_offset) for >= constraints
|
|
17
|
+
# sum(coeff_i * x_i) == (rhs - constant_offset) for == constraints
|
|
18
|
+
#
|
|
19
|
+
# @example Creating a constraint specification
|
|
20
|
+
# x = model.add_variable(:x, lb: 0)
|
|
21
|
+
# y = model.add_variable(:y, lb: 0)
|
|
22
|
+
# spec = (x * 2 + y * 3 + 5) <= 100
|
|
23
|
+
# spec.operator # => :le
|
|
24
|
+
# spec.terms # => {x_idx => 2.0, y_idx => 3.0}
|
|
25
|
+
# spec.lhs_constant # => 5.0
|
|
26
|
+
# spec.rhs # => 100.0
|
|
27
|
+
# spec.bounds # => [-Infinity, 95.0]
|
|
28
|
+
#
|
|
29
|
+
# @example Using in a model
|
|
30
|
+
# model.add_constraint(:budget, (x * 2 + y * 3 + 5) <= 100)
|
|
31
|
+
class ConstraintSpec
|
|
32
|
+
# @return [Symbol] The constraint operator: :le (<=), :ge (>=), or :eq (==).
|
|
33
|
+
attr_reader :operator
|
|
34
|
+
|
|
35
|
+
# @return [Hash{Integer => Float}] Maps variable indices to their coefficients
|
|
36
|
+
# in the expression (excluding the constant offset).
|
|
37
|
+
attr_reader :terms
|
|
38
|
+
|
|
39
|
+
# @return [Float] The constant offset on the left-hand side of the comparison.
|
|
40
|
+
# For example, in `(x * 2 + y * 3 + 5) <= 100`, this is 5.0.
|
|
41
|
+
attr_reader :lhs_constant
|
|
42
|
+
|
|
43
|
+
# @return [Float] The right-hand side value of the comparison.
|
|
44
|
+
# For example, in `(x * 2 + y * 3 + 5) <= 100`, this is 100.0.
|
|
45
|
+
attr_reader :rhs
|
|
46
|
+
|
|
47
|
+
# Creates a new ConstraintSpec.
|
|
48
|
+
#
|
|
49
|
+
# @param operator [Symbol] The constraint operator: :le, :ge, or :eq.
|
|
50
|
+
# @param terms [Hash{Integer => Float}] Maps variable indices to coefficients.
|
|
51
|
+
# @param lhs_constant [Float] The constant offset on the left-hand side.
|
|
52
|
+
# @param rhs [Float] The right-hand side value.
|
|
53
|
+
def initialize(operator, terms, lhs_constant, rhs)
|
|
54
|
+
@operator = operator
|
|
55
|
+
@terms = terms
|
|
56
|
+
@lhs_constant = lhs_constant
|
|
57
|
+
@rhs = rhs
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Converts this constraint specification to lower and upper bounds.
|
|
61
|
+
#
|
|
62
|
+
# Rearranges the constraint expression to isolate the variable terms,
|
|
63
|
+
# computing the effective bounds for the expression.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Float, Float>] An array [lb, ub] representing the
|
|
66
|
+
# lower and upper bounds for the variable terms.
|
|
67
|
+
# @example For (x * 2 + y * 3 + 5) <= 100
|
|
68
|
+
# spec.bounds # => [-Infinity, 95.0]
|
|
69
|
+
# @example For (x * 2 + y * 3 + 5) >= 50
|
|
70
|
+
# spec.bounds # => [45.0, Infinity]
|
|
71
|
+
# @example For (x * 2 + y * 3 + 5) == 75
|
|
72
|
+
# spec.bounds # => [70.0, 70.0]
|
|
73
|
+
def bounds
|
|
74
|
+
case @operator
|
|
75
|
+
when :le
|
|
76
|
+
[-Float::INFINITY, @rhs - @lhs_constant]
|
|
77
|
+
when :ge
|
|
78
|
+
[@rhs - @lhs_constant, Float::INFINITY]
|
|
79
|
+
when :eq
|
|
80
|
+
v = @rhs - @lhs_constant
|
|
81
|
+
[v, v]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns the expression terms as an array of [variable_index, coefficient] pairs.
|
|
86
|
+
#
|
|
87
|
+
# This format is used internally when serializing the model to HiGHS LP format.
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<[Integer, Float]>] Array of [var_index, coefficient] pairs.
|
|
90
|
+
# @example
|
|
91
|
+
# spec.expr # => [[0, 2.0], [1, 3.0]]
|
|
92
|
+
def expr
|
|
93
|
+
@terms.map { |idx, coeff| [idx, coeff] }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LpSolver
|
|
4
|
+
# The base exception class for all LpSolver errors.
|
|
5
|
+
#
|
|
6
|
+
# All LpSolver-specific exceptions inherit from this class.
|
|
7
|
+
#
|
|
8
|
+
# @example Rescuing LpSolver errors
|
|
9
|
+
# begin
|
|
10
|
+
# model.solve
|
|
11
|
+
# rescue LpSolver::Error => e
|
|
12
|
+
# puts "Solver error: #{e.message}"
|
|
13
|
+
# end
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Raised when the HiGHS solver encounters an error.
|
|
17
|
+
#
|
|
18
|
+
# This exception is raised when the HiGHS command-line tool exits
|
|
19
|
+
# with a non-zero status, indicating a problem with the model
|
|
20
|
+
# (e.g., syntax error, infeasibility not handled, etc.).
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# begin
|
|
24
|
+
# model.solve
|
|
25
|
+
# rescue LpSolver::SolverError => e
|
|
26
|
+
# puts "HiGHS error: #{e.message}"
|
|
27
|
+
# puts "Stderr: #{e.stderr}" if e.stderr
|
|
28
|
+
# end
|
|
29
|
+
class SolverError < Error
|
|
30
|
+
# @return [String, nil] The stderr output from the HiGHS solver.
|
|
31
|
+
attr_reader :stderr
|
|
32
|
+
|
|
33
|
+
# Creates a new SolverError.
|
|
34
|
+
#
|
|
35
|
+
# @param message [String] The error message.
|
|
36
|
+
# @param stderr [String, nil] The stderr output from HiGHS.
|
|
37
|
+
def initialize(message, stderr: nil)
|
|
38
|
+
@stderr = stderr
|
|
39
|
+
super(message)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when the HiGHS binary cannot be found.
|
|
44
|
+
#
|
|
45
|
+
# This exception is raised when the HIGHS_PATH environment variable
|
|
46
|
+
# is not set and 'highs' is not on the system PATH.
|
|
47
|
+
#
|
|
48
|
+
# @example
|
|
49
|
+
# begin
|
|
50
|
+
# model.solve
|
|
51
|
+
# rescue LpSolver::NotFoundError => e
|
|
52
|
+
# puts "HiGHS not found. Install it or set HIGHS_PATH."
|
|
53
|
+
# end
|
|
54
|
+
class NotFoundError < Error; end
|
|
55
|
+
end
|