diff --git a/dependencies.yaml b/dependencies.yaml index 3aa6c9460..b382a9a0e 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -537,7 +537,6 @@ dependencies: - output_types: conda packages: - cupy>=12.0.0 - depends_on_rapids_logger: common: - output_types: [conda, requirements, pyproject] diff --git a/docs/cuopt/source/cuopt-python/index.rst b/docs/cuopt/source/cuopt-python/index.rst index d54d1f835..7a412804a 100644 --- a/docs/cuopt/source/cuopt-python/index.rst +++ b/docs/cuopt/source/cuopt-python/index.rst @@ -21,4 +21,13 @@ This section contains details on the cuOpt Python package. :name: Routing Optimization :titlesonly: - Routing Optimization \ No newline at end of file + Routing Optimization + + +.. toctree:: + :maxdepth: 3 + :caption: Linear Programming and Mixed Integer Linear Programming + :name: LP and MILP API + :titlesonly: + + Linear Programming and Mixed Integer Linear Programming \ No newline at end of file diff --git a/docs/cuopt/source/cuopt-python/lp-milp/index.rst b/docs/cuopt/source/cuopt-python/lp-milp/index.rst new file mode 100644 index 000000000..0d60ccc41 --- /dev/null +++ b/docs/cuopt/source/cuopt-python/lp-milp/index.rst @@ -0,0 +1,14 @@ +======================================================= +Linear Programming and Mixed Integer Linear Programming +======================================================= + +This section contains details on the cuOpt linear programming and mixed integer linear programming Python API. + +.. toctree:: + :maxdepth: 3 + :caption: LP and MILP + :name: LP and MILP + :titlesonly: + + lp-milp-api.rst + lp-milp-examples.rst \ No newline at end of file diff --git a/docs/cuopt/source/cuopt-python/lp-milp/lp-milp-api.rst b/docs/cuopt/source/cuopt-python/lp-milp/lp-milp-api.rst new file mode 100644 index 000000000..ea6b0ff79 --- /dev/null +++ b/docs/cuopt/source/cuopt-python/lp-milp/lp-milp-api.rst @@ -0,0 +1,44 @@ +========================= +LP and MILP API Reference +========================= + +.. autoclass:: cuopt.linear_programming.problem.VType + :members: + :member-order: bysource + :undoc-members: + :exclude-members: capitalize, casefold, center, count, encode, endswith, expandtabs, find, format, format_map, index, isalnum, isalpha, isascii, isdecimal, isdigit, isidentifier, islower, isnumeric, isprintable, isspace, istitle, isupper, join, ljust, lower, lstrip, maketrans, partition, removeprefix, removesuffix, replace, rfind, rindex, rjust, rpartition, rsplit, rstrip, split, splitlines, startswith, strip, swapcase, title, translate, upper, zfill + +.. autoclass:: cuopt.linear_programming.problem.CType + :members: + :member-order: bysource + :undoc-members: + :exclude-members: capitalize, casefold, center, count, encode, endswith, expandtabs, find, format, format_map, index, isalnum, isalpha, isascii, isdecimal, isdigit, isidentifier, islower, isnumeric, isprintable, isspace, istitle, isupper, join, ljust, lower, lstrip, maketrans, partition, removeprefix, removesuffix, replace, rfind, rindex, rjust, rpartition, rsplit, rstrip, split, splitlines, startswith, strip, swapcase, title, translate, upper, zfill + +.. autoclass:: cuopt.linear_programming.problem.sense + :members: + :member-order: bysource + :exclude-members: __new__, __init__, _generate_next_value_, as_integer_ratio, bit_count, bit_length, conjugate, denominator, from_bytes, imag, is_integer, numerator, real, to_bytes + :no-inherited-members: + +.. autoclass:: cuopt.linear_programming.problem.Problem + :members: + :undoc-members: + :show-inheritance: + :exclude-members: reset_solved_values, post_solve, dict_to_object, NumNZs, NumVariables, NumConstraints, IsMIP + +.. autoclass:: cuopt.linear_programming.problem.Variable + :members: + :undoc-members: + :show-inheritance: + :exclude-members: + +.. autoclass:: cuopt.linear_programming.problem.LinearExpression + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: cuopt.linear_programming.problem.Constraint + :members: + :undoc-members: + :show-inheritance: + :exclude-members: compute_slack diff --git a/docs/cuopt/source/cuopt-python/lp-milp/lp-milp-examples.rst b/docs/cuopt/source/cuopt-python/lp-milp/lp-milp-examples.rst new file mode 100644 index 000000000..98ef2d75d --- /dev/null +++ b/docs/cuopt/source/cuopt-python/lp-milp/lp-milp-examples.rst @@ -0,0 +1,313 @@ +==================== +LP and MILP Examples +==================== + +This section contains examples of how to use the cuOpt linear programming and mixed integer linear programming Python API. + +.. note:: + + The examples in this section are not exhaustive. They are provided to help you get started with the cuOpt linear programming and mixed integer linear programming Python API. For more examples, please refer to the `cuopt-examples GitHub repository `_. + + +Simple Linear Programming Example +--------------------------------- + +.. code-block:: python + + from cuopt.linear_programming.problem import Problem, CONTINUOUS, MAXIMIZE + from cuopt.linear_programming.solver_settings import SolverSettings + + # Create a new problem + problem = Problem("Simple LP") + + # Add variables + x = problem.addVariable(lb=0, vtype=CONTINUOUS, name="x") + y = problem.addVariable(lb=0, vtype=CONTINUOUS, name="y") + + # Add constraints + problem.addConstraint(x + y <= 10, name="c1") + problem.addConstraint(x - y >= 0, name="c2") + + # Set objective function + problem.setObjective(x + y, sense=MAXIMIZE) + + # Configure solver settings + settings = SolverSettings() + settings.set_parameter("time_limit", 60) + + # Solve the problem + problem.solve(settings) + + # Check solution status + if problem.Status.name == "Optimal": + print(f"Optimal solution found in {problem.SolveTime:.2f} seconds") + print(f"x = {x.getValue()}") + print(f"y = {y.getValue()}") + print(f"Objective value = {problem.ObjValue}") + +The response is as follows: + +.. code-block:: text + + Optimal solution found in 0.01 seconds + x = 10.0 + y = 0.0 + Objective value = 10.0 + +Mixed Integer Linear Programming Example +---------------------------------------- + +.. code-block:: python + + from cuopt.linear_programming.problem import Problem, INTEGER, MAXIMIZE + from cuopt.linear_programming.solver_settings import SolverSettings + + # Create a new MIP problem + problem = Problem("Simple MIP") + + # Add integer variables with bounds + x = problem.addVariable(vtype=INTEGER, name="V_x") + y = problem.addVariable(lb=10, ub=50, vtype=INTEGER, name="V_y") + + # Add constraints + problem.addConstraint(2 * x + 4 * y >= 230, name="C1") + problem.addConstraint(3 * x + 2 * y <= 190, name="C2") + + # Set objective function + problem.setObjective(5 * x + 3 * y, sense=MAXIMIZE) + + # Configure solver settings + settings = SolverSettings() + settings.set_parameter("time_limit", 60) + + # Solve the problem + problem.solve(settings) + + # Check solution status and results + if problem.Status.name == "Optimal": + print(f"Optimal solution found in {problem.SolveTime:.2f} seconds") + print(f"x = {x.getValue()}") + print(f"y = {y.getValue()}") + print(f"Objective value = {problem.ObjValue}") + else: + print(f"Problem status: {problem.Status.name}") + +The response is as follows: + +.. code-block:: text + + Optimal solution found in 0.00 seconds + x = 36.0 + y = 40.99999999999999 + Objective value = 303.0 + + +Advanced Example: Production Planning +------------------------------------- + +.. code-block:: python + + from cuopt.linear_programming.problem import Problem, INTEGER, MAXIMIZE + from cuopt.linear_programming.solver_settings import SolverSettings + + # Production planning problem + problem = Problem("Production Planning") + + # Decision variables: production quantities + # x1 = units of product A + # x2 = units of product B + x1 = problem.addVariable(lb=10, vtype=INTEGER, name="Product_A") + x2 = problem.addVariable(lb=15, vtype=INTEGER, name="Product_B") + + # Resource constraints + # Machine time: 2 hours per unit of A, 1 hour per unit of B, max 100 hours + problem.addConstraint(2 * x1 + x2 <= 100, name="Machine_Time") + + # Labor: 1 hour per unit of A, 3 hours per unit of B, max 120 hours + problem.addConstraint(x1 + 3 * x2 <= 120, name="Labor_Hours") + + # Material: 4 units per unit of A, 2 units per unit of B, max 200 units + problem.addConstraint(4 * x1 + 2 * x2 <= 200, name="Material") + + # Objective: maximize profit + # Profit: $50 per unit of A, $30 per unit of B + problem.setObjective(50 * x1 + 30 * x2, sense=MAXIMIZE) + + # Solve with time limit + settings = SolverSettings() + settings.set_parameter("time_limit", 30) + problem.solve(settings) + + # Display results + if problem.Status.name == "Optimal": + print("=== Production Planning Solution ===") + print(f"Status: {problem.Status.name}") + print(f"Solve time: {problem.SolveTime:.2f} seconds") + print(f"Product A production: {x1.getValue()} units") + print(f"Product B production: {x2.getValue()} units") + print(f"Total profit: ${problem.ObjValue:.2f}") + + else: + print(f"Problem not solved optimally. Status: {problem.Status.name}") + +The response is as follows: + +.. code-block:: text + + === Production Planning Solution === + + Status: Optimal + Solve time: 0.09 seconds + Product A production: 36.0 units + Product B production: 28.000000000000004 units + Total profit: $2640.00 + +Working with Expressions and Constraints +---------------------------------------- + +.. code-block:: python + + from cuopt.linear_programming.problem import Problem, MAXIMIZE + from cuopt.linear_programming.solver_settings import SolverSettings + + problem = Problem("Expression Example") + + # Create variables + x = problem.addVariable(lb=0, name="x") + y = problem.addVariable(lb=0, name="y") + z = problem.addVariable(lb=0, name="z") + + # Create complex expressions + expr1 = 2 * x + 3 * y - z + expr2 = x + y + z + + # Add constraints using expressions + problem.addConstraint(expr1 <= 100, name="Complex_Constraint_1") + problem.addConstraint(expr2 >= 20, name="Complex_Constraint_2") + + # Add constraint with different senses + problem.addConstraint(x + y == 50, name="Equality_Constraint") + problem.addConstraint(1 * x <= 30, name="Upper_Bound_X") + problem.addConstraint(1 * y >= 10, name="Lower_Bound_Y") + problem.addConstraint(1 * z <= 100, name="Upper_Bound_Z") + + # Set objective + problem.setObjective(x + 2 * y + 3 * z, sense=MAXIMIZE) + + settings = SolverSettings() + settings.set_parameter("time_limit", 20) + + problem.solve(settings) + + + if problem.Status.name == "Optimal": + print("=== Expression Example Results ===") + print(f"x = {x.getValue()}") + print(f"y = {y.getValue()}") + print(f"z = {z.getValue()}") + print(f"Objective value = {problem.ObjValue}") + +The response is as follows: + +.. code-block:: text + + === Expression Example Results === + x = 0.0 + y = 50.0 + z = 99.99999999999999 + Objective value = 399.99999999999994 + +Working with Incumbent Solutions +-------------------------------- + +Incumbent solutions are intermediate feasible solutions found during the MIP solving process. They represent the best integer-feasible solution discovered so far and can be accessed through callback functions. + +.. note:: + Incumbent solutions are only available for Mixed Integer Programming (MIP) problems, not for pure Linear Programming (LP) problems. + +.. code-block:: python + + from cuopt.linear_programming.problem import Problem, INTEGER, MAXIMIZE + from cuopt.linear_programming.solver_settings import SolverSettings + from cuopt.linear_programming.solver.solver_parameters import CUOPT_TIME_LIMIT + from cuopt.linear_programming.internals import GetSolutionCallback, SetSolutionCallback + + # Create a callback class to receive incumbent solutions + class IncumbentCallback(GetSolutionCallback): + def __init__(self): + super().__init__() + self.solutions = [] + self.n_callbacks = 0 + + def get_solution(self, solution, solution_cost): + """ + Called whenever the solver finds a new incumbent solution. + + Parameters + ---------- + solution : array-like + The variable values of the incumbent solution + solution_cost : array-like + The objective value of the incumbent solution + """ + self.n_callbacks += 1 + + # Store the incumbent solution + incumbent = { + "solution": solution.copy_to_host(), + "cost": solution_cost.copy_to_host()[0], + "iteration": self.n_callbacks + } + self.solutions.append(incumbent) + + print(f"Incumbent {self.n_callbacks}: {incumbent['solution']}, cost: {incumbent['cost']:.2f}") + + # Create a more complex MIP problem that will generate multiple incumbents + problem = Problem("Incumbent Example") + + # Add integer variables + x = problem.addVariable(vtype=INTEGER) + y = problem.addVariable(vtype=INTEGER) + + # Add constraints to create a problem that will generate multiple incumbents + problem.addConstraint(2 * x + 4 * y >= 230) + problem.addConstraint(3 * x + 2 * y <= 190) + + # Set objective to maximize + problem.setObjective(5 * x + 3 * y, sense=MAXIMIZE) + + # Configure solver settings with callback + settings = SolverSettings() + # Set the incumbent callback + incumbent_callback = IncumbentCallback() + settings.set_mip_callback(incumbent_callback) + settings.set_parameter(CUOPT_TIME_LIMIT, 30) # Allow enough time to find multiple incumbents + + # Solve the problem + problem.solve(settings) + + # Display final results + print(f"\n=== Final Results ===") + print(f"Problem status: {problem.Status.name}") + print(f"Solve time: {problem.SolveTime:.2f} seconds") + print(f"Final solution: x={x.getValue()}, y={y.getValue()}") + print(f"Final objective value: {problem.ObjValue:.2f}") + +The response is as follows: + +.. code-block:: text + + Optimal solution found. + Incumbent 1: [ 0. 58.], cost: 174.00 + Incumbent 2: [36. 41.], cost: 303.00 + Generated fast solution in 0.158467 seconds with objective 303.000000 + Consuming B&B solutions, solution queue size 2 + Solution objective: 303.000000 , relative_mip_gap 0.000000 solution_bound 303.000000 presolve_time 0.043211 total_solve_time 0.160270 max constraint violation 0.000000 max int violation 0.000000 max var bounds violation 0.000000 nodes 4 simplex_iterations 3 + + === Final Results === + Problem status: Optimal + Solve time: 0.16 seconds + Final solution: x=36.0, y=40.99999999999999 + Final objective value: 303.00 + + diff --git a/docs/cuopt/source/introduction.rst b/docs/cuopt/source/introduction.rst index 671446727..aaf164198 100644 --- a/docs/cuopt/source/introduction.rst +++ b/docs/cuopt/source/introduction.rst @@ -112,8 +112,7 @@ cuOpt supports the following APIs: - cuOpt is written in C++ and includes a native C++ API. However, we do not provide documentation for the C++ API at this time. We anticipate that the C++ API will change significantly in the future. Use it at your own risk. - Python support - :doc:`Routing (TSP, VRP, and PDP) - Python ` - - Linear Programming (LP) and Mixed Integer Linear Programming (MILP) - - cuOpt includes a Python API that is used as the backend of the cuOpt server. However, we do not provide documentation for the Python API at this time. We suggest using cuOpt server to access cuOpt via Python. We anticipate that the Python API will change significantly in the future. Use it at your own risk. + - :doc:`Linear Programming (LP) and Mixed Integer Linear Programming (MILP) - Python ` - Server support - :doc:`Linear Programming (LP) - Server ` - :doc:`Mixed Integer Linear Programming (MILP) - Server ` diff --git a/docs/cuopt/source/lp-features.rst b/docs/cuopt/source/lp-features.rst index e89ee8f00..b89ace5d3 100644 --- a/docs/cuopt/source/lp-features.rst +++ b/docs/cuopt/source/lp-features.rst @@ -16,6 +16,8 @@ The LP solver can be accessed in the following ways: - **C API**: A native C API that provides direct low-level access to cuOpt's LP capabilities, enabling integration into any application or system that can interface with C. +- **Python SDK**: A Python package that provides direct access to cuOpt's LP capabilities through a simple, intuitive API. This allows for seamless integration into Python applications and workflows. For more information, see :doc:`cuopt-python/quick-start`. + - **As a Self-Hosted Service**: cuOpt's LP solver can be deployed as a in your own infrastructure, enabling you to maintain full control while integrating it into your existing systems. Each option provide the same powerful linear optimization capabilities while offering flexibility in deployment and integration. diff --git a/docs/cuopt/source/milp-features.rst b/docs/cuopt/source/milp-features.rst index 389156108..40eba5c40 100644 --- a/docs/cuopt/source/milp-features.rst +++ b/docs/cuopt/source/milp-features.rst @@ -16,6 +16,8 @@ The MILP solver can be accessed in the following ways: - **C API**: A native C API that provides direct low-level access to cuOpt's MILP solver, enabling integration into any application or system that can interface with C. +- **Python SDK**: A Python package that provides direct access to cuOpt's MILP capabilities through a simple, intuitive API. This allows for seamless integration into Python applications and workflows. For more information, see :doc:`cuopt-python/quick-start`. + - **As a Self-Hosted Service**: cuOpt's MILP solver can be deployed in your own infrastructure, enabling you to maintain full control while integrating it into your existing systems. Each option provide the same powerful mixed-integer linear optimization capabilities while offering flexibility in deployment and integration. diff --git a/python/cuopt/cuopt/linear_programming/__init__.py b/python/cuopt/cuopt/linear_programming/__init__.py index 4d88382eb..7941ad911 100644 --- a/python/cuopt/cuopt/linear_programming/__init__.py +++ b/python/cuopt/cuopt/linear_programming/__init__.py @@ -15,6 +15,7 @@ from cuopt.linear_programming import internals from cuopt.linear_programming.data_model import DataModel +from cuopt.linear_programming.problem import Problem from cuopt.linear_programming.solution import Solution from cuopt.linear_programming.solver import BatchSolve, Solve from cuopt.linear_programming.solver_settings import ( diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py new file mode 100644 index 000000000..1a14e17cf --- /dev/null +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -0,0 +1,997 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from enum import Enum + +import numpy as np + +import cuopt.linear_programming.data_model as data_model +import cuopt.linear_programming.solver as solver +import cuopt.linear_programming.solver_settings as solver_settings + + +class VType(str, Enum): + """ + The type of a variable is either continuous or integer. + Variable Types can be directly used as a constant. + CONTINUOUS is VType.CONTINUOUS + INTEGER is VType.INTEGER + """ + + CONTINUOUS = "C" + INTEGER = "I" + + +CONTINUOUS = VType.CONTINUOUS +INTEGER = VType.INTEGER + + +class CType(str, Enum): + """ + The sense of a constraint is either LE, GE or EQ. + Constraint Sense Types can be directly used as a constant. + LE is CType.LE + GE is CType.GE + EQ is CType EQ + """ + + LE = "L" + GE = "G" + EQ = "E" + + +LE = CType.LE +GE = CType.GE +EQ = CType.EQ + + +class sense(int, Enum): + """ + The sense of a model is either MINIMIZE or MAXIMIZE. + Model objective sense can be directly used as a constant. + MINIMIZE is sense.MINIMIZE + MAXIMIZE is sense.MAXIMIZE + """ + + MAXIMIZE = -1 + MINIMIZE = 1 + + +MAXIMIZE = sense.MAXIMIZE +MINIMIZE = sense.MINIMIZE + + +class Variable: + """ + cuOpt variable object initialized with details of the variable + such as lower bound, upper bound, type and name. + Variables are always associated with a problem and can be + created using problem.addVariable (See problem class). + + Parameters + ---------- + lb : float + Lower bound of the variable. Defaults to 0. + ub : float + Upper bound of the variable. Defaults to infinity. + vtype : enum + CONTINUOUS or INTEGER. Defaults to CONTINUOUS. + obj : float + Coefficient of the Variable in the objective. + name : str + Name of the variable. Optional. + + Attributes + ---------- + VariableName : str + Name of the Variable. + VariableType : CONTINUOUS or INTEGER + Variable type. + LB : float + Lower Bound of the Variable. + UB : float + Upper Bound of the Variable. + Obj : float + Coefficient of the variable in the Objective function. + Value : float + Value of the variable after solving. + ReducedCost : float + Reduced Cost after solving an LP problem. + """ + + def __init__( + self, + lb=0.0, + ub=float("inf"), + obj=0.0, + vtype=CONTINUOUS, + vname="", + ): + self.index = -1 + self.LB = lb + self.UB = ub + self.Obj = obj + self.Value = float("nan") + self.ReducedCost = float("nan") + self.VariableType = vtype + self.VariableName = vname + + def getIndex(self): + """ + Get the index position of the variable in the problem. + """ + return self.index + + def getValue(self): + """ + Returns the Value of the variable computed in current solution. + Defaults to 0 + """ + return self.Value + + def getObjectiveCoefficient(self): + """ + Returns the objective coefficient of the variable. + """ + return self.Obj + + def setObjectiveCoefficient(self, val): + """ + Sets the objective cofficient of the variable. + """ + self.Obj = val + + def setLowerBound(self, val): + """ + Sets the lower bound of the variable. + """ + self.LB = val + + def getLowerBound(self): + """ + Returns the lower bound of the variable. + """ + return self.LB + + def setUpperBound(self, val): + """ + Sets the upper bound of the variable. + """ + self.UB = val + + def getUpperBound(self): + """ + Returns the upper bound of the variable. + """ + return self.UB + + def setVariableType(self, val): + """ + Sets the variable type of the variable. + Variable types can be either CONTINUOUS or INTEGER. + """ + self.VariableType = val + + def getVariableType(self): + """ + Returns the type of the variable. + """ + return self.VariableType + + def setVariableName(self, val): + """ + Sets the name of the variable. + """ + self.VariableName = val + + def getVariableName(self): + """ + Returns the name of the variable. + """ + return self.VariableName + + def __add__(self, other): + match other: + case int() | float(): + return LinearExpression([self], [1.0], float(other)) + case Variable(): + # Change? + return LinearExpression([self, other], [1.0, 1.0], 0.0) + case LinearExpression(): + return other + self + case _: + raise ValueError( + "Cannot add type %s to variable" % type(other).__name__ + ) + + def __radd__(self, other): + return self + other + + def __sub__(self, other): + match other: + case int() | float(): + return LinearExpression([self], [1.0], -float(other)) + case Variable(): + return LinearExpression([self, other], [1.0, -1.0], 0.0) + case LinearExpression(): + # self - other -> other * -1.0 + self + return other * -1.0 + self + case _: + raise ValueError( + "Cannot subtract type %s from variable" + % type(other).__name__ + ) + + def __rsub__(self, other): + # other - self -> other + self * -1.0 + return other + self * -1.0 + + def __mul__(self, other): + match other: + case int() | float(): + return LinearExpression([self], [float(other)], 0.0) + case _: + raise ValueError( + "Cannot multiply type %s with variable" + % type(other).__name__ + ) + + def __rmul__(self, other): + return self * other + + def __le__(self, other): + match other: + case int() | float(): + expr = LinearExpression([self], [1.0], 0.0) + return Constraint(expr, LE, float(other)) + case Variable() | LinearExpression(): + # var1 <= var2 -> var1 - var2 <= 0 + expr = self - other + return Constraint(expr, LE, 0.0) + case _: + raise ValueError("Unsupported operation") + + def __ge__(self, other): + match other: + case int() | float(): + expr = LinearExpression([self], [1.0], 0.0) + return Constraint(expr, GE, float(other)) + case Variable() | LinearExpression(): + # var1 >= var2 -> var1 - var2 >= 0 + expr = self - other + return Constraint(expr, GE, 0.0) + case _: + raise ValueError("Unsupported operation") + + def __eq__(self, other): + match other: + case int() | float(): + expr = LinearExpression([self], [1.0], 0.0) + return Constraint(expr, EQ, float(other)) + case Variable() | LinearExpression(): + # var1 == var2 -> var1 - var2 == 0 + expr = self - other + return Constraint(expr, EQ, 0.0) + case _: + raise ValueError("Unsupported operation") + + +class LinearExpression: + """ + LinearExpressions contain a set of variables, the coefficients + for the variables, and a constant. + LinearExpressions can be used to create constraints and the + objective in the Problem. + LinearExpressions can be added and subtracted with other + LinearExpressions and Variables and can also be multiplied and + divided by scalars. + LinearExpressions can be compared with scalars, Variables, and + other LinearExpressions to create Constraints. + + Parameters + ---------- + vars : List + List of Variables in the linear expression. + coefficients : List + List of coefficients corresponding to the variables. + constant : float + Constant of the linear expression. + """ + + def __init__(self, vars, coefficients, constant): + self.vars = vars + self.coefficients = coefficients + self.constant = constant + + def getVariables(self): + """ + Returns all the variables in the linear expression. + """ + return self.vars + + def getVariable(self, i): + """ + Gets Variable at ith index in the linear expression. + """ + return self.vars[i] + + def getCoefficients(self): + """ + Returns all the coefficients in the linear expression. + """ + return self.coefficients + + def getCoefficient(self, i): + """ + Gets the coefficient of the variable at ith index of the + linear expression. + """ + return self.coefficients[i] + + def getConstant(self): + """ + Returns the constant in the linear expression. + """ + return self.constant + + def zipVarCoefficients(self): + return zip(self.vars, self.coefficients) + + def getValue(self): + """ + Returns the value of the expression computed with the + current solution. + """ + value = 0.0 + for i, var in enumerate(self.vars): + value += var.Value * self.coefficients[i] + return value + self.constant + + def __len__(self): + return len(self.vars) + + def __iadd__(self, other): + # Compute expr1 += expr2 + match other: + case int() | float(): + # Update just the constant value + self.constant += float(other) + return self + case Variable(): + # Append just a variable with coefficient 1.0 + self.vars.append(other) + self.coefficients.append(1.0) + return self + case LinearExpression(): + # Append all variables, coefficients and constants + self.vars.extend(other.vars) + self.coefficients.extend(other.coefficients) + self.constant += other.constant + return self + case _: + raise ValueError( + "Can't add type %s to Linear Expression" + % type(other).__name__ + ) + + def __add__(self, other): + # Compute expr3 = expr1 + expr2 + match other: + case int() | float(): + # Update just the constant value + return LinearExpression( + self.vars, self.coefficients, self.constant + float(other) + ) + case Variable(): + # Append just a variable with coefficient 1.0 + vars = self.vars + [other] + coeffs = self.coefficients + [1.0] + return LinearExpression(vars, coeffs, self.constant) + case LinearExpression(): + # Append all variables, coefficients and constants + vars = self.vars + other.vars + coeffs = self.coefficients + other.coefficients + constant = self.constant + other.constant + return LinearExpression(vars, coeffs, constant) + + def __radd__(self, other): + return self + other + + def __isub__(self, other): + # Compute expr1 -= expr2 + match other: + case int() | float(): + # Update just the constant value + self.constant -= float(other) + return self + case Variable(): + # Append just a variable with coefficient -1.0 + self.vars.append(other) + self.coefficients.append(-1.0) + return self + case LinearExpression(): + # Append all variables, coefficients and constants + self.vars.extend(other.vars) + for coeff in other.coefficients: + self.coefficients.append(-coeff) + self.constant -= other.constant + return self + case _: + raise ValueError( + "Can't sub type %s from LinearExpression" + % type(other).__name__ + ) + + def __sub__(self, other): + # Compute expr3 = expr1 - expr2 + match other: + case int() | float(): + # Update just the constant value + return LinearExpression( + self.vars, self.coefficients, self.constant - float(other) + ) + case Variable(): + # Append just a variable with coefficient -1.0 + vars = self.vars + [other] + coeffs = self.coefficients + [-1.0] + return LinearExpression(vars, coeffs, self.constant) + case LinearExpression(): + # Append all variables, coefficients and constants + vars = self.vars + other.vars + coeffs = [] + for i in self.coefficients: + coeffs.append(i) + for i in other.coefficients: + coeffs.append(-1.0 * i) + constant = self.constant - other.constant + return LinearExpression(vars, coeffs, constant) + + def __rsub__(self, other): + # other - self -> other + self * -1.0 + return other + self * -1.0 + + def __imul__(self, other): + # Compute expr *= constant + match other: + case int() | float(): + self.coefficients = [ + coeff * float(other) for coeff in self.coefficients + ] + self.constant = self.constant * float(other) + return self + case _: + raise ValueError( + "Can't multiply type %s by LinearExpresson" + % type(other).__name__ + ) + + def __mul__(self, other): + # Compute expr2 = expr1 * constant + match other: + case int() | float(): + coeffs = [coeff * float(other) for coeff in self.coefficients] + constant = self.constant * float(other) + return LinearExpression(self.vars, coeffs, constant) + case _: + raise ValueError( + "Can't multiply type %s by LinearExpresson" + % type(other).__name__ + ) + + def __rmul__(self, other): + return self * other + + def __itruediv__(self, other): + # Compute expr /= constant + match other: + case int() | float(): + self.coefficients = [ + coeff / float(other) for coeff in self.coefficients + ] + self.constant = self.constant / float(other) + return self + case _: + raise ValueError( + "Can't divide LinearExpression by type %s" + % type(other).__name__ + ) + + def __truediv__(self, other): + # Compute expr2 = expr1 / constant + match other: + case int() | float(): + coeffs = [coeff / float(other) for coeff in self.coefficients] + constant = self.constant / float(other) + return LinearExpression(self.vars, coeffs, constant) + case _: + raise ValueError( + "Can't divide LinearExpression by type %s" + % type(other).__name__ + ) + + def __le__(self, other): + match other: + case int() | float(): + return Constraint(self, LE, float(other)) + case Variable() | LinearExpression(): + # expr1 <= expr2 -> expr1 - expr2 <= 0 + expr = self - other + return Constraint(expr, LE, 0.0) + + def __ge__(self, other): + match other: + case int() | float(): + return Constraint(self, GE, float(other)) + case Variable() | LinearExpression(): + # expr1 >= expr2 -> expr1 - expr2 >= 0 + expr = self - other + return Constraint(expr, GE, 0.0) + + def __eq__(self, other): + match other: + case int() | float(): + return Constraint(self, EQ, float(other)) + case Variable() | LinearExpression(): + # expr1 == expr2 -> expr1 - expr2 == 0 + expr = self - other + return Constraint(expr, EQ, 0.0) + + +class Constraint: + """ + cuOpt constraint object containing a linear expression, + the sense of the constraint, and the right-hand side of + the constraint. + Constraints are associated with a problem and can be + created using problem.addConstraint (See problem class). + + Parameters + ---------- + expr : LinearExpression + Linear expression corresponding to a problem. + sense : enum + Sense of the constraint. Either LE for <=, + GE for >= or EQ for == . + rhs : float + Constraint right-hand side value. + name : str, Optional + Name of the constraint. Optional. + + Attributes + ---------- + ConstraintName : str + Name of the constraint. + Sense : LE, GE or EQ + Row sense. LE for >=, GE for <= or EQ for == . + RHS : float + Constraint right-hand side value. + Slack : float + Computed LHS - RHS with current solution. + DualValue : float + Constraint dual value in the current solution. + """ + + def __init__(self, expr, sense, rhs, name=""): + self.vindex_coeff_dict = {} + nz = len(expr) + self.vars = expr.vars + self.index = -1 + for i in range(nz): + v_idx = expr.vars[i].index + v_coeff = expr.coefficients[i] + self.vindex_coeff_dict[v_idx] = ( + self.vindex_coeff_dict[v_idx] + v_coeff + if v_idx in self.vindex_coeff_dict + else v_coeff + ) + self.Sense = sense + self.RHS = rhs - expr.getConstant() + self.ConstraintName = name + self.DualValue = float("nan") + self.Slack = float("nan") + + def __len__(self): + return len(self.vindex_coeff_dict) + + def getConstraintName(self): + """ + Returns the name of the constraint. + """ + return self.ConstraintName + + def getSense(self): + """ + Returns the sense of the constraint. + Constraint sense can be LE(<=), GE(>=) or EQ(==). + """ + return self.Sense + + def getRHS(self): + """ + Returns the right-hand side value of the constraint. + """ + return self.RHS + + def getCoefficient(self, var): + """ + Returns the coefficient of a variable in the constraint. + """ + v_idx = var.index + return self.vindex_coeff_dict[v_idx] + + def compute_slack(self): + # Computes the constraint Slack in the current solution. + lhs = 0.0 + for var in self.vars: + lhs += var.Value * self.vindex_coeff_dict[var.index] + return self.RHS - lhs + + +class Problem: + """ + A Problem defines a Linear Program or Mixed Integer Program + Variable can be be created by calling addVariable() + Constraints can be added by calling addConstraint() + The objective can be set by calling setObjective() + The problem data is formed when calling solve(). + + Parameters + ---------- + model_name : str, optional + Name of the model. Default is an empty string. + + Attributes + ---------- + Name : str + Name of the model. + ObjSense : sense + Objective sense (MINIMIZE or MAXIMIZE). + ObjConstant : float + Constant term in the objective. + Status : int + Status of the problem after solving. + SolveTime : float + Time taken to solve the problem. + SolutionStats : object + Solution statistics for LP or MIP problem. + ObjValue : float + Objective value of the problem. + IsMIP : bool + Indicates if the problem is a Mixed Integer Program. + NumVariables : int + Number of Variables in the problem. + NumConstraints : int + Number of constraints in the problem. + NumNZs : int + Number of non-zeros in the problem. + + Examples + -------- + >>> problem = problem.Problem("MIP_model") + >>> x = problem.addVariable(lb=-2.0, ub=8.0, vtype=INTEGER) + >>> y = problem.addVariable(name="Var2") + >>> problem.addConstraint(2*x - 3*y <= 10, name="Constr1") + >>> expr = 3*x + y + >>> problem.addConstraint(expr + x == 20, name="Constr2") + >>> problem.setObjective(x + y, sense=MAXIMIZE) + >>> problem.solve() + """ + + def __init__(self, model_name=""): + self.Name = model_name + self.vars = [] + self.constrs = [] + self.ObjSense = MINIMIZE + self.Obj = None + self.ObjConstant = 0.0 + self.Status = -1 + self.ObjValue = float("nan") + + self.solved = False + self.rhs = None + self.row_sense = None + self.row_pointers = None + self.column_indicies = None + self.values = None + self.lower_bound = None + self.upper_bound = None + self.var_type = None + + class dict_to_object: + def __init__(self, mdict): + for key, value in mdict.items(): + setattr(self, key, value) + + def reset_solved_values(self): + # Resets all post solve values + for var in self.vars: + var.Value = float("nan") + var.ReducedCost = float("nan") + + for constr in self.constrs: + constr.Slack = float("nan") + constr.DualValue = float("nan") + + self.ObjValue = float("nan") + self.solved = False + + def addVariable( + self, lb=0.0, ub=float("inf"), obj=0.0, vtype=CONTINUOUS, name="" + ): + """ + Adds a variable to the problem defined by lower bound, + upper bound, type and name. + + Parameters + ---------- + lb : float + Lower bound of the variable. Defaults to 0. + ub : float + Upper bound of the variable. Defaults to infinity. + vtype : enum + vtype.CONTINUOUS or vtype.INTEGER. Defaults to CONTINUOUS. + name : string + Name of the variable. Optional. + + Examples + -------- + >>> problem = problem.Problem("MIP_model") + >>> x = problem.addVariable(lb=-2.0, ub=8.0, vtype=INTEGER, + name="Var1") + """ + if self.solved: + self.reset_solved_values() # Reset all solved values + n = len(self.vars) + var = Variable(lb, ub, obj, vtype, name) + var.index = n + self.vars.append(var) + return var + + def addConstraint(self, constr, name=""): + """ + Adds a constraint to the problem defined by constraint object + and name. A constraint is generated using LinearExpression, + Sense and RHS. + + Parameters + ---------- + constr : Constraint + Constructed using LinearExpressions (See Examples) + name : string + Name of the variable. Optional. + + Examples + -------- + >>> problem = problem.Problem("MIP_model") + >>> x = problem.addVariable(lb=-2.0, ub=8.0, vtype=INTEGER) + >>> y = problem.addVariable(name="Var2") + >>> problem.addConstraint(2*x - 3*y <= 10, name="Constr1") + >>> expr = 3*x + y + >>> problem.addConstraint(expr + x == 20, name="Constr2") + """ + if self.solved: + self.reset_solved_values() # Reset all solved values + n = len(self.constrs) + match constr: + case Constraint(): + constr.index = n + constr.ConstraintName = name + self.constrs.append(constr) + case _: + raise ValueError("addConstraint requires a Constraint object") + + def setObjective(self, expr, sense=MINIMIZE): + """ + Set the Objective of the problem with an expression that needs to + be MINIMIZED or MAXIMIZED. + + Parameters + ---------- + expr : LinearExpression or Variable or Constant + Objective expression that needs maximization or minimization. + sense : enum + Sets whether the problem is a maximization or a minimization + problem. Values passed can either be MINIMIZE or MAXIMIZE. + Defaults to MINIMIZE. + + Examples + -------- + >>> problem = problem.Problem("MIP_model") + >>> x = problem.addVariable(lb=-2.0, ub=8.0, vtype=INTEGER) + >>> y = problem.addVariable(name="Var2") + >>> problem.addConstraint(2*x - 3*y <= 10, name="Constr1") + >>> expr = 3*x + y + >>> problem.addConstraint(expr + x == 20, name="Constr2") + >>> problem.setObjective(x + y, sense=MAXIMIZE) + """ + if self.solved: + self.reset_solved_values() # Reset all solved values + self.ObjSense = sense + match expr: + case int() | float(): + for var in self.vars: + var.setObjectiveCoefficient(0.0) + self.ObjCon = float(expr) + case Variable(): + for var in self.vars: + var.setObjectiveCoefficient(0.0) + if var.getIndex() == expr.getIndex(): + var.setObjectiveCoefficient(1.0) + case LinearExpression(): + for var, coeff in expr.zipVarCoefficients(): + self.vars[var.getIndex()].setObjectiveCoefficient(coeff) + case _: + raise ValueError( + "Objective must be a LinearExpression or a constant" + ) + self.Obj = expr + + def getObjective(self): + """ + Get the Objective expression of the problem. + """ + return self.Obj + + def getVariables(self): + """ + Get a list of all the variables in the problem. + """ + return self.vars + + def getConstraints(self): + """ + Get a list of all the Constraints in a problem. + """ + return self.constrs + + @property + def NumVariables(self): + # Returns number of variables in the problem + return len(self.vars) + + @property + def NumConstraints(self): + # Returns number of contraints in the problem. + return len(self.constrs) + + @property + def NumNZs(self): + # Returns number of non-zeros in the problem. + nnz = 0 + for constr in self.constrs: + nnz += len(constr) + return nnz + + @property + def IsMIP(self): + # Returns if the problem is a MIP problem. + for var in self.vars: + if var.VariableType == "I": + return True + return False + + def getCSR(self): + """ + Computes and returns the CSR representation of the + constraint matrix. + """ + csr_dict = {"row_pointers": [0], "column_indices": [], "values": []} + for constr in self.constrs: + csr_dict["column_indices"].extend( + list(constr.vindex_coeff_dict.keys()) + ) + csr_dict["values"].extend(list(constr.vindex_coeff_dict.values())) + csr_dict["row_pointers"].append(len(csr_dict["column_indices"])) + return self.dict_to_object(csr_dict) + + def get_incumbent_values(self, solution, vars): + """ + Extract incumbent values of the vars from a problem solution. + """ + values = [] + for var in vars: + values.append(solution[var.index]) + return values + + def post_solve(self, solution): + self.Status = solution.get_termination_status() + self.SolveTime = solution.get_solve_time() + + IsMIP = False + if solution.problem_category == 0: + self.SolutionStats = self.dict_to_object(solution.get_lp_stats()) + else: + IsMIP = True + self.SolutionStats = self.dict_to_object(solution.get_milp_stats()) + + primal_sol = solution.get_primal_solution() + reduced_cost = solution.get_reduced_cost() + if len(primal_sol) > 0: + for var in self.vars: + var.Value = primal_sol[var.index] + if not IsMIP: + var.ReducedCost = reduced_cost[var.index] + dual_sol = None + if not IsMIP: + dual_sol = solution.get_dual_solution() + for i, constr in enumerate(self.constrs): + if dual_sol is not None: + constr.DualValue = dual_sol[i] + constr.Slack = constr.compute_slack() + self.ObjValue = self.Obj.getValue() + self.solved = True + + def solve(self, settings=solver_settings.SolverSettings()): + """ + Optimizes the LP or MIP problem with the added variables, + constraints and objective. + + Examples + -------- + >>> problem = problem.Problem("MIP_model") + >>> x = problem.addVariable(lb=-2.0, ub=8.0, vtype=INTEGER) + >>> y = problem.addVariable(name="Var2") + >>> problem.addConstraint(2*x - 3*y <= 10, name="Constr1") + >>> expr = 3*x + y + >>> problem.addConstraint(expr + x == 20, name="Constr2") + >>> problem.setObjective(x + y, sense=MAXIMIZE) + >>> problem.solve() + """ + + # iterate through the constraints and construct the constraint matrix + n = len(self.vars) + self.row_pointers = [0] + self.column_indicies = [] + self.values = [] + self.rhs = [] + self.row_sense = [] + for constr in self.constrs: + self.column_indicies.extend(list(constr.vindex_coeff_dict.keys())) + self.values.extend(list(constr.vindex_coeff_dict.values())) + self.row_pointers.append(len(self.column_indicies)) + self.rhs.append(constr.RHS) + self.row_sense.append(constr.Sense) + + self.objective = np.zeros(n) + self.lower_bound, self.upper_bound = np.zeros(n), np.zeros(n) + self.var_type = np.empty(n, dtype="S1") + + for j in range(n): + self.objective[j] = self.vars[j].getObjectiveCoefficient() + self.var_type[j] = self.vars[j].getVariableType() + self.lower_bound[j] = self.vars[j].getLowerBound() + self.upper_bound[j] = self.vars[j].getUpperBound() + + # Initialize datamodel + dm = data_model.DataModel() + dm.set_csr_constraint_matrix( + np.array(self.values), + np.array(self.column_indicies), + np.array(self.row_pointers), + ) + if self.ObjSense == -1: + dm.set_maximize(True) + dm.set_constraint_bounds(np.array(self.rhs)) + dm.set_row_types(np.array(self.row_sense, dtype="S1")) + dm.set_objective_coefficients(self.objective) + dm.set_variable_lower_bounds(self.lower_bound) + dm.set_variable_upper_bounds(self.upper_bound) + dm.set_variable_types(self.var_type) + + # Call Solver + solution = solver.Solve(dm, settings) + + # Post Solve + self.post_solve(solution) diff --git a/python/cuopt/cuopt/linear_programming/solver/solver.py b/python/cuopt/cuopt/linear_programming/solver/solver.py index 4543cd523..12921ae7c 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver.py +++ b/python/cuopt/cuopt/linear_programming/solver/solver.py @@ -84,10 +84,16 @@ def Solve(data_model, solver_settings=None): def is_mip(var_types): if len(var_types) == 0: return False - elif "I" in var_types: - return True - - return False + # Check if all types are the same (fast check) + if len(set(map(type, var_types))) == 1: + # Homogeneous - use appropriate check + if isinstance(var_types[0], bytes): + return b"I" in var_types + else: + return "I" in var_types + else: + # Mixed types - fallback to comprehensive check + return any(vt == "I" or vt == b"I" for vt in var_types) return solver_wrapper.Solve( data_model, diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py new file mode 100644 index 000000000..132920a86 --- /dev/null +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -0,0 +1,328 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import pytest + +from cuopt.linear_programming import SolverSettings +from cuopt.linear_programming.internals import ( + GetSolutionCallback, + SetSolutionCallback, +) +from cuopt.linear_programming.problem import ( + CONTINUOUS, + INTEGER, + MAXIMIZE, + CType, + Problem, + VType, + sense, +) + + +def test_model(): + + prob = Problem("Simple MIP") + assert prob.Name == "Simple MIP" + + # Adding Variable + x = prob.addVariable(lb=0, vtype=VType.INTEGER, name="V_x") + y = prob.addVariable(lb=10, ub=50, vtype=INTEGER, name="V_y") + + assert x.getVariableName() == "V_x" + assert y.getUpperBound() == 50 + assert y.getLowerBound() == 10 + assert x.getVariableType() == VType.INTEGER + assert y.getVariableType() == "I" + assert [x.getIndex(), y.getIndex()] == [0, 1] + assert prob.IsMIP + + # Adding Constraints + prob.addConstraint(2 * x + 4 * y >= 230, name="C1") + prob.addConstraint(3 * x + 2 * y + 10 <= 200, name="C2") + + expected_name = ["C1", "C2"] + expected_coefficient_x = [2, 3] + expected_coefficient_y = [4, 2] + expected_sense = [CType.GE, "L"] + expected_rhs = [230, 190] + for i, c in enumerate(prob.getConstraints()): + assert c.getConstraintName() == expected_name[i] + assert c.getSense() == expected_sense[i] + assert c.getRHS() == expected_rhs[i] + assert c.getCoefficient(x) == expected_coefficient_x[i] + assert c.getCoefficient(y) == expected_coefficient_y[i] + + assert prob.NumVariables == 2 + assert prob.NumConstraints == 2 + assert prob.NumNZs == 4 + + # Setting Objective + expr = 5 * x + 3 * y + 50 + prob.setObjective(expr, sense=MAXIMIZE) + + expected_obj_coeff = [5, 3] + assert expr.getVariables() == [x, y] + assert expr.getCoefficients() == expected_obj_coeff + assert expr.getConstant() == 50 + assert prob.ObjSense == sense.MAXIMIZE + assert prob.getObjective() is expr + + # Initialize Settings + settings = SolverSettings() + settings.set_parameter("time_limit", 5) + + assert not prob.solved + # Solving Problem + prob.solve(settings) + assert prob.solved + assert prob.Status.name == "Optimal" + assert prob.SolveTime < 5 + + csr = prob.getCSR() + expected_row_pointers = [0, 2, 4] + expected_column_indices = [0, 1, 0, 1] + expected_values = [2.0, 4.0, 3.0, 2.0] + + assert csr.row_pointers == expected_row_pointers + assert csr.column_indices == expected_column_indices + assert csr.values == expected_values + + expected_slack = [-6, 0] + expected_var_values = [36, 41] + + for i, var in enumerate(prob.getVariables()): + assert var.Value == pytest.approx(expected_var_values[i]) + assert var.getObjectiveCoefficient() == expected_obj_coeff[i] + + assert prob.ObjValue == 353 + + for i, c in enumerate(prob.getConstraints()): + assert c.Slack == pytest.approx(expected_slack[i]) + + assert hasattr(prob.SolutionStats, "mip_gap") + + # Change Objective + prob.setObjective(expr + 20, sense.MINIMIZE) + assert not prob.solved + + # Check if values reset + for i, var in enumerate(prob.getVariables()): + assert math.isnan(var.Value) and math.isnan(var.ReducedCost) + for i, c in enumerate(prob.getConstraints()): + assert math.isnan(c.Slack) and math.isnan(c.DualValue) + + # Change Problem to LP + x.VariableType = VType.CONTINUOUS + y.VariableType = CONTINUOUS + y.UB = 45.5 + assert not prob.IsMIP + + prob.solve(settings) + assert prob.solved + assert prob.Status.name == "Optimal" + assert hasattr(prob.SolutionStats, "primal_residual") + + assert x.getValue() == 24 + assert y.getValue() == pytest.approx(45.5) + + assert prob.ObjValue == pytest.approx(5 * x.Value + 3 * y.Value + 70) + + +def test_linear_expression(): + + prob = Problem() + + x = prob.addVariable() + y = prob.addVariable() + z = prob.addVariable() + + expr1 = 2 * x + 5 + 3 * y + expr2 = y - z + 2 * x - 3 + + expr3 = expr1 + expr2 + expr4 = expr1 - expr2 + + # Test expr1 and expr 2 is unchanged + assert expr1.getCoefficients() == [2, 3] + assert expr1.getVariables() == [x, y] + assert expr1.getConstant() == 5 + assert expr2.getCoefficients() == [1, -1, 2] + assert expr2.getVariables() == [y, z, x] + assert expr2.getConstant() == -3 + + # Testing add and sub + assert expr3.getCoefficients() == [2, 3, 1, -1, 2] + assert expr3.getVariables() == [x, y, y, z, x] + assert expr3.getConstant() == 2 + assert expr4.getCoefficients() == [2, 3, -1, 1, -2] + assert expr4.getVariables() == [x, y, y, z, x] + assert expr4.getConstant() == 8 + + expr5 = 8 * y - x - 5 + expr6 = expr5 / 2 + expr7 = expr5 * 2 + + # Test expr5 is unchanged + assert expr5.getCoefficients() == [8, -1] + assert expr5.getVariables() == [y, x] + assert expr5.getConstant() == -5 + + # Test mul and truediv + assert expr6.getCoefficients() == [4, -0.5] + assert expr6.getVariables() == [y, x] + assert expr6.getConstant() == -2.5 + assert expr7.getCoefficients() == [16, -2] + assert expr7.getVariables() == [y, x] + assert expr7.getConstant() == -10 + + expr6 *= 2 + expr7 /= 2 + + # Test imul and itruediv + assert expr6.getCoefficients() == [8, -1] + assert expr6.getVariables() == [y, x] + assert expr6.getConstant() == -5 + assert expr7.getCoefficients() == [8, -1] + assert expr7.getVariables() == [y, x] + assert expr7.getConstant() == -5 + + +def test_constraint_matrix(): + + prob = Problem() + + a = prob.addVariable(lb=0, ub=float("inf"), vtype="C", name="a") + b = prob.addVariable(lb=0, ub=float("inf"), vtype="C", name="b") + c = prob.addVariable(lb=0, ub=float("inf"), vtype="C", name="c") + d = prob.addVariable(lb=0, ub=float("inf"), vtype="C", name="d") + e = prob.addVariable(lb=0, ub=float("inf"), vtype="C", name="e") + f = prob.addVariable(lb=0, ub=float("inf"), vtype="C", name="f") + + # 2*a + 3*e + 1 + 4*d - 2*e + f - 8 <= 90 i.e. 2a + e + 4d + f <= 97 + prob.addConstraint(2 * a + 3 * e + 1 + 4 * d - 2 * e + f - 8 <= 90, "C1") + # d + 5*c - a - 4*d - 2 + 5*b - 20 >= 10 i.e. -3d + 5c - a + 5b >= 32 + prob.addConstraint(d + 5 * c - a - 4 * d - 2 + 5 * b - 20 >= 10, "C2") + # 7*f + 3 - 2*b + c == 3*f - 61 + 8*e i.e. 4f - 2b + c - 8e == -64 + prob.addConstraint(7 * f + 3 - 2 * b + c == 3 * f - 61 + 8 * e, "C3") + # a <= 5 + prob.addConstraint(a <= 5, "C4") + # d >= 7*f - b - 27 i.e. d - 7*f + b >= -27 + prob.addConstraint(d >= 7 * f - b - 27, "C5") + # c == e i.e. c - e == 0 + prob.addConstraint(c == e, "C6") + + sense = [] + rhs = [] + for c in prob.getConstraints(): + sense.append(c.Sense) + rhs.append(c.RHS) + + csr = prob.getCSR() + + exp_row_pointers = [0, 4, 8, 12, 13, 16, 18] + exp_column_indices = [0, 4, 3, 5, 2, 3, 0, 1, 5, 1, 2, 4, 0, 5, 1, 3, 2, 4] + exp_values = [ + 2.0, + 1.0, + 4.0, + 1.0, + 5.0, + -3.0, + -1.0, + 5.0, + 4.0, + -2.0, + 1.0, + -8.0, + 1.0, + -7.0, + 1.0, + 1.0, + 1.0, + -1.0, + ] + exp_sense = ["L", "G", "E", "L", "G", "E"] + exp_rhs = [97, 32, -64, 5, -27, 0] + + assert csr.row_pointers == exp_row_pointers + assert csr.column_indices == exp_column_indices + assert csr.values == exp_values + assert sense == exp_sense + assert rhs == exp_rhs + + +def test_incumbent_solutions(): + + # Callback for incumbent solution + class CustomGetSolutionCallback(GetSolutionCallback): + def __init__(self): + super().__init__() + self.n_callbacks = 0 + self.solutions = [] + + def get_solution(self, solution, solution_cost): + + self.n_callbacks += 1 + assert len(solution) > 0 + assert len(solution_cost) == 1 + + self.solutions.append( + { + "solution": solution.copy_to_host(), + "cost": solution_cost.copy_to_host()[0], + } + ) + + class CustomSetSolutionCallback(SetSolutionCallback): + def __init__(self, get_callback): + super().__init__() + self.n_callbacks = 0 + self.get_callback = get_callback + + def set_solution(self, solution, solution_cost): + self.n_callbacks += 1 + if self.get_callback.solutions: + solution[:] = self.get_callback.solutions[-1]["solution"] + solution_cost[0] = float( + self.get_callback.solutions[-1]["cost"] + ) + + prob = Problem() + x = prob.addVariable(vtype=VType.INTEGER) + y = prob.addVariable(vtype=VType.INTEGER) + prob.addConstraint(2 * x + 4 * y >= 230) + prob.addConstraint(3 * x + 2 * y <= 190) + prob.setObjective(5 * x + 3 * y, sense=sense.MAXIMIZE) + + get_callback = CustomGetSolutionCallback() + set_callback = CustomSetSolutionCallback(get_callback) + settings = SolverSettings() + settings.set_mip_callback(get_callback) + settings.set_mip_callback(set_callback) + settings.set_parameter("time_limit", 1) + + prob.solve(settings) + + assert get_callback.n_callbacks > 0 + + for sol in get_callback.solutions: + x_val = sol["solution"][0] + y_val = sol["solution"][1] + cost = sol["cost"] + assert 2 * x_val + 4 * y_val >= 230 + assert 3 * x_val + 2 * y_val <= 190 + assert 5 * x_val + 3 * y_val == cost