Repository: Pyomo/pyomo

Issue:
deepcopy of SolverResults duplicates keys
<!-- DO NOT DELETE OR IGNORE THIS TEMPLATE.  Failing to adhere to this template and provide the necessary information may lead to your Issue being closed without consideration. -->

## Summary

<!-- Explain, in a clear and concise way, the command you ran and the result you were trying to achieve.
Example: "I ran `pyomo solve` to optimize a model and ..." -->

When performing a deepcopy of `SolverResults`, they keys become duplicated.

### Steps to reproduce the issue

<!-- Please include the command run and/or provide an example script that demonstrates the problem. -->

```
import copy

import pyomo.environ as pyo

m = pyo.ConcreteModel()
m.a = pyo.Var(bounds=(0, 1))
m.b = pyo.Var(bounds=(0, 1))
m.obj = pyo.Objective(rule=m.a+m.b)

opt = pyo.SolverFactory('highs')
result = opt.solve(m)
print(result.keys())
# ['Problem', 'Solver', 'Solution', 'Timing info']

result2 = copy.deepcopy(result)
print(result2.keys())
# ['Problem', 'Solver', 'Solution', 'Timing info', 'Problem', 'Solver', 'Solution', 'Timing info']
```

### Information on your system

Pyomo version: 6.9.2
Python version: 3.10.17
Operating system: Mac
How Pyomo was installed (PyPI, conda, source): PyPI
Solver (if applicable): NA


### Additional information

I haven't throughly checked the remaining state of the copied SolverResults to see if there are any other issues.




Discussion:

Patch:
diff --git a/pyomo/opt/results/container.py b/pyomo/opt/results/container.py
index 541f56d3692..57cbf8a594e 100644
--- a/pyomo/opt/results/container.py
+++ b/pyomo/opt/results/container.py
@@ -9,12 +9,11 @@
 #  This software is distributed under the 3-clause BSD License.
 #  ___________________________________________________________________________
 
-import copy
 import enum
 from io import StringIO
 from math import inf
 
-from pyomo.common.collections import Bunch
+from pyomo.common.collections import Bunch, Sequence, Mapping
 
 
 class ScalarType(str, enum.Enum):
@@ -35,17 +34,31 @@ def __str__(self):
 
 
 default_print_options = Bunch(schema=False, ignore_time=False)
-
 strict = False
 
 
 class UndefinedData(object):
+    singleton = {}
+
+    def __new__(cls, name='undefined'):
+        if name not in UndefinedData.singleton:
+            UndefinedData.singleton[name] = super().__new__(cls)
+            UndefinedData.singleton[name].name = name
+        return UndefinedData.singleton[name]
+
+    def __deepcopy__(self, memo):
+        # Prevent deepcopy from duplicating this object
+        return self
+
+    def __reduce__(self):
+        return self.__class__, (self.name,)
+
     def __str__(self):
-        return "<undefined>"
+        return f"<{self.name}>"
 
 
-undefined = UndefinedData()
-ignore = UndefinedData()
+undefined = UndefinedData('undefined')
+ignore = UndefinedData('ignore')
 
 
 class ScalarData(object):
@@ -64,6 +77,10 @@ def __init__(
         self.scalar_description = scalar_description
         self.scalar_type = type
         self._required = required
+        self._active = False
+
+    def __eq__(self, other):
+        return self.__dict__ == other.__dict__
 
     def get_value(self):
         if isinstance(self.value, enum.Enum):
@@ -109,9 +126,9 @@ def pprint(self, ostream, option, prefix="", repn=None):
 
         value = self.yaml_fix(self.get_value())
 
-        if value is inf:
+        if value == inf:
             value = '.inf'
-        elif value is -inf:
+        elif value == -inf:
             value = '-.inf'
 
         if not option.schema and self.description is None and self.units is None:
@@ -149,8 +166,8 @@ def yaml_fix(self, val):
 
     def load(self, repn):
         if type(repn) is dict:
-            for key in repn:
-                setattr(self, key, repn[key])
+            for key, val in repn.items():
+                setattr(self, key, val)
         else:
             self.value = repn
 
@@ -167,12 +184,15 @@ def __init__(self, cls):
 
     def __len__(self):
         if '_list' in self.__dict__:
-            return len(self.__dict__['_list'])
+            return len(self._list)
         return 0
 
     def __getitem__(self, i):
         return self._list[i]
 
+    def __eq__(self, other):
+        return self.__dict__ == other.__dict__
+
     def clear(self):
         self._list = []
 
@@ -183,21 +203,15 @@ def __call__(self, i=0):
         return self._list[i]
 
     def __getattr__(self, name):
-        try:
-            return self.__dict__[name]
-        except:
-            pass
+        if name[0] == "_":
+            super().__getattr__(name)
         if len(self) == 0:
             self.add()
         return getattr(self._list[0], name)
 
     def __setattr__(self, name, val):
-        if name == "__class__":
-            self.__class__ = val
-            return
         if name[0] == "_":
-            self.__dict__[name] = val
-            return
+            return super().__setattr__(name, val)
         if len(self) == 0:
             self.add()
         setattr(self._list[0], name, val)
@@ -239,16 +253,10 @@ def load(self, repn):
             item = self.add()
             item.load(data)
 
-    def __getstate__(self):
-        return copy.copy(self.__dict__)
-
-    def __setstate__(self, state):
-        self.__dict__.update(state)
-
     def __str__(self):
         ostream = StringIO()
         option = default_print_options
-        self.pprint(ostream, self._option, repn=self._repn_(self._option))
+        self.pprint(ostream, option, repn=self._repn_(option))
         return ostream.getvalue()
 
 
@@ -259,41 +267,21 @@ def __str__(self):
 # first letter is capitalized.
 #
 class MapContainer(dict):
-    def __getnewargs_ex__(self):
-        # Pass arguments to __new__ when unpickling
-        return ((0, 0), {})
-
-    def __getnewargs__(self):
-        # Pass arguments to __new__ when unpickling
-        return (0, 0)
-
-    def __new__(cls, *args, **kwargs):
-        #
-        # If the user provides "too many" arguments, then
-        # pre-initialize the '_order' attribute.  This pre-initializes
-        # the class during unpickling.
-        #
-        _instance = super(MapContainer, cls).__new__(cls, *args, **kwargs)
-        if len(args) > 1:
-            super(MapContainer, _instance).__setattr__('_order', [])
-        return _instance
 
     def __init__(self, ordered=False):
-        dict.__init__(self)
+        super().__init__()
         self._active = True
         self._required = False
-        self._ordered = ordered
-        self._order = []
         self._option = default_print_options
 
-    def keys(self):
-        return self._order
+    def __eq__(self, other):
+        # We need to check both our __dict__ (local attributes) and the
+        # underlying dict data (which doesn't show up in the __dict__).
+        # So we will use the base __eq__ in addition to checking
+        # __dict__.
+        return super().__eq__(other) and self.__dict__ == other.__dict__
 
     def __getattr__(self, name):
-        try:
-            return self.__dict__[name]
-        except:
-            pass
         try:
             self._active = True
             return self[self._convert(name)]
@@ -307,12 +295,8 @@ def __getattr__(self, name):
         )
 
     def __setattr__(self, name, val):
-        if name == "__class__":
-            self.__class__ = val
-            return
         if name[0] == "_":
-            self.__dict__[name] = val
-            return
+            return super().__setattr__(name, val)
         self._active = True
         tmp = self._convert(name)
         if tmp not in self:
@@ -341,12 +325,18 @@ def __setitem__(self, name, val):
         self._set_value(tmp, val)
 
     def _set_value(self, name, val):
-        if isinstance(val, ListContainer) or isinstance(val, MapContainer):
-            dict.__setitem__(self, name, val)
+        if isinstance(val, (ListContainer, MapContainer)):
+            super().__setitem__(name, val)
         elif isinstance(val, ScalarData):
-            dict.__getitem__(self, name).value = val.value
+            data = super().__getitem__(name)
+            data.value = val.value
+            data._active = val._active
+            data._required = val._required
+            data.scalar_type = val.scalar_type
         else:
-            dict.__getitem__(self, name).value = val
+            data = super().__getitem__(name)
+            data.value = val
+            data._active = True
 
     def __getitem__(self, name):
         tmp = self._convert(name)
@@ -357,25 +347,21 @@ def __getitem__(self, name):
                 + "' for object with type "
                 + str(type(self))
             )
-        item = dict.__getitem__(self, tmp)
-        if isinstance(item, ListContainer) or isinstance(item, MapContainer):
+        item = super().__getitem__(tmp)
+        if isinstance(item, (ListContainer, MapContainer)):
             return item
         return item.value
 
     def declare(self, name, **kwds):
         if name in self or type(name) is int:
             return
-        tmp = self._convert(name)
-        self._order.append(tmp)
-        if 'value' in kwds and (
-            isinstance(kwds['value'], MapContainer)
-            or isinstance(kwds['value'], ListContainer)
-        ):
+        data = kwds.get('value', None)
+        if isinstance(data, (MapContainer, ListContainer)):
             if 'active' in kwds:
-                kwds['value']._active = kwds['active']
+                data._active = kwds['active']
             if 'required' in kwds and kwds['required'] is True:
-                kwds['value']._required = True
-            dict.__setitem__(self, tmp, kwds['value'])
+                data._required = True
+            super().__setitem__(self._convert(name), data)
         else:
             data = ScalarData(**kwds)
             if 'required' in kwds and kwds['required'] is True:
@@ -387,23 +373,16 @@ def declare(self, name, **kwds):
             #
             # if 'value' in kwds:
             #    data._default = kwds['value']
-            dict.__setitem__(self, tmp, data)
+            super().__setitem__(self._convert(name), data)
 
     def _repn_(self, option):
         if not option.schema and not self._active and not self._required:
             return ignore
-        if self._ordered:
-            tmp = []
-            for key in self._order:
-                rep = dict.__getitem__(self, key)._repn_(option)
-                if not rep == ignore:
-                    tmp.append({key: rep})
-        else:
-            tmp = {}
-            for key in self.keys():
-                rep = dict.__getitem__(self, key)._repn_(option)
-                if not rep == ignore:
-                    tmp[key] = rep
+        tmp = {}
+        for key, val in self.items():
+            rep = val._repn_(option)
+            if not rep == ignore:
+                tmp[key] = rep
         return tmp
 
     def _convert(self, name):
@@ -417,7 +396,6 @@ def __repr__(self):
 
     def __str__(self):
         ostream = StringIO()
-        option = default_print_options
         self.pprint(ostream, self._option, repn=self._repn_(self._option))
         return ostream.getvalue()
 
@@ -427,10 +405,9 @@ def pprint(self, ostream, option, from_list=False, prefix="", repn=None):
         else:
             _prefix = prefix
             ostream.write('\n')
-        for key in self._order:
+        for key, item in self.items():
             if not key in repn:
                 continue
-            item = dict.__getitem__(self, key)
             ostream.write(_prefix + key + ": ")
             _prefix = prefix
             if isinstance(item, ListContainer):
@@ -439,46 +416,16 @@ def pprint(self, ostream, option, from_list=False, prefix="", repn=None):
                 item.pprint(ostream, option, prefix=_prefix + "  ", repn=repn[key])
 
     def load(self, repn):
-        for key in repn:
+        for key, val in repn.items():
             tmp = self._convert(key)
             if tmp not in self:
                 self.declare(tmp)
-            item = dict.__getitem__(self, tmp)
+            item = super().__getitem__(tmp)
             item._active = True
-            item.load(repn[key])
-
-    def __getnewargs__(self):
-        return (False, False)
-
-    def __getstate__(self):
-        return copy.copy(self.__dict__)
-
-    def __setstate__(self, state):
-        self.__dict__.update(state)
-
-
-if __name__ == '__main__':
-    d = MapContainer()
-    d.declare('f')
-    d.declare('g')
-    d.declare('h')
-    d.declare('i', value=ListContainer(UndefinedData))
-    d.declare('j', value=ListContainer(UndefinedData), active=False)
-    print("X")
-    d.f = 1
-    print("Y")
-    print(d.f)
-    print(d.keys())
-    d.g = None
-    print(d.keys())
-    try:
-        print(d.f, d.g, d.h)
-    except:
-        pass
-    d['h'] = None
-    print("")
-    print("FINAL")
-    print(d.f, d.g, d.h, d.i, d.j)
-    print(d.i._active, d.j._active)
-    d.j.add()
-    print(d.i._active, d.j._active)
+            item.load(val)
+
+
+# Register these as sequence / mapping types (so things like
+# assertStructuredAlmostEqual will process them correctly)
+Sequence.register(ListContainer)
+Mapping.register(MapContainer)
diff --git a/pyomo/opt/results/problem.py b/pyomo/opt/results/problem.py
index b53ea67da24..c61dcba2ef5 100644
--- a/pyomo/opt/results/problem.py
+++ b/pyomo/opt/results/problem.py
@@ -24,7 +24,7 @@ def __str__(self):
 
 class ProblemInformation(MapContainer):
     def __init__(self):
-        MapContainer.__init__(self)
+        super().__init__()
         self.declare('name')
         self.declare('lower_bound', value=float('-inf'))
         self.declare('upper_bound', value=float('inf'))
diff --git a/pyomo/opt/results/results_.py b/pyomo/opt/results/results_.py
index b542deb9f79..19c5d93c4a8 100644
--- a/pyomo/opt/results/results_.py
+++ b/pyomo/opt/results/results_.py
@@ -29,12 +29,19 @@
 logger = logging.getLogger(__name__)
 
 
+def _guess_format(filename):
+    "Return a standardized file format by looking at the filename extension"
+    return {'.json': 'json', '.jsn': 'json', '.yaml': 'yaml', '.yml': 'yaml'}.get(
+        os.path.splitext(filename)[1].lower(), None
+    )
+
+
 class SolverResults(MapContainer):
     undefined = undefined
     default_print_options = dpo
 
     def __init__(self):
-        MapContainer.__init__(self)
+        super().__init__()
         self._sections = []
         self._descriptions = {}
         self.add(
@@ -79,51 +86,40 @@ def _repn_(self, option):
         return tmp
 
     def write(self, **kwds):
-        _fmt = kwds.pop('format', None)
-        if _fmt:
-            _fmt = _fmt.lower()
-        fname = kwds.pop('filename', None)
-
-        if fname:
-            ext = os.path.splitext(fname)[1].lstrip('.')
-            normalized_ext = {
-                'json': 'json',
-                'jsn': 'json',
-                'yaml': 'yaml',
-                'yml': 'yaml',
-            }.get(ext, None)
-            if not _fmt:
-                _fmt = normalized_ext
-            elif normalized_ext and _fmt != normalized_ext:
+        _format = kwds.pop('format', None)
+        if _format:
+            _format = _format.lower()
+        if 'filename' in kwds:
+            filename = kwds.pop('filename')
+            _guess = _guess_format(filename)
+            if not _format:
+                _format = _guess
+            if _guess and _format != _guess:
                 logger.warning(
                     "writing results to file (%s) using what appears "
-                    "to be an incompatible format (%s)" % (fname, _fmt)
+                    "to be an incompatible format (%s)" % (filename, _format)
                 )
-            with open(fname, "w") as OUTPUT:
-                kwds['ostream'] = OUTPUT
-                kwds['format'] = _fmt
-                self.write(**kwds)
+            if 'ostream' in kwds:
+                raise ValueError("Cannot specify both filename and ostream")
+            with open(filename, "w") as OUTPUT:
+                return self.write(ostream=OUTPUT, format=_format, **kwds)
+
+        ostream = kwds.pop('ostream', sys.stdout)
+        if not _format:
+            _format = 'yaml'
+        if _format == 'yaml':
+            self.write_yaml(ostream=ostream, **kwds)
+        elif _format == 'json':
+            self.write_json(ostream=ostream, **kwds)
         else:
-            if not _fmt:
-                _fmt = 'yaml'
-            if _fmt == 'yaml':
-                self.write_yaml(**kwds)
-            elif _fmt == 'json':
-                self.write_json(**kwds)
-            else:
-                raise ValueError("Unknown results file format: %s" % (_fmt,))
+            raise ValueError("Unknown results file format: %s" % (_format,))
 
     def write_json(self, **kwds):
-        if 'ostream' in kwds:
-            ostream = kwds['ostream']
-            del kwds['ostream']
-        else:
-            ostream = sys.stdout
-
+        ostream = kwds.pop('ostream', sys.stdout)
         option = copy.copy(SolverResults.default_print_options)
         # TODO: verify that we need this for-loop
-        for key in kwds:
-            setattr(option, key, kwds[key])
+        for key, val in kwds.items():
+            setattr(option, key, val)
         repn = self.json_repn(option)
 
         for soln in repn.get('Solution', []):
@@ -158,12 +154,7 @@ def write_json(self, **kwds):
         json.dump(repn, ostream, indent=4, sort_keys=True)
 
     def write_yaml(self, **kwds):
-        if 'ostream' in kwds:
-            ostream = kwds['ostream']
-            del kwds['ostream']
-        else:
-            ostream = sys.stdout
-
+        ostream = kwds.pop('ostream', sys.stdout)
         option = copy.copy(SolverResults.default_print_options)
         # TODO: verify that we need this for-loop
         for key in kwds:
@@ -173,11 +164,9 @@ def write_yaml(self, **kwds):
         ostream.write("# ==========================================================\n")
         ostream.write("# = Solver Results                                         =\n")
         ostream.write("# ==========================================================\n")
-        for i in range(len(self._order)):
-            key = self._order[i]
+        for key, item in self.items():
             if not key in repn:
                 continue
-            item = dict.__getitem__(self, key)
             ostream.write(
                 "# ----------------------------------------------------------\n"
             )
@@ -192,29 +181,27 @@ def write_yaml(self, **kwds):
                 item.pprint(ostream, option, prefix="  ", repn=repn[key])
 
     def read(self, **kwds):
-        if 'istream' in kwds:
-            istream = kwds['istream']
-            del kwds['istream']
-        else:
-            ostream = sys.stdin
+        _format = kwds.pop('format', None)
+        if _format:
+            _format = _format.lower()
         if 'filename' in kwds:
-            INPUT = open(kwds['filename'], "r")
-            del kwds['filename']
-            kwds['istream'] = INPUT
-            self.read(**kwds)
-            INPUT.close()
-            return
-
-        if not 'format' in kwds or kwds['format'] == 'yaml':
+            filename = kwds.pop('filename')
+            if not _format:
+                _format = _guess_format(filename)
+            if 'istream' in kwds:
+                raise ValueError("Cannot specify both filename and istream")
+            with open(filename, "r") as INPUT:
+                return self.read(istream=INPUT, format=_format, **kwds)
+
+        istream = kwds.pop('istream', sys.stdin)
+        if not _format or _format == 'yaml':
             repn = yaml.load(istream, **yaml_load_args)
-        else:
+        elif _format == 'json':
             repn = json.load(istream)
-        for i in range(len(self._order)):
-            key = self._order[i]
-            if not key in repn:
-                continue
-            item = dict.__getitem__(self, key)
-            item.load(repn[key])
+        else:
+            raise ValueError(f"Unknown SolverResults format: '{_format}'")
+        for key, item in repn.items():
+            dict.__getitem__(self, key).load(item)
 
     def __repr__(self):
         return str(self._repn_(SolverResults.default_print_options))
diff --git a/pyomo/opt/results/solution.py b/pyomo/opt/results/solution.py
index bf6c419ea20..fdf70eb4949 100644
--- a/pyomo/opt/results/solution.py
+++ b/pyomo/opt/results/solution.py
@@ -67,22 +67,13 @@ def __init__(self):
 
     def load(self, repn):
         # delete key from dictionary, call base class load, handle variable loading.
-        if "Variable" in repn:
-            tmp_ = repn["Variable"]
-            del repn["Variable"]
-            self.variable = tmp_
-        if "Constraint" in repn:
-            tmp_ = repn["Constraint"]
-            del repn["Constraint"]
-            self.constraint = tmp_
-        if "Problem" in repn:
-            tmp_ = repn["Problem"]
-            del repn["Problem"]
-            self.problem = tmp_
-        if "Objective" in repn:
-            tmp_ = repn["Objective"]
-            del repn["Objective"]
-            self.objective = tmp_
+        for field in ("Variable", "Constraint", "Problem", "Objective"):
+            if field in repn:
+                tmp_ = repn[field]
+                del repn[field]
+            if tmp_ == "No values":
+                tmp_ = {}
+            setattr(self, field.lower(), tmp_)
         MapContainer.load(self, repn)
 
     def pprint(self, ostream, option, from_list=False, prefix="", repn=None):
@@ -92,11 +83,10 @@ def pprint(self, ostream, option, from_list=False, prefix="", repn=None):
         # at a minimum an "id" element per sub-dictionary.
         #
         first = True
-        for key in self._order:
+        for key, item in self.items():
             if not key in repn or key == 'Problem':
                 continue
-            item = dict.__getitem__(self, key)
-            if not type(item.value) is dict:
+            if type(item.value) is not dict:
                 #
                 # Do a normal print
                 #


Test patch:
diff --git a/pyomo/opt/testing/__init__.py b/pyomo/opt/testing/__init__.py
deleted file mode 100644
index 515520123ac..00000000000
--- a/pyomo/opt/testing/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-#  ___________________________________________________________________________
-#
-#  Pyomo: Python Optimization Modeling Objects
-#  Copyright (c) 2008-2025
-#  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.
-#  ___________________________________________________________________________
-
-from pyomo.opt.testing.pyunit import _failIfPyomoResultsDiffer, TestCase
diff --git a/pyomo/opt/testing/pyunit.py b/pyomo/opt/testing/pyunit.py
deleted file mode 100644
index 98c96ee20a5..00000000000
--- a/pyomo/opt/testing/pyunit.py
+++ /dev/null
@@ -1,108 +0,0 @@
-#  ___________________________________________________________________________
-#
-#  Pyomo: Python Optimization Modeling Objects
-#  Copyright (c) 2008-2025
-#  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.
-#  ___________________________________________________________________________
-
-import sys
-import os
-import re
-from inspect import getfile
-
-import pyomo.common.unittest as unittest
-import subprocess
-
-
-def _failIfPyomoResultsDiffer(self, cmd=None, baseline=None, cwd=None):
-    if cwd is None:
-        cwd = os.path.dirname(os.path.abspath(getfile(self.__class__)))
-    oldpwd = os.getcwd()
-    os.chdir(cwd)
-
-    try:
-        # get the baseline file
-        if os.path.exists(baseline):
-            INPUT = open(baseline, 'r')
-            baseline = "\n".join(INPUT.readlines())
-            INPUT.close()
-
-        output = subprocess.run(
-            cmd,
-            stdout=subprocess.PIPE,
-            stderr=subprocess.STDOUT,
-            universal_newlines=True,
-        )
-    finally:
-        os.chdir(oldpwd)
-
-    if output.returncode != 0:
-        self.fail("Command terminated with nonzero status: '%s'" % cmd)
-    # !!THIS SEEMS LIKE A BUG!! - mrmundt #
-    # There is nothing imported called "extract_results" nor "compare_results"
-    results = extract_results(re.split('\n', output.stdout))
-    try:
-        compare_results(results, baseline)
-    except IOError:
-        err = sys.exc_info()[1]
-        self.fail(
-            "Command failed to generate results that can be compared with the baseline: '%s'"
-            % err
-        )
-    except ValueError:
-        err = sys.exc_info()[1]
-        self.fail("Difference between results and baseline: '%s'" % err)
-
-
-class TestCase(unittest.TestCase):
-    def __init__(self, methodName='runTest'):
-        unittest.TestCase.__init__(self, methodName)
-
-    def failIfPyomoResultsDiffer(self, cmd, baseline, cwd=None):
-        _failIfPyomoResultsDiffer(self, cmd=cmd, baseline=baseline, cwd=cwd)
-
-    def add_pyomo_results(cls, name=None, cmd=None, fn=None, baseline=None, cwd=None):
-        if cmd is None and fn is None:
-            print(
-                "ERROR: must specify either the 'cmd' or 'fn' option to define how the output file is generated"
-            )
-            return
-        if name is None and baseline is None:
-            print("ERROR: must specify a baseline comparison file, or the test name")
-            return
-        if baseline is None:
-            baseline = name + ".txt"
-        tmp = name.replace("/", "_")
-        tmp = tmp.replace("\\", "_")
-        tmp = tmp.replace(".", "_")
-        #
-        # Create an explicit function so we can assign it a __name__ attribute.
-        # This is needed by the 'nose' package
-        #
-        if fn is None:
-            func = lambda self, c1=cwd, c2=cmd, c3=tmp + ".out", c4=baseline: _failIfPyomoResultsDiffer(
-                self, cwd=c1, cmd=c2, baseline=c4
-            )
-        else:
-            # This option isn't implemented...
-            sys.exit(1)
-            func = lambda self, c1=fn, c2=tmp, c3=baseline: _failIfPyomoResultsDiffer(
-                self, fn=c1, name=c2, baseline=c3
-            )
-        func.__name__ = "test_" + tmp
-        func.__doc__ = (
-            "pyomo result test: "
-            + func.__name__
-            + " ("
-            + str(cls.__module__)
-            + '.'
-            + str(cls.__name__)
-            + ")"
-        )
-        setattr(cls, "test_" + tmp, func)
-
-    add_pyomo_results = classmethod(add_pyomo_results)
diff --git a/pyomo/opt/tests/base/test1_soln.txt b/pyomo/opt/tests/base/test1_soln.txt
deleted file mode 100644
index dee560f2991..00000000000
--- a/pyomo/opt/tests/base/test1_soln.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-# ==========================================================
-# = Solver Results                                         =
-# ==========================================================
-# ----------------------------------------------------------
-#   Solution Information
-# ----------------------------------------------------------
-Solution: 
-- number of solutions: 1
-  number of solutions displayed: 1
-- Status: unknown
-  Objective: No values
-  Variable: No nonzero values
-  Constraint: No values
diff --git a/pyomo/opt/tests/base/test2_soln.txt b/pyomo/opt/tests/base/test2_soln.txt
deleted file mode 100644
index dee560f2991..00000000000
--- a/pyomo/opt/tests/base/test2_soln.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-# ==========================================================
-# = Solver Results                                         =
-# ==========================================================
-# ----------------------------------------------------------
-#   Solution Information
-# ----------------------------------------------------------
-Solution: 
-- number of solutions: 1
-  number of solutions displayed: 1
-- Status: unknown
-  Objective: No values
-  Variable: No nonzero values
-  Constraint: No values
diff --git a/pyomo/opt/tests/base/test3_soln.txt b/pyomo/opt/tests/base/test3_soln.txt
deleted file mode 100644
index dee560f2991..00000000000
--- a/pyomo/opt/tests/base/test3_soln.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-# ==========================================================
-# = Solver Results                                         =
-# ==========================================================
-# ----------------------------------------------------------
-#   Solution Information
-# ----------------------------------------------------------
-Solution: 
-- number of solutions: 1
-  number of solutions displayed: 1
-- Status: unknown
-  Objective: No values
-  Variable: No nonzero values
-  Constraint: No values
diff --git a/pyomo/opt/tests/base/test4_soln.txt b/pyomo/opt/tests/base/test4_soln.txt
deleted file mode 100644
index 7b0e676fe93..00000000000
--- a/pyomo/opt/tests/base/test4_soln.txt
+++ /dev/null
@@ -1,21 +0,0 @@
-==========================================================
----  Solver Results                                    ---
-==========================================================
-----------------------------------------------------------
-------  Problem Information                         ------
-----------------------------------------------------------
-  lower_bound: None
-  name: None
-  num_constraints: None
-  num_objectives: None
-  num_variables: None
-  sense: unknown
-  upper_bound: None
-----------------------------------------------------------
-------  Solver Information                          ------
-----------------------------------------------------------
-  status: ok
-  systime: None
-  usrtime: None
-  num_solutions: 0
-----------------------------------------------------------
diff --git a/pyomo/opt/tests/base/test_soln.py b/pyomo/opt/tests/base/test_soln.py
index 3d45ddfec0d..c3547f3f48f 100644
--- a/pyomo/opt/tests/base/test_soln.py
+++ b/pyomo/opt/tests/base/test_soln.py
@@ -9,150 +9,385 @@
 #  This software is distributed under the 3-clause BSD License.
 #  ___________________________________________________________________________
 #
-# Unit Tests for pyomo.opt.base.solution
+# Unit Tests for pyomo.opt.results
 #
-
+import copy
 import json
 import pickle
 import os
-from os.path import abspath, dirname, join
-
-pyomodir = dirname(abspath(__file__)) + os.sep + ".." + os.sep + ".." + os.sep
-currdir = dirname(abspath(__file__)) + os.sep
 
+from io import StringIO
+from os.path import join
 from filecmp import cmp
-import pyomo.common.unittest as unittest
 
+import pyomo.common.unittest as unittest
+from pyomo.common.dependencies import yaml, yaml_available
+from pyomo.common.fileutils import this_file_dir
 from pyomo.common.tempfiles import TempfileManager
-import pyomo.opt
 
-from pyomo.common.dependencies import yaml, yaml_available
+import pyomo.opt.results.container as container
+from pyomo.opt import SolverResults
+
+currdir = this_file_dir()
+
+ref_yaml_soln = """\
+# ==========================================================
+# = Solver Results                                         =
+# ==========================================================
+
+# ----------------------------------------------------------
+#   Problem Information
+# ----------------------------------------------------------
+Problem: 
+- Lower bound: -Infinity
+  Upper bound: Infinity
+  Number of objectives: 1
+  Number of constraints: 24
+  Number of variables: 32
+  Sense: unknown
+
+# ----------------------------------------------------------
+#   Solver Information
+# ----------------------------------------------------------
+Solver: 
+- Status: ok
+  Message: PICO Solver\\x3a final f = 88.200000
+  Termination condition: optimal
+  Id: 0
 
-old_tempdir = TempfileManager.tempdir
+# ----------------------------------------------------------
+#   Solution Information
+# ----------------------------------------------------------
+Solution: 
+- number of solutions: 1
+  number of solutions displayed: 1
+- Status: optimal
+  Variable: 
+    v4:
+      Value: 46.6666666667
+    v11:
+      Value: 933.333333333
+    v12:
+      Value: 10000
+    v13:
+      Value: 10000
+    v14:
+      Value: 10000
+    v15:
+      Value: 10000
+    v17:
+      Value: 100
+    v19:
+      Value: 100
+    v21:
+      Value: 100
+    v23:
+      Value: 100
+    v24:
+      Value: 46.6666666667
+    v25:
+      Value: 53.3333333333
+    v27:
+      Value: 100
+    v29:
+      Value: 100
+    v31:
+      Value: 100
+  Constraint: 
+    c2: 
+      Dual: 0.126
+  Status description: OPTIMAL SOLUTION FOUND!
+"""
+
+ref_json_soln = """\
+{
+    "Problem": [
+        {
+            "Lower bound": -Infinity, 
+            "Number of constraints": 24, 
+            "Number of objectives": 1, 
+            "Number of variables": 32, 
+            "Sense": "unknown", 
+            "Upper bound": Infinity
+        }
+    ], 
+    "Solution": [
+        {
+            "number of solutions": 1, 
+            "number of solutions displayed": 1
+        }, 
+        {
+            "Constraint": {
+                "c2": {
+                    "Dual": 0.12599999999999997
+                }
+            }, 
+            "Message": "PICO Solver\\\\x3a final f = 88.200000", 
+            "Status": "optimal", 
+            "Status description": "OPTIMAL SOLUTION FOUND!", 
+            "Variable": {
+                "v11": {
+                    "Value": 933.3333333333336
+                }, 
+                "v12": {
+                    "Value": 10000.0
+                }, 
+                "v13": {
+                    "Value": 10000.0
+                }, 
+                "v14": {
+                    "Value": 10000.0
+                }, 
+                "v15": {
+                    "Value": 10000.0
+                }, 
+                "v17": {
+                    "Value": 100.0
+                }, 
+                "v19": {
+                    "Value": 100.0
+                }, 
+                "v21": {
+                    "Value": 100.0
+                }, 
+                "v23": {
+                    "Value": 100.0
+                }, 
+                "v24": {
+                    "Value": 46.666666666666664
+                }, 
+                "v25": {
+                    "Value": 53.333333333333336
+                }, 
+                "v27": {
+                    "Value": 100.0
+                }, 
+                "v29": {
+                    "Value": 100.0
+                }, 
+                "v31": {
+                    "Value": 100.0
+                }, 
+                "v4": {
+                    "Value": 46.666666666666664
+                }
+            }
+        }
+    ], 
+    "Solver": [
+        {
+            "Id": 0, 
+            "Message": "PICO Solver\\\\x3a final f = 88.200000", 
+            "Status": "ok", 
+            "Termination condition": "optimal"
+        }
+    ]
+}
+"""
 
 
 class Test(unittest.TestCase):
-    def setUp(self):
-        TempfileManager.tempdir = currdir
-        self.results = pyomo.opt.SolverResults()
-        self.soln = self.results.solution.add()
-        self.soln.variable[1] = {"Value": 0}
-        self.soln.variable[2] = {"Value": 0}
-        self.soln.variable[4] = {"Value": 0}
-
-    def tearDown(self):
-        TempfileManager.clear_tempfiles()
-        TempfileManager.tempdir = old_tempdir
-        del self.results
-
-    def test_write_solution1(self):
-        """Write a SolverResults Object with solutions"""
-        self.results.write(filename=join(currdir, "write_solution1.txt"))
-        if not os.path.exists(join(currdir, "write_solution1.txt")):
-            self.fail("test_write_solution - failed to write write_solution1.txt")
-        _log, _out = join(currdir, "write_solution1.txt"), join(
-            currdir, "test1_soln.txt"
-        )
-        self.assertTrue(cmp(_out, _log), msg="Files %s and %s differ" % (_out, _log))
-
-    def test_write_solution2(self):
-        """Write a SolverResults Object without solutions"""
-        self.results.write(num=None, filename=join(currdir, "write_solution2.txt"))
-        if not os.path.exists(join(currdir, "write_solution2.txt")):
-            self.fail("test_write_solution - failed to write write_solution2.txt")
-        _out, _log = join(currdir, "write_solution2.txt"), join(
-            currdir, "test2_soln.txt"
-        )
-        self.assertTrue(cmp(_out, _log), msg="Files %s and %s differ" % (_out, _log))
-
-    @unittest.skipIf(not yaml_available, "Cannot import 'yaml'")
-    def test_read_solution1(self):
-        """Read a SolverResults Object"""
-        self.results = pyomo.opt.SolverResults()
-        self.results.read(filename=join(currdir, "test4_sol.txt"))
-        self.results.write(filename=join(currdir, "read_solution1.out"))
-        if not os.path.exists(join(currdir, "read_solution1.out")):
-            self.fail("test_read_solution1 - failed to write read_solution1.out")
-        with open(join(currdir, "read_solution1.out"), 'r') as out, open(
-            join(currdir, "test4_sol.txt"), 'r'
-        ) as txt:
-            self.assertStructuredAlmostEqual(
-                yaml.full_load(txt), yaml.full_load(out), allow_second_superset=True
-            )
-
-    @unittest.skipIf(not yaml_available, "Cannot import 'yaml'")
-    def test_pickle_solution1(self):
-        """Read a SolverResults Object"""
-        self.results = pyomo.opt.SolverResults()
-        self.results.read(filename=join(currdir, "test4_sol.txt"))
-        str = pickle.dumps(self.results)
-        res = pickle.loads(str)
-        self.results.write(filename=join(currdir, "read_solution1.out"))
-        if not os.path.exists(join(currdir, "read_solution1.out")):
-            self.fail("test_read_solution1 - failed to write read_solution1.out")
-        with open(join(currdir, "read_solution1.out"), 'r') as out, open(
-            join(currdir, "test4_sol.txt"), 'r'
-        ) as txt:
-            self.assertStructuredAlmostEqual(
-                yaml.full_load(txt), yaml.full_load(out), allow_second_superset=True
-            )
-
-    def test_read_solution2(self):
-        """Read a SolverResults Object"""
-        self.results = pyomo.opt.SolverResults()
-        self.results.read(filename=join(currdir, "test4_sol.jsn"), format='json')
-        self.results.write(filename=join(currdir, "read_solution2.out"), format='json')
-        if not os.path.exists(join(currdir, "read_solution2.out")):
-            self.fail("test_read_solution2 - failed to write read_solution2.out")
-        with open(join(currdir, "read_solution2.out"), 'r') as out, open(
-            join(currdir, "test4_sol.jsn"), 'r'
-        ) as txt:
-            self.assertStructuredAlmostEqual(
-                json.load(txt), json.load(out), allow_second_superset=True
-            )
-
-    def test_pickle_solution2(self):
+
+    def test_write_solution(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        OUT = StringIO()
+        results.write(ostream=OUT)
+        self.assertEqual(
+            """
+# ==========================================================
+# = Solver Results                                         =
+# ==========================================================
+# ----------------------------------------------------------
+#   Solution Information
+# ----------------------------------------------------------
+Solution: 
+- number of solutions: 1
+  number of solutions displayed: 1
+- Status: unknown
+  Objective: No values
+  Variable: No nonzero values
+  Constraint: No values
+            """.strip(),
+            OUT.getvalue().strip(),
+        )
+
+    def test_write_empty_solution(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        OUT = StringIO()
+        results.write(num=None, ostream=OUT)
+        self.assertEqual(
+            """
+# ==========================================================
+# = Solver Results                                         =
+# ==========================================================
+# ----------------------------------------------------------
+#   Solution Information
+# ----------------------------------------------------------
+Solution: 
+- number of solutions: 1
+  number of solutions displayed: 1
+- Status: unknown
+  Objective: No values
+  Variable: No nonzero values
+  Constraint: No values
+            """.strip(),
+            OUT.getvalue().strip(),
+        )
+
+    @unittest.skipIf(not yaml_available, "Test requires 'yaml'")
+    def test_read_write_yaml_solution(self):
         """Read a SolverResults Object"""
-        self.results = pyomo.opt.SolverResults()
-        self.results.read(filename=join(currdir, "test4_sol.jsn"), format='json')
-        str = pickle.dumps(self.results)
-        res = pickle.loads(str)
-        self.results.write(filename=join(currdir, "read_solution2.out"), format='json')
-        if not os.path.exists(join(currdir, "read_solution2.out")):
-            self.fail("test_read_solution2 - failed to write read_solution2.out")
-        with open(join(currdir, "read_solution2.out"), 'r') as out, open(
-            join(currdir, "test4_sol.jsn"), 'r'
-        ) as txt:
-            self.assertStructuredAlmostEqual(
-                json.load(txt), json.load(out), allow_second_superset=True
-            )
+        IN = StringIO(ref_yaml_soln)
+        results = SolverResults()
+        results.read(istream=IN, format='yaml')
+        OUT = StringIO()
+        results.write(ostream=OUT, format='yaml')
+
+        self.assertStructuredAlmostEqual(
+            yaml.full_load(StringIO(ref_yaml_soln)),
+            yaml.full_load(StringIO(OUT.getvalue())),
+            allow_second_superset=True,
+        )
+
+    @unittest.skipIf(not yaml_available, "Test requires 'yaml'")
+    def test_pickle_yaml_solution(self):
+        IN = StringIO(ref_yaml_soln)
+        results = SolverResults()
+        results.read(istream=IN, format='yaml')
+
+        new_results = pickle.loads(pickle.dumps(results))
+        OUT = StringIO()
+        new_results.write(ostream=OUT, format='yaml')
+
+        self.assertStructuredAlmostEqual(
+            yaml.full_load(StringIO(ref_yaml_soln)),
+            yaml.full_load(StringIO(OUT.getvalue())),
+            allow_second_superset=True,
+        )
+
+    @unittest.skipIf(not yaml_available, "Test requires 'yaml'")
+    def test_write_read_yaml_file(self):
+        IN = StringIO(ref_yaml_soln)
+        results = SolverResults()
+        results.read(istream=IN, format='yaml')
+
+        with TempfileManager.new_context() as temp:
+            fname = temp.create_tempfile('yaml')
+            results.write(filename=fname)
+            new_results = SolverResults()
+            new_results.read(filename=fname)
+
+        self.assertStructuredAlmostEqual(
+            results, new_results, allow_second_superset=True
+        )
+
+    def test_read_write_json_solution(self):
+        IN = StringIO(ref_json_soln)
+        results = SolverResults()
+        results.read(istream=IN, format='json')
+        OUT = StringIO()
+        results.write(ostream=OUT, format='json')
+
+        self.assertStructuredAlmostEqual(
+            json.loads(ref_json_soln),
+            json.loads(OUT.getvalue()),
+            allow_second_superset=True,
+        )
+
+    def test_pickle_json_solution(self):
+        IN = StringIO(ref_json_soln)
+        results = SolverResults()
+        results.read(istream=IN, format='json')
+
+        new_results = pickle.loads(pickle.dumps(results))
+        OUT = StringIO()
+        new_results.write(ostream=OUT, format='json')
+
+        self.assertStructuredAlmostEqual(
+            json.loads(ref_json_soln),
+            json.loads(OUT.getvalue()),
+            allow_second_superset=True,
+        )
+
+    def test_write_read_json_file(self):
+        IN = StringIO(ref_json_soln)
+        results = SolverResults()
+        results.read(istream=IN, format='json')
+
+        with TempfileManager.new_context() as temp:
+            fname = temp.create_tempfile('.jsn')
+            results.write(filename=fname)
+            new_results = SolverResults()
+            new_results.read(filename=fname)
+
+        self.assertStructuredAlmostEqual(
+            results, new_results, allow_second_superset=True
+        )
+
+    def test_deepcopy_solution(self):
+        IN = StringIO(ref_json_soln)
+        result = SolverResults()
+        result.read(istream=IN, format='json')
+        result_copy = copy.deepcopy(result)
+        self.assertEqual(list(result.keys()), list(result_copy.keys()))
+        self.assertEqual(str(result), str(result_copy))
+        self.assertEqual(result, result)
+        self.assertEqual(result, result_copy)
 
     #
     # deleting is not supported right now
     #
-    def Xtest_delete_solution(self):
-        """Delete a solution from a SolverResults object"""
-        self.results.solution.delete(0)
-        self.results.write(filename=join(currdir, "delete_solution.txt"))
-        if not os.path.exists(join(currdir, "delete_solution.txt")):
-            self.fail("test_write_solution - failed to write delete_solution.txt")
-        _out, _log = join(currdir, "delete_solution.txt"), join(
-            currdir, "test4_soln.txt"
+    def test_delete_solution(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        results.solution.delete(0)
+
+        OUT = StringIO()
+        results.write(ostream=OUT)
+        self.assertEqual(
+            """
+# ==========================================================
+# = Solver Results                                         =
+# ==========================================================
+# ----------------------------------------------------------
+#   Solution Information
+# ----------------------------------------------------------
+Solution: 
+- number of solutions: 0
+  number of solutions displayed: 0
+            """.strip(),
+            OUT.getvalue().strip(),
         )
-        self.assertTrue(cmp(_out, _log), msg="Files %s and %s differ" % (_out, _log))
 
     def test_get_solution(self):
         """Get a solution from a SolverResults object"""
-        tmp = self.results.solution[0]
-        self.assertEqual(tmp, self.soln)
+        results = SolverResults()
+        soln = results.solution.add()
+        self.assertIs(results.solution[0], soln)
 
     def test_get_solution_attr_error(self):
         """Create an error with a solution suffix"""
-        try:
-            tmp = self.soln.bad
-            self.fail("Expected attribute error failure for 'bad'")
-        except AttributeError:
-            pass
+        results = SolverResults()
+        soln = results.solution.add()
+        with self.assertRaisesRegex(
+            AttributeError,
+            r"Unknown attribute `bad' for object with type <.*\.Solution'>",
+        ):
+            tmp = soln.bad
 
     #
     # This is currently allowed, although soln.variable = True is equivalent to
@@ -166,44 +401,219 @@ def Xtest_set_solution_attr_error(self):
         except AttributeError:
             pass
 
-    def test_soln_pprint1(self):
-        """Write a solution with only zero values, using the results 'write()' method"""
-        self.soln.variable[1]["Value"] = 0.0
-        self.soln.variable[2]["Value"] = 0.0
-        self.soln.variable[4]["Value"] = 0.0
-        self.results.write(filename=join(currdir, "soln_pprint.txt"))
-        if not os.path.exists(join(currdir, "soln_pprint.txt")):
-            self.fail("test_write_solution - failed to write soln_pprint.txt")
-        _out, _log = join(currdir, "soln_pprint.txt"), join(currdir, "test3_soln.txt")
-        self.assertTrue(cmp(_out, _log), msg="Files %s and %s differ" % (_out, _log))
-
-    def test_soln_pprint2(self):
-        """Write a solution with only zero values, using the Solution.pprint() method"""
-        self.soln.variable[1]["Value"] = 0.0
-        self.soln.variable[2]["Value"] = 0.0
-        self.soln.variable[4]["Value"] = 0.0
-        with open(join(currdir, 'soln_pprint2.out'), 'w') as f:
-            f.write(str(self.soln))
-        with open(join(currdir, "soln_pprint2.out"), 'r') as f1, open(
-            join(currdir, "soln_pprint2.txt"), 'r'
-        ) as f2:
-            self.assertEqual(f1.read().strip(), f2.read().strip())
-
-    def test_soln_suffix_getiter(self):
-        self.soln.variable[1]["Value"] = 0.0
-        self.soln.variable[2]["Value"] = 0.1
-        self.soln.variable[4]["Value"] = 0.3
-        self.assertEqual(self.soln.variable[4]["Value"], 0.3)
-        self.assertEqual(self.soln.variable[2]["Value"], 0.1)
-
-    def test_soln_suffix_setattr(self):
-        self.soln.variable[1]["Value"] = 0.0
-        self.soln.variable[4]["Value"] = 0.3
-        self.soln.variable[4]["Slack"] = 0.4
-        self.assertEqual(list(self.soln.variable.keys()), [1, 2, 4])
-        self.assertEqual(self.soln.variable[1]["Value"], 0.0)
-        self.assertEqual(self.soln.variable[4]["Value"], 0.3)
-        self.assertEqual(self.soln.variable[4]["Slack"], 0.4)
+    def test_soln_write_zeros(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        soln.variable[1]["Value"] = 0.0
+        soln.variable[2]["Value"] = 0.0
+        soln.variable[4]["Value"] = 0.0
+
+        OUT = StringIO()
+        results.write(ostream=OUT)
+        self.assertEqual(
+            """
+# ==========================================================
+# = Solver Results                                         =
+# ==========================================================
+# ----------------------------------------------------------
+#   Solution Information
+# ----------------------------------------------------------
+Solution: 
+- number of solutions: 1
+  number of solutions displayed: 1
+- Status: unknown
+  Objective: No values
+  Variable: No nonzero values
+  Constraint: No values
+            """.strip(),
+            OUT.getvalue().strip(),
+        )
+
+    def test_soln_write_nonzeros(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        soln.variable[1]["Value"] = 1.0
+        soln.variable[2]["Value"] = 3.0
+        soln.variable[4]["Value"] = 5.0
+
+        OUT = StringIO()
+        results.write(ostream=OUT)
+        self.assertEqual(
+            """
+# ==========================================================
+# = Solver Results                                         =
+# ==========================================================
+# ----------------------------------------------------------
+#   Solution Information
+# ----------------------------------------------------------
+Solution: 
+- number of solutions: 1
+  number of solutions displayed: 1
+- Status: unknown
+  Objective: No values
+  Variable:
+    1:
+      Value: 1
+    2:
+      Value: 3
+    4:
+      Value: 5
+  Constraint: No values
+            """.strip(),
+            OUT.getvalue().strip(),
+        )
+
+    def test_soln_suffix_getattr(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        soln.variable[1]["Value"] = 0.0
+        soln.variable[2]["Value"] = 0.1
+        soln.variable[4]["Value"] = 0.3
+        self.assertEqual(soln.variable[4]["Value"], 0.3)
+        self.assertEqual(soln.variable[2]["Value"], 0.1)
+
+    def test_soln_suffix_setattr_getattr(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        soln.variable[1]["Value"] = 0.0
+        soln.variable[4]["Value"] = 0.3
+        soln.variable[4]["Slack"] = 0.4
+        self.assertEqual(list(soln.variable.keys()), [1, 2, 4])
+        self.assertEqual(soln.variable[1]["Value"], 0.0)
+        self.assertEqual(soln.variable[4]["Value"], 0.3)
+        self.assertEqual(soln.variable[4]["Slack"], 0.4)
+
+    def test_soln_to_str(self):
+        results = SolverResults()
+        soln = results.solution.add()
+        soln.variable[1] = {"Value": 0}
+        soln.variable[2] = {"Value": 0}
+        soln.variable[4] = {"Value": 0}
+
+        self.assertEqual(
+            """
+Solution: 
+- number of solutions: 1
+  number of solutions displayed: 1
+- Status: unknown
+  Objective: No values
+  Variable: No nonzero values
+  Constraint: No values
+            """.strip(),
+            str(results).strip(),
+        )
+
+        soln.variable[1]["Value"] = 1.0
+        soln.variable[2]["Value"] = 3.0
+        soln.variable[4]["Value"] = 5.0
+
+        self.assertEqual(
+            """
+Solution: 
+- number of solutions: 1
+  number of solutions displayed: 1
+- Status: unknown
+  Objective: No values
+  Variable:
+    1:
+      Value: 1
+    2:
+      Value: 3
+    4:
+      Value: 5
+  Constraint: No values
+            """.strip(),
+            str(results).strip(),
+        )
+
+
+class TestContainer(unittest.TestCase):
+    def test_declare_and_str(self):
+        class LocalContainer(container.MapContainer):
+            def __init__(self, **kwds):
+                super().__init__(**kwds)
+                self.declare('a')
+                self.declare('b', value=2)
+                self.declare('c', value=3)
+
+        d = container.MapContainer()
+        d.declare('f')
+        d.declare('g')
+        d.declare('h')
+        d.declare('i', value=container.ListContainer(container.UndefinedData))
+        d.declare('j', value=container.ListContainer(LocalContainer), active=False)
+        self.assertEqual(list(d.keys()), ['F', 'G', 'H', 'I', 'J'])
+
+        self.assertEqual(
+            """
+I: 
+""",
+            str(d),
+        )
+
+        # Assigning to a field activates it
+        d.f = 1
+        self.assertEqual(
+            """
+F: 1
+I: 
+""",
+            str(d),
+        )
+        self.assertEqual(d.f, 1)
+
+        # Adding to a list also activates it, even if it was declared
+        # with active=False
+        self.assertTrue(d.i._active)
+        self.assertFalse(d.j._active)
+        d.j.add()
+        self.assertTrue(d.i._active)
+        self.assertTrue(d.j._active)
+        self.assertEqual(
+            """
+F: 1
+I: 
+J: 
+- B: 2
+  C: 3
+""",
+            str(d),
+        )
+
+        self.assertEqual(
+            """
+- B: 2
+  C: 3
+""",
+            str(d.j),
+        )
+
+    def test_pickle(self):
+        d = container.MapContainer()
+        d.declare('a', value=container.ignore)
+        d.declare('b', value=container.undefined)
+        d.declare('c', value=42)
+        e = pickle.loads(pickle.dumps(d))
+        self.assertEqual(list(d.keys()), list(e.keys()))
+        self.assertIs(d.a, e.a)
+        self.assertIs(d.b, e.b)
+        self.assertEqual(d.c, e.c)
 
 
 if __name__ == "__main__":