Repository: Pyomo/pyomo

Issue:
Handle evaluation errors in PyNumero
## Summary

Currently, PyNumero's ASL interface handles evaluation errors by asserting the return value of the interface's `eval_g` function [here](https://github.com/Pyomo/pyomo/blob/main/pyomo/contrib/pynumero/asl.py#L426C1-L426C47). To handle this error, e.g. in a solver interface, one would have to catch the error, something like:
```python
try:
    nlp.evaluate_constraints()
except AssertionError:
    # Follow whatever convention the solver uses for eval errors
    return False
```
This is dangerous as it catches any assertion error (e.g. the check for consistent dimensions). I propose we have something like a `PyNumeroEvaluationError` that we raise in this case that can safely be caught and handled by solver interfaces.

Presumably, we could also provide some information about which constraint caused the evaluation error, which would be useful for debugging. Ipopt's AMPL interface does this, although from a quick glance at the ASL evaluation functions it was not obvious to me how this is done.

### Rationale

This is one step towards a CyIpopt interface that handles function evaluation errors in the same way that Ipopt (AMPL interface) does. The other steps are to [handle evaluation errors in CyIpopt](https://github.com/mechmotum/cyipopt/issues/210) and to integrate both of these in our CyIpopt interface.


Discussion:
> Presumably, we could also provide some information about which constraint caused the evaluation error, which would be useful for debugging. Ipopt's AMPL interface does this, although from a quick glance at the ASL evaluation functions it was not obvious to me how this is done.

It looks like the additional information that Ipopt displays with `halt_on_ampl_error yes` comes from ASL, and I can't figure out exactly where it comes from. At the high-level ASL interface, we have the following options:
1. Ignore an evaluation error but set the error flag (this is what we currently use in PyNumero)
2. Halt on error and print a message. I don't think this is useful for us. We want the program to keep executing.

One option to provide useful information on errors in Jacobian/constraint evaluation would be to call `conival` and `jacival` for each constraint and make a note of which set the error flag. This information could then be passed up to Python where we can print useful debugging information.

Resolving patch:
diff --git a/pyomo/contrib/pynumero/asl.py b/pyomo/contrib/pynumero/asl.py
--- a/pyomo/contrib/pynumero/asl.py
+++ b/pyomo/contrib/pynumero/asl.py
@@ -11,6 +11,7 @@
 
 from pyomo.common.fileutils import find_library
 from pyomo.common.dependencies import numpy as np
+from pyomo.contrib.pynumero.exceptions import PyNumeroEvaluationError
 import ctypes
 import logging
 import os
@@ -364,7 +365,8 @@ def eval_f(self, x):
         res = self.ASLib.EXTERNAL_AmplInterface_eval_f(
             self._obj, x, self._nx, ctypes.byref(sol)
         )
-        assert res, "Error in AMPL evaluation"
+        if not res:
+            raise PyNumeroEvaluationError("Error in AMPL evaluation")
         return sol.value
 
     def eval_deriv_f(self, x, df):
@@ -373,7 +375,8 @@ def eval_deriv_f(self, x, df):
             x.dtype == np.double
         ), "Error: array type. Function eval_deriv_f expects an array of type double"
         res = self.ASLib.EXTERNAL_AmplInterface_eval_deriv_f(self._obj, x, df, len(x))
-        assert res, "Error in AMPL evaluation"
+        if not res:
+            raise PyNumeroEvaluationError("Error in AMPL evaluation")
 
     def struct_jac_g(self, irow, jcol):
         irow_p = irow.astype(np.intc, casting='safe', copy=False)
@@ -409,7 +412,8 @@ def eval_jac_g(self, x, jac_g_values):
         res = self.ASLib.EXTERNAL_AmplInterface_eval_jac_g(
             self._obj, xeval, self._nx, jac_eval, self._nnz_jac_g
         )
-        assert res, "Error in AMPL evaluation"
+        if not res:
+            raise PyNumeroEvaluationError("Error in AMPL evaluation")
 
     def eval_g(self, x, g):
         assert x.size == self._nx, "Error: Dimension mismatch."
@@ -423,7 +427,8 @@ def eval_g(self, x, g):
         res = self.ASLib.EXTERNAL_AmplInterface_eval_g(
             self._obj, x, self._nx, g, self._ny
         )
-        assert res, "Error in AMPL evaluation"
+        if not res:
+            raise PyNumeroEvaluationError("Error in AMPL evaluation")
 
     def eval_hes_lag(self, x, lam, hes_lag, obj_factor=1.0):
         assert x.size == self._nx, "Error: Dimension mismatch."
@@ -453,7 +458,8 @@ def eval_hes_lag(self, x, lam, hes_lag, obj_factor=1.0):
             res = self.ASLib.EXTERNAL_AmplInterface_eval_hes_lag(
                 self._obj, x, self._nx, lam, self._ny, hes_lag, self._nnz_hess
             )
-        assert res, "Error in AMPL evaluation"
+        if not res:
+            raise PyNumeroEvaluationError("Error in AMPL evaluation")
 
     def finalize_solution(self, ampl_solve_status_num, msg, x, lam):
         b_msg = msg.encode('utf-8')
diff --git a/pyomo/contrib/pynumero/exceptions.py b/pyomo/contrib/pynumero/exceptions.py
new file mode 100644
--- /dev/null
+++ b/pyomo/contrib/pynumero/exceptions.py
@@ -0,0 +1,20 @@
+#  ___________________________________________________________________________
+#
+#  Pyomo: Python Optimization Modeling Objects
+#  Copyright (c) 2008-2022
+#  National Technology and Engineering Solutions of Sandia, LLC
+#  Under the terms of Contract DE-NA0003525 with National Technology and
+#  Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
+#  rights in this software.
+#  This software is distributed under the 3-clause BSD License.
+#  ___________________________________________________________________________
+
+
+class PyNumeroEvaluationError(ArithmeticError):
+    """An exception to be raised by PyNumero evaluation backends in the event
+    of a failed function evaluation. This should be caught by solver interfaces
+    and translated to the solver-specific evaluation error API.
+
+    """
+
+    pass


Test patch:
diff --git a/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py b/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py
--- a/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py
+++ b/pyomo/contrib/pynumero/interfaces/tests/test_nlp.py
@@ -22,6 +22,7 @@
     raise unittest.SkipTest("Pynumero needs scipy and numpy to run NLP tests")
 
 from pyomo.contrib.pynumero.asl import AmplInterface
+from pyomo.contrib.pynumero.exceptions import PyNumeroEvaluationError
 
 if not AmplInterface.available():
     raise unittest.SkipTest("Pynumero needs the ASL extension to run NLP tests")
@@ -816,6 +817,57 @@ def test_util_maps(self):
         self.assertTrue(np.array_equal(expected_full_primals_lb, full_primals_lb))
 
 
+class TestExceptions(unittest.TestCase):
+    def _make_bad_model(self):
+        m = pyo.ConcreteModel()
+        m.I = pyo.Set(initialize=[1, 2, 3])
+        m.x = pyo.Var(m.I, initialize=1)
+
+        m.obj = pyo.Objective(expr=m.x[1] + m.x[2] / m.x[3])
+        m.eq1 = pyo.Constraint(expr=m.x[1] == pyo.sqrt(m.x[2]))
+        return m
+
+    def test_eval_error_in_constraint(self):
+        m = self._make_bad_model()
+        m.x[2] = -1
+        nlp = PyomoNLP(m)
+        msg = "Error in AMPL evaluation"
+        with self.assertRaisesRegex(PyNumeroEvaluationError, msg):
+            residuals = nlp.evaluate_constraints()
+
+    def test_eval_error_in_constraint_jacobian(self):
+        m = self._make_bad_model()
+        m.x[2] = -1
+        nlp = PyomoNLP(m)
+        msg = "Error in AMPL evaluation"
+        with self.assertRaisesRegex(PyNumeroEvaluationError, msg):
+            jacobian = nlp.evaluate_jacobian()
+
+    def test_eval_error_in_objective(self):
+        m = self._make_bad_model()
+        m.x[3] = 0
+        nlp = PyomoNLP(m)
+        msg = "Error in AMPL evaluation"
+        with self.assertRaisesRegex(PyNumeroEvaluationError, msg):
+            objval = nlp.evaluate_objective()
+
+    def test_eval_error_in_objective_gradient(self):
+        m = self._make_bad_model()
+        m.x[3] = 0
+        nlp = PyomoNLP(m)
+        msg = "Error in AMPL evaluation"
+        with self.assertRaisesRegex(PyNumeroEvaluationError, msg):
+            gradient = nlp.evaluate_grad_objective()
+
+    def test_eval_error_in_lagrangian_hessian(self):
+        m = self._make_bad_model()
+        m.x[3] = 0
+        nlp = PyomoNLP(m)
+        msg = "Error in AMPL evaluation"
+        with self.assertRaisesRegex(PyNumeroEvaluationError, msg):
+            hessian = nlp.evaluate_hessian_lag()
+
+
 if __name__ == '__main__':
     TestAslNLP.setUpClass()
     t = TestAslNLP()