From b03bb7d465db22b3c37d4d5a589a3c0f7b992f3a Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Tue, 25 Jul 2023 12:27:31 -0600 Subject: [PATCH 01/18] catch PyNumeroEvaluationErrors and raise CyIpoptEvaluationErrors --- .../pynumero/interfaces/cyipopt_interface.py | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index 19e74625d03..89ce6683f4d 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -23,6 +23,7 @@ import abc from pyomo.common.dependencies import attempt_import, numpy as np, numpy_available +from pyomo.contrib.pynumero.exceptions import PyNumeroEvaluationError def _cyipopt_importer(): @@ -328,24 +329,46 @@ def scaling_factors(self): return obj_scaling, x_scaling, g_scaling def objective(self, x): - self._set_primals_if_necessary(x) - return self._nlp.evaluate_objective() + try: + self._set_primals_if_necessary(x) + return self._nlp.evaluate_objective() + except PyNumeroEvaluationError: + # TODO: halt_on_evaluation_error option. If set, we re-raise the + # original exception. + raise cyipopt.CyIpoptEvaluationError( + "Error in objective function evaluation" + ) def gradient(self, x): - self._set_primals_if_necessary(x) - return self._nlp.evaluate_grad_objective() + try: + self._set_primals_if_necessary(x) + return self._nlp.evaluate_grad_objective() + except PyNumeroEvaluationError: + raise cyipopt.CyIpoptEvaluationError( + "Error in objective gradient evaluation" + ) def constraints(self, x): - self._set_primals_if_necessary(x) - return self._nlp.evaluate_constraints() + try: + self._set_primals_if_necessary(x) + return self._nlp.evaluate_constraints() + except PyNumeroEvaluationError: + raise cyipopt.CyIpoptEvaluationError( + "Error in constraint evaluation" + ) def jacobianstructure(self): return self._jac_g.row, self._jac_g.col def jacobian(self, x): - self._set_primals_if_necessary(x) - self._nlp.evaluate_jacobian(out=self._jac_g) - return self._jac_g.data + try: + self._set_primals_if_necessary(x) + self._nlp.evaluate_jacobian(out=self._jac_g) + return self._jac_g.data + except PyNumeroEvaluationError: + raise cyipopt.CyIpoptEvaluationError( + "Error in constraint Jacobian evaluation" + ) def hessianstructure(self): if not self._hessian_available: @@ -359,12 +382,17 @@ def hessian(self, x, y, obj_factor): if not self._hessian_available: raise ValueError("Hessian requested, but not supported by the NLP") - self._set_primals_if_necessary(x) - self._set_duals_if_necessary(y) - self._set_obj_factor_if_necessary(obj_factor) - self._nlp.evaluate_hessian_lag(out=self._hess_lag) - data = np.compress(self._hess_lower_mask, self._hess_lag.data) - return data + try: + self._set_primals_if_necessary(x) + self._set_duals_if_necessary(y) + self._set_obj_factor_if_necessary(obj_factor) + self._nlp.evaluate_hessian_lag(out=self._hess_lag) + data = np.compress(self._hess_lower_mask, self._hess_lag.data) + return data + except PyNumeroEvaluationError: + raise cyipopt.CyIpoptEvaluationError( + "Error in Lagrangian Hessian evaluation" + ) def intermediate( self, From 8dac4abb67420578fcd8fa83b06d071beed44eb8 Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Tue, 25 Jul 2023 12:28:19 -0600 Subject: [PATCH 02/18] test solving a model that raises an evaluation error --- .../solvers/tests/test_cyipopt_solver.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py index 2a7edb430d4..e3596993082 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py @@ -155,6 +155,32 @@ def f(model): return model +def make_hs071_model(): + # This is a model that is mathematically equivalent to the Hock-Schittkowski + # test problem 071, but that will trigger an evaluation error if x[0] goes + # above 1.1. + m = pyo.ConcreteModel() + m.x = pyo.Var([0, 1, 2, 3], bounds=(1.0, 5.0)) + m.x[0] = 1.0 + m.x[1] = 5.0 + m.x[2] = 5.0 + m.x[3] = 1.0 + m.obj = pyo.Objective(expr=m.x[0] * m.x[3] * (m.x[0] + m.x[1] + m.x[2]) + m.x[2]) + # This expression evaluates to zero, but is not well defined when x[0] > 1.1 + trivial_expr_with_eval_error = ( + # 0.0 + (pyo.sqrt(1.1 - m.x[0])) ** 2 + m.x[0] - 1.1 + ) + m.ineq1 = pyo.Constraint(expr=m.x[0] * m.x[1] * m.x[2] * m.x[3] >= 25.0) + m.eq1 = pyo.Constraint( + expr=( + m.x[0] ** 2 + m.x[1] ** 2 + m.x[2] ** 2 + m.x[3] ** 2 + == 40.0 + trivial_expr_with_eval_error + ) + ) + return m + + @unittest.skipIf(cyipopt_available, "cyipopt is available") class TestCyIpoptNotAvailable(unittest.TestCase): def test_not_available_exception(self): @@ -257,3 +283,12 @@ def test_options(self): x, info = solver.solve(tee=False) nlp.set_primals(x) self.assertAlmostEqual(nlp.evaluate_objective(), -5.0879028e02, places=5) + + def test_hs071_evalerror(self): + m = make_hs071_model() + solver = pyo.SolverFactory("cyipopt") + res = solver.solve(m, tee=True) + + x = list(m.x[:].value) + expected_x = np.array([1.0, 4.74299964, 3.82114998, 1.37940829]) + np.testing.assert_allclose(x, expected_x) From ddc60d76881c31807929def48eda33ebc715f554 Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Tue, 25 Jul 2023 13:14:00 -0600 Subject: [PATCH 03/18] test raising CyIpoptEvaluationError from CyIpoptNLP --- .../tests/test_cyipopt_interface.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py index 2c5d8ff7e4e..dbff12121b0 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py @@ -10,6 +10,7 @@ # ___________________________________________________________________________ import pyomo.common.unittest as unittest +import pyomo.environ as pyo from pyomo.contrib.pynumero.dependencies import ( numpy as np, @@ -25,14 +26,18 @@ if not AmplInterface.available(): raise unittest.SkipTest("Pynumero needs the ASL extension to run CyIpopt tests") +from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP from pyomo.contrib.pynumero.interfaces.cyipopt_interface import ( cyipopt_available, CyIpoptProblemInterface, + CyIpoptNLP, ) if not cyipopt_available: raise unittest.SkipTest("CyIpopt is not available") +import cyipopt + class TestSubclassCyIpoptInterface(unittest.TestCase): def test_subclass_no_init(self): @@ -88,5 +93,49 @@ def hessian(self, x, y, obj_factor): problem.solve(x0) +class TestCyIpoptEvaluationErrors(unittest.TestCase): + def _get_model_nlp_interface(self): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3], initialize=1.0) + m.obj = pyo.Objective(expr=m.x[1] * pyo.sqrt(m.x[2]) + m.x[1] * m.x[3]) + m.eq1 = pyo.Constraint(expr=m.x[1] * pyo.sqrt(m.x[2]) == 1.0) + nlp = PyomoNLP(m) + interface = CyIpoptNLP(nlp) + bad_primals = np.array([1.0, -2.0, 3.0]) + indices = nlp.get_primal_indices([m.x[1], m.x[2], m.x[3]]) + bad_primals = bad_primals[indices] + return m, nlp, interface, bad_primals + + def test_error_in_objective(self): + m, nlp, interface, bad_x = self._get_model_nlp_interface() + msg = "Error in objective function" + with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): + interface.objective(bad_x) + + def test_error_in_gradient(self): + m, nlp, interface, bad_x = self._get_model_nlp_interface() + msg = "Error in objective gradient" + with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): + interface.gradient(bad_x) + + def test_error_in_constraints(self): + m, nlp, interface, bad_x = self._get_model_nlp_interface() + msg = "Error in constraint evaluation" + with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): + interface.constraints(bad_x) + + def test_error_in_jacobian(self): + m, nlp, interface, bad_x = self._get_model_nlp_interface() + msg = "Error in constraint Jacobian" + with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): + interface.jacobian(bad_x) + + def test_error_in_hessian(self): + m, nlp, interface, bad_x = self._get_model_nlp_interface() + msg = "Error in Lagrangian Hessian" + with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): + interface.hessian(bad_x, [1.0], 0.0) + + if __name__ == "__main__": unittest.main() From 549f375278fe120a34786a2773e7c6d1457c979f Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Fri, 15 Sep 2023 17:40:41 -0600 Subject: [PATCH 04/18] black line length stuff --- .../pynumero/algorithms/solvers/tests/test_cyipopt_solver.py | 5 +---- pyomo/contrib/pynumero/interfaces/cyipopt_interface.py | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py index e3596993082..ead6331ed1b 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py @@ -167,10 +167,7 @@ def make_hs071_model(): m.x[3] = 1.0 m.obj = pyo.Objective(expr=m.x[0] * m.x[3] * (m.x[0] + m.x[1] + m.x[2]) + m.x[2]) # This expression evaluates to zero, but is not well defined when x[0] > 1.1 - trivial_expr_with_eval_error = ( - # 0.0 - (pyo.sqrt(1.1 - m.x[0])) ** 2 + m.x[0] - 1.1 - ) + trivial_expr_with_eval_error = ((pyo.sqrt(1.1 - m.x[0])) ** 2 + m.x[0] - 1.1) m.ineq1 = pyo.Constraint(expr=m.x[0] * m.x[1] * m.x[2] * m.x[3] >= 25.0) m.eq1 = pyo.Constraint( expr=( diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index 89ce6683f4d..cddc2ce000f 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -353,9 +353,7 @@ def constraints(self, x): self._set_primals_if_necessary(x) return self._nlp.evaluate_constraints() except PyNumeroEvaluationError: - raise cyipopt.CyIpoptEvaluationError( - "Error in constraint evaluation" - ) + raise cyipopt.CyIpoptEvaluationError("Error in constraint evaluation") def jacobianstructure(self): return self._jac_g.row, self._jac_g.col From 167d11650a3f7c21f9fdb4e7b9086f7257866d78 Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Fri, 15 Sep 2023 17:52:17 -0600 Subject: [PATCH 05/18] black no paren --- .../pynumero/algorithms/solvers/tests/test_cyipopt_solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py index ead6331ed1b..7a670a2e41c 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py @@ -167,7 +167,7 @@ def make_hs071_model(): m.x[3] = 1.0 m.obj = pyo.Objective(expr=m.x[0] * m.x[3] * (m.x[0] + m.x[1] + m.x[2]) + m.x[2]) # This expression evaluates to zero, but is not well defined when x[0] > 1.1 - trivial_expr_with_eval_error = ((pyo.sqrt(1.1 - m.x[0])) ** 2 + m.x[0] - 1.1) + trivial_expr_with_eval_error = (pyo.sqrt(1.1 - m.x[0])) ** 2 + m.x[0] - 1.1 m.ineq1 = pyo.Constraint(expr=m.x[0] * m.x[1] * m.x[2] * m.x[3] >= 25.0) m.eq1 = pyo.Constraint( expr=( From 0ab57d4317578a4a29005470122d7594a79eaac3 Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 19:48:27 -0600 Subject: [PATCH 06/18] add halt_on_evaluation_error option --- .../pynumero/interfaces/cyipopt_interface.py | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index cddc2ce000f..093c9c7ecc1 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -253,7 +253,7 @@ def intermediate( class CyIpoptNLP(CyIpoptProblemInterface): - def __init__(self, nlp, intermediate_callback=None): + def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=None): """This class provides a CyIpoptProblemInterface for use with the CyIpoptSolver class that can take in an NLP as long as it provides vectors as numpy ndarrays and @@ -264,6 +264,18 @@ def __init__(self, nlp, intermediate_callback=None): self._nlp = nlp self._intermediate_callback = intermediate_callback + if halt_on_evaluation_error is None: + # If using cyipopt >= 1.3, the default is to halt. + # Otherwise, the default is not to halt (because we can't). + self._halt_on_evaluation_error = hasattr(cyipopt, "CyIpoptEvaluationError") + elif halt_on_evaluation_error and not hasattr(cyipopt, "CyIpoptEvaluationError"): + raise ValueError( + "halt_on_evaluation_error is only supported for cyipopt >= 1.3.0" + ) + else: + self._halt_on_evaluation_error = halt_on_evaluation_error + + x = nlp.init_primals() y = nlp.init_duals() if np.any(np.isnan(y)): @@ -335,25 +347,34 @@ def objective(self, x): except PyNumeroEvaluationError: # TODO: halt_on_evaluation_error option. If set, we re-raise the # original exception. - raise cyipopt.CyIpoptEvaluationError( - "Error in objective function evaluation" - ) + if self._halt_on_evaluation_error: + raise cyipopt.CyIpoptEvaluationError( + "Error in objective function evaluation" + ) + else: + raise def gradient(self, x): try: self._set_primals_if_necessary(x) return self._nlp.evaluate_grad_objective() except PyNumeroEvaluationError: - raise cyipopt.CyIpoptEvaluationError( - "Error in objective gradient evaluation" - ) + if self._halt_on_evaluation_error: + raise cyipopt.CyIpoptEvaluationError( + "Error in objective gradient evaluation" + ) + else: + raise def constraints(self, x): try: self._set_primals_if_necessary(x) return self._nlp.evaluate_constraints() except PyNumeroEvaluationError: - raise cyipopt.CyIpoptEvaluationError("Error in constraint evaluation") + if self._halt_on_evaluation_error: + raise cyipopt.CyIpoptEvaluationError("Error in constraint evaluation") + else: + raise def jacobianstructure(self): return self._jac_g.row, self._jac_g.col @@ -364,9 +385,12 @@ def jacobian(self, x): self._nlp.evaluate_jacobian(out=self._jac_g) return self._jac_g.data except PyNumeroEvaluationError: - raise cyipopt.CyIpoptEvaluationError( - "Error in constraint Jacobian evaluation" - ) + if self._halt_on_evaluation_error: + raise cyipopt.CyIpoptEvaluationError( + "Error in constraint Jacobian evaluation" + ) + else: + raise def hessianstructure(self): if not self._hessian_available: @@ -388,9 +412,12 @@ def hessian(self, x, y, obj_factor): data = np.compress(self._hess_lower_mask, self._hess_lag.data) return data except PyNumeroEvaluationError: - raise cyipopt.CyIpoptEvaluationError( - "Error in Lagrangian Hessian evaluation" - ) + if self._halt_on_evaluation_error: + raise cyipopt.CyIpoptEvaluationError( + "Error in Lagrangian Hessian evaluation" + ) + else: + raise def intermediate( self, From 761277cc5da5a1062e49d34366c523762a72c94c Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 19:58:43 -0600 Subject: [PATCH 07/18] add halt_on_evaluation_error to PyomoCyIpoptSolver --- .../pynumero/algorithms/solvers/cyipopt_solver.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py index 766ef96322a..cedbf430a12 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py @@ -289,6 +289,13 @@ class PyomoCyIpoptSolver(object): description="Set the function that will be called each iteration.", ), ) + CONFIG.declare( + "halt_on_evaluation_error", + ConfigValue( + default=None, + description="Whether to halt if a function or derivative evaluation fails", + ), + ) def __init__(self, **kwds): """Create an instance of the CyIpoptSolver. You must @@ -332,7 +339,9 @@ def solve(self, model, **kwds): nlp = pyomo_nlp.PyomoNLP(model) problem = cyipopt_interface.CyIpoptNLP( - nlp, intermediate_callback=config.intermediate_callback + nlp, + intermediate_callback=config.intermediate_callback, + halt_on_evaluation_error=config.halt_on_evaluation_error, ) ng = len(problem.g_lb()) nx = len(problem.x_lb()) From 463e434eab721238d968fd9f12ac6fb88a768c4e Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 20:52:39 -0600 Subject: [PATCH 08/18] bug fix: reraise exception if halt=*True* --- .../pynumero/interfaces/cyipopt_interface.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index 093c9c7ecc1..5ac98cdeecc 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -265,9 +265,9 @@ def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=Non self._intermediate_callback = intermediate_callback if halt_on_evaluation_error is None: - # If using cyipopt >= 1.3, the default is to halt. - # Otherwise, the default is not to halt (because we can't). - self._halt_on_evaluation_error = hasattr(cyipopt, "CyIpoptEvaluationError") + # If using cyipopt >= 1.3, the default is to continue. + # Otherwise, the default is to halt (because we are forced to). + self._halt_on_evaluation_error = not hasattr(cyipopt, "CyIpoptEvaluationError") elif halt_on_evaluation_error and not hasattr(cyipopt, "CyIpoptEvaluationError"): raise ValueError( "halt_on_evaluation_error is only supported for cyipopt >= 1.3.0" @@ -348,11 +348,11 @@ def objective(self, x): # TODO: halt_on_evaluation_error option. If set, we re-raise the # original exception. if self._halt_on_evaluation_error: + raise + else: raise cyipopt.CyIpoptEvaluationError( "Error in objective function evaluation" ) - else: - raise def gradient(self, x): try: @@ -360,11 +360,11 @@ def gradient(self, x): return self._nlp.evaluate_grad_objective() except PyNumeroEvaluationError: if self._halt_on_evaluation_error: + raise + else: raise cyipopt.CyIpoptEvaluationError( "Error in objective gradient evaluation" ) - else: - raise def constraints(self, x): try: @@ -372,9 +372,9 @@ def constraints(self, x): return self._nlp.evaluate_constraints() except PyNumeroEvaluationError: if self._halt_on_evaluation_error: - raise cyipopt.CyIpoptEvaluationError("Error in constraint evaluation") - else: raise + else: + raise cyipopt.CyIpoptEvaluationError("Error in constraint evaluation") def jacobianstructure(self): return self._jac_g.row, self._jac_g.col @@ -386,11 +386,11 @@ def jacobian(self, x): return self._jac_g.data except PyNumeroEvaluationError: if self._halt_on_evaluation_error: + raise + else: raise cyipopt.CyIpoptEvaluationError( "Error in constraint Jacobian evaluation" ) - else: - raise def hessianstructure(self): if not self._hessian_available: @@ -413,11 +413,11 @@ def hessian(self, x, y, obj_factor): return data except PyNumeroEvaluationError: if self._halt_on_evaluation_error: + raise + else: raise cyipopt.CyIpoptEvaluationError( "Error in Lagrangian Hessian evaluation" ) - else: - raise def intermediate( self, From 9723b0fcb6fe89e6dbeefaccf36f7bff5a77318f Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 20:53:19 -0600 Subject: [PATCH 09/18] tests for halt_on_evaluation_error and its version-dependent default --- .../tests/test_cyipopt_interface.py | 119 +++++++++++++++--- 1 file changed, 102 insertions(+), 17 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py index dbff12121b0..b2ab837d9c7 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py @@ -21,6 +21,7 @@ if not (numpy_available and scipy_available): raise unittest.SkipTest("Pynumero needs scipy and numpy to run CyIpopt tests") +from pyomo.contrib.pynumero.exceptions import PyNumeroEvaluationError from pyomo.contrib.pynumero.asl import AmplInterface if not AmplInterface.available(): @@ -28,6 +29,7 @@ from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP from pyomo.contrib.pynumero.interfaces.cyipopt_interface import ( + cyipopt, cyipopt_available, CyIpoptProblemInterface, CyIpoptNLP, @@ -36,7 +38,8 @@ if not cyipopt_available: raise unittest.SkipTest("CyIpopt is not available") -import cyipopt + +cyipopt_ge_1_3 = hasattr(cyipopt, "CyIpoptEvaluationError") class TestSubclassCyIpoptInterface(unittest.TestCase): @@ -93,49 +96,131 @@ def hessian(self, x, y, obj_factor): problem.solve(x0) -class TestCyIpoptEvaluationErrors(unittest.TestCase): - def _get_model_nlp_interface(self): - m = pyo.ConcreteModel() - m.x = pyo.Var([1, 2, 3], initialize=1.0) - m.obj = pyo.Objective(expr=m.x[1] * pyo.sqrt(m.x[2]) + m.x[1] * m.x[3]) - m.eq1 = pyo.Constraint(expr=m.x[1] * pyo.sqrt(m.x[2]) == 1.0) - nlp = PyomoNLP(m) +def _get_model_nlp_interface(halt_on_evaluation_error=None): + m = pyo.ConcreteModel() + m.x = pyo.Var([1, 2, 3], initialize=1.0) + m.obj = pyo.Objective(expr=m.x[1] * pyo.sqrt(m.x[2]) + m.x[1] * m.x[3]) + m.eq1 = pyo.Constraint(expr=m.x[1] * pyo.sqrt(m.x[2]) == 1.0) + nlp = PyomoNLP(m) + interface = CyIpoptNLP(nlp, halt_on_evaluation_error=halt_on_evaluation_error) + bad_primals = np.array([1.0, -2.0, 3.0]) + indices = nlp.get_primal_indices([m.x[1], m.x[2], m.x[3]]) + bad_primals = bad_primals[indices] + return m, nlp, interface, bad_primals + + +class TestCyIpoptVersionDependentConfig(unittest.TestCase): + + @unittest.skipIf(cyipopt_ge_1_3, "cyipopt version >= 1.3.0") + def test_config_error(self): + _, nlp, _, _ = _get_model_nlp_interface() + with self.assertRaisesRegex(ValueError, "halt_on_evaluation_error"): + interface = CyIpoptNLP(nlp, halt_on_evaluation_error=False) + + @unittest.skipIf(cyipopt_ge_1_3, "cyipopt version >= 1.3.0") + def test_default_config_with_old_cyipopt(self): + _, nlp, _, bad_x = _get_model_nlp_interface() interface = CyIpoptNLP(nlp) - bad_primals = np.array([1.0, -2.0, 3.0]) - indices = nlp.get_primal_indices([m.x[1], m.x[2], m.x[3]]) - bad_primals = bad_primals[indices] - return m, nlp, interface, bad_primals + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + interface.objective(bad_x) + + @unittest.skipIf(not cyipopt_ge_1_3, "cyipopt version < 1.3.0") + def test_default_config_with_new_cyipopt(self): + _, nlp, _, bad_x = _get_model_nlp_interface() + interface = CyIpoptNLP(nlp) + msg = "Error in objective function" + with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): + interface.objective(bad_x) + +class TestCyIpoptEvaluationErrors(unittest.TestCase): + + @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") def test_error_in_objective(self): - m, nlp, interface, bad_x = self._get_model_nlp_interface() + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=False + ) msg = "Error in objective function" with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): interface.objective(bad_x) + def test_error_in_objective_halt(self): + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=True + ) + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + interface.objective(bad_x) + + @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") def test_error_in_gradient(self): - m, nlp, interface, bad_x = self._get_model_nlp_interface() + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=False + ) msg = "Error in objective gradient" with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): interface.gradient(bad_x) + def test_error_in_gradient_halt(self): + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=True + ) + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + interface.gradient(bad_x) + + @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") def test_error_in_constraints(self): - m, nlp, interface, bad_x = self._get_model_nlp_interface() + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=False + ) msg = "Error in constraint evaluation" with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): interface.constraints(bad_x) + def test_error_in_constraints_halt(self): + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=True + ) + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + interface.constraints(bad_x) + + @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") def test_error_in_jacobian(self): - m, nlp, interface, bad_x = self._get_model_nlp_interface() + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=False + ) msg = "Error in constraint Jacobian" with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): interface.jacobian(bad_x) + def test_error_in_jacobian_halt(self): + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=True + ) + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + interface.jacobian(bad_x) + + @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") def test_error_in_hessian(self): - m, nlp, interface, bad_x = self._get_model_nlp_interface() + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=False + ) msg = "Error in Lagrangian Hessian" with self.assertRaisesRegex(cyipopt.CyIpoptEvaluationError, msg): interface.hessian(bad_x, [1.0], 0.0) + def test_error_in_hessian_halt(self): + m, nlp, interface, bad_x = _get_model_nlp_interface( + halt_on_evaluation_error=True + ) + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + interface.hessian(bad_x, [1.0], 0.0) + if __name__ == "__main__": unittest.main() From 1f63223d6976c2ab7ae75e961660071ac3d8cfe7 Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 21:01:55 -0600 Subject: [PATCH 10/18] test HS071-with-eval-error with halt_on_evaluation_error and cyipopt < 1.3 --- .../solvers/tests/test_cyipopt_solver.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py index 7a670a2e41c..bdd32c6788e 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py @@ -24,6 +24,7 @@ if not (numpy_available and scipy_available): raise unittest.SkipTest("Pynumero needs scipy and numpy to run NLP tests") +from pyomo.contrib.pynumero.exceptions import PyNumeroEvaluationError from pyomo.contrib.pynumero.asl import AmplInterface if not AmplInterface.available(): @@ -34,12 +35,15 @@ from pyomo.contrib.pynumero.interfaces.pyomo_nlp import PyomoNLP from pyomo.contrib.pynumero.interfaces.cyipopt_interface import ( + cyipopt, cyipopt_available, CyIpoptNLP, ) from pyomo.contrib.pynumero.algorithms.solvers.cyipopt_solver import CyIpoptSolver +cyipopt_ge_1_3 = hasattr(cyipopt, "CyIpoptEvaluationError") + def create_model1(): m = pyo.ConcreteModel() @@ -281,6 +285,7 @@ def test_options(self): nlp.set_primals(x) self.assertAlmostEqual(nlp.evaluate_objective(), -5.0879028e02, places=5) + @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") def test_hs071_evalerror(self): m = make_hs071_model() solver = pyo.SolverFactory("cyipopt") @@ -289,3 +294,18 @@ def test_hs071_evalerror(self): x = list(m.x[:].value) expected_x = np.array([1.0, 4.74299964, 3.82114998, 1.37940829]) np.testing.assert_allclose(x, expected_x) + + def test_hs071_evalerror_halt(self): + m = make_hs071_model() + solver = pyo.SolverFactory("cyipopt", halt_on_evaluation_error=True) + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + res = solver.solve(m, tee=True) + + @unittest.skipIf(cyipopt_ge_1_3, "cyipopt version >= 1.3.0") + def test_hs071_evalerror_old_cyipopt(self): + m = make_hs071_model() + solver = pyo.SolverFactory("cyipopt") + msg = "Error in AMPL evaluation" + with self.assertRaisesRegex(PyNumeroEvaluationError, msg): + res = solver.solve(m, tee=True) From 49f49765b74cfa27621ecb165162a53674b236b5 Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 21:40:04 -0600 Subject: [PATCH 11/18] nfc:black --- pyomo/contrib/pynumero/interfaces/cyipopt_interface.py | 8 ++++++-- .../pynumero/interfaces/tests/test_cyipopt_interface.py | 2 -- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index 5ac98cdeecc..6f9251434cb 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -267,8 +267,12 @@ def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=Non if halt_on_evaluation_error is None: # If using cyipopt >= 1.3, the default is to continue. # Otherwise, the default is to halt (because we are forced to). - self._halt_on_evaluation_error = not hasattr(cyipopt, "CyIpoptEvaluationError") - elif halt_on_evaluation_error and not hasattr(cyipopt, "CyIpoptEvaluationError"): + self._halt_on_evaluation_error = not hasattr( + cyipopt, "CyIpoptEvaluationError" + ) + elif halt_on_evaluation_error and not hasattr( + cyipopt, "CyIpoptEvaluationError" + ): raise ValueError( "halt_on_evaluation_error is only supported for cyipopt >= 1.3.0" ) diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py index b2ab837d9c7..f28b7b9b549 100644 --- a/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/tests/test_cyipopt_interface.py @@ -110,7 +110,6 @@ def _get_model_nlp_interface(halt_on_evaluation_error=None): class TestCyIpoptVersionDependentConfig(unittest.TestCase): - @unittest.skipIf(cyipopt_ge_1_3, "cyipopt version >= 1.3.0") def test_config_error(self): _, nlp, _, _ = _get_model_nlp_interface() @@ -135,7 +134,6 @@ def test_default_config_with_new_cyipopt(self): class TestCyIpoptEvaluationErrors(unittest.TestCase): - @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") def test_error_in_objective(self): m, nlp, interface, bad_x = _get_model_nlp_interface( From 7a9209782f46ad641d8e13c2ee8fe760665745cf Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 22:08:28 -0600 Subject: [PATCH 12/18] remove whitespace --- pyomo/contrib/pynumero/interfaces/cyipopt_interface.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index 6f9251434cb..c327dc516a2 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -279,7 +279,6 @@ def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=Non else: self._halt_on_evaluation_error = halt_on_evaluation_error - x = nlp.init_primals() y = nlp.init_duals() if np.any(np.isnan(y)): From 40fd91e4569af7574412ce3ff27e10b001aaf01b Mon Sep 17 00:00:00 2001 From: robbybp Date: Thu, 28 Sep 2023 23:11:19 -0600 Subject: [PATCH 13/18] only assign cyipopt_ge_1_3 if cyipopt_available --- .../pynumero/algorithms/solvers/tests/test_cyipopt_solver.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py index bdd32c6788e..1b185334316 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py @@ -42,7 +42,10 @@ from pyomo.contrib.pynumero.algorithms.solvers.cyipopt_solver import CyIpoptSolver -cyipopt_ge_1_3 = hasattr(cyipopt, "CyIpoptEvaluationError") +if cyipopt_available: + # We don't raise unittest.SkipTest if not cyipopt_available as there is a + # test below that tests an exception when cyipopt is unavailable. + cyipopt_ge_1_3 = hasattr(cyipopt, "CyIpoptEvaluationError") def create_model1(): From f22157905b783a9cdf92ff902511ed1e80f3e73e Mon Sep 17 00:00:00 2001 From: robbybp Date: Fri, 29 Sep 2023 10:07:02 -0600 Subject: [PATCH 14/18] dont evaluate cyipopt_ge_1_3 when cyipopt not available --- .../algorithms/solvers/tests/test_cyipopt_solver.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py index 1b185334316..7ead30117cb 100644 --- a/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py +++ b/pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py @@ -288,7 +288,9 @@ def test_options(self): nlp.set_primals(x) self.assertAlmostEqual(nlp.evaluate_objective(), -5.0879028e02, places=5) - @unittest.skipUnless(cyipopt_ge_1_3, "cyipopt version < 1.3.0") + @unittest.skipUnless( + cyipopt_available and cyipopt_ge_1_3, "cyipopt version < 1.3.0" + ) def test_hs071_evalerror(self): m = make_hs071_model() solver = pyo.SolverFactory("cyipopt") @@ -305,7 +307,9 @@ def test_hs071_evalerror_halt(self): with self.assertRaisesRegex(PyNumeroEvaluationError, msg): res = solver.solve(m, tee=True) - @unittest.skipIf(cyipopt_ge_1_3, "cyipopt version >= 1.3.0") + @unittest.skipIf( + not cyipopt_available or cyipopt_ge_1_3, "cyipopt version >= 1.3.0" + ) def test_hs071_evalerror_old_cyipopt(self): m = make_hs071_model() solver = pyo.SolverFactory("cyipopt") From a7ed2900a647a6a6f485ce6b1afd278803a3e4d2 Mon Sep 17 00:00:00 2001 From: robbybp Date: Fri, 29 Sep 2023 11:31:28 -0600 Subject: [PATCH 15/18] only check hasattr(cyipopt,...) if cyipopt_available --- .../pynumero/interfaces/cyipopt_interface.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index c327dc516a2..3bc69fd35ab 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -264,17 +264,19 @@ def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=Non self._nlp = nlp self._intermediate_callback = intermediate_callback + cyipopt_has_eval_error = ( + cyipopt_available and hasattr(cyipopt, "CyIpoptEvaluationError") + ) if halt_on_evaluation_error is None: # If using cyipopt >= 1.3, the default is to continue. # Otherwise, the default is to halt (because we are forced to). - self._halt_on_evaluation_error = not hasattr( - cyipopt, "CyIpoptEvaluationError" - ) - elif halt_on_evaluation_error and not hasattr( - cyipopt, "CyIpoptEvaluationError" - ): + # + # If CyIpopt is not available, we "halt" (re-raise the original + # exception). + self._halt_on_evaluation_error = not cyipopt_has_eval_error + elif not halt_on_evaluation_error and not has_cyipopt_eval_error: raise ValueError( - "halt_on_evaluation_error is only supported for cyipopt >= 1.3.0" + "halt_on_evaluation_error=False is only supported for cyipopt >= 1.3.0" ) else: self._halt_on_evaluation_error = halt_on_evaluation_error From c39006db2344281bc860aac71d9b42626677e37c Mon Sep 17 00:00:00 2001 From: robbybp Date: Fri, 29 Sep 2023 11:40:19 -0600 Subject: [PATCH 16/18] variable name typo --- pyomo/contrib/pynumero/interfaces/cyipopt_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index 3bc69fd35ab..ee5783cba79 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -274,7 +274,7 @@ def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=Non # If CyIpopt is not available, we "halt" (re-raise the original # exception). self._halt_on_evaluation_error = not cyipopt_has_eval_error - elif not halt_on_evaluation_error and not has_cyipopt_eval_error: + elif not halt_on_evaluation_error and not cyipopt_has_eval_error: raise ValueError( "halt_on_evaluation_error=False is only supported for cyipopt >= 1.3.0" ) From 565c4cd7eb7c3f9ee52785e21f279b63d5fa81c4 Mon Sep 17 00:00:00 2001 From: robbybp Date: Fri, 29 Sep 2023 11:52:30 -0600 Subject: [PATCH 17/18] black --- pyomo/contrib/pynumero/interfaces/cyipopt_interface.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index ee5783cba79..f277fca6231 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -264,8 +264,8 @@ def __init__(self, nlp, intermediate_callback=None, halt_on_evaluation_error=Non self._nlp = nlp self._intermediate_callback = intermediate_callback - cyipopt_has_eval_error = ( - cyipopt_available and hasattr(cyipopt, "CyIpoptEvaluationError") + cyipopt_has_eval_error = cyipopt_available and hasattr( + cyipopt, "CyIpoptEvaluationError" ) if halt_on_evaluation_error is None: # If using cyipopt >= 1.3, the default is to continue. From 6d7ab0063e7a384e4e3648cb19675d641c8ddca9 Mon Sep 17 00:00:00 2001 From: robbybp Date: Wed, 25 Oct 2023 18:26:38 -0600 Subject: [PATCH 18/18] remove outdated comment --- pyomo/contrib/pynumero/interfaces/cyipopt_interface.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py index f277fca6231..fc9c45c6d1a 100644 --- a/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py +++ b/pyomo/contrib/pynumero/interfaces/cyipopt_interface.py @@ -350,8 +350,6 @@ def objective(self, x): self._set_primals_if_necessary(x) return self._nlp.evaluate_objective() except PyNumeroEvaluationError: - # TODO: halt_on_evaluation_error option. If set, we re-raise the - # original exception. if self._halt_on_evaluation_error: raise else: