Repository: OpenMDAO/OpenMDAO

Issue:
Add ability to return a copy with get_val
### Desired capability or behavior.

Depending on the options passed, get_val may currently return a view into an internal OpenMDAO vector.
This can have some unexpected behavior when repeatedly using set_val/get val when interrogating a system.

The simple workaround is to use `np.copy(prob.get_val(...))`, but its not obvious that users may need to do this.
We should add an argument `copy=False` by default.  This will be slightly less verbose for the user, and its presence in the options will document that it is sometimes necessary for users to use.

There's some argument to be made that copying should be the default behavior, but leaving it False for now will maintain backwards compatibility.

### Associated POEM

_No response_

Hints from issue comments:

Solution patch:
diff --git a/openmdao/core/problem.py b/openmdao/core/problem.py
index a3b496ba0..ad35a6a6d 100644
--- a/openmdao/core/problem.py
+++ b/openmdao/core/problem.py
@@ -492,7 +492,7 @@ class Problem(object, metaclass=ProblemMetaclass):
         """
         return self.get_val(name, get_remote=None)
 
-    def get_val(self, name, units=None, indices=None, get_remote=False):
+    def get_val(self, name, units=None, indices=None, get_remote=False, copy=False):
         """
         Get an output/input variable.
 
@@ -513,6 +513,8 @@ class Problem(object, metaclass=ProblemMetaclass):
             If False, only retrieve the value if it is on the current process, or only the part
             of the value that's on the current process for a distributed variable.
             If None and the variable is remote or distributed, a RuntimeError will be raised.
+        copy : bool, optional
+            If True, return a copy of the value.  If False, return a reference to the value.
 
         Returns
         -------
@@ -539,7 +541,10 @@ class Problem(object, metaclass=ProblemMetaclass):
                                    f"rank {self.comm.rank}. You can retrieve values from "
                                    "other processes using `get_val(<name>, get_remote=True)`.")
 
-        return val
+        if copy:
+            return deepcopy(val)
+        else:
+            return val
 
     def __setitem__(self, name, value):
         """
diff --git a/openmdao/core/system.py b/openmdao/core/system.py
index c75ec7ffa..c656dcb80 100644
--- a/openmdao/core/system.py
+++ b/openmdao/core/system.py
@@ -5,6 +5,7 @@ import hashlib
 import pathlib
 import time
 import functools
+from copy import deepcopy
 from contextlib import contextmanager
 from collections import defaultdict
 from itertools import chain
@@ -5544,7 +5545,7 @@ class System(object, metaclass=SystemMetaclass):
         return val
 
     def get_val(self, name, units=None, indices=None, get_remote=False, rank=None,
-                vec_name='nonlinear', kind=None, flat=False, from_src=True):
+                vec_name='nonlinear', kind=None, flat=False, from_src=True, copy=False):
         """
         Get an output/input/residual variable.
 
@@ -5576,6 +5577,8 @@ class System(object, metaclass=SystemMetaclass):
             If True, return the flattened version of the value.
         from_src : bool
             If True, retrieve value of an input variable from its connected source.
+        copy : bool, optional
+            If True, return a copy of the value.  If False, return a reference to the value.
 
         Returns
         -------
@@ -5597,9 +5600,9 @@ class System(object, metaclass=SystemMetaclass):
             else:
                 # src is outside of this system so get the value from the model
                 caller = self._problem_meta['model_ref']()
-            return caller._get_input_from_src(name, abs_names, conns, units=simp_units,
-                                              indices=indices, get_remote=get_remote, rank=rank,
-                                              vec_name=vec_name, flat=flat, scope_sys=self)
+            val = caller._get_input_from_src(name, abs_names, conns, units=simp_units,
+                                             indices=indices, get_remote=get_remote, rank=rank,
+                                             vec_name=vec_name, flat=flat, scope_sys=self)
         else:
             val = self._abs_get_val(abs_names[0], get_remote, rank, vec_name, kind, flat)
 
@@ -5609,7 +5612,10 @@ class System(object, metaclass=SystemMetaclass):
             if units is not None:
                 val = self.convert2units(abs_names[0], val, simp_units)
 
-        return val
+        if copy:
+            return deepcopy(val)
+        else:
+            return val
 
     def _get_cached_val(self, name, abs_names, get_remote=False):
         # We have set and cached already


Test patch:
diff --git a/openmdao/core/tests/test_problem.py b/openmdao/core/tests/test_problem.py
index 1fa86e3f6..bb5e1eeba 100644
--- a/openmdao/core/tests/test_problem.py
+++ b/openmdao/core/tests/test_problem.py
@@ -31,6 +31,56 @@ except ImportError:
 
 @use_tempdirs
 class TestProblem(unittest.TestCase):
+
+    def test_get_val(self):
+        class TestComp(om.ExplicitComponent):
+            def setup(self):
+                self.add_input('foo', shape=(3,))
+                self.add_discrete_input('mul', val=1)
+
+                self.add_output('bar', shape=(3,))
+                # add a mutable NumPy array as an output
+                self.add_discrete_output('obj', val=np.array([1, 'm', [2, 3, 4]], dtype=object))
+
+            def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
+                outputs['bar'] = discrete_inputs['mul']*inputs['foo']
+
+        p = om.Problem()
+        p.model.add_subsystem('comp', TestComp(), promotes=['*'])
+        p.setup()
+
+        p.set_val('foo', np.array([5., 5., 5.]))
+        p.set_val('mul', 100)
+        p.run_model()
+
+        foo = p.get_val('foo')
+        mul = p.get_val('mul')
+        bar = p.get_val('bar')
+        obj = p.get_val('obj')
+
+        self.assertTrue(np.array_equal(foo, np.array([5., 5., 5.])))
+        self.assertEqual(mul, 100)
+        self.assertTrue(np.array_equal(bar, np.array([500., 500., 500.])))
+
+        foo_copy = p.get_val('foo', copy=True)
+        mul_copy = p.get_val('mul', copy=True)
+        bar_copy = p.get_val('bar', copy=True)
+        obj_copy = p.get_val('obj', copy=True)
+
+        self.assertTrue(np.array_equal(foo_copy, np.array([5., 5., 5.])))
+        self.assertEqual(mul_copy, 100)
+        self.assertTrue(np.array_equal(bar_copy, np.array([500., 500., 500.])))
+
+        self.assertTrue(id(foo) != id(foo_copy), f"'foo' is not a copy, {id(foo)=} {id(foo_copy)=}")
+        self.assertTrue(id(mul) == id(mul_copy), f"'mul' is a copy, {id(foo)=} {id(foo_copy)=}")  # mul is a scalar
+        self.assertTrue(id(bar) != id(bar_copy), f"'bar' is not a copy, {id(bar)=} {id(bar_copy)=}")
+        self.assertTrue(id(obj) != id(obj_copy), f"'obj' is not a copy, {id(obj)=} {id(obj_copy)=}")
+
+        obj[2][0] = 10
+        self.assertEqual(obj[2][0], 10)
+        self.assertEqual(p.get_val('obj')[2][0], 10)  # the value in the problem was modified
+        self.assertEqual(obj_copy[2][0], 2)           # the value in the copy was not modified
+
     def test_simple_component_model_with_units(self):
         class TestComp(om.ExplicitComponent):
             def setup(self):
diff --git a/openmdao/core/tests/test_system.py b/openmdao/core/tests/test_system.py
index 4f5ad1637..f69348752 100644
--- a/openmdao/core/tests/test_system.py
+++ b/openmdao/core/tests/test_system.py
@@ -13,6 +13,57 @@ from openmdao.utils.file_utils import _get_work_dir
 @use_tempdirs
 class TestSystem(unittest.TestCase):
 
+    def test_get_val(self):
+        import openmdao.api as om
+
+        class TestComp(om.ExplicitComponent):
+            def setup(self):
+                self.add_input('foo', shape=(3,))
+                self.add_discrete_input('mul', val=1)
+
+                self.add_output('bar', shape=(3,))
+                # add a mutable NumPy array as an output
+                self.add_discrete_output('obj', val=np.array([1, 'm', [2, 3, 4]], dtype=object))
+
+            def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
+                outputs['bar'] = discrete_inputs['mul']*inputs['foo']
+
+        p = om.Problem()
+        comp = p.model.add_subsystem('comp', TestComp(), promotes=['*'])
+        p.setup()
+
+        p.set_val('foo', np.array([5., 5., 5.]))
+        p.set_val('mul', 100)
+        p.run_model()
+
+        foo = comp.get_val('foo')
+        mul = comp.get_val('mul')
+        bar = comp.get_val('bar')
+        obj = comp.get_val('obj')
+
+        self.assertTrue(np.array_equal(foo, np.array([5., 5., 5.])))
+        self.assertEqual(mul, 100)
+        self.assertTrue(np.array_equal(bar, np.array([500., 500., 500.])))
+
+        foo_copy = comp.get_val('foo', copy=True)
+        mul_copy = comp.get_val('mul', copy=True)
+        bar_copy = comp.get_val('bar', copy=True)
+        obj_copy = comp.get_val('obj', copy=True)
+
+        self.assertTrue(np.array_equal(foo_copy, np.array([5., 5., 5.])))
+        self.assertEqual(mul_copy, 100)
+        self.assertTrue(np.array_equal(bar_copy, np.array([500., 500., 500.])))
+
+        self.assertTrue(id(foo) != id(foo_copy), f"'foo' is not a copy, {id(foo)=} {id(foo_copy)=}")
+        self.assertTrue(id(mul) == id(mul_copy), f"'mul' is a copy, {id(foo)=} {id(foo_copy)=}")  # mul is a scalar
+        self.assertTrue(id(bar) != id(bar_copy), f"'bar' is not a copy, {id(bar)=} {id(bar_copy)=}")
+        self.assertTrue(id(obj) != id(obj_copy), f"'obj' is not a copy, {id(obj)=} {id(obj_copy)=}")
+
+        obj[2][0] = 10
+        self.assertEqual(obj[2][0], 10)
+        self.assertEqual(comp.get_val('obj')[2][0], 10)  # the value in the system was modified
+        self.assertEqual(obj_copy[2][0], 2)              # the value in the copy was not modified
+
     def test_vector_context_managers(self):
         g1 = Group()
         g1.add_subsystem('Indep', IndepVarComp('a', 5.0), promotes=['a'])