Repository: Pyomo/pyomo

Issue:
ExternalFunction evaluate_fgh doesn't work correctly with string arguments
## Summary

When you have an ExternalFunction that expects string arguments, the evaluate_fgh method includes the string arguments when counting how big the gradient and hessian arrays are. This means that you get the gradient and hessian but they are larger than they should be and padded with random junk from memory at the end.

### Steps to reproduce the issue

Run the example below (requires IDAES).  If you have an example function that uses string args that will work too. 

```
# example.py
import pyomo.environ as pyo
from idaes.models.properties.general_helmholtz.helmholtz_functions import (
    add_helmholtz_external_functions,
    HelmholtzParameterBlock,
    HelmholtzThermoExpressions,
    AmountBasis,
)

m = pyo.ConcreteModel()
m.param_block = HelmholtzParameterBlock(
    pure_component="r134a", amount_basis=AmountBasis.MASS
)

te = HelmholtzThermoExpressions(m, m.param_block)

add_helmholtz_external_functions(m, "tau_func")

y, g, h = m.tau_func.evaluate_fgh(args=("r134a", 529.47, 100))
print(f"T = {374.18/y}")
print(g)
print(h)
```

### Error Message

There is no error message. 

### Information on your system

Pyomo version: Pyomo 6.4.2 (devel {typing-overload}) (CPython 3.10.4 on Darwin 21.6.0)







Discussion:
@eslickj: what do you think the expected behavior should be?  Should `fgh` only return the gradient / Hessian for the numeric arguments (like the ASL), or should it return the "full" gradient / hessian with 0's inserted at the correct indices.  I tend to favor the latter (especially since the pyomo side doesn't have the indirection that the ASL `ra` / `at` interface enforces).

Resolving patch:
diff --git a/pyomo/core/base/external.py b/pyomo/core/base/external.py
--- a/pyomo/core/base/external.py
+++ b/pyomo/core/base/external.py
@@ -30,7 +30,7 @@
 __all__  = ( 'ExternalFunction', )
 
 logger = logging.getLogger('pyomo.core')
-
+nan = float('nan')
 
 class ExternalFunction(Component):
     """Interface to an external (non-algebraic) function.
@@ -360,11 +360,24 @@ def _evaluate(self, args, fixed, fgh):
         fcn = self._known_functions[self._function][0]
         f = fcn(byref(arglist))
         if fgh >= 1:
-            g = [arglist.derivs[i] for i in range(N)]
+            g = [nan]*N
+            for i in range(N):
+                if arglist.at[i] < 0:
+                    continue
+                g[i] = arglist.derivs[arglist.at[i]]
         else:
             g = None
         if fgh >= 2:
-            h = [arglist.hes[i] for i in range((N + N**2)//2)]
+            h = [nan]*((N + N**2)//2)
+            for j in range(N):
+                j_r = arglist.at[j]
+                if j_r < 0:
+                    continue
+                for i in range(j+1):
+                    i_r = arglist.at[i]
+                    if i_r < 0:
+                        continue
+                    h[i + j*(j + 1)//2] = arglist.hes[i_r + j_r*(j_r + 1)//2]
         else:
             h = None
         return f, g, h


Test patch:
diff --git a/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py b/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py
--- a/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py
+++ b/pyomo/contrib/ampl_function_demo/tests/test_ampl_function_demo.py
@@ -14,6 +14,7 @@
 import pyomo.common.unittest as unittest
 from pyomo.common.fileutils import find_library
 from pyomo.opt import check_available_solvers
+from pyomo.core.base.external import nan
 
 flib = find_library("asl_external_demo")
 is_pypy = platform.python_implementation().lower().startswith('pypy')
@@ -34,6 +35,21 @@ def test_eval_function(self):
             reltol=1e-5
         )
 
+    @unittest.skipIf(is_pypy, 'Cannot evaluate external functions under pypy')
+    def test_eval_function_fgh(self):
+        m = pyo.ConcreteModel()
+        m.tf = pyo.ExternalFunction(library=flib, function="demo_function")
+
+        f, g, h = m.tf.evaluate_fgh(("sum", 1, 2, 3))
+        self.assertEqual(f, 6)
+        self.assertEqual(g, [nan, 1, 1, 1])
+        self.assertEqual(h, [nan, nan, 0, nan, 0, 0, nan, 0, 0, 0])
+
+        f, g, h = m.tf.evaluate_fgh(("inv", 1, 2, 3))
+        self.assertAlmostEqual(f, 1.8333333, 4)
+        self.assertStructuredAlmostEqual(g, [nan, -1, -1/4, -1/9])
+        self.assertStructuredAlmostEqual(h, [nan, nan, 2, nan, 0, 1/4, nan, 0, 0, 2/27])
+
     @unittest.skipUnless(check_available_solvers('ipopt'),
                          "The 'ipopt' solver is not available")
     def test_solve_function(self):