diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36a5442..13a4d51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,7 @@ jobs: RAMS_TEST_CBC: true RAMS_TEST_CLP: true RAMS_TEST_GLPK: true + RAMS_TEST_HIGHS: true RAMS_TEST_SCIP: true steps: @@ -32,7 +33,7 @@ jobs: - name: Install optimization solvers run: | - dnf install -y coin-or-Cbc coin-or-Clp glpk-utils scip + dnf install -y coin-or-Cbc coin-or-Clp coin-or-HiGHS glpk-utils scip - name: Run tests run: bundle exec rake test diff --git a/.gitignore b/.gitignore index 722d5e7..93e278b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .vscode +HiGHS.log diff --git a/README.md b/README.md index e6e5d42..436beba 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Ruby Algebraic Modeling System -RAMS is a library for formulating and solving [Mixed Integer Linear Programs](https://en.wikipedia.org/wiki/Integer_programming) in Ruby. Currently it supports the following solvers: +RAMS is a library for formulating and solving [Mixed Integer Linear Programs](https://en.wikipedia.org/wiki/Integer_programming) in Ruby. Currently it supports the following open source solvers: * [CLP](https://www.coin-or.org/Clp/) * [CBC](https://www.coin-or.org/Cbc/) -* [CPLEX](https://www-01.ibm.com/software/commerce/optimization/cplex-optimizer/) * [GNU Linear Programming Kit](https://www.gnu.org/software/glpk/) -* [SCIP](http://scip.zib.de) +* [HiGHS](https://highs.dev/) +* [SCIP](https://www.scipopt.org/) ## Documentation diff --git a/docs/01-quickstart.md b/docs/01-quickstart.md index 1de1f2f..0aae770 100644 --- a/docs/01-quickstart.md +++ b/docs/01-quickstart.md @@ -2,8 +2,8 @@ ## Installation -RAMS assumes you have the solver you're using in your `PATH`. The default solver is [GLPK]((https://www.gnu.org/software/glpk/)), but you can also use [CLP](https://www.coin-or.org/Clp/), [CBC](https://www.coin-or.org/Cbc/), [CPLEX](https://www-01.ibm.com/software/commerce/optimization/cplex-optimizer/), -and [SCIP](http://scip.zib.de). +RAMS assumes you have the solver you're using in your `PATH`. The default solver is [GLPK]((https://www.gnu.org/software/glpk/)), but you can also use [CLP](https://www.coin-or.org/Clp/), [CBC](https://www.coin-or.org/Cbc/), [HiGHS](https://highs.dev), +and [SCIP](https://scipopt.org). First make sure you have the latest RAMS installed. @@ -14,11 +14,13 @@ gem install rams Now install GLPK or whatever solver you wish. ### Ubuntu + ``` sudo apt-get install glpk-utils ``` ### Mac OSX + ``` brew install glpk ``` diff --git a/docs/02-modeling-primitives.md b/docs/02-modeling-primitives.md index 14123e3..132cf0c 100644 --- a/docs/02-modeling-primitives.md +++ b/docs/02-modeling-primitives.md @@ -23,6 +23,7 @@ By default, a continuous variable has a lower bound of `0` and an upper bound of ```ruby puts "#{m.variables.values.map { |x| [x.low, x.high ]}}" ``` + ``` [[0.0, nil], [0.0, nil], [0.0, nil]] ``` @@ -33,11 +34,12 @@ To set a variable's lower bound to negative infinity, pass a `low: inf` keyword x4 = m.variable(type: :integer, low: nil, high: 10) ``` -The binary variables may appear to have an upper bound of positive infinity, but that becomes `1` when it is written to the solver. To see a model the way it is passed to a solver, use the `to_lp` method. This returns the model in [LP format](http://lpsolve.sourceforge.net/5.0/CPLEX-format.htm). Note that the variable names are different in the `to_lp` output. +The binary variables may appear to have an upper bound of positive infinity, but that becomes `1` when it is written to the solver. To see a model the way it is passed to a solver, use the `to_lp` method. This returns the model in [LP format](https://lpsolve.sourceforge.net/5.0/CPLEX-format.htm). Note that the variable names are different in the `to_lp` output. ```ruby puts m.to_lp ``` + ``` max obj: 0 v1 @@ -75,6 +77,7 @@ puts <<-HERE #{c3.lhs[x2]} * x2 + #{c3.lhs[x3]} #{c3.sense} #{c3.rhs} HERE ``` + ``` 2.0 * x1 + 0.5 * x2 <= 5.0 1.0 * x2 + 1.0 * x3 + 1.0 * x4 >= 2.0 @@ -101,6 +104,7 @@ x = #{[x1, x2, x3, x4].map { |x| solution[x] }} y = #{[c1, c2, c3].map { |c| solution.dual[c] }} HERE ``` + ``` z = 10.0 x = [2.0, 2.0, 1.0, -1.0] diff --git a/docs/03-solver-configuration.md b/docs/03-solver-configuration.md index 65f0e1d..27c9892 100644 --- a/docs/03-solver-configuration.md +++ b/docs/03-solver-configuration.md @@ -27,8 +27,8 @@ If you want to switch to a different solver, install that solver onto your syste ```ruby m.solver = :cbc # or m.solver = :clp # or -m.solver = :cplex # or m.solver = :glpk # or +m.solver = :highs # or m.solver = :scip ``` @@ -38,8 +38,8 @@ By default, RAMS assumes that solvers are available in your system's PATH with t - `RAMS_SOLVER_PATH_CBC` - Override path for CBC (defaults to `coin.cbc`) - `RAMS_SOLVER_PATH_CLP` - Override path for CLP (defaults to `clp`) -- `RAMS_SOLVER_PATH_CPLEX` - Override path for CPLEX (defaults to `cplex`) - `RAMS_SOLVER_PATH_GLPK` - Override path for GLPK (defaults to `glpsol`) +- `RAMS_SOLVER_PATH_HIGHS` - Override path for HiGHS (defaults to `highs`) - `RAMS_SOLVER_PATH_SCIP` - Override path for SCIP (defaults to `scip`) For example, if you have GLPK installed in a custom location: @@ -54,6 +54,12 @@ Or if you want to use a specific version of CBC: export RAMS_SOLVER_PATH_CBC=/usr/local/bin/cbc-2.10 ``` +Or if you have HiGHS installed in a custom location: + +```bash +export RAMS_SOLVER_PATH_HIGHS=/opt/highs/bin/highs +``` + These environment variables are particularly useful when you have multiple versions of solvers installed or when solvers are installed in non-standard locations. ## Solver Arguments @@ -64,6 +70,7 @@ Additional solver arguments can be passed as though they are command line flags. m.args = ['--dfs', '--bib'] m.solve ``` + ``` GLPSOL: GLPK LP/MIP Solver, v4.60 Parameter(s) specified in the command line: @@ -89,4 +96,12 @@ m.args = ['-c', 'set presolving maxrounds 0'] m.solve ``` +Similarly, if you are using HiGHS, you can set a time limit or choose a specific algorithm: + +```ruby +m.solver = :highs +m.args = ['--time_limit', '10', '--solver', 'simplex'] +m.solve +``` + Every solver has different options, so check the manual to see what command line flags are available to you. diff --git a/lib/rams/model.rb b/lib/rams/model.rb index 88d32dd..c41a950 100644 --- a/lib/rams/model.rb +++ b/lib/rams/model.rb @@ -3,8 +3,8 @@ require_relative 'formatters/lp' require_relative 'solvers/cbc' require_relative 'solvers/clp' -require_relative 'solvers/cplex' require_relative 'solvers/glpk' +require_relative 'solvers/highs' require_relative 'solvers/scip' require_relative 'variable' @@ -38,8 +38,8 @@ class Model SOLVERS = { cbc: RAMS::Solvers::CBC.new, clp: RAMS::Solvers::CLP.new, - cplex: RAMS::Solvers::CPLEX.new, glpk: RAMS::Solvers::GLPK.new, + highs: RAMS::Solvers::HiGHS.new, scip: RAMS::Solvers::SCIP.new }.freeze diff --git a/lib/rams/solvers/cplex.rb b/lib/rams/solvers/cplex.rb deleted file mode 100644 index 70c38b1..0000000 --- a/lib/rams/solvers/cplex.rb +++ /dev/null @@ -1,55 +0,0 @@ -require 'nokogiri' -require_relative 'solver' - -module RAMS - module Solvers - # Interface to CPLEX - class CPLEX < Solver - def solve_and_parse(model, model_path, solution_path) - call_solver model, model_path, solution_path - return RAMS::Solution.new(:infeasible, nil, {}, {}) unless File.exist? solution_path - parse_solution model, File.read(solution_path) - end - - def solver_command(model_path, solution_path, args) - [solver_executable('cplex', 'cplex'), '-c', "read #{model_path}"] + args + ['optimize', "write #{solution_path}"] - end - - private - - def parse_solution(model, solution_text) - xml_doc = Nokogiri::XML solution_text - RAMS::Solution.new( - parse_status(model, xml_doc), - parse_objective(model, xml_doc), - parse_primal(model, xml_doc), - parse_dual(model, xml_doc) - ) - end - - def parse_status(_model, xml_doc) - status = xml_doc.css('CPLEXSolution').css('header').first['solutionStatusString'] - return :optimal if status =~ /optimal/i - return :feasible if status =~ /feasible/i - return :unbounded if status =~ /unbounded/i - :unknown - end - - def parse_objective(_model, xml_doc) - xml_doc.css('CPLEXSolution').css('header').first['objectiveValue'].to_f - end - - def parse_primal(model, xml_doc) - xml_doc.css('CPLEXSolution').css('variables').css('variable').map do |v| - [model.variables[v['name']], v['value'].to_f] - end.to_h - end - - def parse_dual(model, xml_doc) - xml_doc.css('CPLEXSolution').css('linearConstraints').css('constraint').map do |c| - [model.constraints[c['name']], c['dual'].to_f] - end.to_h - end - end - end -end diff --git a/lib/rams/solvers/highs.rb b/lib/rams/solvers/highs.rb new file mode 100644 index 0000000..94bfe5d --- /dev/null +++ b/lib/rams/solvers/highs.rb @@ -0,0 +1,94 @@ +require_relative 'solver' + +module RAMS + module Solvers + # Interface to HiGHS solver + class HiGHS < Solver + def solver_command(model_path, solution_path, args) + [solver_executable('highs', 'highs'), model_path, '--solution_file', solution_path] + args + end + + private + + def parse_status(_model, lines) + status_idx = lines.index { |l| l =~ /^Model status/ } + return :undefined unless status_idx + + # Status is on the next line after "Model status" + status = lines[status_idx + 1] + return :undefined unless status + + return :optimal if status =~ /optimal/i + return :feasible if status =~ /^feasible/i + return :infeasible if status =~ /infeasible/i + return :unbounded if status =~ /unbounded/i + :undefined + end + + def parse_objective(_model, lines) + objective_line = lines.find { |l| l =~ /^Objective/ } + return nil unless objective_line + objective_line.split.last.to_f + end + + def parse_primal(model, lines) + # Find the primal section + start_idx = lines.index { |l| l =~ /^# Columns/ } + return {} unless start_idx + + num_columns = lines[start_idx].split.last.to_i + primal_values = {} + + # Parse variable values + (1..num_columns).each do |i| + line = lines[start_idx + i] + next unless line + + parts = line.split + next unless parts.length >= 2 + + var_name = parts[0] + var_value = parts[1].to_f + variable = model.variables[var_name] + primal_values[variable] = var_value if variable + end + + primal_values + end + + def parse_dual(model, lines) + # Find the dual section + dual_start_idx = lines.index { |l| l =~ /^# Dual solution values/ } + return {} unless dual_start_idx + + + # Check if dual solution is available + lines = lines[dual_start_idx..-1] + return {} if lines[dual_start_idx + 1] =~ /^None/ + + # Find the rows section within dual values + rows_idx = lines.index { |l| l =~ /^# Rows/ } + return {} unless rows_idx + + num_rows = lines[rows_idx].split.last.to_i + dual_values = {} + + # Parse constraint dual values + (1..num_rows).each do |i| + line = lines[rows_idx + i] + next unless line + + parts = line.split + next unless parts.length >= 2 + + constraint_name = parts[0] + dual_value = parts[1].to_f + constraint = model.constraints[constraint_name] + dual_values[constraint] = dual_value if constraint + end + + dual_values + end + end + end +end \ No newline at end of file diff --git a/rams.gemspec b/rams.gemspec index 353413a..e98a608 100644 --- a/rams.gemspec +++ b/rams.gemspec @@ -4,7 +4,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) Gem::Specification.new do |spec| spec.name = 'rams' - spec.version = '0.1.7' + spec.version = '0.1.8' spec.authors = ["Ryan J. O'Neil"] spec.email = ['ryanjoneil@gmail.com'] spec.summary = 'Ruby Algebraic Modeling System' diff --git a/tests/test_model.rb b/tests/test_model.rb index 331b682..0192317 100644 --- a/tests/test_model.rb +++ b/tests/test_model.rb @@ -4,52 +4,61 @@ # RAMS::Model tests # rubocop:disable ClassLength class TestModel < Test::Unit::TestCase - def test_simple - run_test_simple :cbc if ENV['RAMS_TEST_CBC'] - run_test_simple :clp if ENV['RAMS_TEST_CLP'] - run_test_simple :cplex if ENV['RAMS_TEST_CPLEX'] - run_test_simple :glpk if ENV['RAMS_TEST_GLPK'] + def test_simple_primal + run_test_simple_primal :cbc if ENV['RAMS_TEST_CBC'] + run_test_simple_primal :clp if ENV['RAMS_TEST_CLP'] + run_test_simple_primal :glpk if ENV['RAMS_TEST_GLPK'] + run_test_simple_primal :highs if ENV['RAMS_TEST_HIGHS'] + run_test_simple_primal :scip if ENV['RAMS_TEST_SCIP'] end + def test_simple_dual + run_test_simple_dual :cbc if ENV['RAMS_TEST_CBC'] + run_test_simple_dual :clp if ENV['RAMS_TEST_CLP'] + run_test_simple_dual :glpk if ENV['RAMS_TEST_GLPK'] + run_test_simple_dual :highs if ENV['RAMS_TEST_HIGHS'] + end + + def test_binary run_test_binary :cbc if ENV['RAMS_TEST_CBC'] - run_test_binary :cplex if ENV['RAMS_TEST_CPLEX'] run_test_binary :glpk if ENV['RAMS_TEST_GLPK'] + run_test_binary :highs if ENV['RAMS_TEST_HIGHS'] run_test_binary :scip if ENV['RAMS_TEST_SCIP'] end def test_integer run_test_integer :cbc if ENV['RAMS_TEST_CBC'] - run_test_integer :cplex if ENV['RAMS_TEST_CPLEX'] run_test_integer :glpk if ENV['RAMS_TEST_GLPK'] + run_test_integer :highs if ENV['RAMS_TEST_HIGHS'] run_test_integer :scip if ENV['RAMS_TEST_SCIP'] end def test_infeasible run_test_infeasible :cbc if ENV['RAMS_TEST_CBC'] run_test_infeasible :clp if ENV['RAMS_TEST_CLP'] - run_test_infeasible :cplex if ENV['RAMS_TEST_CPLEX'] run_test_infeasible :glpk if ENV['RAMS_TEST_GLPK'] + run_test_infeasible :highs if ENV['RAMS_TEST_HIGHS'] run_test_infeasible :scip if ENV['RAMS_TEST_SCIP'] end def test_unbounded run_test_unbounded :cbc if ENV['RAMS_TEST_CBC'] run_test_unbounded :clp if ENV['RAMS_TEST_CLP'] - run_test_unbounded :cplex if ENV['RAMS_TEST_CPLEX'] run_test_unbounded :glpk if ENV['RAMS_TEST_GLPK'] + run_test_unbounded :highs if ENV['RAMS_TEST_HIGHS'] run_test_unbounded :scip if ENV['RAMS_TEST_SCIP'] end def test_implication run_test_implication :cbc if ENV['RAMS_TEST_CBC'] - run_test_implication :cplex if ENV['RAMS_TEST_CPLEX'] run_test_implication :glpk if ENV['RAMS_TEST_GLPK'] + run_test_implication :highs if ENV['RAMS_TEST_HIGHS'] run_test_implication :scip if ENV['RAMS_TEST_SCIP'] end # rubocop:disable MethodLength - def run_test_simple(solver, args = []) + def run_test_simple_primal(solver, args = []) m = RAMS::Model.new m.solver = solver m.args = args @@ -57,7 +66,7 @@ def run_test_simple(solver, args = []) x1 = m.variable low: 0.5 x2 = m.variable - c = m.constrain(x1 + x2 <= 1) + m.constrain(x1 + x2 <= 1) m.sense = :max m.objective = x1 + (2 * x2) @@ -67,6 +76,27 @@ def run_test_simple(solver, args = []) assert_in_delta 1.5, solution.objective, 10e-7 assert_in_delta 0.5, solution[x1], 10e-7 assert_in_delta 0.5, solution[x2], 10e-7 + end + # rubocop:enable MethodLength + + + # rubocop:disable MethodLength + def run_test_simple_dual(solver, args = []) + m = RAMS::Model.new + m.solver = solver + m.args = args + + x1 = m.variable low: 0.5 + x2 = m.variable + + c = m.constrain(x1 + x2 <= 1) + + m.sense = :max + m.objective = x1 + (2 * x2) + solution = m.solve + + assert_equal :optimal, solution.status + assert_in_delta 1.5, solution.objective, 10e-7 assert_in_delta 2.0, solution.dual[c], 10e-7 end # rubocop:enable MethodLength @@ -147,7 +177,7 @@ def run_test_unbounded(solver, args = []) m.objective = x solution = m.solve - assert_includes [:unbounded, :undefined], solution.status + assert_includes [:infeasible, :unbounded, :undefined], solution.status end # rubocop:disable MethodLength diff --git a/tests/test_solver_paths.rb b/tests/test_solver_paths.rb index 164fa5e..d932dfc 100644 --- a/tests/test_solver_paths.rb +++ b/tests/test_solver_paths.rb @@ -6,7 +6,7 @@ class TestSolverPaths < Test::Unit::TestCase def setup # Clear any existing environment variables @old_env_vars = {} - %w[CBC CLP SCIP CPLEX GLPK].each do |solver| + %w[CBC CLP SCIP GLPK HIGHS].each do |solver| env_var = "RAMS_SOLVER_PATH_#{solver}" @old_env_vars[env_var] = ENV[env_var] ENV.delete(env_var) @@ -63,19 +63,6 @@ def test_scip_custom_solver_command assert_equal '/custom/path/to/scip', command[0] end - def test_cplex_default_solver_command - solver = RAMS::Solvers::CPLEX.new - command = solver.solver_command('/path/to/model.lp', '/path/to/solution.sol', []) - assert_equal 'cplex', command[0] - end - - def test_cplex_custom_solver_command - ENV['RAMS_SOLVER_PATH_CPLEX'] = '/custom/path/to/cplex' - solver = RAMS::Solvers::CPLEX.new - command = solver.solver_command('/path/to/model.lp', '/path/to/solution.sol', []) - assert_equal '/custom/path/to/cplex', command[0] - end - def test_glpk_default_solver_command solver = RAMS::Solvers::GLPK.new command = solver.solver_command('/path/to/model.lp', '/path/to/solution.sol', []) @@ -88,4 +75,17 @@ def test_glpk_custom_solver_command command = solver.solver_command('/path/to/model.lp', '/path/to/solution.sol', []) assert_equal '/custom/path/to/glpsol', command[0] end + + def test_highs_default_solver_command + solver = RAMS::Solvers::HiGHS.new + command = solver.solver_command('/path/to/model.lp', '/path/to/solution.sol', []) + assert_equal 'highs', command[0] + end + + def test_highs_custom_solver_command + ENV['RAMS_SOLVER_PATH_HIGHS'] = '/custom/path/to/highs' + solver = RAMS::Solvers::HiGHS.new + command = solver.solver_command('/path/to/model.lp', '/path/to/solution.sol', []) + assert_equal '/custom/path/to/highs', command[0] + end end \ No newline at end of file