From 91df6aebd281f82e7d121cb6a3a1a91868e495f8 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 10 Jun 2025 15:54:09 -0500 Subject: [PATCH 01/19] Add a temporary fix to decouple thin client from cuopt --- .../linear_programming/solution/solution.py | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/solution/solution.py b/python/cuopt/cuopt/linear_programming/solution/solution.py index 952ecb73b9..b0b5afed7e 100644 --- a/python/cuopt/cuopt/linear_programming/solution/solution.py +++ b/python/cuopt/cuopt/linear_programming/solution/solution.py @@ -13,11 +13,51 @@ # See the License for the specific language governing permissions and # limitations under the License. -from cuopt.linear_programming.solver.solver_wrapper import ( - LPTerminationStatus, - MILPTerminationStatus, - ProblemCategory, -) +from enum import IntEnum + + +# TODO: Remove this once we have a way to not include main cuOpt +# libs in in thin client +class ProblemCategory(IntEnum): + """ + Problem category enum + """ + + LP = 0 + MIP = 1 + IP = 2 + + +# TODO: Remove this once we have a way to not include main cuOpt +# libs in in thin client +class LPTerminationStatus(IntEnum): + """ + LP termination status enum + """ + + NoTermination = 0 + NumericalError = 6 + Optimal = 1 + PrimalInfeasible = 2 + DualInfeasible = 3 + IterationLimit = 4 + TimeLimit = 5 + PrimalFeasible = 7 + + +# TODO: Remove this once we have a way to not include main cuOpt +# libs in in thin client +class MILPTerminationStatus(IntEnum): + """ + MILP termination status enum + """ + + NoTermination = 0 + Optimal = 1 + FeasibleFound = 8 + Infeasible = 2 + Unbounded = 3 + TimeLimit = 5 class PDLPWarmStartData: From ec141ddb201fa103401d8e43053771eb1a5230bf Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 10 Jun 2025 16:51:10 -0500 Subject: [PATCH 02/19] fix example --- .../cuopt/source/cuopt-server/examples/lp-examples.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst index 01bb356ffb..5136180ec5 100644 --- a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst +++ b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst @@ -521,17 +521,17 @@ Use a datamodel generated from mps file as input; this yields a solution object # Check found objective value print("Objective Value:") - print(solution_obj["primal_objective"]) + print(solution_obj.get_primal_objective()) # Check the MPS parse time print(f"Mps Parse time: {parse_time:.3f} sec") # Check network time (client call - solve time) - network_time = network_time - (solution_obj["solver_time"]) + network_time = network_time - (solution_obj.get_solve_time()) print(f"Network time: {network_time:.3f} sec") # Check solver time - solve_time = solution_obj["solver_time"] + solve_time = solution_obj.get_solve_time() print(f"Engine Solve time: {solve_time:.3f} sec") # Check the total end to end time (mps parsing + network + solve time) @@ -540,7 +540,7 @@ Use a datamodel generated from mps file as input; this yields a solution object # Print the found decision variables print("Variables Values:") - print(solution_obj["vars"]) + print(solution_obj.get_vars()) The response would be as follows: @@ -713,4 +713,4 @@ Aborting a Running Job In CLI Please refer to the `MILP Example `_ for more details. .. note:: - Please use solver settings while using .mps files. \ No newline at end of file + Please use solver settings while using .mps files. From 9fa14630af04b73ddc65e2b31cc293ac2fe8f35c Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 10 Jun 2025 21:31:44 -0500 Subject: [PATCH 03/19] change problem category and termination status to string --- .../cuopt-server/examples/lp-examples.rst | 3 +- .../linear_programming/solution/solution.py | 80 +++---------------- .../solver/solver_wrapper.pyx | 80 ++++++++++--------- .../linear_programming/test_lp_solver.py | 4 +- .../utils/linear_programming/solver.py | 17 ++-- 5 files changed, 64 insertions(+), 120 deletions(-) diff --git a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst index 5136180ec5..7b3f006b1b 100644 --- a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst +++ b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst @@ -515,8 +515,7 @@ Use a datamodel generated from mps file as input; this yields a solution object solution_obj = solution["response"]["solver_response"]["solution"] # Check Termination Reason - # For more detail on termination reasons: checkout `Solution.get_termination_reason()` - print("Termination Reason: (1 is Optimal)") + print("Termination Reason: ") print(solution_status) # Check found objective value diff --git a/python/cuopt/cuopt/linear_programming/solution/solution.py b/python/cuopt/cuopt/linear_programming/solution/solution.py index b0b5afed7e..f1b17a5278 100644 --- a/python/cuopt/cuopt/linear_programming/solution/solution.py +++ b/python/cuopt/cuopt/linear_programming/solution/solution.py @@ -13,52 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from enum import IntEnum - - -# TODO: Remove this once we have a way to not include main cuOpt -# libs in in thin client -class ProblemCategory(IntEnum): - """ - Problem category enum - """ - - LP = 0 - MIP = 1 - IP = 2 - - -# TODO: Remove this once we have a way to not include main cuOpt -# libs in in thin client -class LPTerminationStatus(IntEnum): - """ - LP termination status enum - """ - - NoTermination = 0 - NumericalError = 6 - Optimal = 1 - PrimalInfeasible = 2 - DualInfeasible = 3 - IterationLimit = 4 - TimeLimit = 5 - PrimalFeasible = 7 - - -# TODO: Remove this once we have a way to not include main cuOpt -# libs in in thin client -class MILPTerminationStatus(IntEnum): - """ - MILP termination status enum - """ - - NoTermination = 0 - Optimal = 1 - FeasibleFound = 8 - Infeasible = 2 - Unbounded = 3 - TimeLimit = 5 - class PDLPWarmStartData: def __init__( @@ -110,7 +64,7 @@ class Solution: Parameters ---------- - problem_category : int + problem_category : str Whether it is a LP-0, MIP-1 or IP-2 solution vars : Dict[str, float64] Dictionary mapping each variable (name) to its value. @@ -123,8 +77,8 @@ class Solution: Note: Applicable to only LP The reduced cost. It contains the dual multipliers for the linear constraints. - termination_status: Integer - Termination status value. + termination_status: str + Termination status. primal_residual: Float64 L2 norm of the primal residual: measurement of the primal infeasibility dual_residual: Float64 @@ -197,7 +151,7 @@ def __init__( last_restart_kkt_score=0.0, sum_solution_weight=0.0, iterations_since_last_restart=0, - termination_status=0, + termination_status="Error", error_status=0, error_message="", primal_residual=0.0, @@ -238,7 +192,7 @@ def __init__( sum_solution_weight, iterations_since_last_restart, ) - self._set_termination_status(termination_status) + self.termination_status = termination_status self.error_status = error_status self.error_message = error_message @@ -265,20 +219,14 @@ def __init__( "num_simplex_iterations": num_simplex_iterations, } - def _set_termination_status(self, ts): - if self.problem_category == ProblemCategory.LP: - self.termination_status = LPTerminationStatus(ts) - else: - self.termination_status = MILPTerminationStatus(ts) - def raise_if_milp_solution(self, function_name): - if self.problem_category in (ProblemCategory.MIP, ProblemCategory.IP): + if self.problem_category in ("MIP", "IP"): raise AttributeError( f"Attribute {function_name} is not supported for milp solution" ) def raise_if_lp_solution(self, function_name): - if self.problem_category == ProblemCategory.LP: + if self.problem_category == "LP": raise AttributeError( f"Attribute {function_name} is not supported for lp solution" ) @@ -313,16 +261,10 @@ def get_dual_objective(self): def get_termination_status(self): """ - Returns the termination status as per TerminationReason. + Returns the termination status. """ return self.termination_status - def get_termination_reason(self): - """ - Returns the termination reason as per TerminationReason. - """ - return self.termination_status.name - def get_error_status(self): """ Returns the error status as per ErrorStatus. @@ -446,9 +388,9 @@ def get_problem_category(self): """ Returns one of the problem category from ProblemCategory - LP - 0 - MIP - 1 - IP - 2 + LP + MIP + IP """ return self.problem_category diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx index 9ba65bbc08..1298a3abc7 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx @@ -465,12 +465,14 @@ cdef create_solution(unique_ptr[solver_ret_t] sol_ret_ptr, ) ).to_numpy() + problem_category = ProblemCategory(sol_ret.problem_type) + return Solution( - ProblemCategory(sol_ret.problem_type), + ProblemCategory(sol_ret.problem_type).name, dict(zip(data_model_obj.get_variable_names(), solution)), solve_time, primal_solution=solution, - termination_status=MILPTerminationStatus(termination_status), + termination_status=MILPTerminationStatus(termination_status).name, error_status=ErrorStatus(error_status), error_message=str(error_message), primal_objective=objective, @@ -616,48 +618,54 @@ cdef create_solution(unique_ptr[solver_ret_t] sol_ret_ptr, ).to_numpy() return Solution( - ProblemCategory(sol_ret.problem_type), + ProblemCategory(sol_ret.problem_type).name, dict(zip(data_model_obj.get_variable_names(), primal_solution)), # noqa - solve_time, - primal_solution, - dual_solution, - reduced_cost, - current_primal_solution, - current_dual_solution, - initial_primal_average, - initial_dual_average, - current_ATY, - sum_primal_solutions, - sum_dual_solutions, - last_restart_duality_gap_primal_solution, - last_restart_duality_gap_dual_solution, - initial_primal_weight, - initial_step_size, - total_pdlp_iterations, - total_pdhg_iterations, - last_candidate_kkt_score, - last_restart_kkt_score, - sum_solution_weight, - iterations_since_last_restart, - LPTerminationStatus(termination_status), - ErrorStatus(error_status), - str(error_message), - l2_primal_residual, - l2_dual_residual, - primal_objective, - dual_objective, - gap, - nb_iterations, - solved_by_pdlp, + solve_time=solve_time, + primal_solution=primal_solution, + dual_solution=dual_solution, + reduced_cost=reduced_cost, + current_primal_solution=current_primal_solution, + current_dual_solution=current_dual_solution, + initial_primal_average=initial_primal_average, + initial_dual_average=initial_dual_average, + current_ATY=current_ATY, + sum_primal_solutions=sum_primal_solutions, + sum_dual_solutions=sum_dual_solutions, + last_restart_duality_gap_primal_solution=( + last_restart_duality_gap_primal_solution + ), + last_restart_duality_gap_dual_solution=( + last_restart_duality_gap_dual_solution + ), + initial_primal_weight=initial_primal_weight, + initial_step_size=initial_step_size, + total_pdlp_iterations=total_pdlp_iterations, + total_pdhg_iterations=total_pdhg_iterations, + last_candidate_kkt_score=last_candidate_kkt_score, + last_restart_kkt_score=last_restart_kkt_score, + sum_solution_weight=sum_solution_weight, + iterations_since_last_restart=iterations_since_last_restart, + termination_status=( + LPTerminationStatus(termination_status).name + ), + error_status=ErrorStatus(error_status), + error_message=str(error_message), + l2_primal_residual=l2_primal_residual, + l2_dual_residual=l2_dual_residual, + primal_objective=primal_objective, + dual_objective=dual_objective, + gap=gap, + nb_iterations=nb_iterations, + solved_by_pdlp=solved_by_pdlp, ) return Solution( - problem_category=ProblemCategory(sol_ret.problem_type), + problem_category=ProblemCategory(sol_ret.problem_type).name, vars=dict(zip(data_model_obj.get_variable_names(), primal_solution)), # noqa solve_time=solve_time, primal_solution=primal_solution, dual_solution=dual_solution, reduced_cost=reduced_cost, - termination_status=LPTerminationStatus(termination_status), + termination_status=LPTerminationStatus(termination_status).name, error_status=ErrorStatus(error_status), error_message=str(error_message), primal_residual=l2_primal_residual, diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index eeb5400ad4..7196458ed8 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -77,7 +77,7 @@ def test_solver(): settings.set_parameter(CUOPT_METHOD, SolverMethod.PDLP) solution = solver.Solve(data_model_obj, settings) - assert solution.get_termination_reason() == "Optimal" + assert solution.get_termination_status() == "Optimal" assert solution.get_primal_solution()[0] == pytest.approx(0.0) assert solution.get_lp_stats()["primal_residual"] == pytest.approx(0.0) assert solution.get_lp_stats()["dual_residual"] == pytest.approx(0.0) @@ -95,7 +95,7 @@ def test_parser_and_solver(): settings = solver_settings.SolverSettings() settings.set_optimality_tolerance(1e-2) solution = solver.Solve(data_model_obj, settings) - assert solution.get_termination_reason() == "Optimal" + assert solution.get_termination_status() == "Optimal" def test_very_low_tolerance(): diff --git a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py index 5d16185955..5dbac0926b 100644 --- a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py +++ b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py @@ -44,11 +44,7 @@ CUOPT_RELATIVE_PRIMAL_TOLERANCE, CUOPT_TIME_LIMIT, ) -from cuopt.linear_programming.solver.solver_wrapper import ( - ErrorStatus, - LPTerminationStatus, - MILPTerminationStatus, -) +from cuopt.linear_programming.solver.solver_wrapper import ErrorStatus from cuopt.utilities import ( InputRuntimeError, InputValidationError, @@ -337,11 +333,10 @@ def create_solution(sol): solution = {} status = sol.get_termination_status() if status in ( - LPTerminationStatus.Optimal, - LPTerminationStatus.IterationLimit, - LPTerminationStatus.TimeLimit, - MILPTerminationStatus.Optimal, - MILPTerminationStatus.FeasibleFound, + "Optimal", + "IterationLimit", + "TimeLimit", + "FeasibleFound", ): primal_solution = get_if_attribute_is_valid_else_none( @@ -395,7 +390,7 @@ def create_solution(sol): "status": status, "solution": solution, } - notes.append(sol.get_termination_reason()) + notes.append(sol.get_termination_status()) return res try: From 3caa0f874d0e881f7979145250fc9cec85b64f6f Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 10 Jun 2025 21:56:13 -0500 Subject: [PATCH 04/19] fix tests --- .../solver/solver_wrapper.pyx | 4 ++-- .../test_incumbent_callbacks.py | 2 +- .../linear_programming/test_lp_solver.py | 19 ++++++++++++++----- .../cuopt_server/tests/test_lp.py | 18 +++++++++++------- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx index 1298a3abc7..9a87aaa809 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx @@ -650,8 +650,8 @@ cdef create_solution(unique_ptr[solver_ret_t] sol_ret_ptr, ), error_status=ErrorStatus(error_status), error_message=str(error_message), - l2_primal_residual=l2_primal_residual, - l2_dual_residual=l2_dual_residual, + primal_residual=l2_primal_residual, + dual_residual=l2_dual_residual, primal_objective=primal_objective, dual_objective=dual_objective, gap=gap, diff --git a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py index 73e2c95383..e84c138e65 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py @@ -94,7 +94,7 @@ def set_solution(self, solution, solution_cost): assert set_callback.n_callbacks > 0 assert ( solution.get_termination_status() - == MILPTerminationStatus.FeasibleFound + == MILPTerminationStatus.FeasibleFound.name ) for sol in get_callback.solutions: diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index 7196458ed8..f9118d549c 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -115,7 +115,9 @@ def test_very_low_tolerance(): expected_time = 69 - assert solution.get_termination_status() == LPTerminationStatus.Optimal + assert ( + solution.get_termination_status() == LPTerminationStatus.Optimal.name + ) assert solution.get_primal_objective() == pytest.approx(-464.7531) # Rougly up to 5 times slower on V100 assert solution.get_solve_time() <= expected_time * 5 @@ -136,7 +138,8 @@ def test_iteration_limit_solver(): solution = solver.Solve(data_model_obj, settings) assert ( - solution.get_termination_status() == LPTerminationStatus.IterationLimit + solution.get_termination_status() + == LPTerminationStatus.IterationLimit.name ) # Check we don't return empty (all 0) solution assert solution.get_primal_objective() != 0.0 @@ -159,7 +162,9 @@ def test_time_limit_solver(): settings.set_parameter(CUOPT_ITERATION_LIMIT, 99999999) solution = solver.Solve(data_model_obj, settings) - assert solution.get_termination_status() == LPTerminationStatus.TimeLimit + assert ( + solution.get_termination_status() == LPTerminationStatus.TimeLimit.name + ) # Check that around 200 ms has passed with some tolerance assert solution.get_solve_time() <= (time_limit_seconds * 10) * 1000 # Not all 0 @@ -601,7 +606,9 @@ def test_dual_simplex(): solution = solver.Solve(data_model_obj, settings) - assert solution.get_termination_status() == LPTerminationStatus.Optimal + assert ( + solution.get_termination_status() == LPTerminationStatus.Optimal.name + ) assert solution.get_primal_objective() == pytest.approx(-464.7531) assert not solution.get_solved_by_pdlp() @@ -695,7 +702,9 @@ def test_write_files(): solution = solver.Solve(afiro, settings) - assert solution.get_termination_status() == LPTerminationStatus.Optimal + assert ( + solution.get_termination_status() == LPTerminationStatus.Optimal.name + ) assert solution.get_primal_objective() == pytest.approx(-464.7531) assert os.path.isfile("afiro.sol") diff --git a/python/cuopt_server/cuopt_server/tests/test_lp.py b/python/cuopt_server/cuopt_server/tests/test_lp.py index 449756fe0d..8fc85aa3a0 100644 --- a/python/cuopt_server/cuopt_server/tests/test_lp.py +++ b/python/cuopt_server/cuopt_server/tests/test_lp.py @@ -32,7 +32,7 @@ def validate_lp_result( expected_termination_status, ): sol = res["solution"] - assert sol["problem_category"] == ProblemCategory.LP + assert sol["problem_category"] == ProblemCategory.LP.name assert res["status"] == expected_termination_status assert len(sol["lp_statistics"]) > 1 @@ -45,7 +45,10 @@ def validate_milp_result( expected_termination_status, ): sol = res["solution"] - assert sol["problem_category"] in (ProblemCategory.MIP, ProblemCategory.IP) + assert sol["problem_category"] in ( + ProblemCategory.MIP.name, + ProblemCategory.IP.name, + ) assert res["status"] == expected_termination_status assert len(sol["milp_statistics"].keys()) > 0 @@ -111,17 +114,18 @@ def test_sample_lp(cuoptproc): # noqa print(res.json()) validate_lp_result( - res.json()["response"]["solver_response"], LPTerminationStatus.Optimal + res.json()["response"]["solver_response"], + LPTerminationStatus.Optimal.name, ) @pytest.mark.parametrize( "maximize, scaling, expected_status, heuristics_only", [ - (True, True, MILPTerminationStatus.Optimal, True), - (False, True, MILPTerminationStatus.Optimal, False), - (True, False, MILPTerminationStatus.Optimal, True), - (False, False, MILPTerminationStatus.Optimal, False), + (True, True, MILPTerminationStatus.Optimal.name, True), + (False, True, MILPTerminationStatus.Optimal.name, False), + (True, False, MILPTerminationStatus.Optimal.name, True), + (False, False, MILPTerminationStatus.Optimal.name, False), ], ) def test_sample_milp( From 334a40478ddcb0495bbf879e8db28f6864aebc7d Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 10 Jun 2025 22:10:38 -0500 Subject: [PATCH 05/19] fix test --- python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py b/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py index 1e4eb4f880..8d67f4fe13 100644 --- a/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py +++ b/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py @@ -69,7 +69,7 @@ def test_warmstart(cuoptproc): # noqa assert res.status_code == 200 response = res.json()["response"]["solver_response"] reqId = res.json()["reqId"] - assert response["status"] == 1 + assert response["status"] == "Optimal" solve_2_iter = response["solution"]["lp_statistics"]["nb_iterations"] res = client.get( @@ -90,7 +90,7 @@ def test_warmstart(cuoptproc): # noqa ) assert res.status_code == 200 response = res.json()["response"]["solver_response"] - assert response["status"] == 1 + assert response["status"] == "Optimal" solve_3_iter = response["solution"]["lp_statistics"]["nb_iterations"] assert solve_3_iter + solve_2_iter == solve_1_iter From 4e4fa5fd657546b28f88d69fb86304d77b45c4e8 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 10 Jun 2025 22:22:41 -0500 Subject: [PATCH 06/19] fix test --- python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py b/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py index 8d67f4fe13..577bf2fca8 100644 --- a/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py +++ b/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py @@ -54,7 +54,7 @@ def test_warmstart(cuoptproc): # noqa ) assert res.status_code == 200 response = res.json()["response"]["solver_response"] - assert response["status"] == 1 + assert response["status"] == "Optimal" solve_1_iter = response["solution"]["lp_statistics"]["nb_iterations"] settings.set_optimality_tolerance(1e-3) From df9597ac23e5c01b211f4ff9d1c758fa671980f0 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 07:37:00 -0500 Subject: [PATCH 07/19] update tests --- ci/test_self_hosted_service.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ci/test_self_hosted_service.sh b/ci/test_self_hosted_service.sh index c64554883e..80015d673d 100755 --- a/ci/test_self_hosted_service.sh +++ b/ci/test_self_hosted_service.sh @@ -111,16 +111,16 @@ if [ "$doservertest" -eq 1 ]; then run_cli_test "'status': 0" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -f cuopt_problem_data.json # Success, small LP problem with pure JSON - run_cli_test "'status': 1" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/cuopt_service_data/good_lp.json + run_cli_test "'status': Optimal" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/cuopt_service_data/good_lp.json # Success, small MILP problem with pure JSON which returns a solution with Optimal status - run_cli_test "'status': 1" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT -t LP ../../datasets/mixed_integer_programming/milp_data.json + run_cli_test "'status': Optimal" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT -t LP ../../datasets/mixed_integer_programming/milp_data.json # Succes, small LP problem with mps. Data will be transformed to JSON - run_cli_test "'status': 1" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps + run_cli_test "'status': Optimal" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps # Succes, small Batch LP problem with mps. Data will be transformed to JSON - run_cli_test "'status': 1" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps ../../datasets/linear_programming/good-mps-1.mps + run_cli_test "'status': Optimal" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps ../../datasets/linear_programming/good-mps-1.mps # Error, local file mode is not allowed with mps run_cli_test "Cannot use local file mode with MPS data" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP -f good-mps-1.mps From 6a1450a17de4e5e21b03b8d65eef1bc619697385 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 07:39:57 -0500 Subject: [PATCH 08/19] update examples --- .../cuopt-server/examples/lp-examples.rst | 24 +++++++++---------- .../cuopt-server/examples/milp-examples.rst | 8 +++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst index 7b3f006b1b..6d5b780b6a 100644 --- a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst +++ b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst @@ -115,9 +115,9 @@ Normal mode response: { "response": { "solver_response": { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 0, + "problem_category": "LP", "primal_solution": [ 1.8, 0.0 @@ -159,9 +159,9 @@ Batch mode response: "response": { "solver_response": [ { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 0, + "problem_category": "LP", "primal_solution": [ 1.8, 0.0 @@ -188,9 +188,9 @@ Batch mode response: } }, { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 0, + "problem_category": "LP", "primal_solution": [ 1.8, 0.0 @@ -294,9 +294,9 @@ The response would be as follows: { "response": { "solver_response": { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 0, + "problem_category": "LP", "primal_solution": [ 1.8, 0.0 @@ -388,9 +388,9 @@ The response is: { "response": { "solver_response": { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 0, + "problem_category": "LP", "primal_solution": [ 1.8, 0.0 @@ -624,9 +624,9 @@ Response is as follows: { "response": { "solver_response": { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 0, + "problem_category": "LP", "primal_solution": [1.8, 0.0], "dual_solution": [-0.06666666666666668, 0.0], "primal_objective": -0.36000000000000004, diff --git a/docs/cuopt/source/cuopt-server/examples/milp-examples.rst b/docs/cuopt/source/cuopt-server/examples/milp-examples.rst index c29c693a5b..be76fb97e7 100644 --- a/docs/cuopt/source/cuopt-server/examples/milp-examples.rst +++ b/docs/cuopt/source/cuopt-server/examples/milp-examples.rst @@ -86,9 +86,9 @@ The response would be as follows: { "response": { "solver_response": { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 1, + "problem_category": "MIP", "primal_solution": [ 0.0, 5000.0 @@ -210,9 +210,9 @@ Incumbent callback response: { "response": { "solver_response": { - "status": 1, + "status": "Optimal", "solution": { - "problem_category": 1, + "problem_category": "MIP", "primal_solution": [ 0.0, 5000.0 From d7f9eec8fc3d31784162df82746f38f9e0261fba Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 08:25:11 -0500 Subject: [PATCH 09/19] Add AMPL example notebook link to doc --- docs/cuopt/source/thirdparty_modeling_languages/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cuopt/source/thirdparty_modeling_languages/index.rst b/docs/cuopt/source/thirdparty_modeling_languages/index.rst index 8a5024e9ea..a6ba110f04 100644 --- a/docs/cuopt/source/thirdparty_modeling_languages/index.rst +++ b/docs/cuopt/source/thirdparty_modeling_languages/index.rst @@ -7,7 +7,7 @@ Third-Party Modeling Languages AMPL Support -------------------------- -AMPL can be used with near zero code changes: simply switch to cuOpt as a solver to solve linear and mixed-integer programming problems. Please refer to the `AMPL documentation `_ for more information. +AMPL can be used with near zero code changes: simply switch to cuOpt as a solver to solve linear and mixed-integer programming problems. Please refer to the `AMPL documentation `_ for more information. Also, see the example notebook in the `colab `_. -------------------------- PuLP Support From e185f7876c4506afff693dc522e40d2860d8c56f Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 09:49:55 -0500 Subject: [PATCH 10/19] fix server tests --- ci/test_self_hosted_service.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ci/test_self_hosted_service.sh b/ci/test_self_hosted_service.sh index 80015d673d..280c428aa2 100755 --- a/ci/test_self_hosted_service.sh +++ b/ci/test_self_hosted_service.sh @@ -111,16 +111,16 @@ if [ "$doservertest" -eq 1 ]; then run_cli_test "'status': 0" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -f cuopt_problem_data.json # Success, small LP problem with pure JSON - run_cli_test "'status': Optimal" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/cuopt_service_data/good_lp.json + run_cli_test "'status': 'Optimal'" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/cuopt_service_data/good_lp.json # Success, small MILP problem with pure JSON which returns a solution with Optimal status - run_cli_test "'status': Optimal" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT -t LP ../../datasets/mixed_integer_programming/milp_data.json + run_cli_test "'status': 'Optimal'" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT -t LP ../../datasets/mixed_integer_programming/milp_data.json # Succes, small LP problem with mps. Data will be transformed to JSON - run_cli_test "'status': Optimal" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps + run_cli_test "'status': 'Optimal'" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps # Succes, small Batch LP problem with mps. Data will be transformed to JSON - run_cli_test "'status': Optimal" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps ../../datasets/linear_programming/good-mps-1.mps + run_cli_test "'status': 'Optimal'" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps ../../datasets/linear_programming/good-mps-1.mps # Error, local file mode is not allowed with mps run_cli_test "Cannot use local file mode with MPS data" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP -f good-mps-1.mps From d22d556e798e58d9cc8727f9a6003983d10e6908 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 11:43:08 -0500 Subject: [PATCH 11/19] reset and add new solutions file --- .../cuopt/linear_programming/pyproject.toml | 2 +- .../linear_programming/solution/solution.py | 40 ++- .../solver/solver_wrapper.pyx | 80 ++--- .../cuopt_sh_client/__init__.py | 2 + .../cuopt_sh_client/cuopt_self_host_client.py | 13 +- .../cuopt_sh_client/thin_client_solution.py | 315 ++++++++++++++++++ .../utils/linear_programming/solver.py | 17 +- 7 files changed, 401 insertions(+), 68 deletions(-) create mode 100644 python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py diff --git a/python/cuopt/cuopt/linear_programming/pyproject.toml b/python/cuopt/cuopt/linear_programming/pyproject.toml index 47c94b139f..e3ef785c07 100644 --- a/python/cuopt/cuopt/linear_programming/pyproject.toml +++ b/python/cuopt/cuopt/linear_programming/pyproject.toml @@ -55,7 +55,7 @@ cmake.version = "CMakeLists.txt" minimum-version = "build-system.requires" ninja.make-fallback = false sdist.reproducible = true -wheel.packages=["data_model", "solution", "cuopt_mps_parser"] +wheel.packages=["data_model", "cuopt_mps_parser"] [tool.scikit-build.metadata.version] provider = "scikit_build_core.metadata.regex" diff --git a/python/cuopt/cuopt/linear_programming/solution/solution.py b/python/cuopt/cuopt/linear_programming/solution/solution.py index f1b17a5278..952ecb73b9 100644 --- a/python/cuopt/cuopt/linear_programming/solution/solution.py +++ b/python/cuopt/cuopt/linear_programming/solution/solution.py @@ -13,6 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from cuopt.linear_programming.solver.solver_wrapper import ( + LPTerminationStatus, + MILPTerminationStatus, + ProblemCategory, +) + class PDLPWarmStartData: def __init__( @@ -64,7 +70,7 @@ class Solution: Parameters ---------- - problem_category : str + problem_category : int Whether it is a LP-0, MIP-1 or IP-2 solution vars : Dict[str, float64] Dictionary mapping each variable (name) to its value. @@ -77,8 +83,8 @@ class Solution: Note: Applicable to only LP The reduced cost. It contains the dual multipliers for the linear constraints. - termination_status: str - Termination status. + termination_status: Integer + Termination status value. primal_residual: Float64 L2 norm of the primal residual: measurement of the primal infeasibility dual_residual: Float64 @@ -151,7 +157,7 @@ def __init__( last_restart_kkt_score=0.0, sum_solution_weight=0.0, iterations_since_last_restart=0, - termination_status="Error", + termination_status=0, error_status=0, error_message="", primal_residual=0.0, @@ -192,7 +198,7 @@ def __init__( sum_solution_weight, iterations_since_last_restart, ) - self.termination_status = termination_status + self._set_termination_status(termination_status) self.error_status = error_status self.error_message = error_message @@ -219,14 +225,20 @@ def __init__( "num_simplex_iterations": num_simplex_iterations, } + def _set_termination_status(self, ts): + if self.problem_category == ProblemCategory.LP: + self.termination_status = LPTerminationStatus(ts) + else: + self.termination_status = MILPTerminationStatus(ts) + def raise_if_milp_solution(self, function_name): - if self.problem_category in ("MIP", "IP"): + if self.problem_category in (ProblemCategory.MIP, ProblemCategory.IP): raise AttributeError( f"Attribute {function_name} is not supported for milp solution" ) def raise_if_lp_solution(self, function_name): - if self.problem_category == "LP": + if self.problem_category == ProblemCategory.LP: raise AttributeError( f"Attribute {function_name} is not supported for lp solution" ) @@ -261,10 +273,16 @@ def get_dual_objective(self): def get_termination_status(self): """ - Returns the termination status. + Returns the termination status as per TerminationReason. """ return self.termination_status + def get_termination_reason(self): + """ + Returns the termination reason as per TerminationReason. + """ + return self.termination_status.name + def get_error_status(self): """ Returns the error status as per ErrorStatus. @@ -388,9 +406,9 @@ def get_problem_category(self): """ Returns one of the problem category from ProblemCategory - LP - MIP - IP + LP - 0 + MIP - 1 + IP - 2 """ return self.problem_category diff --git a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx index 9a87aaa809..9ba65bbc08 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx @@ -465,14 +465,12 @@ cdef create_solution(unique_ptr[solver_ret_t] sol_ret_ptr, ) ).to_numpy() - problem_category = ProblemCategory(sol_ret.problem_type) - return Solution( - ProblemCategory(sol_ret.problem_type).name, + ProblemCategory(sol_ret.problem_type), dict(zip(data_model_obj.get_variable_names(), solution)), solve_time, primal_solution=solution, - termination_status=MILPTerminationStatus(termination_status).name, + termination_status=MILPTerminationStatus(termination_status), error_status=ErrorStatus(error_status), error_message=str(error_message), primal_objective=objective, @@ -618,54 +616,48 @@ cdef create_solution(unique_ptr[solver_ret_t] sol_ret_ptr, ).to_numpy() return Solution( - ProblemCategory(sol_ret.problem_type).name, + ProblemCategory(sol_ret.problem_type), dict(zip(data_model_obj.get_variable_names(), primal_solution)), # noqa - solve_time=solve_time, - primal_solution=primal_solution, - dual_solution=dual_solution, - reduced_cost=reduced_cost, - current_primal_solution=current_primal_solution, - current_dual_solution=current_dual_solution, - initial_primal_average=initial_primal_average, - initial_dual_average=initial_dual_average, - current_ATY=current_ATY, - sum_primal_solutions=sum_primal_solutions, - sum_dual_solutions=sum_dual_solutions, - last_restart_duality_gap_primal_solution=( - last_restart_duality_gap_primal_solution - ), - last_restart_duality_gap_dual_solution=( - last_restart_duality_gap_dual_solution - ), - initial_primal_weight=initial_primal_weight, - initial_step_size=initial_step_size, - total_pdlp_iterations=total_pdlp_iterations, - total_pdhg_iterations=total_pdhg_iterations, - last_candidate_kkt_score=last_candidate_kkt_score, - last_restart_kkt_score=last_restart_kkt_score, - sum_solution_weight=sum_solution_weight, - iterations_since_last_restart=iterations_since_last_restart, - termination_status=( - LPTerminationStatus(termination_status).name - ), - error_status=ErrorStatus(error_status), - error_message=str(error_message), - primal_residual=l2_primal_residual, - dual_residual=l2_dual_residual, - primal_objective=primal_objective, - dual_objective=dual_objective, - gap=gap, - nb_iterations=nb_iterations, - solved_by_pdlp=solved_by_pdlp, + solve_time, + primal_solution, + dual_solution, + reduced_cost, + current_primal_solution, + current_dual_solution, + initial_primal_average, + initial_dual_average, + current_ATY, + sum_primal_solutions, + sum_dual_solutions, + last_restart_duality_gap_primal_solution, + last_restart_duality_gap_dual_solution, + initial_primal_weight, + initial_step_size, + total_pdlp_iterations, + total_pdhg_iterations, + last_candidate_kkt_score, + last_restart_kkt_score, + sum_solution_weight, + iterations_since_last_restart, + LPTerminationStatus(termination_status), + ErrorStatus(error_status), + str(error_message), + l2_primal_residual, + l2_dual_residual, + primal_objective, + dual_objective, + gap, + nb_iterations, + solved_by_pdlp, ) return Solution( - problem_category=ProblemCategory(sol_ret.problem_type).name, + problem_category=ProblemCategory(sol_ret.problem_type), vars=dict(zip(data_model_obj.get_variable_names(), primal_solution)), # noqa solve_time=solve_time, primal_solution=primal_solution, dual_solution=dual_solution, reduced_cost=reduced_cost, - termination_status=LPTerminationStatus(termination_status).name, + termination_status=LPTerminationStatus(termination_status), error_status=ErrorStatus(error_status), error_message=str(error_message), primal_residual=l2_primal_residual, diff --git a/python/cuopt_self_hosted/cuopt_sh_client/__init__.py b/python/cuopt_self_hosted/cuopt_sh_client/__init__.py index 6dfc92bf29..da8da64299 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/__init__.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/__init__.py @@ -27,3 +27,5 @@ SolverMethod, ThinClientSolverSettings, ) + +from .thin_client_solution import ThinClientSolution diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py index 03a3e23a3f..049d43310d 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py @@ -32,6 +32,7 @@ from . import _version from .thin_client_solver_settings import ThinClientSolverSettings +from .thin_client_solution import ThinClientSolution msgpack_numpy.patch() @@ -173,8 +174,8 @@ def create_solution_obj(solver_response): problem_category = sol["problem_category"] # MILP - if problem_category in [1, 2]: - solution_obj = solution.Solution( + if problem_category in ["MIP", "IP"]: + solution_obj = ThinClientSolution( problem_category, sol["vars"], solve_time=sol["solver_time"], @@ -198,7 +199,7 @@ def create_solution_obj(solver_response): ], ) else: - solution_obj = solution.Solution( + solution_obj = ThinClientSolution( problem_category, sol["vars"], solve_time=sol["solver_time"], @@ -716,7 +717,7 @@ def get_LP_solve( See the LP documentation for details on solver settings. response_type : str Choose "dict" if response should be returned as a dictionary or - "obj" for Solution object. Defaults to "obj" + "obj" for ThinClientSolution object. Defaults to "obj" filepath : boolean Indicates that cuopt_problem_json_data is the relative path of a cuopt data file under the server's @@ -750,7 +751,7 @@ def get_LP_solve( a list of strings. The LP solver will not return any incumbent solutions. Default is None. - Returns: dict or Solution object. + Returns: dict or ThinClientSolution object. """ if incumbent_callback is not None and not callable(incumbent_callback): @@ -923,7 +924,7 @@ def repoll(self, data, response_type="obj", delete_solution=True): containing the key 'reqId' where the value is the uuid. response_type: str For LP problem choose "dict" if response should be returned - as a dictionary or "obj" for Solution object. + as a dictionary or "obj" for ThinClientSolution object. Defaults to "obj". For VRP problem, response_type is ignored and always returns a dict. diff --git a/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py new file mode 100644 index 0000000000..b24d2ab201 --- /dev/null +++ b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py @@ -0,0 +1,315 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-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. + +class ThinClientSolution: + """ + A container of LP solver output + + Parameters + ---------- + problem_category : str + Whether it is a LP, MIP or IP solution + vars : Dict[str, float64] + Dictionary mapping each variable (name) to its value. + primal_solution : numpy.array + Primal solution of the LP problem + dual_solution : numpy.array + Note: Applicable to only LP + Dual solution of the LP problem + reduced_cost : numpy.array + Note: Applicable to only LP + The reduced cost. + It contains the dual multipliers for the linear constraints. + termination_status: str + Termination status . + primal_residual: Float64 + L2 norm of the primal residual: measurement of the primal infeasibility + dual_residual: Float64 + Note: Applicable to only LP + L2 norm of the dual residual: measurement of the dual infeasibility + primal_objective: Float64 + Value of the primal objective + dual_objective: Float64 + Note: Applicable to only LP + Value of the dual objective + gap: Float64 + Difference between the primal and dual objective + nb_iterations: Int + Number of iterations the LP solver did before converging + mip_gap: float64 + Note: Applicable to only MILP + The relative difference between the best integer objective value + found so far and the objective bound. A value of 0.01 means the + solution is guaranteed to be within 1% of optimal. + solution_bound: float64 + Note: Applicable to only MILP + The best known bound on the optimal objective value. + For minimization problems, this is a lower bound on the optimal value. + For maximization problems, this is an upper bound. + max_constraint_violation: float64 + Note: Applicable to only MILP + The maximum amount by which any constraint is violated in + the current solution. Should be close to zero for a feasible solution. + max_int_violation: float64 + Note: Applicable to only MILP + The maximum amount by which any integer variable deviates from being + an integer. A value of 0 means all integer variables have + integral values. + max_variable_bound_violation: float64 + Note: Applicable to only MILP + The maximum amount by which any variable violates its upper or + lower bounds in the current solution. Should be zero for a + feasible solution. + presolve_time: float64 + Note: Applicable to only MILP + Time used for pre-solve + solve_time: Float64 + Solve time in seconds + solved_by_pdlp: bool + Whether the problem was solved by PDLP or Dual Simplex + """ + + def __init__( + self, + problem_category, + vars, + solve_time=0.0, + primal_solution=None, + dual_solution=None, + reduced_cost=None, + termination_status="Error", + error_status=0, + error_message="", + primal_residual=0.0, + dual_residual=0.0, + primal_objective=0.0, + dual_objective=0.0, + gap=0.0, + nb_iterations=0, + solved_by_pdlp=None, + mip_gap=0.0, + solution_bound=0.0, + presolve_time=0.0, + max_constraint_violation=0.0, + max_int_violation=0.0, + max_variable_bound_violation=0.0, + num_nodes=0, + num_simplex_iterations=0, + ): + self.problem_category = problem_category + self.primal_solution = primal_solution + self.dual_solution = dual_solution + self.termination_status = termination_status + self.error_status = error_status + self.error_message = error_message + + self.primal_objective = primal_objective + self.dual_objective = dual_objective + self.solve_time = solve_time + self.solved_by_pdlp = solved_by_pdlp + self.vars = vars + self.lp_stats = { + "primal_residual": primal_residual, + "dual_residual": dual_residual, + "gap": gap, + "nb_iterations": nb_iterations, + } + self.reduced_cost = reduced_cost + self.milp_stats = { + "mip_gap": mip_gap, + "solution_bound": solution_bound, + "presolve_time": presolve_time, + "max_constraint_violation": max_constraint_violation, + "max_int_violation": max_int_violation, + "max_variable_bound_violation": max_variable_bound_violation, + "num_nodes": num_nodes, + "num_simplex_iterations": num_simplex_iterations, + } + + def raise_if_milp_solution(self, function_name): + if self.problem_category in (ProblemCategory.MIP, ProblemCategory.IP): + raise AttributeError( + f"Attribute {function_name} is not supported for milp solution" + ) + + def raise_if_lp_solution(self, function_name): + if self.problem_category == ProblemCategory.LP: + raise AttributeError( + f"Attribute {function_name} is not supported for lp solution" + ) + + def get_primal_solution(self): + """ + Returns the primal solution as numpy.array with float64 type. + """ + return self.primal_solution + + def get_dual_solution(self): + """ + Note: Applicable to only LP + Returns the dual solution as numpy.array with float64 type. + """ + self.raise_if_milp_solution(__name__) + return self.dual_solution + + def get_primal_objective(self): + """ + Returns the primal objective as a float64. + """ + return self.primal_objective + + def get_dual_objective(self): + """ + Note: Applicable to only LP + Returns the dual objective as a float64. + """ + self.raise_if_milp_solution(__name__) + return self.dual_objective + + def get_termination_status(self): + """ + Returns the termination status. + """ + return self.termination_status + + def get_error_status(self): + """ + Returns the error status as per ErrorStatus. + """ + return self.error_status + + def get_error_message(self): + """ + Returns the error message as per ErrorMessage. + """ + return self.error_message + + def get_solve_time(self): + """ + Returns the engine solve time in seconds as a float64. + """ + return self.solve_time + + def get_solved_by_pdlp(self): + """ + Returns whether the problem was solved by PDLP or Dual Simplex + """ + return self.solved_by_pdlp + + def get_vars(self): + """ + Returns the dictionnary mapping each variable (name) to its value. + """ + return self.vars + + def get_lp_stats(self): + """ + Note: Applicable to only LP + Returns the convergence statistics as a dictionary: + + "primal_residual": float64 + Measurement of the primal infeasibility. + This quantity is being reduced until primal tolerance is met + (see SolverSettings primal_tolerance). + + "dual_residual": float64, + Measurement of the dual infeasibility. + This quantity is being reduced until dual tolerance is met + (see SolverSettings dual_tolerance). + + "gap": float64 + Difference between the primal and dual objective. + This quantity is being reduced until gap tolerance is met + (see SolverSettings gap_tolerance). + + - "nb_iterations": int + Number of iterations the LP solver did before converging. + """ + + self.raise_if_milp_solution(__name__) + + return self.lp_stats + + def get_reduced_cost(self): + """ + Returns the reduced cost as numpy.array with float64 type. + """ + return self.reduced_cost + + def get_pdlp_warm_start_data(self): + """ + Note: Applicable to only LP + + Allows to retrieve the warm start data from the PDLP solver. + + See `SolverSettings.set_pdlp_warm_start_data` for more details. + """ + return self.pdlp_warm_start_data + + def get_milp_stats(self): + """ + Note: Applicable to only MILP + Returns the convergence statistics as a dictionary: + + mip_gap: float64 + The relative difference between the best integer objective value + found so far and the objective bound. A value of 0.01 means the + solution is guaranteed to be within 1% of optimal. + + presolve_time: float64 + Time took for pre-solve + + max_constraint_violation: float64 + The maximum amount by which any constraint is violated + in the current solution. + Should be close to zero for a feasible solution + . + max_int_violation: float64 + The maximum amount by which any integer variable deviates + from being an integer. A value of 0 means all integer variables + have integral values. + + max_variable_bound_violation: float64 + The maximum amount by which any variable violates + its upper or lower bounds in the current solution. + Should be zero for a feasible solution. + + solution_bound: float64 + The best known bound on the optimal objective value. + For minimization problems, this is a lower bound on the optimal + value. + For maximization problems, this is an upper bound. + + num_nodes: int + Number of nodes explored during the MIP solve + + num_simplex_iterations: int + Number of simplex iterations performed during the MIP solve + """ + + self.raise_if_lp_solution(__name__) + + return self.milp_stats + + def get_problem_category(self): + """ + Returns one of the problem category from ProblemCategory + + LP + MIP + IP + """ + + return self.problem_category diff --git a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py index 5dbac0926b..5d16185955 100644 --- a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py +++ b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py @@ -44,7 +44,11 @@ CUOPT_RELATIVE_PRIMAL_TOLERANCE, CUOPT_TIME_LIMIT, ) -from cuopt.linear_programming.solver.solver_wrapper import ErrorStatus +from cuopt.linear_programming.solver.solver_wrapper import ( + ErrorStatus, + LPTerminationStatus, + MILPTerminationStatus, +) from cuopt.utilities import ( InputRuntimeError, InputValidationError, @@ -333,10 +337,11 @@ def create_solution(sol): solution = {} status = sol.get_termination_status() if status in ( - "Optimal", - "IterationLimit", - "TimeLimit", - "FeasibleFound", + LPTerminationStatus.Optimal, + LPTerminationStatus.IterationLimit, + LPTerminationStatus.TimeLimit, + MILPTerminationStatus.Optimal, + MILPTerminationStatus.FeasibleFound, ): primal_solution = get_if_attribute_is_valid_else_none( @@ -390,7 +395,7 @@ def create_solution(sol): "status": status, "solution": solution, } - notes.append(sol.get_termination_status()) + notes.append(sol.get_termination_reason()) return res try: From 0e790a5aeffef3bbccfa9a992fa0829372a227bd Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 11:46:26 -0500 Subject: [PATCH 12/19] update server to send string --- .../cuopt_server/utils/linear_programming/solver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py index 5d16185955..fcb9d07649 100644 --- a/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py +++ b/python/cuopt_server/cuopt_server/utils/linear_programming/solver.py @@ -370,7 +370,7 @@ def create_solution(sol): milp_stats = get_if_attribute_is_valid_else_none( sol.get_milp_stats ) - solution["problem_category"] = sol.get_problem_category() + solution["problem_category"] = sol.get_problem_category().name solution["primal_solution"] = primal_solution solution["dual_solution"] = dual_solution solution["primal_objective"] = get_if_attribute_is_valid_else_none( @@ -392,7 +392,7 @@ def create_solution(sol): ) res = { - "status": status, + "status": status.name, "solution": solution, } notes.append(sol.get_termination_reason()) From e8ff1401dd86fed3ae6107e6d53640fde280f19b Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 11:47:09 -0500 Subject: [PATCH 13/19] style --- python/cuopt_self_hosted/cuopt_sh_client/__init__.py | 3 +-- .../cuopt_sh_client/cuopt_self_host_client.py | 2 +- .../cuopt_sh_client/thin_client_solution.py | 5 +++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python/cuopt_self_hosted/cuopt_sh_client/__init__.py b/python/cuopt_self_hosted/cuopt_sh_client/__init__.py index da8da64299..38069d79e9 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/__init__.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/__init__.py @@ -22,10 +22,9 @@ mime_type, set_log_level, ) +from .thin_client_solution import ThinClientSolution from .thin_client_solver_settings import ( PDLPSolverMode, SolverMethod, ThinClientSolverSettings, ) - -from .thin_client_solution import ThinClientSolution diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py index 049d43310d..e476916cea 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py @@ -31,8 +31,8 @@ import solution from . import _version -from .thin_client_solver_settings import ThinClientSolverSettings from .thin_client_solution import ThinClientSolution +from .thin_client_solver_settings import ThinClientSolverSettings msgpack_numpy.patch() diff --git a/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py index b24d2ab201..45cee0c1bc 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + class ThinClientSolution: """ A container of LP solver output @@ -307,9 +308,9 @@ def get_problem_category(self): """ Returns one of the problem category from ProblemCategory - LP + LP MIP - IP + IP """ return self.problem_category From a540e4781c2f41ba3b9943e8026f2c14bd3d0b80 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 11:49:04 -0500 Subject: [PATCH 14/19] style --- .../cuopt_sh_client/cuopt_self_host_client.py | 1 - .../cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py index e476916cea..7418516f99 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py @@ -28,7 +28,6 @@ import msgpack_numpy import numpy as np import requests -import solution from . import _version from .thin_client_solution import ThinClientSolution diff --git a/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py index 45cee0c1bc..a27e1d8aea 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py @@ -140,13 +140,13 @@ def __init__( } def raise_if_milp_solution(self, function_name): - if self.problem_category in (ProblemCategory.MIP, ProblemCategory.IP): + if self.problem_category in ("MIP", "IP"): raise AttributeError( f"Attribute {function_name} is not supported for milp solution" ) def raise_if_lp_solution(self, function_name): - if self.problem_category == ProblemCategory.LP: + if self.problem_category == "LP": raise AttributeError( f"Attribute {function_name} is not supported for lp solution" ) From 2d3355fb01fa821df93d33ccbeecabb3710aed8a Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 11:57:32 -0500 Subject: [PATCH 15/19] update Thin client docs --- docs/cuopt/source/cuopt-server/client-api/sh-cli-api.rst | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/cuopt/source/cuopt-server/client-api/sh-cli-api.rst b/docs/cuopt/source/cuopt-server/client-api/sh-cli-api.rst index 443a6b5810..28374ed165 100644 --- a/docs/cuopt/source/cuopt-server/client-api/sh-cli-api.rst +++ b/docs/cuopt/source/cuopt-server/client-api/sh-cli-api.rst @@ -25,11 +25,6 @@ LP Supporting Classes :undoc-members: :no-inherited-members: -.. autoclass:: solution.solution.PDLPWarmStartData - :members: - :undoc-members: - :no-inherited-members: - .. autoclass:: data_model.DataModel :members: :undoc-members: @@ -38,11 +33,10 @@ LP Supporting Classes :members: :undoc-members: -.. autoclass:: solution.Solution +.. autoclass:: cuopt_sh_client.ThinClientSolution :members: :undoc-members: - Self-Hosted Client CLI ---------------------- From 4932ed0edf311383d243e702d940f4358f9de0c5 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 12:12:48 -0500 Subject: [PATCH 16/19] fix tests --- .../test_incumbent_callbacks.py | 2 +- .../linear_programming/test_lp_solver.py | 23 ++++++------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py index e84c138e65..73e2c95383 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py @@ -94,7 +94,7 @@ def set_solution(self, solution, solution_cost): assert set_callback.n_callbacks > 0 assert ( solution.get_termination_status() - == MILPTerminationStatus.FeasibleFound.name + == MILPTerminationStatus.FeasibleFound ) for sol in get_callback.solutions: diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index f9118d549c..eeb5400ad4 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -77,7 +77,7 @@ def test_solver(): settings.set_parameter(CUOPT_METHOD, SolverMethod.PDLP) solution = solver.Solve(data_model_obj, settings) - assert solution.get_termination_status() == "Optimal" + assert solution.get_termination_reason() == "Optimal" assert solution.get_primal_solution()[0] == pytest.approx(0.0) assert solution.get_lp_stats()["primal_residual"] == pytest.approx(0.0) assert solution.get_lp_stats()["dual_residual"] == pytest.approx(0.0) @@ -95,7 +95,7 @@ def test_parser_and_solver(): settings = solver_settings.SolverSettings() settings.set_optimality_tolerance(1e-2) solution = solver.Solve(data_model_obj, settings) - assert solution.get_termination_status() == "Optimal" + assert solution.get_termination_reason() == "Optimal" def test_very_low_tolerance(): @@ -115,9 +115,7 @@ def test_very_low_tolerance(): expected_time = 69 - assert ( - solution.get_termination_status() == LPTerminationStatus.Optimal.name - ) + assert solution.get_termination_status() == LPTerminationStatus.Optimal assert solution.get_primal_objective() == pytest.approx(-464.7531) # Rougly up to 5 times slower on V100 assert solution.get_solve_time() <= expected_time * 5 @@ -138,8 +136,7 @@ def test_iteration_limit_solver(): solution = solver.Solve(data_model_obj, settings) assert ( - solution.get_termination_status() - == LPTerminationStatus.IterationLimit.name + solution.get_termination_status() == LPTerminationStatus.IterationLimit ) # Check we don't return empty (all 0) solution assert solution.get_primal_objective() != 0.0 @@ -162,9 +159,7 @@ def test_time_limit_solver(): settings.set_parameter(CUOPT_ITERATION_LIMIT, 99999999) solution = solver.Solve(data_model_obj, settings) - assert ( - solution.get_termination_status() == LPTerminationStatus.TimeLimit.name - ) + assert solution.get_termination_status() == LPTerminationStatus.TimeLimit # Check that around 200 ms has passed with some tolerance assert solution.get_solve_time() <= (time_limit_seconds * 10) * 1000 # Not all 0 @@ -606,9 +601,7 @@ def test_dual_simplex(): solution = solver.Solve(data_model_obj, settings) - assert ( - solution.get_termination_status() == LPTerminationStatus.Optimal.name - ) + assert solution.get_termination_status() == LPTerminationStatus.Optimal assert solution.get_primal_objective() == pytest.approx(-464.7531) assert not solution.get_solved_by_pdlp() @@ -702,9 +695,7 @@ def test_write_files(): solution = solver.Solve(afiro, settings) - assert ( - solution.get_termination_status() == LPTerminationStatus.Optimal.name - ) + assert solution.get_termination_status() == LPTerminationStatus.Optimal assert solution.get_primal_objective() == pytest.approx(-464.7531) assert os.path.isfile("afiro.sol") From 1368fbd2117bf7c51790abbce3ddbd80c9fcf709 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 13:41:06 -0500 Subject: [PATCH 17/19] update self hosted test --- ci/test_self_hosted_service.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/test_self_hosted_service.sh b/ci/test_self_hosted_service.sh index 280c428aa2..48a91dd4ca 100755 --- a/ci/test_self_hosted_service.sh +++ b/ci/test_self_hosted_service.sh @@ -167,7 +167,7 @@ if [ "$doservertest" -eq 1 ]; then requestid=$(echo "$cli_test" | tail -1) requestid=$(echo ${requestid#cuopt_sh } | sed "s/-p $CUOPT_SERVER_PORT//g" | tr -d "'" | tr -d " ") run_cli_test "'status': 1" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT $requestid -k - run_cli_test "'status': 1" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT ../../datasets/cuopt_service_data/good_lp.json -wid $requestid + run_cli_test "'status': 'Optimal'" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT ../../datasets/cuopt_service_data/good_lp.json -wid $requestid # Success, larger problem, result comes back in results dir run_cli_test "'result_file': 'data.result'" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT ../../datasets/cuopt_service_data/service_data_200r.json -o data.result From 5a4c83bc43e2298da3b05605ca6c96d98915ec23 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 11 Jun 2025 14:10:21 -0500 Subject: [PATCH 18/19] fix test --- ci/test_self_hosted_service.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/test_self_hosted_service.sh b/ci/test_self_hosted_service.sh index 48a91dd4ca..5fd8c00f64 100755 --- a/ci/test_self_hosted_service.sh +++ b/ci/test_self_hosted_service.sh @@ -166,7 +166,7 @@ if [ "$doservertest" -eq 1 ]; then run_cli_test "Check for status with the following command" cuopt_sh -s -c "$CLIENT_CERT" -p "$CUOPT_SERVER_PORT" -pt 0 ../../datasets/cuopt_service_data/good_lp.json -k requestid=$(echo "$cli_test" | tail -1) requestid=$(echo ${requestid#cuopt_sh } | sed "s/-p $CUOPT_SERVER_PORT//g" | tr -d "'" | tr -d " ") - run_cli_test "'status': 1" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT $requestid -k + run_cli_test "'status': 'Optimal'" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT $requestid -k run_cli_test "'status': 'Optimal'" cuopt_sh -s -c $CLIENT_CERT -p $CUOPT_SERVER_PORT ../../datasets/cuopt_service_data/good_lp.json -wid $requestid # Success, larger problem, result comes back in results dir From 076f1c917a75fcd522d1fe0a181e9b87f6fd6ef8 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 12 Jun 2025 11:50:17 -0500 Subject: [PATCH 19/19] remove warm start get --- .../cuopt_sh_client/thin_client_solution.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py index a27e1d8aea..1db49a4fc8 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/thin_client_solution.py @@ -249,16 +249,6 @@ def get_reduced_cost(self): """ return self.reduced_cost - def get_pdlp_warm_start_data(self): - """ - Note: Applicable to only LP - - Allows to retrieve the warm start data from the PDLP solver. - - See `SolverSettings.set_pdlp_warm_start_data` for more details. - """ - return self.pdlp_warm_start_data - def get_milp_stats(self): """ Note: Applicable to only MILP