Repository: parmoo/parmoo (language: python)

Issue:
Non-descriptive exceptions when reloading checkpoints
Hi,

I am attempting to load a previous checkpoint with Parmoo (version 0.2.0) but I am getting this obscure exception instead. This is what happens when I call `moop.load(file)` 

```
Traceback (most recent call last):
  File "/home/waldo/Documents/Documentos/CBandV2/Hairpin/BetterOptim/parmoo-refms.py", line 296, in <module>
    parmoo_optimize(filter)
  File "/home/waldo/Documents/Documentos/CBandV2/Hairpin/BetterOptim/parmoo-refms.py", line 283, in parmoo_optimize
    moop.load(FILTER_NAME)
  File "/usr/local/lib/python3.10/dist-packages/parmoo/moop.py", line 2615, in load
    obj_ptr = getattr(mod, obj_name)
AttributeError: module '__main__' has no attribute '<lambda>'
```
The `refmicrostrip.moop` and `refmicrostrip.surrogate.1` files exist (see attachments):

[refmicrostrip.moop.txt](https://github.com/parmoo/parmoo/files/10843325/refmicrostrip.moop.txt)
[refmicrostrip.surrogate.1.txt](https://github.com/parmoo/parmoo/files/10843326/refmicrostrip.surrogate.1.txt)

And this is how I am initializing the MOOP object:

```python
    moop = MOOP(LocalGPS)

    params = filter.get_param_list()
    bounds = filter.get_full_bounds()

    # Add all params
    for i in range(len(params)):
        moop.addDesign(
            {
                'name':     params[i],
                'des_type': 'continuous',
                'lb':       bounds.lb[i],
                'ub':       bounds.ub[i]
            })

    moop.addSimulation(
        {
            'name': "FilterSim",
            'm': 2,
            'sim_func': chrosen,
            'search': LatinHypercube,
            'surrogate': GaussRBF,
            'hyperparams': {'search_budget': 20}
        })
    
    moop.addObjective(
        {'name': 'Score', 'obj_func': lambda x, s: sum(s['FilterSim'])}
    )

    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s %(levelname)-8s %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S')

    moop.load(FILTER_NAME)
```


How many iterations?
Do we have an answer to this/ a built in automatic stop point or is it a case of try 100, then try 1000 and see if theres any better solutions found on the pareto front? 

Thanks!
Exit criteria
 - Added additional exit criteria to both ``MOOP.solve()`` and ``libE_MOOP.solve()``
    - This should help unify the implementations of these 2 classes, so that a script that works for the ``MOOP`` class can easily also work for a ``libE_MOOP`` with minimal changes
    - By carefully ordering exit criteria and maintaining name conventions, this should be a non-API breaking change
    - The exception to above would be if a user is explicitly passing the ``budget`` argument, which is meant to be positional, to the ``MOOP.solve()`` method.  In that case, ``budget`` -> ``iter_max``
- Also updated the ``libE_MOOP`` example to show how to print results threadsafe when running with MPI (an additional ``if``-condition is required when using MPI)
- Above changes also required slight modification to unit tests in order to avoid newly-added warnings


Discussion / hints:
Unfortunately, ``lambda`` functions are not compatible with the way ParMOO saves the problem state.

This is because we are saving the name and namespace of the function definition in a JSON file so that we can reload them after a stop/crash.  Apologies, this notice is a bit buried in the bulleted lists in this section of our docs:
  https://parmoo.readthedocs.io/en/latest/how-to-write.html#logging-and-checkpointing

To use the checkpointing feature, you need to define a formal function (either in a public module or in the global namespace).  Then everything should work.

Hopefully you haven't already run anything too expensive.  If you have, let me know and I can help you with hacking your checkpoint files to reload the newly defined function instead of a lambda definition.
Making a note that we should remove lambda functions from the quickstart demo to prevent further confusion in the future.  This issue will remain open until we have done so.



Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index f52b046..8a32a11 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,88 @@ Below are the release notes for ParMOO.
 May reference issues on:
 https://github.com/parmoo/parmoo/issues
 
+Release 0.3.0
+-------------
+
+:Date: Jul 6, 2023
+
+Significant structural changes for long-term support of future solvers,
+bug-fixes, and significant improvements to documentation.
+
+Major Changes:
+
+ - ``surrogates.GaussRBF`` and ``surrogates.LocalGaussRBF`` now
+   calculate model-form uncertainties
+ - structural changes to ``MOOP`` class to support propagation of
+   uncertainty information
+ - added ``EI_RandomConstraint`` acquisition, which can be used to
+   implement Bayesian optimization -- note that for large budgets,
+   this is not currently recommended due to computational expense
+   of numerical integration
+ - updated ``LocalGPS`` to use trust regions, when provided, and
+   perform multiple restarts
+ - ``SurrogateOptimizer`` class now has access to more information about
+   the objective, including raw simulation outputs, in order to support
+   more diverse structure-exploiting solvers
+ - Added additional stopping criteria to both ``MOOP.solve()`` and
+   ``libE_MOOP.solve()`` -- all stopping criteria are now optional
+   (although at least one must be specified) but they are ordered such
+   that calling ``MOOP.solve(k)``, where ``k`` is a positional input,
+   will pass to the ``iter_max`` criteria and produce the same behavior
+   as before -- closes #18
+
+API Changes:
+
+ - In most cases, none. However, it is possible that if users were previously
+   passing arguments to the ``MOOP.solve()`` method explicitly, then the
+   name of the first positional argument has changed:
+   ``budget`` -> ``max_iters``
+ - For users implementing their own ``searches``, ``surrogates``,
+   ``optimizers``, or ``acquisitions``, several classes in the ``structs``
+   module have been updated to support the present restructuring of
+   the ``MOOP`` class
+
+Docs:
+
+ - Updated Quickstart guide and README to demonstrate recommended inputs
+   and settings for ParMOO -- this includes no more ``lambda`` functions,
+   which closes #50
+ - Added a FAQ page with additional usage details and responses to frequent
+   questions -- the answers in which close #61
+ - Added a new tutorial on how to perform high-dimensional multiobjective
+   optimization on a limited budget with ParMOO
+ - Changed examples and documentation to use and discuss pandas dataframes,
+   which generally produce more legible outputs
+ - Updated ``libE_MOOP`` example to demonstrate how to retrieve data in a
+   way that is threadsafe for both Python MP and MPI usage
+
+Requirements:
+
+ - We now require scipy v1.10 or newer, due to usage of qmc integration tools
+ - At the time of this release, libEnsemble is using a deprecated version of
+   Pydantic -- for this release only we have fixed the requirement on
+   libEnsemble to v0.9.2, but we will relax this requirement in the future
+   once they have patched the issue
+
+Bug-fixes:
+
+ - Fixed an issue where in rare cases, problems with too many categorical
+   variables could produce unexpected batch sizes
+ - Errors in definition of test problems: DTLZ5, 6, and 7 (new implementations
+   have been confirmed against ``pymoo``)
+ - Fixed an issue which occasionally caused the ``libE_MOOP`` class to error
+   out during post-run cleanup when used with MPI
+ - Patched an issue with ``format="pandas"`` option for
+   ``MOOP.getSimulationData()`` class and added a similar option to
+   all ``libE_MOOP`` "getter" functions
+
+Minor changes:
+
+ - Fixed typos in docs/doc-strings
+ - Updated styles to comply with new ``flake8`` recommendations
+ - New unit tests added
+ - Added warnings when ParMOO is run with bad budget settings
+
 Release 0.2.2
 -------------
 
diff --git a/README.rst b/README.rst
index 605bdd7..2df5175 100644
--- a/README.rst
+++ b/README.rst
@@ -214,12 +214,14 @@ and ``MOOP.addConstraint(*args)``. In this example, there are 2 objectives
 .. code-block:: python
 
    # First objective just returns the first simulation output
-   my_moop.addObjective({'name': "f1", 'obj_func': lambda x, s: s["MySim"][0]})
+   def f1(x, s): return s["MySim"][0]
+   my_moop.addObjective({'name': "f1", 'obj_func': f1})
    # Second objective just returns the second simulation output
-   my_moop.addObjective({'name': "f2", 'obj_func': lambda x, s: s["MySim"][1]})
+   def f2(x, s): return s["MySim"][1]
+   my_moop.addObjective({'name': "f2", 'obj_func': f2})
    # Add a single constraint, that x[0] >= 0.1
-   my_moop.addConstraint({'name': "c1",
-                          'constraint': lambda x, s: 0.1 - x["x1"]})
+   def c1(x, s): return 0.1 - x["x1"]
+   my_moop.addConstraint({'name': "c1", 'constraint': c1})
 
 Finally, we must add one or more acquisition functions using
 ``MOOP.addAcquisition(*args)``. These are used to scalarize the surrogate
@@ -229,11 +231,11 @@ are using a parallel solver.
 
 .. code-block:: python
 
-   from parmoo.acquisitions import UniformWeights
+   from parmoo.acquisitions import RandomConstraint
 
    # Add 3 acquisition functions
    for i in range(3):
-      my_moop.addAcquisition({'acquisition': UniformWeights,
+      my_moop.addAcquisition({'acquisition': RandomConstraint,
                               'hyperparams': {}})
 
 Finally, the MOOP is solved using the ``MOOP.solve(budget)`` method, and the
@@ -241,13 +243,15 @@ results can be viewed using ``MOOP.getPF()`` method.
 
 .. code-block:: python
 
+   import pandas as pd
+
    my_moop.solve(5) # Solve with 5 iterations of ParMOO algorithm
-   results = my_moop.getPF() # Extract the results
+   results = my_moop.getPF(format="pandas") # Extract the results as pandas df
 
 After executing the above block of code, the ``results`` variable points to
-a numpy structured array, each of whose entries corresponds to a
-nondominated objective value in the ``my_moop`` object's final database.
-You can reference individual fields in the ``results`` array by using the
+a pandas_ dataframe, each of whose rows corresponds to a nondominated
+objective value in the ``my_moop`` object's final database.
+You can reference individual columns in the ``results`` array by using the
 ``name`` keys that were assigned during ``my_moop``'s construction, or
 plot the results by using the viz_ library.
 
@@ -262,8 +266,10 @@ Next steps:
  * Explore the advanced examples (including a ``libEnsemble`` example)
    in the ``examples`` directory.
  * Install libEnsemble_ and get started solving MOOPs in parallel.
+ * See some of our pre-built solvers in the parmoo_solver_farm_.
  * To interactively explore your solutions, install its extra dependencies and
    use our built-in viz_ tool.
+ * For more advice, consult our FAQs_.
 
 Resources
 ---------
@@ -306,7 +312,7 @@ Our online documentation:
         title       = {{ParMOO}: {P}ython library for parallel multiobjective simulation optimization},
         author      = {Chang, Tyler H. and Wild, Stefan M. and Dickinson, Hyrum},
         institution = {Argonne National Laboratory},
-        number      = {Version 0.2.2},
+        number      = {Version 0.3.0},
         year        = {2023},
         url         = {https://parmoo.readthedocs.io/en/latest}
     }
@@ -314,6 +320,7 @@ Our online documentation:
 .. _Actions: https://github.com/parmoo/parmoo/actions
 .. _CONTRIBUTING: https://github.com/parmoo/parmoo/blob/main/CONTRIBUTING.rst
 .. _dash: https://dash.plotly.com
+.. _FAQs: https://parmoo.readthedocs.io/en/latest/faqs.html
 .. _flake8: https://flake8.pycqa.org/en/latest
 .. _GitHub: https://github.com/parmoo/parmoo
 .. _kaleido: https://github.com/plotly/Kaleido
@@ -321,6 +328,7 @@ Our online documentation:
 .. _LICENSE: https://github.com/parmoo/parmoo/blob/main/LICENSE
 .. _numpy: https://numpy.org
 .. _pandas: https://pandas.pydata.org
+.. _parmoo_solver_farm: https://github.com/parmoo/parmoo-solver-farm
 .. _plotly: https://plotly.com/python
 .. _pyDOE: https://pythonhosted.org/pyDOE
 .. _pytest: https://docs.pytest.org/en/7.0.x
diff --git a/REQUIREMENTS b/REQUIREMENTS
index f2b5378..cb6befe 100644
--- a/REQUIREMENTS
+++ b/REQUIREMENTS
@@ -1,7 +1,7 @@
 numpy>=1.8.2
-scipy>=1.0.0
+scipy>=1.10.0
 pyDOE>=0.3.8
-libensemble>=0.9.0
+libensemble==0.9.3
 pandas>=0.3.0
 plotly>=5.9.0
 dash==2.8.1
diff --git a/docs/Makefile b/docs/Makefile
index 03fb5ee..83ce23e 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -17,5 +17,8 @@ help:
 # Catch-all target: route all unknown targets to Sphinx using the new
 # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
 %: Makefile $(EX_OUT)
-	cd ../examples && make
+	## I commented out the build command below, one of the methods is too
+	## expensive to build on readthedocs server. Should just build and
+	## push examples locally.
+	#cd ../examples && make 
 	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/dev-guide/release-proc.rst b/docs/dev-guide/release-proc.rst
index f2a2b23..ed8272d 100644
--- a/docs/dev-guide/release-proc.rst
+++ b/docs/dev-guide/release-proc.rst
@@ -14,7 +14,7 @@ Before release
 - Release notes for this version are added to the ``CHANGELOG.rst`` file.
 
 - Version number is updated wherever it appears and ``+dev`` suffix is removed
-  (in ``parmoo/version.py`` and ``README.rst``).
+  (in ``parmoo/version.py``, ``README.rst``, and ``docs/refs.rst``).
 
 - Check ``README.rst`` *Citing ParMOO* and ``docs/refs.rst`` for correctness.
 
@@ -60,5 +60,6 @@ After release
 -------------
 
 - Ensure all relevant GitHub issues are closed.
+
 - Check that the conda-forge package has tracked latest release
   and update dependency list if needed.
diff --git a/docs/faqs.rst b/docs/faqs.rst
new file mode 100644
index 0000000..7fb7bc5
--- /dev/null
+++ b/docs/faqs.rst
@@ -0,0 +1,202 @@
+FAQ
+===
+
+Frequently asked questions:
+
+ - Q: Why are there so many hyperparameters?
+
+    - A: ParMOO is **not a solver** it is a
+      **framework for building and deploying customized solvers**.
+      It has been our experience that a single solver cannot obtain the
+      best performance on every problem.
+      Through ParMOO, we are giving you a modeling language and library
+      of useful techniques for building a great solver for your problem
+      and deploying it in parallel environments.
+
+ - Q: There are too many options, where do I start?
+
+    - A: If you are not an optimization expert or if you're just not
+      sure where to start, that's OK.
+      Start from one of our tutorials_ and modify it to match your problem.
+      See how that works for your problem.
+      Then start experimenting with other options, referring to this
+      FAQ for some general guidance.
+      Alternatively, check out some of our existing pre-built solvers in
+      the parmoo_solver_farm_ and see if you can modify one to fit your
+      needs!
+
+ - Q: I am running ParMOO with a single simulation function with
+   the following settings: ``search_budget = B_s``,
+   ``number of acquisition functions = q``,
+   ``max_iters (input to solve method) = k``.
+   How many times will ParMOO run my simulation while solving this problem?
+
+    - A: In this example: ``total_budget = B_s + q * k``
+
+ - Q: Going off the previous example, how should I choose the values of
+   ``B_s``, ``q``, and ``k``?
+
+    - A: The answer is problem dependent, but these are our recommendations:
+
+       - For most of our solver settings
+         ParMOO performs pure "exploitation" after the initial search,
+         so you can think of ``B_s`` as the "exploration" parameter.
+         (There are several notable
+         exceptions, such as if you use the
+         :class:`EI_RandomConstraint <acquisitions.epsilon_constraint.EI_RandomConstraint>`
+         acquisition function, which we generally do not recommend due to its
+         computational expense).
+         If your problem is very
+         smooth or doesn't have many local minima, then you can get away with
+         a small value of ``B_s``. If your problem is nonsmooth or highly
+         nonconvex, you will do better with a larger value of ``B_s``. At a
+         minimum, ``B_s`` must be at least ``n+1`` (recall: ``n`` is the
+         number of design variables). All of our existing surrogate models
+         will raise an error if ``B_s < n+1``.
+         If you are still unsure, a good starting place that works well on
+         a variety of challenging problems is ``B_s = min(2000, 0.5*total_budget)``,
+         where ``total_budget`` is the total budget that you intend to use
+         for this problem.
+       - The value of ``q`` determines how many simulations ParMOO will
+         evaluate per batch. If you want to achieve NP-way parallelism (using
+         the ``libE_MOOP`` class), then you should use ``q=NP``. If you are
+         running serially (using the base ``MOOP`` class), then you should use
+         a small number of acquisition functions in order to get more
+         iterations.
+         In many situations, just 1 acquisition function will work best when
+         running serially. In order to amortize the cost of re-fitting the
+         surrogate model, we typically use ``q=2``, ``3``, or ``4`` when
+         running serially.
+       - The value of ``k`` should be as large as you can afford, given your
+         simulation costs. Note that the ``GaussRBF`` surrogate becomes
+         expensive to fit for ``total_budget > 4000``, although we have used
+         it for up to ``total_budget = 10,000``. If you want more practical
+         advice, see the next question.
+
+ - Q: Given the advice from the previous question, how do I know a good
+   ``total_budget`` for my problem?
+
+    - A: It depends on a lot of factors. In general, if your problem is
+      small (``n < 8`` design variables **and** ``o < 3`` objectives) you can
+      probably get away with a total budget in the **hundreds**.
+      If your problem is large (``n > 8`` design variables **or**
+      ``o > 3`` objectives) you will probably need a total budget in the
+      **thousands** or even **tens of thousands**.
+      In general, as you increase the number of design variables or objectives
+      the problem expense increases **exponentially** if you want to maintain
+      the same accuracy. This is part of the *curse of dimensionality*.
+
+ - Q: I have a lot of design variables but I can't afford that large of a
+   budget, what can I do?
+
+    - A: The key issue is that **global optimization is expensive**.
+      At a fundamental level, *no blackbox solver* can guarantee global
+      convergence without
+      densely sampling the design space, which is exponentially expensive
+      when ``n`` (number of design variables) is large.
+      So what can you do?
+      You can switch to using **local modeling methods**, whose costs
+      generally only grow linearly in the dimension.
+      You will not get any convergence guarantees to global solutions, but in many
+      cases, you will still be able to *approximately* solve your problem.
+      Check out our
+      :ref:`High-dimensional multiobjective optimization tutorial <high_d_ex>`
+      to learn more.
+
+ - Q: How can I determine whether my problem was solved by ParMOO?
+
+    - Short Answer: you can't. Here's why -- in most situations (unless
+      your objectives are not truly conflicting) there are infinitely
+      many solutions to a multiobjective optimization problem
+      with continuous design variables. We cannot find all such
+      solutions with a finite budget. What we can do is give you as
+      many approximate solutions as possible for the budget allocated, so
+      that you can make informed decisions about the inherent tradeoffs,
+      and possibly run a single-objective solver to refine your favorite
+      solution in the future.
+    - Longer Answers:
+
+       - For practical purposes: you could solve the problem with ParMOO on
+         as large of a budget as you can afford with checkpointing turned
+         on. Then plot the results using one of the methods from our
+         :mod:`viz <viz>`
+         library and see how you are doing. If you are unsatisfied with the
+         results, re-load from the last checkpoint and solve with a few added
+         iterations. Then plot your results again and see if the performance
+         has improved.
+       - For small problems: you could solve the problem with ParMOO on
+         as large of a budget as you can afford. Then plot the convergence
+         over time, according to one of the common multiobjective performance
+         indicators, such as hypervolume. If you are seeing diminishing to no
+         improvements in late iterations, then you may have
+         solved the problem. Note that hypervolume is exponentially expensive
+         to compute when you have a large number of objectives. Therefore, we
+         do not have a hypervolume metric calculator available in ParMOO at
+         this time, but we will add it in the future.
+
+ - Q: Why are the iteration costs (time spent generating a batch) so high?
+
+    - A: The majority of ParMOO's overhead comes from fitting the surrogate
+      models and solving the scalarized surrogate problems. If you followed
+      the quickstart_, then the default method for surrogate modeling
+      was to fit a Gaussian process. For numerical stability reasons,
+      we fit our Gaussian processes via a *symmetric-eigensolve*,
+      which is not inexpensive. Then you may have to evaluate the Gaussian
+      process thousands of times while solving the surrogate problem.
+      All of this expense adds up, especially if you are using a large
+      total budget, since the cost of fitting such Gaussian processes grows
+      cubically with the number of data points.
+      One solution is to switch to using a
+      :class:`LocalGaussRBF <surrogates.gaussian_proc.LocalGaussRBF>`
+      surrogate, which does not use the entire database when fitting
+      surrogates, and therefore is more scalable for handling large budgets.
+      See our :ref:`tutorial on local methods <high_d_ex>`
+      for an example.
+
+ - Q: Surrogate models, acquisition functions, search techniques, and
+   optimization solvers -- how do I know which ones to pick?
+
+    - A: Generally, we recommend sticking with
+      :class:`LatinHypercube <searches.latin_hypercube.LatinHypercube>`
+      search and
+      :class:`RandomConstraint <acquisitions.epsilon_constraint.RandomConstraint>`
+      acquisition functions, unless you have a good reason for changing.
+      These options work best for many of our test problems,
+      and they are demonstrated in our tutorials.
+      For the surrogate model and optimization solver, start out with
+      :class:`LocalGPS <optimizers.gps_search.LocalGPS>` optimizer
+      and :class:`GaussRBF <surrogates.gaussian_proc.GaussRBF>` surrogate
+      model, as in the quickstart_.
+      Then:
+
+       - If you are willing to code the derivative for your objective
+         and constraint functions (not the simulations), then you can
+         follow the advanced_example_ and switch to using the
+         :class:`LBFGSB <optimizers.lbfgsb.LBFGSB>` optimizer.
+       - If you have a lot of design variables, then you might do better
+         with a local solver, by switching your surrogate to the
+         :class:`LocalGaussRBF <surrogates.gaussian_proc.LocalGaussRBF>`
+         surrogate.
+         If you are using the
+         :class:`LBFGSB <optimizers.lbfgsb.LBFGSB>` optimizer, then you
+         will also need to switch to the
+         :class:`TR_LBFGSB <optimizers.lbfgsb.TR_LBFGSB>` optimizer.
+       - If you're a professional optimizer or researcher and you want
+         to try your own methods, then you can do so by writing your own
+         implementation for one of our
+         :mod:`Abstract Base Classes <structs>`.
+         If you try a novel method and it works and you're ready to publish
+         it, consider sharing your novel solver on the
+         parmoo_solver_farm_!
+
+
+We would like to acknowledge the following users, whose helpful discussions
+with us inspired this FAQ:
+ - Sarah Salem (Bundeswehr University Munich)
+ - Nicholas Antoniou (independent researcher)
+
+
+.. _advanced_example: https://parmoo.readthedocs.io/en/latest/tutorials/basic-tutorials.html#Solving
+.. _parmoo_solver_farm: https://github.com/parmoo/parmoo-solver-farm
+.. _quickstart: quickstart.html
+.. _tutorials: tutorials/basic-tutorials.html
diff --git a/docs/how-to-write.rst b/docs/how-to-write.rst
index 4be8695..c0132d0 100644
--- a/docs/how-to-write.rst
+++ b/docs/how-to-write.rst
@@ -593,9 +593,9 @@ method.
 
 .. code-block:: python
 
-    from parmoo.acquisitions import UniformWeights, FixedWeights
+    from parmoo.acquisitions import RandomConstraint, FixedWeights
 
-    moop.addAcquisition({'acquisition': UniformWeights})
+    moop.addAcquisition({'acquisition': RandomConstraint})
     moop.addAcquisition({'acquisition': FixedWeights,
                          'hyperparams': {'weights': np.array([0.5, 0.5])}})
 
@@ -796,6 +796,17 @@ After solving the MOOP, you can view the results using
 
     soln = moop.getPF()
 
+The output format defaults to a numpy structured array.
+However, you can change it to a pandas dataframe using the optional
+``format`` argument.
+
+.. code-block:: python
+
+    soln = moop.getPF(format="pandas")
+
+Note that ``format="pandas"`` is only supported when working with
+:ref:`named outputs <naming>`.
+
 To get the full simulation and objective databases, you can also use
 :meth:`MOOP.getSimulationData() <moop.MOOP.getSimulationData>`
 and
diff --git a/docs/index.rst b/docs/index.rst
index 5651f34..388a72f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -39,6 +39,7 @@ Table of Contents
    about
    how-to-write
    extras
+   faqs
    refs
 
 .. toctree::
@@ -53,6 +54,7 @@ Table of Contents
 
    tutorials/basic-tutorials
    tutorials/libe-tutorial
+   tutorials/local_method
 
 .. toctree::
    :maxdepth: 2
diff --git a/docs/modules/acquisitions.rst b/docs/modules/acquisitions.rst
index dec47e8..114ac1b 100644
--- a/docs/modules/acquisitions.rst
+++ b/docs/modules/acquisitions.rst
@@ -47,3 +47,9 @@ Epsilon Constraint Methods
    :members:
 
    .. automethod:: __init__
+
+.. autoclass:: EI_RandomConstraint
+   :member-order: bysource
+   :members:
+
+   .. automethod:: __init__
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index fffb2fe..f5d7ef3 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -205,12 +205,14 @@ simulation output) and one constraint.
 .. code-block:: python
 
    # First objective just returns the first simulation output
-   my_moop.addObjective({'name': "f1", 'obj_func': lambda x, s: s["MySim"][0]})
+   def f1(x, s): return s["MySim"][0]
+   my_moop.addObjective({'name': "f1", 'obj_func': f1})
    # Second objective just returns the second simulation output
-   my_moop.addObjective({'name': "f2", 'obj_func': lambda x, s: s["MySim"][1]})
+   def f2(x, s): return s["MySim"][1]
+   my_moop.addObjective({'name': "f2", 'obj_func': f2})
    # Add a single constraint, that x[0] >= 0.1
-   my_moop.addConstraint({'name': "c1",
-                          'constraint': lambda x, s: 0.1 - x["x1"]})
+   def c1(x, s): return 0.1 - x["x1"]
+   my_moop.addConstraint({'name': "c1", 'constraint': c1})
 
 Finally, we must add one or more acquisition functions using
 :meth:`MOOP.addAcquisition(*args) <moop.MOOP.addAcquisition>`.
@@ -221,11 +223,11 @@ This is useful to know if you are using a parallel solver.
 
 .. code-block:: python
 
-   from parmoo.acquisitions import UniformWeights
+   from parmoo.acquisitions import RandomConstraint
 
    # Add 3 acquisition functions
    for i in range(3):
-      my_moop.addAcquisition({'acquisition': UniformWeights,
+      my_moop.addAcquisition({'acquisition': RandomConstraint,
                               'hyperparams': {}})
 
 Finally, the MOOP is solved using the
@@ -235,13 +237,15 @@ results can be viewed using
 
 .. code-block:: python
 
+   import pandas as pd
+
    my_moop.solve(5) # Solve with 5 iterations of ParMOO algorithm
-   results = my_moop.getPF() # Extract the results
+   results = my_moop.getPF(format="pandas") # Extract the results as pandas df
 
 After executing the above block of code, the ``results`` variable points to
-a numpy structured array, each of whose entries corresponds to a
-nondominated objective value in the ``my_moop`` object's final database.
-You can reference individual fields in the ``results`` array by using the
+a pandas_ dataframe, each of whose rows corresponds to a nondominated
+objective value in the ``my_moop`` object's final database.
+You can reference individual columns in the ``results`` array by using the
 ``name`` keys that were assigned during ``my_moop``'s construction, or
 plot the results by using the :doc:`viz <modules/viz>` library.
 
@@ -263,7 +267,7 @@ output:
 
 And produces the following figure of the Pareto points:
 
-.. figure:: ../examples/Pareto\ Front.jpeg
+.. figure:: ../examples/quickstart.jpeg
     :alt: Scatter plot of the Pareto front after solving demo problem
     :align: center
 
@@ -281,8 +285,10 @@ Next Steps
    :doc:`More Tutorials <tutorials/basic-tutorials>`.
  * To start solving MOOPs on parallel hardware, install libEnsemble_ and
    see the :doc:`libEnsemble tutorial <tutorials/libe-tutorial>`.
+ * See some of our pre-built solvers in the parmoo_solver_farm_.
  * To interactively explore your solutions, install its extra dependencies and
    use our built-in :doc:`viz <modules/viz>` tool.
+ * For more advice, consult our :doc:`FAQs <faqs>`.
 
 Resources
 ---------
@@ -308,6 +314,7 @@ Please read our LICENSE_ and CONTRIBUTING_ files.
 .. _LICENSE: https://github.com/parmoo/parmoo/blob/main/LICENSE
 .. _numpy: https://numpy.org
 .. _pandas: https://pandas.pydata.org
+.. _parmoo_solver_farm: https://github.com/parmoo/parmoo-solver-farm
 .. _plotly: https://plotly.com/python
 .. _pyDOE: https://pythonhosted.org/pyDOE
 .. _pytest: https://docs.pytest.org/en/7.0.x
diff --git a/docs/refs.rst b/docs/refs.rst
index 9557de1..964cd42 100644
--- a/docs/refs.rst
+++ b/docs/refs.rst
@@ -45,7 +45,7 @@ Our online documentation:
         title       = {{ParMOO}: {P}ython library for parallel multiobjective simulation optimization},
         author      = {Chang, Tyler H. and Wild, Stefan M. and Dickinson, Hyrum},
         institution = {Argonne National Laboratory},
-        number      = {Version 0.2.2},
+        number      = {Version 0.3.0},
         year        = {2023},
         url         = {https://parmoo.readthedocs.io/en/latest}
     }
diff --git a/docs/tutorials/basic-tutorials.rst b/docs/tutorials/basic-tutorials.rst
index d783a6a..50b38e9 100644
--- a/docs/tutorials/basic-tutorials.rst
+++ b/docs/tutorials/basic-tutorials.rst
@@ -16,14 +16,14 @@ taken from the :doc:`Quickstart <../quickstart>` guide.
     :language: python
 
 The above code saves all (approximate) Pareto optimal solutions in the
-``results`` variable, and prints the ``results`` variable to the standard
+``results`` dataframe, and prints the ``results`` dataframe to the standard
 output:
 
 .. literalinclude:: ../../examples/quickstart.out
 
 And produces the following figure of the Pareto points:
 
-.. figure:: ../../examples/Pareto\ Front.jpeg
+.. figure:: ../../examples/quickstart.jpeg
     :alt: Scatter plot of the Pareto front after solving demo problem
     :align: center
 
diff --git a/docs/tutorials/local_method.rst b/docs/tutorials/local_method.rst
new file mode 100644
index 0000000..a5d621f
--- /dev/null
+++ b/docs/tutorials/local_method.rst
@@ -0,0 +1,100 @@
+Solving High-Dimensional Multiobjective Optimization Problems
+=============================================================
+
+.. _high_d_ex:
+
+Solving high-dimensional blackbox optimization problems is very hard.
+Solving them in the multiobjective sense is even harder.
+Doing this efficiently on a limited budget could be considered a
+*grand challenge* type problem.
+
+To quote our FAQ_:
+
+ - The key issue is that **global optimization is expensive**.
+   At a fundamental level, *no blackbox solver* can guarantee global
+   convergence without
+   densely sampling the design space, which is exponentially expensive
+   when ``n`` (number of design variables) is large.
+   So what can you do?
+   You can switch to using **local modeling methods**, whose costs
+   generally only grow linearly in the dimension.
+   You will not get any global convergence guarantees, but in many
+   cases, you will still be able to *approximately* solve your problem.
+ - If you have a lot of design variables, then you might do better
+   with a local solver, by switching your surrogate to the
+   :class:`LocalGaussRBF <surrogates.gaussian_proc.LocalGaussRBF>`
+   surrogate.
+   If you are using the
+   :class:`LBFGSB <optimizers.lbfgsb.LBFGSB>` optimizer, then you
+   will also need to switch to the
+   :class:`TR_LBFGSB <optimizers.lbfgsb.TR_LBFGSB>` optimizer.
+ - The majority of ParMOO's overhead comes from fitting the surrogate
+   models and solving the scalarized surrogate problems. If you followed
+   the quickstart_, then the default method for surrogate modeling
+   was to fit a Gaussian process. For numerical stability reasons,
+   we fit our Gaussian processes via a *symmetric-eigensolve*,
+   which is not cheap. Then you may have to evaluate the Gaussian
+   process thousands of times while solving the surrogate problem.
+   All of this expense adds up, especially if you are using a large
+   total budget, since the cost of fitting Gaussian processes grows
+   cubically with the number of data points.
+   One solution is to switch to using a
+   :class:`LocalGaussRBF <surrogates.gaussian_proc.LocalGaussRBF>`
+   surrogate, which does not use the entire database when fitting
+   surrogates, and therefore is more scalable for handling large budgets.
+
+**We will attempt to solve a convex 50-design variable, 2-objective problem
+on a budget of just 1000 simulation evaluations.**
+Going off a modification to the quickstart_, this will produce the
+following script. We also have a similar example in the solver_farm_.
+
+.. literalinclude:: ../../examples/local_method.py
+    :language: python
+
+The above code is able to *approximately* solve the problem on an
+extremely limited budget given the large dimension, and it
+produces the following figure of the nondominated points:
+
+.. figure:: ../../examples/local_method.jpeg
+    :alt: Scatter plot of solution points found for a high-dimensional problem
+    :align: center
+
+|
+
+The solution is inexact, but the general shape of the Pareto front is
+already visible.
+**Running for more iterations would further increase the accuracy.**
+
+Based on the problem definition in the code block above, a necessary but
+not sufficient condition for Pareto optimality would be
+``x26=0.5``, ``x27=0.5``, ... ``x50=0.5``.
+To guess at our performance, the above method prints a csv file,
+selecting just columns ``x26``, ... ``x50`` from the final dataframe:
+
+.. literalinclude:: ../../examples/local_method.csv
+
+
+Clearly, ParMOO has not found the exact solutions, but many of the solutions
+are quite close in all but a few columns of ``x26``, ..., ``x50``.
+**Whether these solutions would be accurate enough for a given application is
+entirely application dependent.**
+
+
+If you use these techniques in your research, consider citing our design
+paper, where we describe a similar example available in the solver_farm_
+in Section 5:
+
+.. code-block:: bibtex
+
+    @techreport{parmoo-design,
+        author={Chang, Tyler H. and Wild, Stefan M.},
+        title={Designing a Framework for Solving Multiobjective Simulation Optimization Problems},
+        year={2023},
+        institution={arXiv preprint},
+        doi={10.48550/arXiv.2304.06881}
+    }
+
+
+.. _FAQ: ../faqs.html
+.. _solver_farm: https://github.com/parmoo/parmoo-solver-farm/tree/main/fayans-model-calibration-2022
+.. _quickstart: ../quickstart.html
diff --git a/examples/Makefile b/examples/Makefile
index e1e3b84..65be893 100644
--- a/examples/Makefile
+++ b/examples/Makefile
@@ -1,13 +1,16 @@
 OUT_FILES = quickstart.out named_var_ex.out unnamed_var_ex.out \
 			precomputed_data.out checkpointing.out advanced_ex.out \
-			libe_basic_ex.out Pareto\ Front.jpeg
+			local_method.jpeg libe_basic_ex.out quickstart.jpeg
 
 all: $(OUT_FILES)
 
-Pareto Front.jpeg: quickstart.out
+quickstart.jpeg: quickstart.py
+	python3 quickstart.py > quickstart.out
+	mv Pareto\ Front.jpeg quickstart.jpeg
 
 quickstart.out: quickstart.py
 	python3 quickstart.py > quickstart.out
+	mv Pareto\ Front.jpeg quickstart.jpeg
 
 named_var_ex.out: named_var_ex.py
 	python3 named_var_ex.py > named_var_ex.out
@@ -26,9 +29,13 @@ advanced_ex.out: advanced_ex.py
 	python3 advanced_ex.py > advanced_ex.out
 	rm -f parmoo.moop parmoo.surrogate.1
 
+local_method.jpeg: local_method.py
+	python3 local_method.py
+	mv Pareto\ Front.jpeg local_method.jpeg
+
 libe_basic_ex.out: libe_basic_ex.py
 	python3 libe_basic_ex.py --comms local --nworkers 3 > libe_basic_ex.out
 	rm -f parmoo.moop parmoo.surrogate.1 libE_stats.txt ensemble.log
 
 clean:
-	rm -f *.out libE_stats.txt ensemble.log
+	rm -f *.out libE_stats.txt ensemble.log *.jpeg
diff --git a/examples/Pareto Front.jpeg b/examples/Pareto Front.jpeg
deleted file mode 100644
index 5fa7a76..0000000
Binary files a/examples/Pareto Front.jpeg and /dev/null differ
diff --git a/examples/advanced_ex.out b/examples/advanced_ex.out
index c08ed09..ebc1e8b 100644
--- a/examples/advanced_ex.out
+++ b/examples/advanced_ex.out
@@ -1,31 +1,36 @@
 Simulation data:
-{'f_conv': array([(0.11022377, 0.23513168, 0.19981213, 1, [0.10736108, 2.0170259 ]),
-       (0.53579252, 0.76464951, 0.91814704, 2, [1.71475649, 0.27757835]),
-       (0.91905002, 0.11120525, 0.0262472 , 2, [0.85770847, 1.74470352]),
-       (0.29310833, 0.02111603, 0.36324516, 0, [0.21830543, 1.86336639]),
-       (0.70767269, 0.32984221, 0.69284079, 0, [1.08962488, 0.6289135 ]),
-       (0.81871679, 0.99787368, 0.72000579, 2, [2.18445741, 0.11126488]),
-       (0.6548903 , 0.57251322, 0.59872521, 2, [1.11512458, 0.4628671 ]),
-       (0.40692451, 0.82950591, 0.26550651, 0, [0.92416132, 0.92028746]),
-       (0.36296499, 0.4240694 , 0.85268714, 1, [1.0386538 , 0.75921074]),
-       (0.07970264, 0.6565336 , 0.41666456, 1, [0.61099824, 1.30519663]),
-       (1.        , 1.        , 1.        , 0, [3.        , 0.        ]),
-       (1.        , 0.        , 1.        , 0, [2.        , 1.        ]),
-       (1.        , 1.        , 0.        , 0, [2.        , 1.        ]),
-       (0.        , 1.        , 0.        , 0, [1.        , 2.        ]),
-       (0.        , 1.        , 1.        , 0, [2.        , 1.        ]),
-       (0.37029969, 0.0033497 , 0.58906285, 1, [0.48412812, 1.55870364]),
-       (0.        , 0.        , 0.        , 0, [0.        , 3.        ]),
-       (0.59290032, 0.50006659, 0.41667761, 0, [0.77521761, 0.75592858]),
-       (0.22483158, 0.00326198, 0.70273262, 2, [0.54439301, 1.68274067]),
-       (0.        , 0.        , 1.        , 0, [1.        , 2.        ])],
-      dtype=[('x1', '<f8'), ('x2', '<f8'), ('x3', '<f8'), ('x4', '<i4'), ('out', '<f8', (2,))])}
+	f_conv:
+          x1        x2        x3  x4     out_0     out_1
+0   0.164589  0.071519  0.256804   0  0.098153  2.112328
+1   0.279173  0.761210  0.446148   2  0.856425  0.883365
+2   0.054881  0.143759  0.645615   1  0.440497  1.751987
+3   0.761764  0.514335  0.869763   1  1.601312  0.309588
+4   0.563992  0.252889  0.383262   1  0.528930  1.128643
+5   0.626456  0.302022  0.761693   2  1.063841  0.683499
+6   0.835951  0.921038  0.912893   1  2.380498  0.040735
+7   0.308713  0.677423  0.189177   0  0.589994  1.239367
+8   0.497862  0.843703  0.060276   0  0.963335  1.159652
+9   0.967064  0.479916  0.594467   0  1.518922  0.436029
+10  0.198616  0.279706  0.253141   0  0.181764  1.718838
+11  0.104311  0.713583  0.360565   0  0.650089  1.293170
+12  0.990617  0.496152  0.621227   2  1.613413  0.397420
+13  0.322478  0.475506  0.173748   1  0.360287  1.416822
+14  0.143465  0.160993  0.217433   0  0.093778  2.049997
+15  0.317939  0.476173  0.171654   0  0.357290  1.425761
+16  0.711117  0.366636  0.640507   0  1.050358  0.613839
+17  0.373639  0.410939  0.465101   0  0.524796  1.025438
+18  0.193532  0.456200  0.200312   0  0.285698  1.585610
+19  0.448389  0.368552  0.567048   0  0.658426  0.890449
 
 
 
 Solution points:
-[(1.        , 1.        , 1.        , 0, 3.        , 0.        , 0.)
- (0.70767269, 0.32984221, 0.69284079, 0, 1.08962488, 0.6289135 , 0.)
- (0.59290032, 0.50006659, 0.41667761, 0, 0.77521761, 0.75592858, 0.)
- (0.29310833, 0.02111603, 0.36324516, 0, 0.21830543, 1.86336639, 0.)
- (0.        , 0.        , 0.        , 0, 0.        , 3.        , 0.)]
+         x1        x2        x3  x4        f1        f2  c_x4
+0  0.967064  0.479916  0.594467   0  1.518922  0.436029   0.0
+1  0.711117  0.366636  0.640507   0  1.050358  0.613839   0.0
+2  0.448389  0.368552  0.567048   0  0.658426  0.890449   0.0
+3  0.373639  0.410939  0.465101   0  0.524796  1.025438   0.0
+4  0.317939  0.476173  0.171654   0  0.357290  1.425761   0.0
+5  0.193532  0.456200  0.200312   0  0.285698  1.585610   0.0
+6  0.198616  0.279706  0.253141   0  0.181764  1.718838   0.0
+7  0.143465  0.160993  0.217433   0  0.093778  2.049997   0.0
diff --git a/examples/advanced_ex.py b/examples/advanced_ex.py
index fd0de39..ed85319 100644
--- a/examples/advanced_ex.py
+++ b/examples/advanced_ex.py
@@ -1,11 +1,14 @@
 
 import numpy as np
 from parmoo import MOOP
-from parmoo.acquisitions import UniformWeights, FixedWeights
+from parmoo.acquisitions import RandomConstraint, FixedWeights
 from parmoo.searches import LatinHypercube
 from parmoo.surrogates import GaussRBF
 from parmoo.optimizers import LBFGSB
 
+# Fix the random seed for reproducibility
+np.random.seed(0)
+
 # Create a new MOOP with a derivative-based solver
 my_moop = MOOP(LBFGSB, hyperparams={})
 
@@ -90,7 +93,7 @@ def const_x4(x, sim, der=0):
                        'constraint': const_x4})
 
 # Add 2 different acquisition functions to the problem
-my_moop.addAcquisition({'acquisition': UniformWeights})
+my_moop.addAcquisition({'acquisition': RandomConstraint})
 my_moop.addAcquisition({'acquisition': FixedWeights,
                         # Fixed weight with equal weight on both objectives
                         'hyperparams': {'weights': np.array([0.5, 0.5])}})
@@ -108,12 +111,14 @@ def const_x4(x, sim, der=0):
 my_moop.solve(5)
 
 # Get and print full simulation database
-sim_db = my_moop.getSimulationData()
+sim_db = my_moop.getSimulationData(format="pandas")
 print("Simulation data:")
-print(sim_db)
+for key in sim_db.keys():
+    print(f"\t{key}:")
+    print(sim_db[key])
 
 # Get and print results
-soln = my_moop.getPF()
+soln = my_moop.getPF(format="pandas")
 print("\n\n")
 print("Solution points:")
 print(soln)
diff --git a/examples/checkpointing.out b/examples/checkpointing.out
index 5328bc4..96ea9e6 100644
--- a/examples/checkpointing.out
+++ b/examples/checkpointing.out
@@ -1,26 +1,18 @@
-[(0.79432817, 0, 3.53225972e-01, 3.21696685e-05, -0.69432817)
- (0.75428911, 0, 3.07236413e-01, 2.08948580e-03, -0.65428911)
- (0.74842973, 0, 3.00775170e-01, 2.65949261e-03, -0.64842973)
- (0.74208208, 0, 2.93852976e-01, 3.35448602e-03, -0.64208208)
- (0.70136828, 0, 2.51370153e-01, 9.72821611e-03, -0.60136828)
- (0.68964953, 0, 2.39756663e-01, 1.21772261e-02, -0.58964953)
- (0.61103625, 0, 1.68950798e-01, 3.57072991e-02, -0.51103625)
- (0.5849132 , 0, 1.48158173e-01, 4.62623305e-02, -0.4849132 )
- (0.50538467, 0, 9.32597987e-02, 8.67981908e-02, -0.40538467)
- (0.47806531, 0, 7.73203180e-02, 1.03641943e-01, -0.37806531)
- (0.47025281, 0, 7.30365826e-02, 1.08733208e-01, -0.37025281)
- (0.43328659, 0, 5.44226336e-02, 1.34478724e-01, -0.33328659)
- (0.40936081, 0, 4.38319487e-02, 1.52598977e-01, -0.30936081)
- (0.40643112, 0, 4.26138083e-02, 1.54896461e-01, -0.30643112)
- (0.40154831, 0, 4.06217212e-02, 1.58763749e-01, -0.30154831)
- (0.3756694 , 0, 3.08597394e-02, 1.80056455e-01, -0.2756694 )
- (0.35108734, 0, 2.28273846e-02, 2.01522575e-01, -0.25108734)
- (0.33448578, 0, 1.80864247e-02, 2.16703490e-01, -0.23448578)
- (0.32459808, 0, 1.55246824e-02, 2.26006982e-01, -0.22459808)
- (0.30994965, 0, 1.20889246e-02, 2.40149350e-01, -0.20994965)
- (0.30262543, 0, 1.05319783e-02, 2.47381466e-01, -0.20262543)
- (0.29920746, 0, 9.84211978e-03, 2.50793170e-01, -0.19920746)
- (0.28065277, 0, 6.50486944e-03, 2.69721544e-01, -0.18065277)
- (0.2469228 , 0, 2.20174956e-03, 3.05894384e-01, -0.1469228 )
- (0.19333394, 0, 4.44363953e-05, 3.68043712e-01, -0.09333394)] 
+[(0.79013666, 0, 3.48261275e-01, 9.72855201e-05, -0.69013666)
+ (0.7895263 , 0, 3.47541259e-01, 1.09698380e-04, -0.6895263 )
+ (0.73488156, 0, 2.86098283e-01, 4.24041125e-03, -0.63488156)
+ (0.70656124, 0, 2.56604287e-01, 8.73080244e-03, -0.60656124)
+ (0.68409101, 0, 2.34344111e-01, 1.34348928e-02, -0.58409101)
+ (0.67237225, 0, 2.23135544e-01, 1.62888423e-02, -0.57237225)
+ (0.5784217 , 0, 1.43202981e-01, 4.90969442e-02, -0.4784217 )
+ (0.53031761, 0, 1.09109723e-01, 7.27285920e-02, -0.43031761)
+ (0.51322778, 0, 9.81116425e-02, 8.22383058e-02, -0.41322778)
+ (0.49723345, 0, 8.83477213e-02, 9.16675863e-02, -0.39723345)
+ (0.44987019, 0, 6.24351129e-02, 1.22590882e-01, -0.34987019)
+ (0.40591372, 0, 4.24004606e-02, 1.55303995e-01, -0.30591372)
+ (0.39028872, 0, 3.62097978e-02, 1.67863331e-01, -0.29028872)
+ (0.38995793, 0, 3.60840145e-02, 1.68134501e-01, -0.28995793)
+ (0.38287784, 0, 3.34443041e-02, 1.73990897e-01, -0.28287784)
+ (0.25435646, 0, 2.95462529e-03, 2.97726867e-01, -0.15435646)
+ (0.20137796, 0, 1.89878434e-06, 3.58348342e-01, -0.10137796)] 
  dtype=[('x1', '<f8'), ('x2', '<i4'), ('f1', '<f8'), ('f2', '<f8'), ('c1', '<f8')]
diff --git a/examples/checkpointing.py b/examples/checkpointing.py
index f1fd688..5ccdda0 100644
--- a/examples/checkpointing.py
+++ b/examples/checkpointing.py
@@ -7,6 +7,9 @@
 from parmoo.optimizers import LocalGPS
 import logging
 
+# Fix the random seed for reproducibility
+np.random.seed(0)
+
 # Create a new MOOP
 my_moop = MOOP(LocalGPS)
 
diff --git a/examples/libe_basic_ex.out b/examples/libe_basic_ex.out
index 822ae57..7a762a5 100644
--- a/examples/libe_basic_ex.out
+++ b/examples/libe_basic_ex.out
@@ -1,16 +1,10 @@
-[(0.81868988, 0, 3.82777168e-01, 0.00034931, -0.71868988)
- (0.76668793, 0, 3.21135206e-01, 0.00110969, -0.66668793)
- (0.75667816, 0, 3.09890575e-01, 0.00187678, -0.65667816)
- (0.71229286, 0, 2.62443978e-01, 0.00769254, -0.61229286)
- (0.71070595, 0, 2.60820566e-01, 0.00797343, -0.61070595)
- (0.65059906, 0, 2.03039513e-01, 0.02232064, -0.55059906)
- (0.64278656, 0, 1.96059937e-01, 0.02471607, -0.54278656)
- (0.55465179, 0, 1.25777895e-01, 0.06019574, -0.45465179)
- (0.52340179, 0, 1.04588720e-01, 0.07650657, -0.42340179)
- (0.45243645, 0, 6.37241603e-02, 0.12080042, -0.35243645)
- (0.41679192, 0, 4.69987352e-02, 0.14684843, -0.31679192)
- (0.35895449, 0, 2.52665290e-02, 0.19452114, -0.25895449)
- (0.31098085, 0, 1.23167500e-02, 0.23913972, -0.21098085)
- (0.28949648, 0, 8.00961981e-03, 0.26061384, -0.18949648)
- (0.21818059, 0, 3.30533993e-04, 0.33851382, -0.11818059)] 
- dtype=[('x1', '<f8'), ('x2', '<i4'), ('f1', '<f8'), ('f2', '<f8'), ('c1', '<f8')]
+         x1  x2        f1        f2        c1
+0  0.742825   0  0.294659  0.003269 -0.642825
+1  0.680283   0  0.230672  0.014332 -0.580283
+2  0.616501   0  0.173473  0.033672 -0.516501
+3  0.580369   0  0.144680  0.048238 -0.480369
+4  0.555222   0  0.126183  0.059916 -0.455222
+5  0.518980   0  0.101749  0.078972 -0.418980
+6  0.475477   0  0.075888  0.105315 -0.375477
+7  0.302503   0  0.010507  0.247503 -0.202503
+8  0.201285   0  0.000002  0.358460 -0.101285
diff --git a/examples/libe_basic_ex.py b/examples/libe_basic_ex.py
index c5aff7b..22b44a8 100644
--- a/examples/libe_basic_ex.py
+++ b/examples/libe_basic_ex.py
@@ -6,6 +6,11 @@
 from parmoo.acquisitions import UniformWeights
 from parmoo.optimizers import LocalGPS
 
+# When running with MPI, we need to keep track of which thread is the manager
+# using libensemble.tools.parse_args()
+from libensemble.tools import parse_args
+_, is_manager, _, _ = parse_args()
+
 # All functions are defined below.
 
 def sim_func(x):
@@ -26,6 +31,9 @@ def const_c1(x, s):
 # When using libEnsemble with Python MP, the "solve" command must be enclosed
 # in an "if __name__ == '__main__':" block, as shown below
 if __name__ == "__main__":
+    # Fix the random seed for reproducibility
+    np.random.seed(0)
+
     # Create a libE_MOOP
     my_moop = libE_MOOP(LocalGPS)
     
@@ -61,7 +69,8 @@ def const_c1(x, s):
     
     # Use sim_max = 30 to perform just 30 simulations
     my_moop.solve(sim_max=30)
-    results = my_moop.getPF()
     
-    # Display the solution
-    print(results, "\n dtype=" + str(results.dtype))
+    # Display the solution -- this "if" clause is needed when running with MPI
+    if is_manager:
+        results = my_moop.getPF(format="pandas")
+        print(results)
diff --git a/examples/local_method.csv b/examples/local_method.csv
new file mode 100644
index 0000000..d565b5b
--- /dev/null
+++ b/examples/local_method.csv
@@ -0,0 +1,21 @@
+,x26,x27,x28,x29,x30,x31,x32,x33,x34,x35,x36,x37,x38,x39,x40,x41,x42,x43,x44,x45,x46,x47,x48,x49,x50
+0,0.5366946835438436,0.480535634334331,0.5160475188419098,0.6070197065831299,0.6672172711094266,0.48551619978110366,0.42610540131960145,0.4811010738722131,0.3425328910576567,0.7054110833892607,0.4352889180964643,0.7978816841760015,0.3063578175995894,0.5292602000450671,0.700800224549289,0.5377027107208002,0.25115698686137533,0.5288995346995834,0.4950069227982256,0.17614554775339386,0.43159599264801474,0.5490206585918878,0.6063607938295565,0.5025712376141976,0.47940143509202565
+1,0.21201656695993382,0.3702045521967413,0.6190734162294054,0.6239469986512967,0.2862085782607271,0.517951134771002,0.5298746636054862,0.3585855775673113,0.3996404437543248,0.3506929113945627,0.5651786195889051,0.530138859271535,0.3864477482979602,0.6248489519955427,0.3162146560016008,0.24391249573503515,0.28071059514585706,0.370883066418232,0.5541698806411045,0.2265722859000449,0.47532448644242864,0.5388644174110008,0.6616590569894238,0.44813936868080606,0.4009390125097746
+2,0.5561424758032281,0.44346734011216216,0.38998215633756317,0.4591350346310652,0.7328207110981155,0.44930979877679217,0.32144499533332593,0.39650142970701746,0.48863474948227376,0.507833114500235,0.26086792936556025,0.8056729955634335,0.33940034592710544,0.345840903491873,0.4053338723248634,0.3930238052957297,0.6466367621586258,0.5565063641229069,0.5887740374359774,0.4198769007664722,0.4827112230905737,0.24421871197214853,0.33210789218288583,0.39605677887110347,0.6878048361733072
+3,0.44286300869997175,0.42778547451795623,0.35186496326814265,0.6513758449970273,0.4052515181173766,0.4078043109512294,0.5073185829990507,0.15106299313818577,0.4856048764844075,0.6413239003132395,0.6304134167473617,0.45121397858198714,0.5889145607293959,0.42748007301056445,0.40715934094130535,0.3592057831636401,0.4960409128034893,0.5380332043625424,0.36382354945134554,0.2013616120308228,0.49030851239578516,0.554521649474869,0.6327178428704407,0.41751222510187963,0.6772355123380417
+4,0.23595077432304035,0.2732761441136762,0.6638340784257559,0.5120017574606377,0.2758737217174738,0.7126421327883947,0.275295911191266,0.7638257646563237,0.6382591391425461,0.48435491499904243,0.3626957409583479,0.5621126041659689,0.7893338288441889,0.52226623612655,0.3059973866695175,0.552189752202072,0.8135134811086226,0.567471337713878,0.5579479179061301,0.3894876873448983,0.3215337303042187,0.2700470849747288,0.5160923190693019,0.4965910930525222,0.5061571642517574
+5,0.46149336815190034,0.629538330175257,0.7127560377416142,0.4442887226265191,0.4541224130287897,0.2633243966165434,0.24692939062063107,0.3784509135054398,0.26898224709879226,0.7357933931843939,0.3475382778291568,0.5171148295386534,0.3501570231861545,0.7028838956548591,0.716751049973538,0.5542627521702089,0.3166422001483687,0.732718408693968,0.44179341859052684,0.6915282387249422,0.45862007404657235,0.6557503406166848,0.7416320493350386,0.18700464597241073,0.4738179362452168
+6,0.6492155188914149,0.7386180921623362,0.8191657705640728,0.6177120946296875,0.25545730090450314,0.6279436681962359,0.7456089531715299,0.4301192581459022,0.431919833147515,0.1825871560697172,0.6449108450032169,0.31818089007798733,0.595526081062856,0.5665665151006083,0.7741614283541227,0.36394801685054523,0.37142174861180277,0.270050607879592,0.30698220823439876,0.4400586749726125,0.7280106145094976,0.25166356859756034,0.7608913835265945,0.7186735352378809,0.21475152967771133
+7,0.46511624903347193,0.5325359660189231,0.7363345308549895,0.47213948139686746,0.25221355324330574,0.702611596430013,0.19801360881641222,0.6751423733962472,0.5562234092469281,0.42163280717603746,0.7037384241492336,0.5266846511578003,0.35220590955802933,0.7865953485969641,0.5754496822029926,0.27427612361645637,0.6029163148214325,0.5187626047343203,0.8090796351007774,0.6298538773862838,0.3147845801056105,0.7979756538309042,0.33750508850227334,0.5539840566523027,0.28944326413397214
+8,0.6240532503636728,0.8715552000151949,0.6698354610710465,0.3540450624148303,0.5379965591514205,0.523314143146974,0.17951857907235985,0.5644640557818029,0.7453920798128206,0.14683389329059543,0.6744902863826904,0.42902093888927334,0.6946287384636932,0.4387464460786994,0.6567829228190275,0.7714918137748795,0.20838260380861762,0.3866154762935723,0.7258152024877955,0.37958037618930507,0.4166675771106789,0.799775088460385,0.6424508553766978,0.5073058489695419,0.5122928196153836
+9,0.8083228274693901,0.39564148332653515,0.35028907654874825,0.9668048714410297,0.2907483221250661,0.11999807062618467,0.5379576515967259,0.6673637446575738,0.2775098975155653,0.38931769193400007,0.534830206698696,0.37199641724695753,0.5451064223164794,0.1814521626676714,0.29179908599486015,0.7381192982516395,0.6697243534585621,0.5317930404167982,0.7379681323797941,0.64677195699539,0.7248551457955743,0.36593461099865,0.7152835594401916,0.41988317942116643,0.5214843561645818
+10,0.4971316388237236,0.22682229991421982,0.5034779108983308,0.43868655362420184,0.7385335954546681,0.32204439723191597,0.9344507452250923,0.5779280376271245,0.6463574448323753,0.219346142470507,0.6288287743306861,0.3788840056203297,0.4041686697010589,0.4951429228829251,0.24735083357256366,0.4738042240031955,0.3974284988300453,0.5358793405580311,0.7142121969463976,0.8058424404118725,0.5819444460124938,0.541959646374663,0.45085970031475414,0.36754077273742913,0.3113884969912084
+11,0.19951350287858372,0.7671634234797564,0.17989868355870525,0.34584115254781567,0.24481664558855418,0.3740867927207997,0.22618779201293282,0.40600617794191374,0.3074439894811378,0.5888098866502656,0.8233099259558825,0.6555812450709618,0.8236265332663156,0.3588332743741342,0.5425462065497779,0.26422902032702544,0.6440623209479265,0.5088102854045872,0.7009006177389732,0.5988893832521629,0.22836153221409056,0.48754020083308086,0.6534480841631513,0.2545829602727406,0.17811085551158462
+12,0.619188400594239,0.6569994787428085,0.34386908022015206,0.3580530393730327,0.5519159178238913,0.7752589759093589,0.2882404268420529,0.5885724416310214,0.6484140329822388,0.25753817513149696,0.3786197761212532,0.21936649953049903,0.7588403580742802,0.573424933491924,0.49377388838693637,0.6049172970859933,0.29007401048194426,0.5191808323155452,0.5803150813931853,0.13360549678995692,0.7543773269703189,0.29244776310832327,0.16992855824317116,0.20649592811337603,0.7684992936901676
+13,0.8089043454126528,0.24558629620503594,0.4156763954417,0.7750751509175771,0.5733692545340998,0.2679485326366635,0.53750309489663,0.33303474406978856,0.7084503201360435,0.8832895613058815,0.551111375044852,0.41344448359974495,0.401510817411759,0.3315761394811299,0.8119844473304381,0.49679670410916005,0.2331289666950867,0.3191770127712278,0.3905585632566304,0.8341357429713513,0.6363225606564503,0.7055674552769213,0.6318181997230172,0.6148893630916908,0.2473977765575856
+14,0.3630333976468133,0.7312284036556909,0.4063879600787254,0.34682011182462447,0.43076419574801894,0.49614735963150935,0.5642599399841538,0.23592585554747167,0.4735232573773983,0.41591286744870337,0.7107062739910608,0.582879903830216,0.5784965960257064,0.6804472686056164,0.5271577265185332,0.42950454556205403,0.7094201476841995,0.15356846899708243,0.41489067248084943,0.6767144767496903,0.7291544282078588,0.3937279161140911,0.43346368619686954,0.6167654985691386,0.23988257785104986
+15,0.6017643073886613,0.3922882389716225,0.6865410366409341,0.8524120136792644,0.33314740251996156,0.5517539198107381,0.2327922616398413,0.5536736125248068,0.5370815585082317,0.23074641605982843,0.435554111119259,0.5808195289088446,0.484419418046966,0.394608239223725,0.7211474951159841,0.375611436745661,0.7875268518268734,0.25454616319718004,0.45458907552995853,0.47339219099230107,0.4243546655769569,0.3260372408529185,0.6515562334746124,0.4616192647290729,0.5240954207844519
+16,0.305441663990466,0.4256369598641956,0.5530375899730964,0.2833604667426151,0.51441960076015,0.5490441252909793,0.31899614978547386,0.39610768732306634,0.8387623919193579,0.5866002922680319,0.41109892150846616,0.7926501023816348,0.46490465122520463,0.5890330085147424,0.3337441558064368,0.8138882756594232,0.27921683389559543,0.4580925338430854,0.46433812752959863,0.4798901616824015,0.3568627007507853,0.27221780616665187,0.4295268033041352,0.6602843904712186,0.34315577280675214
+17,0.48493200481135573,0.43609521301064386,0.3765575487986266,0.842615143516219,0.5137473362949972,0.7105427987004205,0.5959799744216097,0.5157955128852942,0.3549782073134401,0.40418583378308176,0.5831240472544407,0.4968322893613715,0.29510391175219086,0.7470204825699024,0.7292694773175746,0.49290601696963143,0.5269182911645355,0.5181521228053719,0.7265849547557288,0.6124109945880645,0.5432599236594075,0.33655171706368814,0.786333946818256,0.5276579600099066,0.6026212160010831
+18,0.26073856777261273,0.34602946393585,0.5885191774152281,0.6017552570462076,0.36859034061765555,0.6226629674181852,0.36783675425109297,0.3522830393328373,0.4144792031204204,0.6621695010830078,0.7885543219711353,0.45723993940160584,0.31166586047143485,0.4538507250015391,0.3047875639565541,0.6051068870484023,0.24855959667438468,0.347584731181543,0.483278403568783,0.2745221614931336,0.717695929306571,0.3882041691579231,0.35282408842752033,0.7576750787772889,0.27371532305000945
+19,0.6087577032918362,0.5163737338642169,0.7927612250769009,0.40107675259140624,0.25074158880469255,0.48682983665780055,0.7777176294352055,0.43527911526968577,0.7306670027820905,0.18286849588030185,0.4208555425616807,0.3320613413189294,0.5301087333811775,0.3562850384182146,0.47420942900080615,0.7022023824030751,0.23628440604611273,0.37753529090993965,0.615703375152078,0.5013486360338666,0.4763234219308978,0.6107173315558655,0.2209548846031878,0.5721368098253389,0.6748518330113652
diff --git a/examples/local_method.jpeg b/examples/local_method.jpeg
new file mode 100644
index 0000000..8ccf950
Binary files /dev/null and b/examples/local_method.jpeg differ
diff --git a/examples/local_method.py b/examples/local_method.py
new file mode 100644
index 0000000..6abc5bf
--- /dev/null
+++ b/examples/local_method.py
@@ -0,0 +1,86 @@
+
+import numpy as np
+import pandas as pd
+from parmoo import MOOP
+from parmoo.searches import LatinHypercube
+from parmoo.surrogates import LocalGaussRBF
+from parmoo.acquisitions import RandomConstraint, FixedWeights
+from parmoo.optimizers import TR_LBFGSB
+from parmoo.objectives import single_sim_out
+import logging
+
+# Fix the random seed for reproducibility
+np.random.seed(0)
+
+# Switch to using the TR_LBFGSB solver to solve surrogate problems in
+# a trust region with multi-start LBFGSB
+my_moop = MOOP(TR_LBFGSB)
+
+# Massive 50-variable black-box optimization problem
+# Completely hopeless for methods that rely on global models
+for i in range(1, 51):
+    my_moop.addDesign({'name': f"x{i}",
+                       'des_type': "continuous",
+                       'lb': 0.0, 'ub': 1.0})
+
+# A simple convex simulation output with 2 outputs
+def sim_func(x):
+    xx = np.zeros(50)
+    for i in range(50):
+        xx[i] = x[f"x{i+1}"]
+    # 25 variables that don't affect tradeoff, but need to be minimized
+    tail = np.linalg.norm(xx[25:] - 0.5) ** 2 / 25
+    # 25 variables that do affect tradeoff
+    s1 = np.linalg.norm(xx[:25] - 0.2) ** 2 / 25 + tail
+    s2 = np.linalg.norm(xx[:25] - 0.8) ** 2 / 25 + tail
+    return np.array([s1, s2])
+
+# Using a local surrogate to dodge the curse of dimensionality
+# Notice that search_budget has to be greater than the number of variables
+my_moop.addSimulation({'name': "MySim",
+                       'm': 2,
+                       'sim_func': sim_func,
+                       'search': LatinHypercube,
+                       'surrogate': LocalGaussRBF,
+                       'hyperparams': {'search_budget': 200}})
+
+# 2 objectives (using the single_sim_out library objective to minimize a
+# single output of the simulation function)
+my_moop.addObjective({'name': "f1",
+                      'obj_func': single_sim_out(my_moop.getDesignType(),
+                                                 my_moop.getSimulationType(),
+                                                 ("MySim", 0))})
+my_moop.addObjective({'name': "f2",
+                      'obj_func': single_sim_out(my_moop.getDesignType(),
+                                                 my_moop.getSimulationType(),
+                                                 ("MySim", 1))})
+
+# When solving big problems, it's often better to fix some acquisitions so
+# we can focus on a few high-quality solutions
+my_moop.addAcquisition({'acquisition': FixedWeights,
+                        'hyperparams': {'weights': np.eye(2)[0]}})
+my_moop.addAcquisition({'acquisition': FixedWeights,
+                        'hyperparams': {'weights': np.eye(2)[1]}})
+my_moop.addAcquisition({'acquisition': FixedWeights,
+                        'hyperparams': {'weights': np.ones(2) / 2}})
+# Keep one randomized acquisition to get some coverage of the Pareto front
+my_moop.addAcquisition({'acquisition': RandomConstraint,
+                        'hyperparams': {}})
+
+# Turn on logging with timestamps
+logging.basicConfig(level=logging.INFO,
+                    format='%(asctime)s %(levelname)-8s %(message)s',
+                    datefmt='%Y-%m-%d %H:%M:%S')
+
+# 200 iterations * 4 acquisition funcs + 200 point search = 1000 eval budget
+# This could take a few mins to run...
+my_moop.solve(200)
+
+# Display the values of x26, ..., x50 for all solution points
+results = my_moop.getPF(format="pandas")
+results[[f"x{i}" for i in range(26, 51)]].to_csv("local_method.csv")
+
+# Plot results -- must have extra viz dependencies installed
+from parmoo.viz import scatter
+# The optional arg `output` exports directly to jpg instead of interactive mode
+scatter(my_moop, output="jpeg")
diff --git a/examples/quickstart.jpeg b/examples/quickstart.jpeg
new file mode 100644
index 0000000..b46e159
Binary files /dev/null and b/examples/quickstart.jpeg differ
diff --git a/examples/quickstart.out b/examples/quickstart.out
index 3d577a1..b265409 100644
--- a/examples/quickstart.out
+++ b/examples/quickstart.out
@@ -1,22 +1,11 @@
-[(0.80441659, 'good', 0.36531941, 1.95062539e-05, -0.70441659)
- (0.78824227, 'good', 0.34602897, 1.38244166e-04, -0.68824227)
- (0.76163094, 'good', 0.31542932, 1.47218446e-03, -0.66163094)
- (0.75247567, 'good', 0.30522937, 2.25856189e-03, -0.65247567)
- (0.71996629, 'good', 0.27036494, 6.40539502e-03, -0.61996629)
- (0.70092332, 'good', 0.25092417, 9.81618862e-03, -0.60092332)
- (0.67848867, 'good', 0.22895141, 1.47650036e-02, -0.57848867)
- (0.64212037, 'good', 0.19547042, 2.49259786e-02, -0.54212037)
- (0.63406373, 'good', 0.18841132, 2.75348470e-02, -0.53406373)
- (0.47151461, 'good', 0.07372019, 1.07902648e-01, -0.37151461)
- (0.44295016, 'good', 0.05902478, 1.27484587e-01, -0.34295016)
- (0.41267672, 'good', 0.04523139, 1.50019320e-01, -0.31267672)
- (0.41218844, 'good', 0.04502394, 1.50397804e-01, -0.31218844)
- (0.40621449, 'good', 0.04252441, 1.55067032e-01, -0.30621449)
- (0.3588512 , 'good', 0.0252337 , 1.94612260e-01, -0.2588512 )
- (0.34420277, 'good', 0.02079444, 2.07751118e-01, -0.24420277)
- (0.30611683, 'good', 0.01126078, 2.43920587e-01, -0.20611683)
- (0.27909944, 'good', 0.00625672, 2.71337398e-01, -0.17909944)
- (0.27629182, 'good', 0.00582044, 2.74270259e-01, -0.17629182)
- (0.27250764, 'good', 0.00525736, 2.78248191e-01, -0.17250764)
- (0.23224828, 'good', 0.00103995, 3.22342019e-01, -0.13224828)] 
- dtype=[('x1', '<f8'), ('x2', '<U25'), ('f1', '<f8'), ('f2', '<f8'), ('c1', '<f8')]
+         x1    x2        f1        f2        c1
+0  0.742840  good  0.294675  0.003267 -0.642840
+1  0.726092  good  0.276773  0.005462 -0.626092
+2  0.605914  good  0.164766  0.037669 -0.505914
+3  0.548931  good  0.121753  0.063036 -0.448931
+4  0.543499  good  0.117991  0.065793 -0.443499
+5  0.401011  good  0.040405  0.159192 -0.301011
+6  0.353552  good  0.023578  0.199316 -0.253552
+7  0.328402  good  0.016487  0.222404 -0.228402
+8  0.269175  good  0.004785  0.281775 -0.169175
+9  0.248183  good  0.002322  0.304502 -0.148183
diff --git a/examples/quickstart.py b/examples/quickstart.py
index ae628f4..35861f1 100644
--- a/examples/quickstart.py
+++ b/examples/quickstart.py
@@ -1,11 +1,15 @@
 
 import numpy as np
+import pandas as pd
 from parmoo import MOOP
 from parmoo.searches import LatinHypercube
 from parmoo.surrogates import GaussRBF
-from parmoo.acquisitions import UniformWeights
+from parmoo.acquisitions import RandomConstraint
 from parmoo.optimizers import LocalGPS
 
+# Fix the random seed for reproducibility
+np.random.seed(0)
+
 my_moop = MOOP(LocalGPS)
 
 my_moop.addDesign({'name': "x1",
@@ -27,20 +31,23 @@ def sim_func(x):
                        'surrogate': GaussRBF,
                        'hyperparams': {'search_budget': 20}})
 
-my_moop.addObjective({'name': "f1", 'obj_func': lambda x, s: s["MySim"][0]})
-my_moop.addObjective({'name': "f2", 'obj_func': lambda x, s: s["MySim"][1]})
+def f1(x, s): return s["MySim"][0]
+def f2(x, s): return s["MySim"][1]
+my_moop.addObjective({'name': "f1", 'obj_func': f1})
+my_moop.addObjective({'name': "f2", 'obj_func': f2})
 
-my_moop.addConstraint({'name': "c1", 'constraint': lambda x, s: 0.1 - x["x1"]})
+def c1(x, s): return 0.1 - x["x1"]
+my_moop.addConstraint({'name': "c1", 'constraint': c1})
 
 for i in range(3):
-   my_moop.addAcquisition({'acquisition': UniformWeights,
+   my_moop.addAcquisition({'acquisition': RandomConstraint,
                            'hyperparams': {}})
 
 my_moop.solve(5)
-results = my_moop.getPF()
+results = my_moop.getPF(format="pandas")
 
 # Display solution
-print(results, "\n dtype=" + str(results.dtype))
+print(results)
 
 # Plot results -- must have extra viz dependencies installed
 from parmoo.viz import scatter
diff --git a/examples/unnamed_var_ex.py b/examples/unnamed_var_ex.py
index 7b54755..0950880 100644
--- a/examples/unnamed_var_ex.py
+++ b/examples/unnamed_var_ex.py
@@ -6,6 +6,9 @@
 from parmoo.acquisitions import UniformWeights
 from parmoo.optimizers import LocalGPS
 
+# Fix the random seed for reproducibility
+np.random.seed(0)
+
 my_moop = MOOP(LocalGPS)
 
 # Define a simulation to use below
diff --git a/parmoo/acquisitions/__init__.py b/parmoo/acquisitions/__init__.py
index 9d5d7ce..45879ac 100644
--- a/parmoo/acquisitions/__init__.py
+++ b/parmoo/acquisitions/__init__.py
@@ -1,2 +1,2 @@
 from .weighted_sum import UniformWeights, FixedWeights
-from .epsilon_constraint import RandomConstraint
+from .epsilon_constraint import RandomConstraint, EI_RandomConstraint
diff --git a/parmoo/acquisitions/epsilon_constraint.py b/parmoo/acquisitions/epsilon_constraint.py
index 2c3624d..faf66a6 100644
--- a/parmoo/acquisitions/epsilon_constraint.py
+++ b/parmoo/acquisitions/epsilon_constraint.py
@@ -10,6 +10,7 @@
 """
 
 import numpy as np
+from scipy import stats, integrate
 import inspect
 from parmoo.structs import AcquisitionFunction
 from parmoo.util import xerror
@@ -61,7 +62,17 @@ def __init__(self, o, lb, ub, hyperparams):
         self.lb = lb
         return
 
-    def setTarget(self, data, lagrange_func, history):
+    def useSD(self):
+        """ Querry whether this method uses uncertainties.
+
+        When False, allows users to shortcut expensive uncertainty
+        computations.
+
+        """
+
+        return False
+
+    def setTarget(self, data, penalty_func, history):
         """ Randomly generate a target based on current nondominated points.
 
         Args:
@@ -72,8 +83,8 @@ def setTarget(self, data, lagrange_func, history):
                  * 'f_vals' (numpy.ndarray): A 2d array containing the
                    corresponding list of objective values.
 
-            lagrange_func (function): A function whose components correspond
-                to constraint violation amounts.
+            penalty_func (function): A function of one (x) or two (x, sx)
+                inputs that evaluates the (penalized) objectives.
 
             history (dict): A persistent dictionary that could be used by
                 the implementation of the AcquisitionFunction to pass data
@@ -113,13 +124,13 @@ def setTarget(self, data, lagrange_func, history):
                     no_data = True
             else:
                 no_data = True
-        # Check whether lagrange_func() has an appropriate signature
-        if callable(lagrange_func):
-            if len(inspect.signature(lagrange_func).parameters) != 1:
-                raise ValueError("lagrange_func() must accept exactly one"
-                                 + " input")
+        # Check whether penalty_func() has an appropriate signature
+        if callable(penalty_func):
+            if len(inspect.signature(penalty_func).parameters) not in [1, 2]:
+                raise ValueError("penalty_func() must accept 1 or 2"
+                                 + " inputs")
         else:
-            raise TypeError("lagrange_func() must be callable")
+            raise TypeError("penalty_func() must be callable")
         if no_data:
             # If data is empty, then the Pareto front is empty
             pf = {'x_vals': np.zeros((0, self.n)),
@@ -134,13 +145,13 @@ def setTarget(self, data, lagrange_func, history):
             self.weights = -np.log(1.0 - np.random.random_sample(self.o))
             self.weights = self.weights[:] / sum(self.weights[:])
             # Randomly select a feasible starting point
-            x_min = np.random.random_sample(self.n) * (self.ub - self.lb) \
-                    + self.lb
+            x_min = (np.random.random_sample(self.n) * (self.ub - self.lb)
+                     + self.lb)
             for count in range(1000):
-                x = np.random.random_sample(self.n) * (self.ub - self.lb) \
-                    + self.lb
-                if np.dot(self.weights, lagrange_func(x)) \
-                   < np.dot(self.weights, lagrange_func(x_min)):
+                x = (np.random.random_sample(self.n) * (self.ub - self.lb)
+                     + self.lb)
+                if np.dot(self.weights, penalty_func(x)) \
+                   < np.dot(self.weights, penalty_func(x_min)):
                     x_min[:] = x[:]
             return x_min
         else:
@@ -165,13 +176,24 @@ def setTarget(self, data, lagrange_func, history):
             # The corresponding x_val is feasible by construction
             return pf['x_vals'][ipts[0], :]
 
-    def scalarize(self, f_vals):
+    def scalarize(self, f_vals, x_vals, s_vals_mean, s_vals_sd):
         """ Scalarize a vector of function values using the current bounds.
 
         Args:
             f_vals (numpy.ndarray): A 1d array specifying the function
                 values to be scalarized.
 
+            x_vals (np.ndarray): A 1D array specifying a vector the design
+                point corresponding to f_vals (unused by this method).
+
+            s_vals_mean (np.ndarray): A 1D array specifying the expected
+                simulation outputs for the x value being scalarized
+                (unused by this method).
+
+            s_vals_sd (np.ndarray): A 1D array specifying the standard
+                deviation for each of the simulation outputs (unused by
+                this method).
+
         Returns:
             float: The scalarized value.
 
@@ -225,3 +247,263 @@ def scalarizeGrad(self, f_vals, g_vals):
             if f_vals[i] > self.f_ub[i]:
                 result = result + 10.0 * g_vals[i]
         return result
+
+
+class EI_RandomConstraint(AcquisitionFunction):
+    """ Expected improvement of a randomly set target point.
+
+    Randomly sets a target point inside the current Pareto front.
+    Attempts to improve one of the objective values by reformulating
+    all other objectives as constraints, upper bounded by their target
+    value. Uses surrogate uncertainties to maximize expected improvement
+    in the target objective subject to constraints.
+
+    """
+
+    # Slots for the RandomConstraint class
+    __slots__ = ['n', 'o', 'lb', 'ub', 'f_ub', 'weights', 'best', 'f']
+
+    def __init__(self, o, lb, ub, hyperparams):
+        """ Constructor for the RandomConstraint class.
+
+        Args:
+            o (int): The number of objectives.
+
+            lb (numpy.ndarray): A 1d array of lower bounds for the design
+                region. The number of design variables is inferred from the
+                dimension of lb.
+
+            ub (numpy.ndarray): A 1d array of upper bounds for the design
+                region. The dimension must match ub.
+
+            hyperparams (dict): A dictionary of hyperparameters for tuning
+                the acquisition function. Including
+                 * mc_sample_size (int): The number of samples to use for
+                   monte carlo integration (defaults to 10 * m ** 2).
+
+        Returns:
+            RandomConstraint: A new RandomConstraint scalarizer.
+
+        """
+
+        # Check inputs
+        xerror(o=o, lb=lb, ub=ub, hyperparams=hyperparams)
+        self.o = o
+        # Set the design variable count
+        self.n = np.size(lb)
+        # Initialize the objective/design bounds
+        self.f_ub = np.zeros(self.o)
+        self.weights = np.zeros(self.o)
+        self.ub = ub
+        self.lb = lb
+        if 'mc_sample_size' in hyperparams.keys():
+            self.sample_size = hyperparams['mc_sample_size']
+        else:
+            self.sample_size = None
+        return
+
+    def useSD(self):
+        """ Querry whether this method uses uncertainties.
+
+        When False, allows users to shortcut expensive uncertainty
+        computations.
+
+        """
+
+        return True
+
+    def setTarget(self, data, penalty_func, history):
+        """ Randomly generate a target based on current nondominated points.
+
+        Args:
+            data (dict): A dictionary specifying the current function
+                evaluation database. It contains two mandatory fields:
+                 * 'x_vals' (numpy.ndarray): A 2d array containing the
+                   list of design points.
+                 * 'f_vals' (numpy.ndarray): A 2d array containing the
+                   corresponding list of objective values.
+
+            penalty_func (function): A function of one (x) or two (x, sx)
+                inputs that evaluates the (penalized) objectives.
+
+            history (dict): A persistent dictionary that could be used by
+                the implementation of the AcquisitionFunction to pass data
+                between iterations; also unused by this scheme.
+
+        Returns:
+            numpy.ndarray: A 1d array containing the 'best' feasible starting
+            point for the scalarized problem (if any previous evaluations
+            were feasible) or the point in the existing database that is
+            most nearly feasible.
+
+        """
+
+        from parmoo.util import updatePF
+
+        # Check whether any data was given
+        no_data = False
+        # Check for illegal input from data
+        if not isinstance(data, dict):
+            raise TypeError("data must be a dict")
+        else:
+            if ('x_vals' in data) != ('f_vals' in data):
+                raise AttributeError("if x_vals is a key in data, then " +
+                                     "f_vals must also appear")
+            elif 'x_vals' in data:
+                if data['x_vals'] is not None and data['f_vals'] is not None:
+                    if data['x_vals'].shape[0] != data['f_vals'].shape[0]:
+                        raise ValueError("x_vals and f_vals must be equal " +
+                                         "length")
+                    if data['x_vals'].shape[1] != self.n:
+                        raise ValueError("The rows of x_vals must have " +
+                                         "length n")
+                    if data['f_vals'].shape[1] != self.o:
+                        raise ValueError("The rows of f_vals must have " +
+                                         "length o")
+                else:
+                    no_data = True
+            else:
+                no_data = True
+        # Check whether penalty_func() has an appropriate signature
+        if callable(penalty_func):
+            if len(inspect.signature(penalty_func).parameters) not in [1, 2]:
+                raise ValueError("penalty_func() must accept 1 or 2"
+                                 + " inputs")
+        else:
+            raise TypeError("penalty_func() must be callable")
+        # Save the penalty function for later
+        self.f = penalty_func
+        if no_data:
+            # If data is empty, then the Pareto front is empty
+            pf = {'x_vals': np.zeros((0, self.n)),
+                  'f_vals': np.zeros((0, self.o)),
+                  'c_vals': np.zeros((0, 1))}
+        else:
+            # Get the Pareto front
+            pf = updatePF(data, {})
+        # If pf is empty, randomly select weights and starting point
+        if pf['x_vals'].shape[0] == 0:
+            self.f_ub[:] = np.inf
+            self.weights = -np.log(1.0 - np.random.random_sample(self.o))
+            self.weights = self.weights[:] / sum(self.weights[:])
+            # Randomly select a feasible starting point
+            x_min = np.random.random_sample(self.n) * (self.ub - self.lb) \
+                    + self.lb
+            for count in range(1000):
+                x = np.random.random_sample(self.n) * (self.ub - self.lb) \
+                    + self.lb
+                if np.dot(self.weights, penalty_func(x)) \
+                   < np.dot(self.weights, penalty_func(x_min)):
+                    x_min[:] = x[:]
+            # No point is feasible, so there is no limit
+            self.best = None
+            return x_min
+        else:
+            # Randomly select pts in the convex hull of the nondominate pts
+            ipts = np.random.randint(0, pf['f_vals'].shape[0], size=self.o)
+            self.weights = -np.log(1.0 - np.random.random_sample(self.o))
+            self.weights[:] = self.weights[:] / np.linalg.norm(self.weights)
+            target = np.dot(self.weights, pf['f_vals'][ipts, :])
+            fi = pf['f_vals'][ipts[0], :]
+            # Set the bounds
+            self.f_ub[:] = np.inf
+            self.weights[:] = 0.0001
+            for j in range(self.o):
+                # If fi[j] is less than target[j], this is a bound
+                if fi[j] + 0.00000001 < target[j]:
+                    self.f_ub[j] = target[j]
+                # Otherwise, it is an objective
+                else:
+                    self.weights[j] = 1.0
+            # Normalize the weights
+            self.weights[:] = self.weights[:] / np.linalg.norm(self.weights)
+            # The corresponding x_val is feasible by construction
+            self.best = np.dot(pf['f_vals'][ipts[0], :], self.weights)
+            return pf['x_vals'][ipts[0], :]
+
+    def scalarize(self, f_vals, x_vals, s_vals_mean, s_vals_sd):
+        """ Scalarize a vector of function values using the current bounds.
+
+        Args:
+            f_vals (numpy.ndarray): A 1d array specifying the function
+                values to be scalarized.
+
+            x_vals (np.ndarray): A 1D array specifying a vector the design
+                point corresponding to f_vals (unused by this method).
+
+            s_vals_mean (np.ndarray): A 1D array specifying the expected
+                simulation outputs for the x value being scalarized
+                (unused by this method).
+
+            s_vals_sd (np.ndarray): A 1D array specifying the standard
+                deviation for each of the simulation outputs (unused by
+                this method).
+
+        Returns:
+            float: The scalarized value.
+
+        """
+
+        # Check that the function values are legal
+        if isinstance(f_vals, np.ndarray):
+            if self.o != np.size(f_vals):
+                raise ValueError("f_vals must have length o")
+        else:
+            raise TypeError("f_vals must be a numpy array")
+        # If the feasible set was empty, just use the given fi
+        if self.best is None:
+            result = np.dot(f_vals, self.weights)
+            for i in range(self.o):
+                if f_vals[i] > self.f_ub[i]:
+                    result = result + 10.0 * (f_vals[i] - self.f_ub[i])
+            return result
+        # If the feasible set was nonempty and the number of sim outs is 1,
+        # then calculate the EI over the best seen feasible fi for the
+        # current penalty function
+        elif s_vals_mean.size == 1:
+            # Construct the distribution for sampling
+            s_cov = stats.Covariance.from_diagonal(s_vals_sd)
+            s_dist = stats.multivariate_normal(mean=s_vals_mean, cov=s_cov)
+
+            def weighted_f(sx):
+                """ Calculates the pdf-weighted value of f at sx """
+
+                fx = self.f(x_vals, np.array([sx]))
+                # Add penalty
+                for j in range(self.o):
+                    if fx[j] > self.f_ub[j]:
+                        fx[:] = fx[:] + 10.0 * (fx[j] - self.f_ub[j])
+                result = min(np.dot(fx, self.weights) - self.best, 0.0)
+                return result * s_dist.pdf(np.array([sx]))
+
+            y = integrate.quad(weighted_f, -np.infty, np.infty)
+            return y[0]
+        # If there is at least one feasible point and the number of sim outs
+        # is greater than 1, then evaluate the EI over fi* via MC integration
+        else:
+            if self.sample_size is None:
+                self.sample_size = int(10 * s_vals_mean.size ** 2)
+            # Construct the distribution for sampling
+            s_cov = stats.Covariance.from_diagonal(s_vals_sd)
+            s_dist = stats.multivariate_normal(mean=s_vals_mean, cov=s_cov)
+            result = 0.0
+            # Loop over sample size
+            for i in range(self.sample_size):
+                s_vals = s_dist.rvs().flatten()
+                fi = self.f(x_vals, s_vals)
+                # Add penalty
+                for j in range(self.o):
+                    if fi[j] > self.f_ub[j]:
+                        fi[:] = fi[:] + 10.0 * (fi[j] - self.f_ub[j])
+                result = min(np.dot(fi, self.weights) - self.best, 0.0)
+            result /= self.sample_size
+            return result
+
+    def scalarizeGrad(self, f_vals, g_vals):
+        """ Not implemented for this acquisition function, do not use
+        gradient-based methods.
+
+        """
+
+        raise NotImplementedError("The EI-based acquisition does not " +
+                                  "support the usage of gradients")
diff --git a/parmoo/acquisitions/weighted_sum.py b/parmoo/acquisitions/weighted_sum.py
index 07cccb5..4089610 100644
--- a/parmoo/acquisitions/weighted_sum.py
+++ b/parmoo/acquisitions/weighted_sum.py
@@ -61,16 +61,25 @@ def __init__(self, o, lb, ub, hyperparams):
         self.weights = np.zeros(o)
         return
 
-    def setTarget(self, data, lagrange_func, history):
+    def useSD(self):
+        """ Query whether this method uses uncertainties.
+
+        When False, allows users to shortcut expensive uncertainty
+        computations.
+
+        """
+
+        return False
+
+    def setTarget(self, data, penalty_func, history):
         """ Randomly generate a new vector of scalarizing weights.
 
         Args:
             data (dict): A dictionary specifying the current function
                 evaluation database.
 
-            lagrange_func (function): A function whose components correspond
-                to constraint violation amounts.
-
+            penalty_func (function): A function of one (x) or two (x, sx)
+                inputs that evaluates the (penalized) objectives.
 
             history (dict): Another unused argument for this function.
 
@@ -108,13 +117,13 @@ def setTarget(self, data, lagrange_func, history):
                     no_data = True
             else:
                 no_data = True
-        # Check whether lagrange_func() has an appropriate signature
-        if callable(lagrange_func):
-            if len(inspect.signature(lagrange_func).parameters) != 1:
-                raise ValueError("lagrange_func() must accept exactly one"
+        # Check whether penalty_func() has an appropriate signature
+        if callable(penalty_func):
+            if len(inspect.signature(penalty_func).parameters) not in [1, 2]:
+                raise ValueError("penalty_func() must accept exactly one"
                                  + " input")
         else:
-            raise TypeError("lagrange_func() must be callable")
+            raise TypeError("penalty_func() must be callable")
         if no_data:
             # If data is empty, then the Pareto front is empty
             pf = {'x_vals': np.zeros((0, self.n)),
@@ -134,8 +143,8 @@ def setTarget(self, data, lagrange_func, history):
             for count in range(1000):
                 x = np.random.random_sample(self.n) * (self.ub - self.lb) \
                     + self.lb
-                if np.dot(self.weights, lagrange_func(x)) \
-                   < np.dot(self.weights, lagrange_func(x_min)):
+                if np.dot(self.weights, penalty_func(x)) \
+                   < np.dot(self.weights, penalty_func(x_min)):
                     x_min[:] = x[:]
             return x_min
         else:
@@ -144,13 +153,24 @@ def setTarget(self, data, lagrange_func, history):
             x = pf['x_vals'][i, :]
             return x
 
-    def scalarize(self, f_vals):
+    def scalarize(self, f_vals, x_vals, s_vals_mean, s_vals_sd):
         """ Scalarize a vector of function values using the current weights.
 
         Args:
             f_vals (numpy.ndarray): A 1d array specifying the function
                 values to be scalarized.
 
+            x_vals (np.ndarray): A 1D array specifying a vector the design
+                point corresponding to f_vals (unused by this method).
+
+            s_vals_mean (np.ndarray): A 1D array specifying the expected
+                simulation outputs for the x value being scalarized
+                (unused by this method).
+
+            s_vals_sd (np.ndarray): A 1D array specifying the standard
+                deviation for each of the simulation outputs (unused by
+                this method).
+
         Returns:
             float: The scalarized value.
 
@@ -257,15 +277,25 @@ def __init__(self, o, lb, ub, hyperparams):
             self.weights = np.ones(self.o) / float(self.o)
         return
 
-    def setTarget(self, data, lagrange_func, history):
+    def useSD(self):
+        """ Querry whether this method uses uncertainties.
+
+        When False, allows users to shortcut expensive uncertainty
+        computations.
+
+        """
+
+        return False
+
+    def setTarget(self, data, penalty_func, history):
         """ Randomly generate a feasible starting point.
 
         Args:
             data (dict): A dictionary specifying the current function
                 evaluation database.
 
-            lagrange_func (function): A function whose components correspond
-                to constraint violation amounts.
+            penalty_func (function): A function of one (x) or two (x, sx)
+                inputs that evaluates the (penalized) objectives.
 
             history (dict): Another unused argument for this function.
 
@@ -303,13 +333,13 @@ def setTarget(self, data, lagrange_func, history):
                     no_data = True
             else:
                 no_data = True
-        # Check whether lagrange_func() has an appropriate signature
-        if callable(lagrange_func):
-            if len(inspect.signature(lagrange_func).parameters) != 1:
-                raise ValueError("lagrange_func() must accept exactly one"
+        # Check whether penalty_func() has an appropriate signature
+        if callable(penalty_func):
+            if len(inspect.signature(penalty_func).parameters) not in [1, 2]:
+                raise ValueError("penalty_func() must accept exactly one"
                                  + " input")
         else:
-            raise TypeError("lagrange_func() must be callable")
+            raise TypeError("penalty_func() must be callable")
         if no_data:
             # If data is empty, then the Pareto front is empty
             pf = {'x_vals': np.zeros((0, self.n)),
@@ -326,8 +356,8 @@ def setTarget(self, data, lagrange_func, history):
             for count in range(1000):
                 x = np.random.random_sample(self.n) * (self.ub - self.lb) \
                     + self.lb
-                if np.dot(self.weights, lagrange_func(x)) \
-                   < np.dot(self.weights, lagrange_func(x_min)):
+                if np.dot(self.weights, penalty_func(x)) \
+                   < np.dot(self.weights, penalty_func(x_min)):
                     x_min[:] = x[:]
             return x_min
         else:
@@ -336,13 +366,24 @@ def setTarget(self, data, lagrange_func, history):
             x = pf['x_vals'][i, :]
             return x
 
-    def scalarize(self, f_vals):
+    def scalarize(self, f_vals, x_vals, s_vals_mean, s_vals_sd):
         """ Scalarize a vector of function values using the current weights.
 
         Args:
             f_vals (numpy.ndarray): A 1d array specifying the function
                 values to be scalarized.
 
+            x_vals (np.ndarray): A 1D array specifying a vector the design
+                point corresponding to f_vals (unused by this method).
+
+            s_vals_mean (np.ndarray): A 1D array specifying the expected
+                simulation outputs for the x value being scalarized
+                (unused by this method).
+
+            s_vals_sd (np.ndarray): A 1D array specifying the standard
+                deviation for each of the simulation outputs (unused by
+                this method).
+
         Returns:
             float: The scalarized value.
 
diff --git a/parmoo/extras/libe.py b/parmoo/extras/libe.py
index fd321ad..75db451 100644
--- a/parmoo/extras/libe.py
+++ b/parmoo/extras/libe.py
@@ -14,6 +14,7 @@
 
 import numpy as np
 from parmoo import MOOP
+import warnings
 
 
 def parmoo_persis_gen(H, persis_info, gen_specs, libE_info):
@@ -190,7 +191,8 @@ class libE_MOOP(MOOP):
     After defining the MOOP and setting up checkpointing and logging,
     use the following method to solve the MOOP (using libEnsemble
     to distribute simulation evaluations):
-     * ``libE_MOOP.solve(sim_max=200, wt_max=3600, profile=False)``
+     * ``libE_MOOP.solve(iter_max=None, sim_max=None, wt_max=864000,
+                         profile=False)``
 
     The following methods are used for managing ParMOO's internal
     simulation/objective databases. Note that these databases are
@@ -204,9 +206,9 @@ class libE_MOOP(MOOP):
 
     Finally, the following methods are used to retrieve data after the
     problem has been solved:
-     * ``libE_MOOP.getPF()``
-     * ``libE_MOOP.getSimulationData()``
-     * ``libE_MOOP.getObjectiveData()``
+     * ``libE_MOOP.getPF(format='ndarray')``
+     * ``libE_MOOP.getSimulationData(format='ndarray')``
+     * ``libE_MOOP.getObjectiveData(format='ndarray')``
 
     Other private methods from the MOOP class do not work for a libE_MOOP.
 
@@ -669,7 +671,7 @@ def moop_sim(self, H, persis_info, sim_specs, _):
                     self.moop.sim_funcs[j](H['x'][i])
         return H_o, persis_info
 
-    def solve(self, sim_max=200, wt_max=3600, profile=False):
+    def solve(self, iter_max=None, sim_max=None, wt_max=864000, profile=False):
         """ Solve a MOOP using ParMOO + libEnsemble.
 
         If desired, be sure to turn on checkpointing before starting the
@@ -681,11 +683,14 @@ def solve(self, sim_max=200, wt_max=3600, profile=False):
         simulations over available resources.
 
         Args:
+            iter_max (int): The max number of ParMOO iterations to be
+                performed by libEnsemble (default is unlimited).
+
             sim_max (int): The max number of simulation to be performed by
-                libEnsemble (default is 200).
+                libEnsemble (default is unlimited).
 
             wt_max (int): The max number of seconds that the simulation may
-                run for (the default is 3600 secs, i.e., 1 hr).
+                run for (the default is 864000 secs, i.e., 10 days).
 
             profile (bool): Specifies whether to run libE with the profiler.
 
@@ -697,6 +702,51 @@ def solve(self, sim_max=200, wt_max=3600, profile=False):
             import only_persistent_gens as alloc_f
         from libensemble.tools import parse_args
 
+        # Check that at least one budget variable was given
+        if iter_max is None and sim_max is None:
+            raise ValueError("At least one of the following arguments " +
+                             "must be set: 'iter_max' or 'sim_max'")
+        # Check that the iter_max is a legal integer
+        if isinstance(iter_max, int):
+            if iter_max < 0:
+                raise ValueError("When present, iter_max must be nonnegative")
+        elif iter_max is not None:
+            raise TypeError("When present, iter_max must be an int type")
+        # Check that the sim_max is a legal integer
+        if isinstance(sim_max, int):
+            if sim_max < 0:
+                raise ValueError("When present, sim_max must be nonnegative")
+        elif sim_max is not None:
+            raise TypeError("When present, sim_max must be an int type")
+        # Set iter_max large enough if None
+        if iter_max is None:
+            if self.moop.s == 0:
+                raise ValueError("If 0 simulations are given, then iter_max" +
+                                 "must be provided")
+            iter_max = sim_max
+        # Count the total search budget
+        total_search_budget = 0
+        for search in self.moop.searches:
+            total_search_budget += search.budget
+        total_sims_per_iter = len(self.moop.acquisitions) * self.moop.s
+        # Count the total sims to exhaust iter_max if sim_max is None
+        if sim_max is None:
+            sim_max = total_search_budget + iter_max * total_sims_per_iter
+        # libE only uses sim_max, so set it appropriately
+        sim_max = min(sim_max,
+                      total_search_budget + iter_max * total_sims_per_iter)
+        # Warning for the uninitiated
+        if sim_max <= total_search_budget:
+            warnings.warn("You are running ParMOO with a total search budget" +
+                          f" of {total_search_budget} and a sim_max of " +
+                          f"just {sim_max}... This will result in pure " +
+                          "design space exploration with no exploitation/" +
+                          "optimization. Consider increasing the value of " +
+                          "sim_max, decreasing your search_budget, " +
+                          "or using the iter_max stopping criteria, unless " +
+                          "you are really only interested in design space " +
+                          "exploration without exploitation/optimization.")
+
         # Create libEnsemble dictionaries
         nworkers, is_manager, libE_specs, _ = parse_args()
         if self.moop.use_names:
@@ -761,12 +811,20 @@ def solve(self, sim_max=200, wt_max=3600, profile=False):
         H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria,
                                     persis_info, alloc_specs, libE_specs)
 
-        self.moop = persis_info[1]['moop']
+        # When running with MPI, only the manager returns results
+        if is_manager:
+            self.moop = persis_info[1]['moop']
         return
 
-    def getPF(self):
+    def getPF(self, format='ndarray'):
         """ Extract nondominated and efficient sets from internal databases.
 
+        Args:
+            format (str, optional): Either 'ndarray' (default) or 'pandas',
+                in order to produce output as a numpy structured array or
+                pandas dataframe. Note: format='pandas' is only valid for
+                named inputs.
+
         Returns:
             A discrete approximation of the Pareto front and efficient set.
 
@@ -786,11 +844,17 @@ def getPF(self):
 
         """
 
-        return self.moop.getPF()
+        return self.moop.getPF(format=format)
 
-    def getSimulationData(self):
+    def getSimulationData(self, format='ndarray'):
         """ Extract all computed simulation outputs from the MOOP's database.
 
+        Args:
+            format (str, optional): Either 'ndarray' (default) or 'pandas',
+                in order to produce output as a numpy structured array or
+                pandas dataframe. Note: format='pandas' is only valid for
+                named inputs.
+
         Returns:
             (dict or list) Either a dictionary or list of dictionaries
             containing every point where a simulation was evaluated.
@@ -811,11 +875,17 @@ def getSimulationData(self):
 
         """
 
-        return self.moop.getSimulationData()
+        return self.moop.getSimulationData(format=format)
 
-    def getObjectiveData(self):
+    def getObjectiveData(self, format='ndarray'):
         """ Extract all computed objective scores from this MOOP's database.
 
+        Args:
+            format (str, optional): Either 'ndarray' (default) or 'pandas',
+                in order to produce output as a numpy structured array or
+                pandas dataframe. Note: format='pandas' is only valid for
+                named inputs.
+
         Returns:
             A database of all designs that have been fully evaluated,
             and their corresponding objective scores.
@@ -835,7 +905,7 @@ def getObjectiveData(self):
 
         """
 
-        return self.moop.getObjectiveData()
+        return self.moop.getObjectiveData(format=format)
 
     def save(self, filename="parmoo"):
         """ Serialize and save the MOOP object and all of its dependencies.
diff --git a/parmoo/moop.py b/parmoo/moop.py
index a083237..22f98ea 100644
--- a/parmoo/moop.py
+++ b/parmoo/moop.py
@@ -8,10 +8,11 @@
 """
 
 import numpy as np
-import json
+import pandas as pd
 from parmoo import structs
 import inspect
-import pandas as pd
+import json
+import warnings
 
 
 class MOOP:
@@ -55,7 +56,7 @@ class MOOP:
 
     After defining the MOOP and setting up checkpointing and logging info,
     use the following method to solve the MOOP (serially):
-     * ``MOOP.solve(budget)``
+     * ``MOOP.solve(iter_max=None, sim_max=None)``
 
     The following methods are used for solving the MOOP and managing the
     internal simulation/objective databases:
@@ -68,9 +69,9 @@ class MOOP:
 
     Finally, the following methods are used to retrieve data after the
     problem has been solved:
-     * ``MOOP.getPF()``
-     * ``MOOP.getSimulationData()``
-     * ``MOOP.getObjectiveData()``
+     * ``MOOP.getPF(format='ndarray')``
+     * ``MOOP.getSimulationData(format='ndarray')``
+     * ``MOOP.getObjectiveData(format='ndarray')``
 
     The following methods are not recommended for external usage:
      * ``MOOP.__extract__(x)``
@@ -80,8 +81,10 @@ class MOOP:
      * ``MOOP.__pack_sim__(sx)``
      * ``MOOP.fitSurrogates()``
      * ``MOOP.updateSurrogates()``
-     * ``MOOP.evaluateSurrogates(x)``
      * ``MOOP.resetSurrogates(center)``
+     * ``MOOP.evaluateSurrogates(x)``
+     * ``MOOP.surrogateUncertainty(x)``
+     * ``MOOP.evaluateObjectives(x)``
      * ``MOOP.evaluateConstraints(x)``
      * ``MOOP.evaluatePenalty(x)``
      * ``MOOP.evaluateGradients(x)``
@@ -102,7 +105,7 @@ class MOOP:
                  'scale', 'scaled_lb', 'scaled_ub', 'scaled_des_tols',
                  'cat_des_tols', 'custom_des_tols', 'use_names', 'iteration',
                  'checkpoint', 'checkpointfile', 'checkpoint_data',
-                 'new_checkpoint', 'new_data']
+                 'new_checkpoint', 'new_data', 'obj_exp_vals', 'c_exp_vals']
 
     def __embed__(self, x):
         """ Embed a design input as n-dimensional vector for ParMOO.
@@ -479,6 +482,8 @@ def __init__(self, opt_func, hyperparams=None):
         self.o = 0
         self.obj_names = []
         self.objectives = []
+        self.obj_exp_vals = []
+        self.c_exp_vals = []
         self.p = 0
         self.const_names = []
         self.constraints = []
@@ -994,6 +999,24 @@ def addObjective(self, *args):
                     * 0 -- no derivative taken, return f(x, sim_func(x))
                     * 1 -- return derivative wrt x, or
                     * 2 -- return derivative wrt sim(x).
+                 * 'exp_func' (function): An algebraic objective function
+                   that calculates the expected value and distribution of
+                   an objective, given that surrogate outputs are Gaussian
+                   distributed with given mean and variance. Accepts three
+                   inputs. The first input represents x, the second input
+                   is the expected value of S(x), and the third input
+                   represents the standard deviation of S(x) -- assuming
+                   S(x) is Gaussian distributed.
+                   The output is the expected value of f(x, S).
+                   Interface should match:
+                   `cost = exp_func(x, sim_mean, sim_std_dev, der=0,
+                                    sd=False)`,
+                   where `der` is an optional argument specifying whether to
+                   evaluate the derivative
+                    * 0 -- no derivative taken, return f(x, sim_func(x))
+                    * 1 -- return derivative wrt x,
+                    * 2 -- return derivative wrt expected value of sim(x), or
+                    * 3 -- return derivative wrt std deviation of sim(x).
 
         """
 
@@ -1001,24 +1024,30 @@ def addObjective(self, *args):
         if len(self.acquisitions) > 0:
             raise RuntimeError("Cannot add more objectives after"
                                + " adding acquisition functions")
-        # Check that arg and 'obj_func' field are legal types
+        # Check that arg and 'exp_func' or 'obj_func' fields are legal types
         for arg in args:
             if not isinstance(arg, dict):
                 raise TypeError("Each arg must be a Python dict")
-            if 'obj_func' in arg:
+            if 'exp_func' in arg:
+                if callable(arg['exp_func']):
+                    if not (len(inspect.signature(arg['exp_func']).parameters)
+                            in [3, 4]):
+                        raise ValueError("The 'exp_func' must take 3 or 4"
+                                         + " arguments")
+                else:
+                    raise TypeError("The 'exp_func' must be callable")
+            elif 'obj_func' in arg:
                 if callable(arg['obj_func']):
                     if not (len(inspect.signature(arg['obj_func']).parameters)
-                            == 2 or
-                            len(inspect.signature(arg['obj_func']).parameters)
-                            == 3):
+                            in [2, 3]):
                         raise ValueError("The 'obj_func' must take 2 "
                                          + "(no derivatives) or 3 "
                                          + "(derivative option) arguments")
                 else:
                     raise TypeError("The 'obj_func' must be callable")
             else:
-                raise AttributeError("The 'obj_func' field must be "
-                                     + "present in each arg")
+                raise AttributeError("The 'exp_func' or 'obj_func' field must "
+                                     + "be present in each arg")
             # Add the objective name
             if 'name' in arg:
                 if not isinstance(arg['name'], str):
@@ -1031,7 +1060,12 @@ def addObjective(self, *args):
             else:
                 self.obj_names.append(("f" + str(self.o + 1), 'f8'))
             # Finally, if all else passed, add the objective
-            self.objectives.append(arg['obj_func'])
+            if 'exp_func' in arg.keys():
+                self.objectives.append(arg['exp_func'])
+                self.obj_exp_vals.append(True)
+            else:
+                self.objectives.append(arg['obj_func'])
+                self.obj_exp_vals.append(False)
             self.o += 1
         return
 
@@ -1057,7 +1091,7 @@ def addConstraint(self, *args):
                    take the derivative of the constraint function
                     * 0 -- no derivative taken, return c(x, sim_func(x))
                     * 1 -- return derivative wrt x, or
-                    * 2 -- return derivative wrt sim(x).
+                    * 2 -- return derivative wrt  sim(x).
                    Note that any
                    ``constraint(x, sim_func(x), der=0) <= 0``
                    indicates that x is feaseible, while
@@ -1067,6 +1101,24 @@ def addConstraint(self, *args):
                    It is the user's responsibility to ensure that after adding
                    all constraints, the feasible region is nonempty and has
                    nonzero measure in the design space.
+                 * 'exp_func' (function): An algebraic objective function
+                   that calculates the expected value and distribution of
+                   the constraint penalty, given that surrogate outputs are
+                   Gaussian distributed with given mean and variance. Accepts
+                   three inputs. The first input represents x, the second
+                   input is the expected value of S(x), and the third input
+                   represents the standard deviation of S(x) -- assuming
+                   S(x) is Gaussian distributed.
+                   The output is the expected value of c(x, S).
+                   Interface should match:
+                   `penalty = exp_func(x, sim_mean, sim_std_dev, der=0,
+                                       sd=False)`,
+                   where `der` is an optional argument specifying whether to
+                   evaluate the derivative
+                    * 0 -- no derivative taken, return exp val c(x, Sx)
+                    * 1 -- return derivative wrt x,
+                    * 2 -- return derivative wrt expected value of sim(x), or
+                    * 3 -- return derivative wrt std deviation of sim(x).
 
         """
 
@@ -1074,20 +1126,26 @@ def addConstraint(self, *args):
         for arg in args:
             if not isinstance(arg, dict):
                 raise TypeError("Each arg must be a Python dict")
-            if 'constraint' in arg:
+            if 'exp_func' in arg:
+                if callable(arg['exp_func']):
+                    if not (len(inspect.signature(arg['exp_func']).parameters)
+                            in [3, 4]):
+                        raise ValueError("The 'exp_func' must take 3 or 4"
+                                         + " arguments")
+                else:
+                    raise TypeError("The 'exp_func' must be callable")
+            elif 'constraint' in arg:
                 if callable(arg['constraint']):
                     if not (len(inspect.signature(arg['constraint']).
-                                parameters) == 2 or
-                            len(inspect.signature(arg['constraint']).
-                                parameters) == 3):
+                                parameters) in [2, 3]):
                         raise ValueError("The 'constraint' must take 2 "
                                          + "(no derivatives) or 3 "
                                          + "(derivative option) arguments")
                 else:
                     raise TypeError("The 'constraint' must be callable")
             else:
-                raise AttributeError("The 'constraint' field must be "
-                                     + "present in each arg")
+                raise AttributeError("The 'exp_func' or 'constraint' field "
+                                     + "must be present in each arg")
             # Add the constraint name
             if 'name' in arg:
                 if not isinstance(arg['name'], str):
@@ -1100,7 +1158,12 @@ def addConstraint(self, *args):
             else:
                 self.const_names.append(("c" + str(self.p + 1), 'f8'))
             # Finally, if all else passed, add the constraint
-            self.constraints.append(arg['constraint'])
+            if 'exp_func' in arg.keys():
+                self.constraints.append(arg['exp_func'])
+                self.c_exp_vals.append(True)
+            else:
+                self.constraints.append(arg['constraint'])
+                self.c_exp_vals.append(False)
             self.p += 1
         return
 
@@ -1471,6 +1534,80 @@ def resetSurrogates(self, center):
         return rad
 
     def evaluateSurrogates(self, x):
+        """ Evaluate all simulation surrogates.
+
+        Warning: Not recommended for external usage!
+
+        Args:
+            x (numpy.ndarray): A 1d numpy.ndarray containing the (embedded)
+                design point to evaluate.
+
+        Returns:
+            numpy.ndarray: A 1d numpy.ndarray containing the (embedded) result
+            of the surrogate model evaluations.
+
+        """
+
+        # Check for illegal input
+        if isinstance(x, np.ndarray):
+            if x.shape[0] != self.n:
+                raise ValueError("x must have length n")
+        else:
+            raise TypeError("x must be a numpy array")
+        # Evaluate the surrogate models to approximate the simulation outputs
+        sim = np.zeros(self.m_total)
+        m_count = 0
+        for i, surrogate in enumerate(self.surrogates):
+            sim[m_count:m_count+self.m[i]] = surrogate.evaluate(x)
+            m_count += self.m[i]
+        return sim
+
+    def surrogateUncertainty(self, x, grad=False):
+        """ Evaluate uncertainty (standard deviation) of all surrogates.
+
+        Assumes a Gaussian distribution on simulation outputs.
+
+        Warning: Not recommended for external usage!
+
+        Args:
+            x (numpy.ndarray): A 1d numpy.ndarray containing the (embedded)
+                design point to evaluate.
+
+            grad (bool): Specifies whether or not to evalaute the gradients.
+
+        Returns:
+            numpy.ndarray: (When grad is False) a 1d numpy.ndarray containing
+            the std deviation of the surrogates at x. (When grad is True)
+            a 2d numpy.ndarray containing the Jacobian of gradients of
+            all surrogates uncertainties at x.
+
+        """
+
+        # Check for illegal input
+        if isinstance(x, np.ndarray):
+            if x.shape[0] != self.n:
+                raise ValueError("x must have length n")
+        else:
+            raise TypeError("x must be a numpy array")
+        # If gradient evaluation was requested
+        if grad:
+            dstdD_dx = np.zeros((self.m_total, self.n))
+            m_count = 0
+            for i, surrogate in enumerate(self.surrogates):
+                dstdD_dx[m_count:m_count+self.m[i]] = \
+                    surrogate.stdDevGrad(x)
+                m_count += self.m[i]
+            return dstdD_dx
+        # Otherwise, evaluate the surrogate standard deviations
+        else:
+            sim_stdD = np.zeros(self.m_total)
+            m_count = 0
+            for i, surrogate in enumerate(self.surrogates):
+                sim_stdD[m_count:m_count+self.m[i]] = surrogate.stdDev(x)
+                m_count += self.m[i]
+            return sim_stdD
+
+    def evaluateObjectives(self, x):
         """ Evaluate all objectives using the simulation surrogates as needed.
 
         Warning: Not recommended for external usage!
@@ -1493,14 +1630,23 @@ def evaluateSurrogates(self, x):
             raise TypeError("x must be a numpy array")
         # Evaluate the surrogate models to approximate the simulation outputs
         sim = np.zeros(self.m_total)
+        sim_std_dev = np.zeros(self.m_total)
         m_count = 0
         for i, surrogate in enumerate(self.surrogates):
             sim[m_count:m_count+self.m[i]] = surrogate.evaluate(x)
+            if any(self.obj_exp_vals):
+                sim_std_dev[m_count:m_count+self.m[i]] = surrogate.stdDev(x)
             m_count += self.m[i]
         # Evaluate the objective functions
         fx = np.zeros(self.o)
         for i, obj_func in enumerate(self.objectives):
-            fx[i] = obj_func(self.__extract__(x), self.__unpack_sim__(sim))
+            if self.obj_exp_vals[i]:
+                fx[i] = obj_func(self.__extract__(x),
+                                 self.__unpack_sim__(sim),
+                                 self.__unpack_sim__(sim_std_dev))
+            else:
+                fx[i] = obj_func(self.__extract__(x),
+                                 self.__unpack_sim__(sim))
         # Return the result
         return fx
 
@@ -1532,19 +1678,28 @@ def evaluateConstraints(self, x):
         else:
             # Evaluate the surrogate models to approximate the sim outputs
             sim = np.zeros(self.m_total)
+            sim_std_dev = np.zeros(self.m_total)
             m_count = 0
             for i, surrogate in enumerate(self.surrogates):
                 sim[m_count:m_count + self.m[i]] = surrogate.evaluate(x)
+                if any(self.c_exp_vals):
+                    sim_std_dev[m_count:m_count+self.m[i]] = \
+                                                    surrogate.stdDev(x)
                 m_count += self.m[i]
             # Evaluate the constraint functions
             cx = np.zeros(self.p)
             for i, constraint_func in enumerate(self.constraints):
-                cx[i] = constraint_func(self.__extract__(x),
-                                        self.__unpack_sim__(sim))
+                if self.c_exp_vals[i]:
+                    cx[i] = constraint_func(self.__extract__(x),
+                                            self.__unpack_sim__(sim),
+                                            self.__unpack_sim__(sim_std_dev))
+                else:
+                    cx[i] = constraint_func(self.__extract__(x),
+                                            self.__unpack_sim__(sim))
             # Return the constraint violations
             return cx
 
-    def evaluatePenalty(self, x):
+    def evaluatePenalty(self, x, sx=None):
         """ Evaluate the penalized objective using the surrogates as needed.
 
         Warning: Not recommended for external usage!
@@ -1567,20 +1722,43 @@ def evaluatePenalty(self, x):
             raise TypeError("x must be a numpy array")
         # Evaluate the surrogate models to approximate the simulation outputs
         sim = np.zeros(self.m_total)
-        m_count = 0
-        for i, surrogate in enumerate(self.surrogates):
-            sim[m_count:m_count+self.m[i]] = surrogate.evaluate(x)
-            m_count += self.m[i]
+        sim_std_dev = np.zeros(self.m_total)
+        if sx is None:
+            m_count = 0
+            for i, surrogate in enumerate(self.surrogates):
+                sim[m_count:m_count+self.m[i]] = surrogate.evaluate(x)
+                if any(self.obj_exp_vals) or any(self.c_exp_vals):
+                    sim_std_dev[m_count:m_count+self.m[i]] = \
+                                                    surrogate.stdDev(x)
+                m_count += self.m[i]
+        else:
+            if isinstance(sx, np.ndarray):
+                if sx.shape[0] != self.m_total:
+                    raise ValueError("sx must have length m when present")
+            else:
+                raise TypeError("sx must be a numpy array when present")
+            sim[:] = sx[:]
         # Evaluate the objective functions
         fx = np.zeros(self.o)
         for i, obj_func in enumerate(self.objectives):
-            fx[i] = obj_func(self.__extract__(x), self.__unpack_sim__(sim))
+            if self.obj_exp_vals[i]:
+                fx[i] = obj_func(self.__extract__(x),
+                                 self.__unpack_sim__(sim),
+                                 self.__unpack_sim__(sim_std_dev))
+            else:
+                fx[i] = obj_func(self.__extract__(x),
+                                 self.__unpack_sim__(sim))
         # Evaluate the constraint functions
         Lx = np.zeros(self.o)
         if self.p > 0:
-            for constraint_func in self.constraints:
-                cx = constraint_func(self.__extract__(x),
-                                     self.__unpack_sim__(sim))
+            for i, constraint_func in enumerate(self.constraints):
+                if self.c_exp_vals[i]:
+                    cx = constraint_func(self.__extract__(x),
+                                         self.__unpack_sim__(sim),
+                                         self.__unpack_sim__(sim_std_dev))
+                else:
+                    cx = constraint_func(self.__extract__(x),
+                                         self.__unpack_sim__(sim))
                 if cx > 0.0:
                     Lx[:] = Lx[:] + cx
         # Compute the penalized objective score
@@ -1611,26 +1789,40 @@ def evaluateGradients(self, x):
             raise TypeError("x must be a numpy array")
         # Evaluate the surrogate models to approximate the simulation outputs
         sim = np.zeros(self.m_total)
+        sim_std_dev = np.zeros(self.m_total)
         m_count = 0
         for i, surrogate in enumerate(self.surrogates):
             sim[m_count:m_count+self.m[i]] = surrogate.evaluate(x)
+            if any(self.obj_exp_vals) or any(self.c_exp_vals):
+                sim_std_dev[m_count:m_count+self.m[i]] = surrogate.stdDev(x)
             m_count += self.m[i]
         # Evaluate the gradients of the surrogates
         if self.m_total > 0:
             dsim_dx = np.zeros((self.m_total, self.n))
+            dstdD_dx = np.zeros((self.m_total, self.n))
             m_count = 0
             for i, surrogate in enumerate(self.surrogates):
                 dsim_dx[m_count:m_count+self.m[i], :] = \
                         surrogate.gradient(x)
+                # Also standard deviation gradients
+                if any(self.obj_exp_vals) or any(self.c_exp_vals):
+                    dstdD_dx[m_count:m_count+self.m[i]] = \
+                        surrogate.stdDevGrad(x)
                 m_count += self.m[i]
         # Evaluate the gradients of the objective functions
         df_dx = np.zeros((self.o, self.n))
         for i, obj_func in enumerate(self.objectives):
             # If names are used, unpack the derivative
             if self.use_names:
-                df_dx_tmp = obj_func(self.__extract__(x),
-                                     self.__unpack_sim__(sim),
-                                     der=1)
+                if self.obj_exp_vals[i]:
+                    df_dx_tmp = obj_func(self.__extract__(x),
+                                         self.__unpack_sim__(sim),
+                                         self.__unpack_sim__(sim_std_dev),
+                                         der=1)
+                else:
+                    df_dx_tmp = obj_func(self.__extract__(x),
+                                         self.__unpack_sim__(sim),
+                                         der=1)
                 for j, d_name in enumerate(self.des_names):
                     if self.des_order[j] < self.n_cont:
                         df_dx[i, j] = df_dx_tmp[d_name[0]]
@@ -1639,47 +1831,85 @@ def evaluateGradients(self, x):
                                           self.scale[:self.n_cont])
             # Otherwise, evaluate normally
             else:
-                df_dx[i, :self.n_cont] = (obj_func(self.__extract__(x),
-                                                   self.__unpack_sim__(sim),
-                                                   der=1)[:self.n_cont] /
-                                          self.scale[:self.n_cont])
+                nn = self.n_cont
+                if self.obj_exp_vals[i]:
+                    df_dx[i, :nn] = (obj_func(self.__extract__(x),
+                                              self.__unpack_sim__(sim),
+                                              self.__unpack_sim__(sim_std_dev),
+                                              der=1)[:self.n_cont] /
+                                     self.scale[:self.n_cont])
+                else:
+                    df_dx[i, :nn] = (obj_func(self.__extract__(x),
+                                              self.__unpack_sim__(sim),
+                                              der=1)[:self.n_cont] /
+                                     self.scale[:self.n_cont])
         # Now evaluate wrt the sims
         if self.m_total > 0:
             df_dsim = np.zeros((self.o, self.m_total))
-            # If names are used, pack the sims
-            if self.use_names:
-                for i, obj_func in enumerate(self.objectives):
-                    df_dsim_tmp = obj_func(self.__extract__(x),
-                                           self.__unpack_sim__(sim),
-                                           der=2)
-                    df_dsim[i, :] = self.__pack_sim__(df_dsim_tmp)
-            else:
-                for i, obj_func in enumerate(self.objectives):
-                    df_dsim[i, :] = obj_func(self.__extract__(x),
-                                             self.__unpack_sim__(sim),
-                                             der=2)
+            for i, obj_func in enumerate(self.objectives):
+                if self.obj_exp_vals[i]:
+                    df_ds_tmp = obj_func(self.__extract__(x),
+                                         self.__unpack_sim__(sim),
+                                         self.__unpack_sim__(sim_std_dev),
+                                         der=2)
+                else:
+                    df_ds_tmp = obj_func(self.__extract__(x),
+                                         self.__unpack_sim__(sim),
+                                         der=2)
+                # If names are used, pack the sims
+                if self.use_names:
+                    df_dsim[i, :] = self.__pack_sim__(df_ds_tmp)
+                else:
+                    df_dsim[i, :] = df_ds_tmp
+        # Now evaluate wrt the std deviations
+        if any(self.obj_exp_vals):
+            df_dstdD = np.zeros((self.o, self.m_total))
+            for i, obj_func in enumerate(self.objectives):
+                if self.obj_exp_vals[i]:
+                    df_dsd_tmp = obj_func(self.__extract__(x),
+                                          self.__unpack_sim__(sim),
+                                          self.__unpack_sim__(sim_std_dev),
+                                          der=3)
+                    # If names are used, pack the sims
+                    if self.use_names:
+                        df_dstdD[i, :] = self.__pack_sim__(df_dsd_tmp)
+                    else:
+                        df_dstdD[i, :] = df_dsd_tmp
         # Finally, evaluate the full objective Jacobian
         dfx = np.zeros((self.o, self.n))
         dfx = df_dx
         if self.m_total > 0:
             dfx = dfx + np.dot(df_dsim, dsim_dx)
+        if any(self.obj_exp_vals):
+            dfx = dfx + np.dot(df_dstdD, dstdD_dx)
         # If there are no constraints, just return zeros
         dcx = np.zeros(self.n)
         # Otherwise, calculate the constraint violation Jacobian
         if self.p > 0:
             # Evaluate the constraint functions
             cx = np.zeros(self.p)
-            for i, constraint_func in enumerate(self.constraints):
-                cx[i] = constraint_func(self.__extract__(x),
-                                        self.__unpack_sim__(sim))
+            for i, const_func in enumerate(self.constraints):
+                if self.c_exp_vals[i]:
+                    cx[i] = const_func(self.__extract__(x),
+                                       self.__unpack_sim__(sim),
+                                       self.__unpack_sim__(sim_std_dev))
+                else:
+                    cx[i] = const_func(self.__extract__(x),
+                                       self.__unpack_sim__(sim))
             # Evaluate the gradients of the constraint functions
             dc_dx = np.zeros((self.p, self.n))
-            for i, constraint_func in enumerate(self.constraints):
+            for i, const_func in enumerate(self.constraints):
+                if self.c_exp_vals[i]:
+                    dc_dx_tmp = const_func(self.__extract__(x),
+                                           self.__unpack_sim__(sim),
+                                           self.__unpack_sim__(sim_std_dev),
+                                           der=1)
+                else:
+                    dc_dx_tmp = const_func(self.__extract__(x),
+                                           self.__unpack_sim__(sim),
+                                           der=1)
                 # If names are used, unpack the derivative
                 if self.use_names:
-                    dc_dx_tmp = constraint_func(self.__extract__(x),
-                                                self.__unpack_sim__(sim),
-                                                der=1)
                     for j, d_name in enumerate(self.des_names):
                         if self.des_order[j] < self.n_cont:
                             dc_dx[i, j] = dc_dx_tmp[d_name[0]]
@@ -1688,33 +1918,59 @@ def evaluateGradients(self, x):
                                               self.scale[:self.n_cont])
                 # Otherwise, evaluate normally
                 else:
-                    dc_dx[i, :self.n_cont] = (constraint_func(
-                                                          self.__extract__(x),
-                                                          sim,
-                                                          der=1)[:self.n_cont]
+                    dc_dx[i, :self.n_cont] = (dc_dx_tmp[:self.n_cont]
                                               / self.scale[:self.n_cont])
             # Now evaluate wrt the sims
             if self.m_total > 0:
                 dc_dsim = np.zeros((self.p, self.m_total))
                 # If names are used, sims need to be packed
                 if self.use_names:
-                    for i, constraint_func in enumerate(self.constraints):
-                        sxx = self.__unpack_sim__(sim)
-                        dc_dsim_tmp = constraint_func(self.__extract__(x),
-                                                      sxx, der=2)
+                    sxx = self.__unpack_sim__(sim)
+                    if any(self.c_exp_vals):
+                        sdxx = self.__unpack_sim__(sim_std_dev)
+                    for i, const_func in enumerate(self.constraints):
+                        if self.c_exp_vals[i]:
+                            dc_dsim_tmp = const_func(self.__extract__(x),
+                                                     sxx, sdxx, der=2)
+                        else:
+                            dc_dsim_tmp = const_func(self.__extract__(x),
+                                                     sxx, der=2)
                         dc_dsim[i, :] = self.__pack_sim__(dc_dsim_tmp)
                 # Otherwise, evaluate normally
                 else:
-                    for i, constraint_func in enumerate(self.constraints):
+                    sxx = self.__unpack_sim__(sim)
+                    if any(self.c_exp_vals):
+                        sdxx = self.__unpack_sim__(sim_std_dev)
+                    for i, const_func in enumerate(self.constraints):
+                        if self.c_exp_vals[i]:
+                            dc_dsim[i, :] = const_func(self.__extract__(x),
+                                                       sxx, sdxx, der=2)
+                        else:
+                            dc_dsim[i, :] = const_func(self.__extract__(x),
+                                                       sxx, der=2)
+            # Now evaluate wrt the std deviations
+            if any(self.c_exp_vals):
+                dc_dstdD = np.zeros((self.p, self.m_total))
+                for i, const_func in enumerate(self.constraints):
+                    if self.c_exp_vals[i]:
                         sxx = self.__unpack_sim__(sim)
-                        dc_dsim[i, :] = constraint_func(self.__extract__(x),
-                                                        sxx, der=2)
+                        sdxx = self.__unpack_sim__(sim_std_dev)
+                        dc_dsd_tmp = const_func(self.__extract__(x),
+                                                sxx, sdxx, der=3)
+                        # If names are used, pack the sims
+                        if self.use_names:
+                            dc_dstdD[i, :] = self.__pack_sim__(dc_dsd_tmp)
+                        else:
+                            dc_dstdD[i, :] = dc_dsd_tmp
             # Finally, evaluate the full Jacobian of the constraints
             for i in range(len(self.constraints)):
                 if cx[i] > 0:
                     dcx[:] = dcx[:] + dc_dx[i, :]
                     if self.m_total > 0:
                         dcx[:] = dcx[:] + np.dot(dc_dsim[i, :], dsim_dx[:, :])
+                    if self.c_exp_vals[i]:
+                        dcx[:] = dcx[:] + np.dot(dc_dstdD[i, :],
+                                                 dstdD_dx[:, :])
         # Construct the Jacobian of the penalized objective function
         dLx = np.zeros((self.o, self.n))
         for i in range(self.o):
@@ -1751,11 +2007,16 @@ def addData(self, x, sx):
         """
 
         # Initialize the database if needed
+        if any(self.obj_exp_vals):
+            sdx = np.zeros(sx.dtype)
         if self.n_dat == 0:
             self.data['x_vals'][0, :] = self.__embed__(x)
             self.data['f_vals'] = np.zeros((1, self.o))
             for i, obj_func in enumerate(self.objectives):
-                self.data['f_vals'][0, i] = obj_func(x, sx)
+                if self.obj_exp_vals[i]:
+                    self.data['f_vals'][0, i] = obj_func(x, sx, sdx)
+                else:
+                    self.data['f_vals'][0, i] = obj_func(x, sx)
             # Check if there are constraint violations to maintain
             if self.p > 0:
                 self.data['c_vals'] = np.zeros((1, self.p))
@@ -1774,7 +2035,10 @@ def addData(self, x, sx):
                                             [self.__embed__(x)], axis=0)
             fx = np.zeros(self.o)
             for i, obj_func in enumerate(self.objectives):
-                fx[i] = obj_func(x, sx)
+                if self.obj_exp_vals[i]:
+                    fx[i] = obj_func(x, sx, sdx)
+                else:
+                    fx[i] = obj_func(x, sx)
             self.data['f_vals'] = np.append(self.data['f_vals'],
                                             [fx], axis=0)
             # Check if there are constraint violations to maintain
@@ -1861,7 +2125,9 @@ def iterate(self, k):
             # Set up the surrogate problem
             opt = self.optimizer(self.o, self.scaled_lb, self.scaled_ub,
                                  self.hyperparams)
-            opt.setObjective(self.evaluateSurrogates)
+            opt.setObjective(self.evaluateObjectives)
+            opt.setSimulation(self.evaluateSurrogates,
+                              self.surrogateUncertainty)
             opt.setPenalty(self.evaluatePenalty, self.evaluateGradients)
             opt.setConstraints(self.evaluateConstraints)
             opt.addAcquisition(*self.acquisitions)
@@ -1875,32 +2141,40 @@ def iterate(self, k):
                     # Check whether it has been evaluated by any simulation
                     for i in range(self.s):
                         xxi = self.__extract__(xi)
+                        # This extract embed, while redundant, is necessarry
+                        # for categorical variables to be processed correctly
+                        xxxi = self.__embed__(xxi)
                         if self.use_names:
                             namei = self.sim_names[i][0]
                         else:
                             namei = i
-                        if not any([np.all(np.abs(xi - self.__embed__(xj)) <
-                                    self.scaled_des_tols)
-                                    and namei == j for (xj, j) in batch]) \
+                        if any([np.all(np.abs(xxxi - self.__embed__(xj)) <
+                                       self.scaled_des_tols)
+                                and namei == j for (xj, j) in batch]) \
                            and self.check_sim_db(xxi, i) is None:
                             # If not, add it to the batch
                             batch.append((xxi, namei))
                         else:
                             # Try to improve surrogate (locally then globally)
-                            x_improv = self.surrogates[i].improve(xi, False)
+                            x_improv = self.surrogates[i].improve(xxxi, False)
+                            # Again, this is needed to handle categorical vars
+                            ibatch = [self.__embed__(self.__extract__(xk))
+                                      for xk in x_improv]
                             while (any([any([np.all(np.abs(self.__embed__(xj)
                                                            - xk) <
                                                     self.scaled_des_tols)
                                              and namei == j for (xj, j)
                                              in batch])
-                                        for xk in x_improv]) or
+                                        for xk in ibatch]) or
                                    any([self.check_sim_db(self.__extract__(xk),
                                                           i)
-                                        is not None for xk in x_improv])):
-                                x_improv = self.surrogates[i].improve(xi,
+                                        is not None for xk in ibatch])):
+                                x_improv = self.surrogates[i].improve(xxxi,
                                                                       True)
+                                ibatch = [self.__embed__(self.__extract__(xk))
+                                          for xk in x_improv]
                             # Add improvement points to the batch
-                            for xj in x_improv:
+                            for xj in ibatch:
                                 batch.append((self.__extract__(xj), namei))
             else:
                 # If there were no simulations, just add all points to batch
@@ -2005,7 +2279,7 @@ def updateAll(self, k, batch):
             self.save(filename=self.checkpointfile)
         return
 
-    def solve(self, budget):
+    def solve(self, iter_max=None, sim_max=None):
         """ Solve a MOOP using ParMOO.
 
         If desired, be sure to turn on checkpointing before starting the
@@ -2023,7 +2297,7 @@ def solve(self, budget):
         ``
 
         Args:
-            budget (int): The max budget for ParMOO's internal iteration
+            iter_max (int): The max limit for ParMOO's internal iteration
                 counter. ParMOO keeps track of how many iterations it has
                 completed internally. This value k specifies the stopping
                 criteria for ParMOO.
@@ -2032,12 +2306,46 @@ def solve(self, budget):
 
         import logging
 
-        # Check that the budget is a legal integer
-        if isinstance(budget, int):
-            if budget < 0:
-                raise ValueError("budget must be nonnegative")
-        else:
-            raise TypeError("budget must be an int type")
+        # Check that at least one budget variable was given
+        if iter_max is None and sim_max is None:
+            raise ValueError("At least one of the following arguments " +
+                             "must be set: 'iter_max' or 'sim_max'")
+        # Check that the iter_max is a legal integer
+        if isinstance(iter_max, int):
+            if iter_max < 0:
+                raise ValueError("When present, iter_max must be nonnegative")
+        elif iter_max is not None:
+            raise TypeError("When present, iter_max must be an int type")
+        # Check that the sim_max is a legal integer
+        if isinstance(sim_max, int):
+            if sim_max < 0:
+                raise ValueError("When present, sim_max must be nonnegative")
+        elif sim_max is not None:
+            raise TypeError("When present, sim_max must be an int type")
+        # Set iter_max large enough if None
+        if iter_max is None:
+            if self.s == 0:
+                raise ValueError("If 0 simulations are given, then iter_max" +
+                                 "must be provided")
+            iter_max = sim_max
+        # Count total sims to exhaust iter_max if sim_max is None
+        total_search_budget = 0
+        for search in self.searches:
+            total_search_budget += search.budget
+        if sim_max is None:
+            sim_max = total_search_budget
+            sim_max += iter_max * len(self.acquisitions) * self.s + 1
+        # Warning for the uninitiated
+        if sim_max <= total_search_budget:
+            warnings.warn("You are running ParMOO with a total search budget" +
+                          f" of {total_search_budget} and a sim_max of " +
+                          f"just {sim_max}... This will result in pure " +
+                          "design space exploration with no exploitation/" +
+                          "optimization. Consider increasing the value of " +
+                          "sim_max, decreasing your search_budget, " +
+                          "or using the iter_max stopping criteria, unless " +
+                          "you are really only interested in design space " +
+                          "exploration without exploitation/optimization.")
 
         # Print logging info summary of problem setup
         logging.info(" Beginning new run of ParMOO...")
@@ -2059,7 +2367,8 @@ def solve(self, budget):
         logging.info(f"   {len(self.acquisitions)} acquisition functions")
         logging.info("   estimated simulation evaluations per iteration:" +
                      f" {len(self.acquisitions) * self.s}")
-        logging.info(f"   iteration limit: {budget}")
+        logging.info(f"   iteration limit: {iter_max}")
+        logging.info(f"   total simulation budget: {sim_max}")
         logging.info(" Done.")
 
         # Perform iterations until budget is exceeded
@@ -2067,7 +2376,11 @@ def solve(self, budget):
 
         # Reset the iteration start
         start = self.iteration
-        for k in range(start, budget + 1):
+        total_sims = 0
+        for k in range(start, iter_max + 1):
+            # Check for the sim_max stop condition
+            if total_sims >= sim_max:
+                break
             # Track iteration counter
             self.iteration = k
             # Generate a batch by running one iteration
@@ -2084,6 +2397,10 @@ def solve(self, budget):
                                  f" for simulation: {i}...")
                     sx = self.evaluateSimulation(x, i)
                     logging.info(f"         result: {sx}")
+                    # Count total simulations taken
+                    total_sims += 1
+                    if total_sims >= sim_max:
+                        logging.info(f"   sim_max of {sim_max} reached")
                 logging.info(f"     finished evaluating {len(batch)}" +
                              " simulations.")
             logging.info("     updating models and internal databases...")
@@ -2098,6 +2415,12 @@ def solve(self, budget):
     def getPF(self, format='ndarray'):
         """ Extract nondominated and efficient sets from internal databases.
 
+        Args:
+            format (str, optional): Either 'ndarray' (default) or 'pandas',
+                in order to produce output as a numpy structured array or
+                pandas dataframe. Note: format='pandas' is only valid for
+                named inputs.
+
         Returns:
             A discrete approximation of the Pareto front and efficient set.
 
@@ -2156,6 +2479,9 @@ def getPF(self, format='ndarray'):
             if self.p > 0:
                 result['c_vals'] = pf['c_vals'].copy()
         if format == 'pandas':
+            if not self.use_names:
+                raise ValueError("format='pandas' is invalid for unnamed " +
+                                 "inputs")
             return pd.DataFrame(result)
         elif format == 'ndarray':
             return result
@@ -2165,6 +2491,12 @@ def getPF(self, format='ndarray'):
     def getSimulationData(self, format='ndarray'):
         """ Extract all computed simulation outputs from the MOOP's database.
 
+        Args:
+            format (str, optional): Either 'ndarray' (default) or 'pandas',
+                in order to produce output as a numpy structured array or
+                pandas dataframe. Note: format='pandas' is only valid for
+                named inputs.
+
         Returns:
             (dict or list) Either a dictionary or list of dictionaries
             containing every point where a simulation was evaluated.
@@ -2212,7 +2544,22 @@ def getSimulationData(self, format='ndarray'):
                     else:
                         result[sname[0]]['out'] = self.sim_db[i]['s_vals']
             if format == 'pandas':
-                return pd.DataFrame(result)
+                # For simulation data, converting to pandas is a little more
+                # complicated...
+                result_pd = {}
+                for i, snamei in enumerate(result.keys()):
+                    rtempi = {}
+                    for (name, t) in self.des_names:
+                        rtempi[name] = result[snamei][name]
+                    # Need to break apart the output column manually
+                    if self.m[i] > 1:
+                        for i in range(self.m[i]):
+                            rtempi[f'out_{i}'] = result[snamei]['out'][:, i]
+                    else:
+                        rtempi['out'] = result[snamei]['out'][:, 0]
+                    # Create dictionary of dataframes, indexed by sim names
+                    result_pd[snamei] = pd.DataFrame(rtempi)
+                return result_pd
             elif format == 'ndarray':
                 return result
             else:
@@ -2233,7 +2580,8 @@ def getSimulationData(self, format='ndarray'):
                     result.append({'x_vals': np.zeros(0),
                                    's_vals': np.zeros(0)})
             if format == 'pandas':
-                return pd.DataFrame(result)
+                raise ValueError("format='pandas' is invalid for unnamed " +
+                                 "inputs")
             elif format == 'ndarray':
                 return result
             else:
@@ -2243,6 +2591,12 @@ def getSimulationData(self, format='ndarray'):
     def getObjectiveData(self, format='ndarray'):
         """ Extract all computed objective scores from this MOOP's database.
 
+        Args:
+            format (str, optional): Either 'ndarray' (default) or 'pandas',
+                in order to produce output as a numpy structured array or
+                pandas dataframe. Note: format='pandas' is only valid for
+                named inputs.
+
         Returns:
             A database of all designs that have been fully evaluated,
             and their corresponding objective scores.
@@ -2294,6 +2648,9 @@ def getObjectiveData(self, format='ndarray'):
                 if self.p > 0:
                     result['c_vals'] = self.data['c_vals'].copy()
         if format == 'pandas':
+            if not self.use_names:
+                raise ValueError("format='pandas' is invalid for unnamed " +
+                                 "inputs")
             return pd.DataFrame(result)
         elif format == 'ndarray':
             return result
@@ -2354,6 +2711,8 @@ def save(self, filename="parmoo"):
                         'des_names': self.des_names,
                         'obj_names': self.obj_names,
                         'const_names': self.const_names,
+                        'obj_exp_vals': self.obj_exp_vals,
+                        'c_exp_vals': self.c_exp_vals,
                         'lam': self.lam,
                         'des_tols': self.des_tols,
                         'epsilon': self.epsilon,
@@ -2563,6 +2922,8 @@ def load(self, filename="parmoo"):
         self.obj_names = [tuple(item) for item in parmoo_state['obj_names']]
         self.const_names = [tuple(item)
                             for item in parmoo_state['const_names']]
+        self.obj_exp_vals = [item for item in parmoo_state['obj_exp_vals']]
+        self.c_exp_vals = [item for item in parmoo_state['c_exp_vals']]
         self.lam = parmoo_state['lam']
         self.des_tols = parmoo_state['des_tols']
         self.epsilon = parmoo_state['epsilon']
diff --git a/parmoo/optimizers/gps_search.py b/parmoo/optimizers/gps_search.py
index 3070f18..4b3325d 100644
--- a/parmoo/optimizers/gps_search.py
+++ b/parmoo/optimizers/gps_search.py
@@ -14,7 +14,6 @@
 """
 
 import numpy as np
-import inspect
 from parmoo.structs import SurrogateOptimizer, AcquisitionFunction
 from parmoo.util import xerror
 
@@ -30,7 +29,8 @@ class LocalGPS(SurrogateOptimizer):
 
     # Slots for the LocalGPS class
     __slots__ = ['n', 'lb', 'ub', 'acquisitions', 'budget', 'constraints',
-                 'objectives']
+                 'objectives', 'simulations', 'gradients', 'resetObjectives',
+                 'penalty_func', 'sim_sd', 'restarts']
 
     def __init__(self, o, lb, ub, hyperparams):
         """ Constructor for the LocalGPS class.
@@ -47,7 +47,9 @@ def __init__(self, o, lb, ub, hyperparams):
 
             hyperparams (dict): A dictionary of hyperparameters for the
                 optimization procedure. It may contain the following:
-                 * opt_budget (int): The evaluation budget (default: 10,000).
+                 * opt_budget (int): The GPS iteration limit (default: 1000).
+                 * opt_restarts (int): Number of multisolve restarts per
+                   scalarization (default: n+1).
 
         Returns:
             SurrogateOptimizer: A new SurrogateOptimizer object.
@@ -60,6 +62,19 @@ def __init__(self, o, lb, ub, hyperparams):
         self.lb = lb
         self.ub = ub
         # Check that the contents of hyperparams is legal
+        if 'opt_restarts' in hyperparams:
+            if isinstance(hyperparams['opt_restarts'], int):
+                if hyperparams['opt_restarts'] < 1:
+                    raise ValueError("hyperparams['opt_restarts'] "
+                                     "must be positive")
+                else:
+                    self.restarts = hyperparams['opt_restarts']
+            else:
+                raise TypeError("hyperparams['opt_restarts'] "
+                                 "must be an integer")
+        else:
+            self.restarts = self.n + 1
+        # Check that the contents of hyperparams is legal
         if 'opt_budget' in hyperparams:
             if isinstance(hyperparams['opt_budget'], int):
                 if hyperparams['opt_budget'] < 1:
@@ -71,95 +86,10 @@ def __init__(self, o, lb, ub, hyperparams):
                 raise TypeError("hyperparams['opt_budget'] "
                                  "must be an integer")
         else:
-            self.budget = 10000
+            self.budget = 1000
         self.acquisitions = []
         return
 
-    def setObjective(self, obj_func):
-        """ Add a vector-valued objective function that will be solved.
-
-        Args:
-            obj_func (function): A vector-valued function that can be evaluated
-                to solve the surrogate optimization problem.
-
-        """
-
-        # Check whether obj_func() has an appropriate signature
-        if callable(obj_func):
-            if len(inspect.signature(obj_func).parameters) != 1:
-                raise ValueError("obj_func() must accept exactly one input")
-            else:
-                # Add obj_func to the problem
-                self.objectives = obj_func
-        else:
-            raise TypeError("obj_func() must be callable")
-        return
-
-    def setReset(self, reset):
-        """ Add a reset function for resetting surrogate updates.
-
-        This method is not used by this class.
-
-        """
-
-        return
-
-    def setPenalty(self, penalty_func, grad_func):
-        """ Add a matrix-valued gradient function for obj_func.
-
-        Args:
-            penalty_func (function): A vector-valued penalized objective
-                that incorporates a penalty for violating constraints.
-
-            grad_func (function): A matrix-valued function that can be
-                evaluated to obtain the Jacobian matrix for obj_func.
-
-        """
-
-        # Do nothing, LocalGPS is gradient free
-        return
-
-    def setConstraints(self, constraint_func):
-        """ Add a constraint function that will be satisfied.
-
-        Args:
-            constraint_func (function): A vector-valued function from the
-                design space whose components correspond to constraint
-                violations. If the problem is unconstrained, a function
-                that returns zeros could be provided.
-
-        """
-
-        # Check whether constraint_func() has an appropriate signature
-        if callable(constraint_func):
-            if len(inspect.signature(constraint_func).parameters) != 1:
-                raise ValueError("constraint_func() must accept exactly one"
-                                 + " input")
-            else:
-                # Add constraint_func to the problem
-                self.constraints = constraint_func
-        else:
-            raise TypeError("constraint_func() must be callable")
-        return
-
-    def addAcquisition(self, *args):
-        """ Add an acquisition function for the surrogate optimizer.
-
-        Args:
-            *args (AcquisitionFunction): Acquisition functions that are used
-                to scalarize the list of objectives in order to solve the
-                surrogate optimization problem.
-
-        """
-
-        # Check for illegal inputs
-        if not all([isinstance(arg, AcquisitionFunction) for arg in args]):
-            raise TypeError("Args must be instances of AcquisitionFunction")
-        # Append all arguments to the acquisitions list
-        for arg in args:
-            self.acquisitions.append(arg)
-        return
-
     def solve(self, x):
         """ Solve the surrogate problem using generalized pattern search (GPS).
 
@@ -182,64 +112,85 @@ def solve(self, x):
                                  "of acquisition functions")
         else:
             raise TypeError("x must be a numpy array")
-        # Check that x is feasible.
-        for xj in x:
-            if any(self.constraints(xj) > 0.00000001) or \
-               np.any(xj[:] < self.lb[:]) or \
-               np.any(xj[:] > self.ub[:]):
-                raise ValueError("some of starting points (x) are infeasible")
         # Initialize an empty list of results
         result = []
         # For each acqusisition function
         for j, acquisition in enumerate(self.acquisitions):
-            # Reset the mesh dimensions
-            mesh = np.diag(self.ub[:] - self.lb[:] * 0.5)
-            # Evaluate the starting point
-            v = np.asarray(self.objectives(x[j, :]))
-            f_min = acquisition.scalarize(v.flatten())
-            x_min = x[j, :]
-            # Loop over the budget
-            for k in range(int(self.budget / (self.n * 2 *
-                                              len(self.acquisitions)))):
-                # Track whether or not there is improvement
-                improve = False
-                for i in range(self.n):
-                    # Evaluate x + mesh[:, i]
-                    x_tmp = x_min + mesh[:, i]
-                    if any(x_tmp > self.ub):
-                        f_tmp = np.inf
-                    elif any(self.constraints(x_tmp) > 0.00000001):
-                        f_tmp = np.inf
-                    else:
-                        v = np.asarray(self.objectives(x_tmp))
-                        f_tmp = acquisition.scalarize(v.flatten())
-                    # Check for improvement
-                    if f_tmp + 10**(-8) < f_min:
-                        f_min = f_tmp
-                        x_min = x_tmp
-                        improve = True
-                    # Evaluate x - mesh[:, i]
-                    x_tmp = x_min - mesh[:, i]
-                    if any(x_tmp < self.lb):
-                        f_tmp = np.inf
-                    elif any(self.constraints(x_tmp) > 0.00000001):
-                        f_tmp = np.inf
-                    else:
-                        v = np.asarray(self.objectives(x_tmp))
-                        f_tmp = acquisition.scalarize(v.flatten())
-                    # Check for improvement
-                    if f_tmp + 10**(-8) < f_min:
-                        f_min = f_tmp
-                        x_min = x_tmp
-                        improve = True
-                # If no improvement, decay the mesh down to the tolerance
-                if not improve:
-                    if any([mesh[i, i] < 0.0001 for i in range(self.n)]):
-                        break
-                    else:
-                        mesh = mesh * 0.5
+            # Create a new trust region
+            rad = self.resetObjectives(x[j, :])
+            lb_tmp = np.zeros(self.n)
+            ub_tmp = np.ones(self.n)
+            for i in range(self.n):
+                lb_tmp[i] = max(self.lb[i], x[j, i] - rad)
+                ub_tmp[i] = min(self.ub[i], x[j, i] + rad)
+            # Loop over restarts
+            x_min = np.zeros((self.restarts, self.n))
+            f_min = np.zeros(self.restarts)
+            for kk in range(self.restarts):
+                # Reset the mesh dimensions
+                mesh = np.diag(ub_tmp[:] - lb_tmp[:] * 0.25)
+                # Evaluate the starting point
+                sx = np.asarray(self.simulations(x[j, :]))
+                if acquisition.useSD():
+                    sdx = np.asarray(self.sim_sd(x[j, :]))
+                else:
+                    sdx = np.zeros(sx.size)
+                if kk == 0:
+                    x_min[kk, :] = x[j, :]
+                else:
+                    x_min[kk, :] = (np.random.random_sample(self.n) *
+                                    (ub_tmp - lb_tmp) + lb_tmp)
+                fx = np.asarray(self.penalty_func(x_min[kk, :], sx))
+                f_min[kk] = acquisition.scalarize(fx.flatten(), x_min[kk, :],
+                                                  sx, sdx)
+                # Loop over the budget
+                for k in range(self.budget):
+                    # Track whether or not there is improvement
+                    improve = False
+                    for i in range(self.n):
+                        # Evaluate x + mesh[:, i]
+                        x_tmp = x_min[kk, :] + mesh[:, i]
+                        if any(x_tmp > ub_tmp):
+                            f_tmp = np.inf
+                        else:
+                            sx = np.asarray(self.simulations(x_tmp))
+                            if acquisition.useSD():
+                                sdx = self.sim_sd(x_tmp)
+                            else:
+                                sdx = 0.0
+                            fx = self.penalty_func(x_tmp, sx)
+                            f_tmp = acquisition.scalarize(fx, x_tmp, sx, sdx)
+                        # Check for improvement
+                        if f_tmp + 1.0e-8 < f_min[kk]:
+                            f_min[kk] = f_tmp
+                            x_min[kk, :] = x_tmp
+                            improve = True
+                        # Evaluate x - mesh[:, i]
+                        x_tmp = x_min[kk, :] - mesh[:, i]
+                        if any(x_tmp < lb_tmp):
+                            f_tmp = np.inf
+                        else:
+                            sx = np.asarray(self.simulations(x_tmp))
+                            if acquisition.useSD():
+                                sdx = self.sim_sd(x_tmp)
+                            else:
+                                sdx = 0.0
+                            fx = self.penalty_func(x_tmp, sx)
+                            f_tmp = acquisition.scalarize(fx, x_tmp, sx, sdx)
+                        # Check for improvement
+                        if f_tmp + 1.0e-8 < f_min[kk]:
+                            f_min[kk] = f_tmp
+                            x_min[kk, :] = x_tmp
+                            improve = True
+                    # If no improvement, decay the mesh down to the tolerance
+                    if not improve:
+                        if any([mesh[i, i] < 1.0e-4 for i in range(self.n)]):
+                            break
+                        else:
+                            mesh = mesh * 0.5
             # Append the found minima to the results list
-            result.append(x_min)
+            x_cand_ind = np.argmin(f_min)
+            result.append(x_min[x_cand_ind, :].copy())
         return np.asarray(result)
 
 
@@ -254,7 +205,8 @@ class GlobalGPS(SurrogateOptimizer):
 
     # Slots for the GlobalGPS class
     __slots__ = ['n', 'lb', 'ub', 'acquisitions', 'constraints', 'objectives',
-                 'search_budget', 'gps_budget']
+                 'simulations', 'gradients', 'resetObjectives', 'penalty_func',
+                 'search_budget', 'gps_budget', 'sim_sd']
 
     def __init__(self, o, lb, ub, hyperparams):
         """ Constructor for the GlobalGPS class.
@@ -299,7 +251,7 @@ def __init__(self, o, lb, ub, hyperparams):
                 raise TypeError("hyperparams['opt_budget'] "
                                  "must be an integer")
         else:
-            budget = 10000
+            budget = 1000
         # Check the GPS budget
         if 'gps_budget' in hyperparams:
             if isinstance(hyperparams['gps_budget'], int):
@@ -316,97 +268,12 @@ def __init__(self, o, lb, ub, hyperparams):
                 raise TypeError("hyperparams['gps_budget'] "
                                  "must be an integer")
         else:
-            self.gps_budget = int(budget / 2)
-        self.search_budget = budget - self.gps_budget
+            self.gps_budget = 1000
+        self.search_budget = budget
         # Initialize the list of acquisition functions
         self.acquisitions = []
         return
 
-    def setObjective(self, obj_func):
-        """ Add a vector-valued objective function that will be solved.
-
-        Args:
-            obj_func (function): A vector-valued function that can be evaluated
-                to solve the surrogate optimization problem.
-
-        """
-
-        # Check whether obj_func() has an appropriate signature
-        if callable(obj_func):
-            if len(inspect.signature(obj_func).parameters) != 1:
-                raise ValueError("obj_func() must accept exactly one input")
-            else:
-                # Add obj_func to the problem
-                self.objectives = obj_func
-        else:
-            raise TypeError("obj_func() must be callable")
-        return
-
-    def setReset(self, reset):
-        """ Add a reset function for resetting surrogate updates.
-
-        This method is not used by this class.
-
-        """
-
-        return
-
-    def setPenalty(self, penalty_func, grad_func):
-        """ Add a matrix-valued gradient function for obj_func.
-
-        Args:
-            penalty_func (function): A vector-valued penalized objective
-                that incorporates a penalty for violating constraints.
-
-            grad_func (function): A matrix-valued function that can be
-                evaluated to obtain the Jacobian matrix for obj_func.
-
-        """
-
-        # Do nothing, GlobalGPS is gradient free
-        return
-
-    def setConstraints(self, constraint_func):
-        """ Add a constraint function that will be satisfied.
-
-        Args:
-            constraint_func (function): A vector-valued function from the
-                design space whose components correspond to constraint
-                violations. If the problem is unconstrained, a function
-                that returns zeros could be provided.
-
-        """
-
-        # Check whether constraint_func() has an appropriate signature
-        if callable(constraint_func):
-            if len(inspect.signature(constraint_func).parameters) != 1:
-                raise ValueError("constraint_func() must accept exactly one"
-                                 + " input")
-            else:
-                # Add constraint_func to the problem
-                self.constraints = constraint_func
-        else:
-            raise TypeError("constraint_func() must be callable")
-        return
-
-    def addAcquisition(self, *args):
-        """ Add an acquisition function for the surrogate optimizer.
-
-        Args:
-            *args (AcquisitionFunction): Acquisition functions that are used
-                to scalarize the list of objectives in order to solve the
-                surrogate optimization problem.
-
-        """
-
-        # Check for illegal inputs
-        if not all([isinstance(arg, AcquisitionFunction) for arg in args]):
-            raise TypeError("Args must be instances of AcquisitionFunction")
-        # Append all arguments to the acquisitions list
-        for arg in args:
-            self.acquisitions.append(arg)
-        return
-
     def solve(self, x):
         """ Solve the surrogate problem by using random search followed by GPS.
 
@@ -431,16 +298,12 @@ def solve(self, x):
                                  "of acquisition functions")
         else:
             raise TypeError("x must be a numpy array")
-        # Check that x is feasible.
-        for xj in x:
-            if any(self.constraints(xj) > 0.00000001) or \
-               np.any(xj[:] < self.lb[:]) or \
-               np.any(xj[:] > self.ub[:]):
-                raise ValueError("some of starting points (x) are infeasible")
         # Do a global search to get global solutions
         gs = RandomSearch(self.n, self.lb, self.ub,
                           {'opt_budget': self.search_budget})
         gs.setObjective(self.objectives)
+        gs.setSimulation(self.simulations, self.sim_sd)
+        gs.setPenalty(self.penalty_func, self.gradients)
         gs.setConstraints(self.constraints)
         gs.addAcquisition(*self.acquisitions)
         gs_soln = gs.solve(x)
@@ -449,8 +312,11 @@ def solve(self, x):
         ls = LocalGPS(self.n, self.lb, self.ub,
                       {'opt_budget': gps_budget_loc})
         ls.setObjective(self.objectives)
+        ls.setSimulation(self.simulations, self.sim_sd)
+        ls.setPenalty(self.penalty_func, self.gradients)
         ls.setConstraints(self.constraints)
         ls.addAcquisition(*self.acquisitions)
+        ls.setReset(self.resetObjectives)
         ls_soln = ls.solve(gs_soln)
         # Return the list of local solutions
         return ls_soln
diff --git a/parmoo/optimizers/lbfgsb.py b/parmoo/optimizers/lbfgsb.py
index 2d13d99..5e88f0d 100644
--- a/parmoo/optimizers/lbfgsb.py
+++ b/parmoo/optimizers/lbfgsb.py
@@ -14,7 +14,6 @@
 """
 
 import numpy as np
-import inspect
 from parmoo.structs import SurrogateOptimizer, AcquisitionFunction
 from parmoo.util import xerror
 
@@ -30,7 +29,8 @@ class LBFGSB(SurrogateOptimizer):
 
     # Slots for the LBFGSB class
     __slots__ = ['n', 'bounds', 'acquisitions', 'budget', 'constraints',
-                 'objectives', 'gradients', 'penalty_func', 'restarts']
+                 'objectives', 'simulations', 'gradients', 'resetObjectives',
+                 'penalty_func', 'sim_sd']
 
     def __init__(self, o, lb, ub, hyperparams):
         """ Constructor for the LBFGSB class.
@@ -91,108 +91,6 @@ def __init__(self, o, lb, ub, hyperparams):
         self.acquisitions = []
         return
 
-    def setObjective(self, obj_func):
-        """ Add a vector-valued objective function that will be solved.
-
-        Args:
-            obj_func (function): A vector-valued function that can be evaluated
-                to solve the surrogate optimization problem.
-
-        """
-
-        # Check whether obj_func() has an appropriate signature
-        if callable(obj_func):
-            if len(inspect.signature(obj_func).parameters) != 1:
-                raise ValueError("obj_func() must accept exactly one input")
-            else:
-                # Add obj_func to the problem
-                self.objectives = obj_func
-        else:
-            raise TypeError("obj_func() must be callable")
-        return
-
-    def setReset(self, reset):
-        """ Add a reset function for resetting surrogate updates.
-
-        This method is not used by this class.
-
-        """
-
-        return
-
-    def setPenalty(self, penalty_func, grad_func):
-        """ Add a matrix-valued gradient function for obj_func.
-
-        Args:
-            penalty_func (function): A vector-valued penalized objective
-                that incorporates a penalty for violating constraints.
-
-            grad_func (function): A matrix-valued function that can be
-                evaluated to obtain the Jacobian matrix for obj_func.
-
-        """
-
-        # Check whether grad_func() has an appropriate signature
-        if callable(grad_func):
-            if len(inspect.signature(grad_func).parameters) != 1:
-                raise ValueError("grad_func must accept exactly one input")
-            else:
-                # Add grad_func to the problem
-                self.gradients = grad_func
-        else:
-            raise TypeError("grad_func() must be callable")
-        # Check whether penalty_func() has an appropriate signature
-        if callable(penalty_func):
-            if len(inspect.signature(penalty_func).parameters) != 1:
-                raise ValueError("penalty_func must accept exactly one input")
-            else:
-                # Add Lagrangian to the problem
-                self.penalty_func = penalty_func
-        else:
-            raise TypeError("penalty_func must be callable")
-        return
-
-    def setConstraints(self, constraint_func):
-        """ Add a constraint function that will be satisfied.
-
-        Args:
-            constraint_func (function): A vector-valued function from the
-                design space whose components correspond to constraint
-                violations. If the problem is unconstrained, a function
-                that returns zeros could be provided.
-
-        """
-
-        # Check whether constraint_func() has an appropriate signature
-        if callable(constraint_func):
-            if len(inspect.signature(constraint_func).parameters) != 1:
-                raise ValueError("constraint_func() must accept exactly one"
-                                 + " input")
-            else:
-                # Add constraint_func to the problem
-                self.constraints = constraint_func
-        else:
-            raise TypeError("constraint_func() must be callable")
-        return
-
-    def addAcquisition(self, *args):
-        """ Add an acquisition function for the surrogate optimizer.
-
-        Args:
-            *args (AcquisitionFunction): Acquisition functions that are used
-                to scalarize the list of objectives in order to solve the
-                surrogate optimization problem.
-
-        """
-
-        # Check for illegal inputs
-        if not all([isinstance(arg, AcquisitionFunction) for arg in args]):
-            raise TypeError("Args must be instances of AcquisitionFunction")
-        # Append all arguments to the acquisitions list
-        for arg in args:
-            self.acquisitions.append(arg)
-        return
-
     def solve(self, x):
         """ Solve the surrogate problem using L-BFGS-B.
 
@@ -228,13 +126,33 @@ def solve(self, x):
         for j, acquisition in enumerate(self.acquisitions):
 
             # Define the scalarized wrapper functions
-            def scalar_f(x, *args):
-                return acquisition.scalarize(self.penalty_func(x))
+            if acquisition.useSD():
+
+                def scalar_f(x, *args):
+                    sx = self.simulations(x)
+                    sdx = self.sim_sd(x)
+                    fx = self.penalty_func(x, sx)
+                    return acquisition.scalarize(fx, x, sx, sdx)
+
+            else:
+
+                def scalar_f(x, *args):
+                    sx = self.simulations(x)
+                    sdx = np.zeros(sx.size)
+                    fx = self.penalty_func(x, sx)
+                    return acquisition.scalarize(fx, x, sx, sdx)
 
             def scalar_g(x, *args):
                 return acquisition.scalarizeGrad(self.penalty_func(x),
                                                  self.gradients(x))
 
+            # Create a new trust region
+            rad = self.resetObjectives(x[j, :])
+            bounds = np.zeros((self.n, 2))
+            for i in range(self.n):
+                bounds[i, 0] = max(self.bounds[i, 0], x[j, i] - rad)
+                bounds[i, 1] = min(self.bounds[i, 1], x[j, i] + rad)
+
             # Get the solution via multistart solve
             soln = x[j, :].copy()
             for i in range(self.restarts):
@@ -247,18 +165,18 @@ def scalar_g(x, *args):
                     gg = scalar_g(x0)
                     for ii in range(self.n):
                         if gg[ii] < 0:
-                            x0[ii] = self.bounds[ii, 1]
+                            x0[ii] = bounds[ii, 1]
                         elif gg[ii] > 0:
-                            x0[ii] = self.bounds[ii, 0]
+                            x0[ii] = bounds[ii, 0]
                 else:
                     # Random starting point within bounds for all other starts
                     x0 = (np.random.random_sample(self.n) *
-                          (self.bounds[:, 1] - self.bounds[:, 0]) +
-                          self.bounds[:, 0])
+                          (bounds[:, 1] - bounds[:, 0]) +
+                          bounds[:, 0])
 
                 # Solve the problem globally within bound constraints
                 res = optimize.minimize(scalar_f, x0, method='L-BFGS-B',
-                                        jac=scalar_g, bounds=self.bounds,
+                                        jac=scalar_g, bounds=bounds,
                                         options={'maxiter': self.budget})
                 if scalar_f(res['x']) < scalar_f(soln):
                     soln = res['x']
@@ -280,7 +198,7 @@ class TR_LBFGSB(SurrogateOptimizer):
     # Slots for the LBFGSB class
     __slots__ = ['n', 'bounds', 'acquisitions', 'budget', 'constraints',
                  'objectives', 'gradients', 'penalty_func', 'resetObjectives',
-                 'restarts']
+                 'restarts', 'simulations', 'sim_sd']
 
     def __init__(self, o, lb, ub, hyperparams):
         """ Constructor for the TR_LBFGSB class.
@@ -342,120 +260,6 @@ def __init__(self, o, lb, ub, hyperparams):
         self.acquisitions = []
         return
 
-    def setObjective(self, obj_func):
-        """ Add a vector-valued objective function that will be solved.
-
-        Args:
-            obj_func (function): A vector-valued function that can be evaluated
-                to solve the surrogate optimization problem.
-
-        """
-
-        # Check whether obj_func() has an appropriate signature
-        if callable(obj_func):
-            if len(inspect.signature(obj_func).parameters) != 1:
-                raise ValueError("obj_func() must accept exactly one input")
-            else:
-                # Add obj_func to the problem
-                self.objectives = obj_func
-        else:
-            raise TypeError("obj_func() must be callable")
-        return
-
-    def setReset(self, reset):
-        """ Add a reset function for resetting surrogate updates.
-
-        Args:
-            reset (function): A function with one input, which will be
-                called prior to solving the surrogate optimization
-                problem with each acquisition function.
-
-        """
-
-        # Check whether reset() has an appropriate signature
-        if callable(reset):
-            if len(inspect.signature(reset).parameters) != 1:
-                raise ValueError("reset() must accept exactly one input")
-            else:
-                # Add obj_func to the problem
-                self.resetObjectives = reset
-        else:
-            raise TypeError("reset() must be callable")
-        return
-
-    def setPenalty(self, penalty_func, grad_func):
-        """ Add a matrix-valued gradient function for obj_func.
-
-        Args:
-            penalty_func (function): A vector-valued penalized objective
-                that incorporates a penalty for violating constraints.
-
-            grad_func (function): A matrix-valued function that can be
-                evaluated to obtain the Jacobian matrix for obj_func.
-
-        """
-
-        # Check whether grad_func() has an appropriate signature
-        if callable(grad_func):
-            if len(inspect.signature(grad_func).parameters) != 1:
-                raise ValueError("grad_func() must accept exactly one input")
-            else:
-                # Add grad_func to the problem
-                self.gradients = grad_func
-        else:
-            raise TypeError("grad_func() must be callable")
-        # Check whether penalty_func() has an appropriate signature
-        if callable(penalty_func):
-            if len(inspect.signature(penalty_func).parameters) != 1:
-                raise ValueError("penalty_func must accept exactly one input")
-            else:
-                # Add Lagrangian to the problem
-                self.penalty_func = penalty_func
-        else:
-            raise TypeError("penalty_func must be callable")
-        return
-
-    def setConstraints(self, constraint_func):
-        """ Add a constraint function that will be satisfied.
-
-        Args:
-            constraint_func (function): A vector-valued function from the
-                design space whose components correspond to constraint
-                violations. If the problem is unconstrained, a function
-                that returns zeros could be provided.
-
-        """
-
-        # Check whether constraint_func() has an appropriate signature
-        if callable(constraint_func):
-            if len(inspect.signature(constraint_func).parameters) != 1:
-                raise ValueError("constraint_func() must accept exactly one"
-                                 + " input")
-            else:
-                # Add constraint_func to the problem
-                self.constraints = constraint_func
-        else:
-            raise TypeError("constraint_func() must be callable")
-        return
-
-    def addAcquisition(self, *args):
-        """ Add an acquisition function for the surrogate optimizer.
-
-        Args:
-            *args (AcquisitionFunction): Acquisition functions that are used
-                to scalarize the list of objectives in order to solve the
-                surrogate optimization problem.
-
-        """
-
-        # Check for illegal inputs
-        if not all([isinstance(arg, AcquisitionFunction) for arg in args]):
-            raise TypeError("Args must be instances of AcquisitionFunction")
-        # Append all arguments to the acquisitions list
-        for arg in args:
-            self.acquisitions.append(arg)
-        return
-
     def solve(self, x):
         """ Solve the surrogate problem using L-BFGS-B.
 
@@ -491,8 +295,21 @@ def solve(self, x):
         for j, acquisition in enumerate(self.acquisitions):
 
             # Define the scalarized wrapper functions
-            def scalar_f(x, *args):
-                return acquisition.scalarize(self.penalty_func(x))
+            if acquisition.useSD():
+
+                def scalar_f(x, *args):
+                    sx = self.simulations(x)
+                    sdx = self.sim_sd(x)
+                    fx = self.penalty_func(x, sx)
+                    return acquisition.scalarize(fx, x, sx, sdx)
+
+            else:
+
+                def scalar_f(x, *args):
+                    sx = self.simulations(x)
+                    sdx = np.zeros(sx.size)
+                    fx = self.penalty_func(x, sx)
+                    return acquisition.scalarize(fx, x, sx, sdx)
 
             def scalar_g(x, *args):
                 return acquisition.scalarizeGrad(self.penalty_func(x),
diff --git a/parmoo/optimizers/random_search.py b/parmoo/optimizers/random_search.py
index d2552d3..e70e7f1 100644
--- a/parmoo/optimizers/random_search.py
+++ b/parmoo/optimizers/random_search.py
@@ -13,7 +13,6 @@
 """
 
 import numpy as np
-import inspect
 from parmoo.structs import SurrogateOptimizer, AcquisitionFunction
 from parmoo.util import xerror
 
@@ -28,7 +27,8 @@ class RandomSearch(SurrogateOptimizer):
 
     # Slots for the RandomSearch class
     __slots__ = ['n', 'lb', 'ub', 'acquisitions', 'constraints', 'objectives',
-                 'budget']
+                 'budget', 'simulations', 'gradients', 'resetObjectives',
+                 'penalty_func', 'sim_sd']
 
     def __init__(self, o, lb, ub, hyperparams):
         """ Constructor for the RandomSearch class.
@@ -74,91 +74,6 @@ def __init__(self, o, lb, ub, hyperparams):
         self.acquisitions = []
         return
 
-    def setObjective(self, obj_func):
-        """ Add a vector-valued objective function that will be solved.
-
-        Args:
-            obj_func (function): A vector-valued function that can be evaluated
-                to solve the surrogate optimization problem.
-
-        """
-
-        # Check whether obj_func() has an appropriate signature
-        if callable(obj_func):
-            if len(inspect.signature(obj_func).parameters) != 1:
-                raise ValueError("obj_func() must accept exactly one input")
-            else:
-                # Add obj_func to the problem
-                self.objectives = obj_func
-        else:
-            raise TypeError("obj_func() must be callable")
-        return
-
-    def setReset(self, reset):
-        """ Add a reset function for resetting surrogate updates.
-
-        This method is not used by this class.
-
-        """
-
-        return
-
-    def setPenalty(self, penalty_func, grad_func):
-        """ Add a matrix-valued gradient function for obj_func.
-
-        Args:
-            penalty_func (function): A vector-valued penalized objective
-                that incorporates a penalty for violating constraints.
-
-            grad_func (function): A matrix-valued function that can be
-                evaluated to obtain the Jacobian matrix for obj_func.
-
-        """
-
-        # Do nothing, RandomSearch is gradient free
-        return
-
-    def setConstraints(self, constraint_func):
-        """ Add a constraint function that will be satisfied.
-
-        Args:
-            constraint_func (function): A vector-valued function from the
-                design space whose components correspond to constraint
-                violations. If the problem is unconstrained, a function
-                that returns zeros could be provided.
-
-        """
-
-        # Check whether constraint_func() has an appropriate signature
-        if callable(constraint_func):
-            if len(inspect.signature(constraint_func).parameters) != 1:
-                raise ValueError("constraint_func() must accept exactly one"
-                                 + " input")
-            else:
-                # Add constraint_func to the problem
-                self.constraints = constraint_func
-        else:
-            raise TypeError("constraint_func() must be callable")
-        return
-
-    def addAcquisition(self, *args):
-        """ Add an acquisition function for the surrogate optimizer.
-
-        Args:
-            *args (AcquisitionFunction): Acquisition functions that are used
-                to scalarize the list of objectives in order to solve the
-                surrogate optimization problem.
-
-        """
-
-        # Check for illegal inputs
-        if not all([isinstance(arg, AcquisitionFunction) for arg in args]):
-            raise TypeError("Args must be instances of AcquisitionFunction")
-        # Append all arguments to the acquisitions list
-        for arg in args:
-            self.acquisitions.append(arg)
-        return
-
     def solve(self, x):
         """ Solve the surrogate problem using random search.
 
@@ -183,20 +98,13 @@ def solve(self, x):
                                  "of acquisition functions")
         else:
             raise TypeError("x must be a numpy array")
-        # Check that x is feasible.
-        for xj in x:
-            if any(self.constraints(xj) > 0.00000001) or \
-               np.any(xj[:] < self.lb[:]) or \
-               np.any(xj[:] > self.ub[:]):
-                raise ValueError("some of starting points (x) are infeasible")
         # Set the batch size
         batch_size = 1000
         # Initialize the database
         o = self.objectives(x[0, :]).size
-        p = self.constraints(x[0, :]).size
         data = {'x_vals': np.zeros((batch_size, self.n)),
                 'f_vals': np.zeros((batch_size, o)),
-                'c_vals': np.zeros((batch_size, p))}
+                'c_vals': np.zeros((batch_size, 0))}
         # Loop over batch size until k == budget
         k = 0
         nondom = {}
@@ -206,20 +114,30 @@ def solve(self, x):
             if k_new < batch_size:
                 data['x_vals'] = np.zeros((k_new, self.n))
                 data['f_vals'] = np.zeros((k_new, o))
-                data['c_vals'] = np.zeros((k_new, p))
+                data['c_vals'] = np.zeros((k_new, 0))
             # Randomly generate k_new new points
             for i in range(k_new):
                 data['x_vals'][i, :] = np.random.random_sample(self.n) \
                                        * (self.ub[:] - self.lb[:]) + self.lb[:]
-                data['f_vals'][i, :] = self.objectives(data['x_vals'][i, :])
-                data['c_vals'][i, :] = self.constraints(data['x_vals'][i, :])
+                data['f_vals'][i, :] = self.penalty_func(data['x_vals'][i, :])
             # Update the PF
             nondom = updatePF(data, nondom)
             k += k_new
         # Use acquisition functions to extract array of results
         results = []
         for acq in self.acquisitions:
-            imin = np.argmin(np.asarray([acq.scalarize(fi)
-                                         for fi in nondom['f_vals']]))
+            f_vals = []
+            if acq.useSD():
+                f_vals = [acq.scalarize(fi, xi, self.simulations(xi),
+                                        self.sim_sd(xi))
+                          for fi, xi in zip(nondom['f_vals'],
+                                            nondom['x_vals'])]
+            else:
+                m = self.simulations(nondom['x_vals'][0]).size
+                f_vals = [acq.scalarize(fi, xi, self.simulations(xi),
+                                        np.zeros(m))
+                          for fi, xi in zip(nondom['f_vals'],
+                                            nondom['x_vals'])]
+            imin = np.argmin(np.asarray([f_vals]))
             results.append(nondom['x_vals'][imin, :])
         return np.asarray(results)
diff --git a/parmoo/simulations/dtlz.py b/parmoo/simulations/dtlz.py
index ad4b873..1d24e60 100644
--- a/parmoo/simulations/dtlz.py
+++ b/parmoo/simulations/dtlz.py
@@ -1,12 +1,21 @@
 """ This module contains simulation function implementations of the DTLZ test
 suite, as described in:
 
-Deb, Thiele, Laumanns, and Zitzler. "Scalable test problems for
-evolutionary multiobjective optimization" in Evolutionary Multiobjective
-Optimization, Theoretical Advances and Applications, Ch. 6 (pp. 105--145).
-Springer-Verlag, London, UK, 2005. Abraham, Jain, and Goldberg (Eds).
+ * Deb, Thiele, Laumanns, and Zitzler. "Scalable test problems for
+   evolutionary multiobjective optimization" in Evolutionary
+   Multiobjective Optimization, Theoretical Advances and Applications,
+   Ch. 6 (pp. 105--145). Springer-Verlag, London, UK, 2005. Abraham,
+   Jain, and Goldberg (Eds).
 
-One drawback of the original DTLZ problems was that their global minima
+When run with default settings, the outputs of ``dtlz{1-7}_sim`` have
+been confirmed against the outputs from the corresponding problems
+in ``pymoo`` up to 8 decimal places of precision.
+
+ * Blank and Deb. "pymoo: Multi-Objective Optimization in Python."
+   IEEE Access 8 (pp. 89497--89509). 2020.
+
+
+One drawback of the original DTLZ problems is that their global minima
 (Pareto points) always corresponded to design points that satisfy
 
 x_i = 0.5, for i = number of objectives, ..., number of design points
@@ -23,8 +32,8 @@
 solution sets must be configurable offset by a user-specified amount,
 as proposed in:
 
-Chang. Mathematical Software for Multiobjective Optimization Problems.
-Ph.D. dissertation, Virginia Tech, Dept. of Computer Science, 2020.
+ * Chang. Mathematical Software for Multiobjective Optimization Problems.
+   Ph.D. dissertation, Virginia Tech, Dept. of Computer Science, 2020.
 
 For the problems DTLZ8 and DTLZ9, only objective outputs are given
 by the simulation function herein. To fully define the problem, also
@@ -633,10 +642,11 @@ def __call__(self, x):
         # Initialize kernel function
         ker = g2_sim(self.n, self.o, self.offset)
         # Calculate theta values
-        theta = np.zeros(self.o - 1)
-        g2x = ker(xx)
-        for i in range(self.o - 1):
-            theta[i] = np.pi * (1 + 2 * g2x * xx[i]) / (4 * (1 + g2x))
+        theta = np.zeros(self.o)
+        g2x = ker(xx)[0]
+        theta[0] = xx[0]
+        for i in range(1, self.o):
+            theta[i] = (1 + 2 * g2x * xx[i]) / (2 * (1 + g2x))
         # Initialize output array
         fx = np.zeros(self.o)
         fx[:] = (1.0 + g2x)
@@ -707,10 +717,11 @@ def __call__(self, x):
         # Initialize kernel function
         ker = g3_sim(self.n, self.o, self.offset)
         # Calculate theta values
-        theta = np.zeros(self.o - 1)
-        g3x = ker(xx)
-        for i in range(self.o - 1):
-            theta[i] = np.pi * (1 + 2 * g3x * xx[i]) / (4 * (1 + g3x))
+        theta = np.zeros(self.o)
+        g3x = ker(xx)[0]
+        theta[0] = xx[0]
+        for i in range(1, self.o):
+            theta[i] = (1 + 2 * g3x * xx[i]) / (2 * (1 + g3x))
         # Initialize output array
         fx = np.zeros(self.o)
         fx[:] = (1.0 + g3x)
@@ -782,12 +793,11 @@ def __call__(self, x):
         ker = g4_sim(self.n, self.o, self.offset)
         # Initialize first o-1 entries in the output array
         fx = np.zeros(self.o)
-        print(xx)
         fx[:self.o-1] = xx[:self.o-1]
         # Calculate kernel functions
-        gx = 1.0 + ker(xx)
+        gx = 1.0 + ker(xx)[0]
         hx = (-np.sum(xx[:self.o-1] *
-                      (1.0 + np.sin(3.0 * np.pi * xx[:self.o-1]) / gx))
+                      (1.0 + np.sin(3.0 * np.pi * xx[:self.o-1])) / gx)
                       + float(self.o))
         # Calculate the last entry in the output array
         fx[self.o-1] = gx * hx
diff --git a/parmoo/structs.py b/parmoo/structs.py
index d1d14e4..d518e17 100644
--- a/parmoo/structs.py
+++ b/parmoo/structs.py
@@ -14,12 +14,16 @@
 """
 
 from abc import ABC, abstractmethod
+import inspect
+import numpy as np
+from scipy.stats import tstd
 
 
 class AcquisitionFunction(ABC):
     """ ABC describing acquisition functions.
 
-    This class contains two methods:
+    This class contains the following methods:
+     * ``useSD()``
      * ``setTarget(data, constraint_func, history)``
      * ``scalarize(f_vals)``
      * ``scalarizeGrad(f_vals, g_vals)``
@@ -50,7 +54,7 @@ def __init__(self, o, lb, ub, hyperparams):
         """
 
     @abstractmethod
-    def setTarget(self, data, constraint_func, history):
+    def setTarget(self, data, penalty_func, history):
         """ Set a new target value or region for the AcquisitionFunction.
 
         Args:
@@ -67,10 +71,8 @@ def setTarget(self, data, constraint_func, history):
                    Jacobian of the objective function at each
                    point in 'x_vals'.
 
-            constraint_func (function): A function whose components evaluate
-                to zero if an only if no constraint is violated. If a
-                constraint is violated, then constraint_func returns the
-                magnitude of the violation.
+            penalty_func (function): A function of one (x) or two (x, sx)
+                inputs that evaluates all (penalized) objective scores.
 
             history (dict): A persistent dictionary that could be used by
                 the implementation of the AcquisitionFunction to pass data
@@ -82,14 +84,36 @@ def setTarget(self, data, constraint_func, history):
 
         """
 
+    def useSD(self):
+        """ Query whether this method uses uncertainties.
+
+        When False, allows users to shortcut expensive uncertainty
+        computations.
+
+        Default implementation returns True, requiring full uncertainty
+        computation for applicable models.
+
+        """
+
+        return True
+
     @abstractmethod
-    def scalarize(self, f_vals):
+    def scalarize(self, f_vals, x_vals, s_vals_mean, s_vals_sd):
         """ Scalarize a vector-valued function using the AcquisitionFunction.
 
         Args:
             f_vals (np.ndarray): A 1D array specifying a vector of function
                 values to be scalarized.
 
+            x_vals (np.ndarray): A 1D array specifying a vector the design
+                point corresponding to f_vals.
+
+            s_vals_mean (np.ndarray): A 1D array specifying the expected
+                simulation outputs for the x value being scalarized.
+
+            s_vals_sd (np.ndarray): A 1D array specifying the standard
+                deviation for each of the simulation outputs.
+
         Returns:
             float: The scalarized value.
 
@@ -146,7 +170,7 @@ def load(self, filename):
 class GlobalSearch(ABC):
     """ ABC describing global search techniques.
 
-    This class contains two methods.
+    This class contains the following methods.
      * ``startSearch(lb, ub)``
      * ``resumeSearch()``
      * ``save(filename)``
@@ -238,13 +262,15 @@ def load(self, filename):
 class SurrogateFunction(ABC):
     """ ABC describing surrogate functions.
 
-    This class contains three methods.
+    This class contains the following methods.
      * ``fit(x, f)``
      * ``update(x, f)``
-     * ``setCenter(x)``
+     * ``setCenter(x)`` (default implementation provided)
      * ``evaluate(x)``
      * ``gradient(x)``
-     * ``improve(x, global_improv)``
+     * ``stdDev(x)``
+     * ``stdDevGrad(x)``
+     * ``improve(x, global_improv)`` (default implementation provided)
      * ``save(filename)``
      * ``load(filename)``
 
@@ -304,23 +330,27 @@ def update(self, x, f):
 
         """
 
-    @abstractmethod
     def setCenter(self, center):
         """ Set the center for the fit, if this is a local method.
 
+        Default implementation returns the diameter of the design
+        space, resulting in a nonbinding trust region.
+
         Args:
             center (numpy.ndarray): A 1d array containing the center for
                 this local fit.
 
         """
 
+        return max(self.ub - self.lb)
+
     @abstractmethod
     def evaluate(self, x):
         """ Evaluate the surrogate at a design point.
 
         Args:
             x (numpy.ndarray): A 1d array containing the design point at
-                which to the Gaussian RBF should be evaluated.
+                which to the surrogate should be evaluated.
 
         Returns:
             numpy.ndarray: A 1d array containing the predicted objective value
@@ -331,22 +361,63 @@ def evaluate(self, x):
     def gradient(self, x):
         """ Evaluate the gradient of the surrogate at a design point.
 
+        Note: this method need not be implemented when using a derivative
+        free SurrogateOptimization solver.
+
         Args:
             x (numpy.ndarray): A 1d array containing the design point at
-                which the gradient of the RBF should be evaluated.
+                which the gradient of the surrogate should be evaluated.
 
         Returns:
             numpy.ndarray: A 2d array containing the Jacobian matrix of the
-            RBF interpolants at x.
+            surrogate at x.
 
         """
 
         raise NotImplementedError("This class method has not been implemented")
 
-    @abstractmethod
-    def improve(self, x, global_imrpov):
+    def stdDev(self, x):
+        """ Evaluate the standard deviation (uncertainty) of the surrogate at x.
+
+        Note: this method need not be implemented when the acquisition
+        function does not use the model uncertainty.
+
+        Args:
+            x (numpy.ndarray): A 1d array containing the design point at
+                which the standard deviation should be evaluated.
+
+        Returns:
+            numpy.ndarray: A 1d array containing the standard deviation at x.
+
+        """
+
+        raise NotImplementedError("This class method has not been implemented")
+
+    def stdDevGrad(self, x):
+        """ Evaluate the gradient of the standard deviation at x.
+
+        Note: this method need not be implemented when the acquisition
+        function does not use both the model uncertainty and gradient.
+
+        Args:
+            x (numpy.ndarray): A 1d array containing the design point at
+                which the gradient of standard deviation should be evaluated.
+
+        Returns:
+            numpy.ndarray: A 2d array containing the Jacobian matrix of the
+            standard deviation at x.
+
+        """
+
+        raise NotImplementedError("This class method has not been implemented")
+
+    def improve(self, x, global_improv):
         """ Suggests a design to evaluate to improve the surrogate near x.
 
+        A default implementation is given based on random sampling.
+        Re-implement the improve method to overwright the default
+        policy.
+
         Args:
             x (numpy.ndarray): A 1d array containing the design point at
                 which the surrogate should be improved.
@@ -360,6 +431,59 @@ def improve(self, x, global_imrpov):
 
         """
 
+        # Check that the x is legal
+        if not isinstance(x, np.ndarray):
+            raise TypeError("x must be a numpy array")
+        else:
+            if x.size != self.n:
+                raise ValueError("x must have length n")
+            elif (np.any(x < self.lb - self.eps) or
+                  np.any(x > self.ub + self.eps)):
+                raise ValueError("x cannot be infeasible")
+        # Allocate the output array.
+        x_new = np.zeros(self.n)
+        if global_improv:
+            # If global improvement has been specified, randomly select a
+            # point from within the bound constraints.
+            x_new[:] = self.lb[:] + (np.random.random(self.n)
+                                     * (self.ub[:] - self.lb[:]))
+            while any([np.all(np.abs(x_new - xj) < self.eps)
+                       for xj in self.x_vals]):
+                x_new[:] = self.lb[:] + (np.random.random(self.n)
+                                         * (self.ub[:] - self.lb[:]))
+        else:
+            # Find the n+1 closest points to x in the current database
+            diffs = np.asarray([np.abs(x - xj) / self.eps
+                                for xj in self.x_vals])
+            dists = np.asarray([np.amax(dj) for dj in diffs])
+            inds = np.argsort(dists)
+            diffs = diffs[inds]
+            if dists[inds[self.n]] > 1.5:
+                # Calculate the normalized sample standard dev along each axis
+                stddev = np.asarray(tstd(diffs[:self.n+1], axis=0))
+                stddev[:] = np.maximum(stddev, np.ones(self.n))
+                stddev[:] = stddev[:] / np.amin(stddev)
+                # Sample within B(x, dists[inds[self.n]] / stddev)
+                rad = (dists[inds[self.n]] * self.eps) / stddev
+                x_new = np.fmin(np.fmax(2.0 * (np.random.random(self.n) - 0.5)
+                                        * rad[:] + x, self.lb), self.ub)
+                while any([np.all(np.abs(x_new - xj) < self.eps)
+                           for xj in self.x_vals]):
+                    x_new = np.fmin(np.fmax(2.0 *
+                                            (np.random.random(self.n) - 0.5)
+                                            * rad[:] + x, self.lb), self.ub)
+            else:
+                # If the n+1st nearest point is too close, use global_improv.
+                x_new[:] = self.lb[:] + np.random.random(self.n) \
+                           * (self.ub[:] - self.lb[:])
+                # If the nearest point is too close, resample.
+                while any([np.all(np.abs(x_new - xj) < self.eps)
+                           for xj in self.x_vals]):
+                    x_new[:] = self.lb[:] + (np.random.random(self.n)
+                                             * (self.ub[:] - self.lb[:]))
+        # Return the point to be sampled in a 2d array.
+        return np.asarray([x_new])
+
     def save(self, filename):
         """ Save important data from this class so that it can be reloaded.
 
@@ -394,12 +518,13 @@ def load(self, filename):
 class SurrogateOptimizer(ABC):
     """ ABC describing surrogate optimization techniques.
 
-    This class contains three methods.
-     * ``setObjective(obj_func)``
-     * ``setGradient(grad_func)``
-     * ``setConstraints(constraint_func)``
-     * ``addAcquisition(*args)``
-     * ``setReset(reset)``
+    This class contains the following methods.
+     * ``setObjective(obj_func)`` (default implementation provided)
+     * ``setSimulation(sim_func, sd_func)`` (default implementation provided)
+     * ``setConstraints(constraint_func)`` (default implementation provided)
+     * ``setPenalty(penaltyFunc, gradFunc)`` (default implementation provided)
+     * ``setReset(reset)`` (default implementation provided)
+     * ``addAcquisition(*args)`` (default implementation provided)
      * ``solve(x)``
      * ``save(filename)``
      * ``load(filename)``
@@ -427,7 +552,6 @@ def __init__(self, o, lb, ub, hyperparams):
 
         """
 
-    @abstractmethod
     def setObjective(self, obj_func):
         """ Add a vector-valued objective function that will be solved.
 
@@ -437,18 +561,82 @@ def setObjective(self, obj_func):
 
         """
 
-    def setGradient(self, grad_func):
+        # Check whether obj_func() has an appropriate signature
+        if callable(obj_func):
+            if len(inspect.signature(obj_func).parameters) != 1:
+                raise ValueError("obj_func() must accept exactly one input")
+            else:
+                # Add obj_func to the problem
+                self.objectives = obj_func
+        else:
+            raise TypeError("obj_func() must be callable")
+        return
+
+    def setSimulation(self, sim_func, sd_func=None):
+        """ Add a vector-valued simulation function, used to calculate objs.
+
+        Args:
+            sim_func (function): A vector-valued function that can be evaluated
+                to determine the surrogate-predicted simulation outputs.
+
+            sd_func (function): A vector-valued function that can be evaluated
+                to determine the standard deviations of the surrogate
+                predictions.
+
+        """
+
+        # Check whether sim_func() has an appropriate signature
+        if callable(sim_func):
+            if len(inspect.signature(sim_func).parameters) != 1:
+                raise ValueError("sim_func() must accept exactly one input")
+            else:
+                # Add sim_func to the problem
+                self.simulations = sim_func
+        else:
+            raise TypeError("sim_func() must be callable")
+        # Check whether sd_func() has an appropriate signature
+        if sd_func is not None and callable(sd_func):
+            if len(inspect.signature(sd_func).parameters) not in (1, 2):
+                raise ValueError("sd_func() must accept one or 2 inputs")
+            else:
+                # Add sd_func to the problem
+                self.sim_sd = sd_func
+        else:
+            raise TypeError("sd_func() must be callable")
+        return
+
+    def setPenalty(self, penalty_func, grad_func):
         """ Add a matrix-valued gradient function for obj_func.
 
         Args:
+            penalty_func (function): A vector-valued penalized objective
+                that incorporates a penalty for violating constraints.
+
             grad_func (function): A matrix-valued function that can be
                 evaluated to obtain the Jacobian matrix for obj_func.
 
         """
 
-        raise NotImplementedError("This class method has not been implemented")
+        # Check whether grad_func() has an appropriate signature
+        if callable(grad_func):
+            if len(inspect.signature(grad_func).parameters) != 1:
+                raise ValueError("grad_func() must accept exactly one input")
+            else:
+                # Add grad_func to the problem
+                self.gradients = grad_func
+        else:
+            raise TypeError("grad_func() must be callable")
+        # Check whether penalty_func() has an appropriate signature
+        if callable(penalty_func):
+            if len(inspect.signature(penalty_func).parameters) not in [1, 2]:
+                raise ValueError("penalty_func must accept exactly one input")
+            else:
+                # Add Lagrangian to the problem
+                self.penalty_func = penalty_func
+        else:
+            raise TypeError("penalty_func must be callable")
+        return
 
-    @abstractmethod
     def setConstraints(self, constraint_func):
         """ Add a constraint function that will be satisfied.
 
@@ -460,18 +648,18 @@ def setConstraints(self, constraint_func):
 
         """
 
-    @abstractmethod
-    def addAcquisition(self, *args):
-        """ Add an acquisition function for the surrogate optimizer.
-
-        Args:
-            args (AcquisitionFunction): Acquisition functions that are used
-                to scalarize the list of surrogates in order to solve the
-                surrogate optimization problem.
+        # Check whether constraint_func() has an appropriate signature
+        if callable(constraint_func):
+            if len(inspect.signature(constraint_func).parameters) != 1:
+                raise ValueError("constraint_func() must accept exactly one"
+                                 + " input")
+            else:
+                # Add constraint_func to the problem
+                self.constraints = constraint_func
+        else:
+            raise TypeError("constraint_func() must be callable")
+        return
 
-        """
-
-    @abstractmethod
     def setReset(self, reset):
         """ Add a reset function for resetting surrogate updates.
 
@@ -482,6 +670,35 @@ def setReset(self, reset):
 
         """
 
+        # Check whether reset() has an appropriate signature
+        if callable(reset):
+            if len(inspect.signature(reset).parameters) != 1:
+                raise ValueError("reset() must accept exactly one input")
+            else:
+                # Add obj_func to the problem
+                self.resetObjectives = reset
+        else:
+            raise TypeError("reset() must be callable")
+        return
+
+    def addAcquisition(self, *args):
+        """ Add an acquisition function for the surrogate optimizer.
+
+        Args:
+            *args (AcquisitionFunction): Acquisition functions that are used
+                to scalarize the list of objectives in order to solve the
+                surrogate optimization problem.
+
+        """
+
+        # Check for illegal inputs
+        if not all([isinstance(arg, AcquisitionFunction) for arg in args]):
+            raise TypeError("Args must be instances of AcquisitionFunction")
+        # Append all arguments to the acquisitions list
+        for arg in args:
+            self.acquisitions.append(arg)
+        return
+
     @abstractmethod
     def solve(self, x):
         """ Solve the surrogate problem.
diff --git a/parmoo/surrogates/gaussian_proc.py b/parmoo/surrogates/gaussian_proc.py
index 6eb46f7..1b095ce 100644
--- a/parmoo/surrogates/gaussian_proc.py
+++ b/parmoo/surrogates/gaussian_proc.py
@@ -27,7 +27,7 @@ class GaussRBF(SurrogateFunction):
 
     # Slots for the UniformRandom class
     __slots__ = ['m', 'n', 'lb', 'ub', 'x_vals', 'f_vals', 'eps', 'std_dev',
-                 'nugget', 'weights', 'mean']
+                 'nugget', 'weights', 'mean', 'v', 'w']
 
     def __init__(self, m, lb, ub, hyperparams):
         """ Constructor for the GaussRBF class.
@@ -67,6 +67,8 @@ def __init__(self, m, lb, ub, hyperparams):
         self.x_vals = np.zeros((0, self.n))
         self.f_vals = np.zeros((0, self.m))
         self.weights = np.zeros((0, 0))
+        self.v = np.zeros((0, 0))
+        self.w = np.zeros((0, 0))
         # Check for the 'nugget' optional value in hyperparams
         if 'nugget' in hyperparams:
             if isinstance(hyperparams['nugget'], float):
@@ -143,13 +145,15 @@ def fit(self, x, f):
             for i in range(self.x_vals.shape[0]):
                 cov[i, i] = cov[i, i] + self.nugget
         # Get eigenvalue decomp to solve the SPD system with multiple RHS
-        w, v = np.linalg.eigh(cov)
+        self.w = np.zeros(cov.shape[0])
+        self.v = np.zeros(cov.shape)
+        self.w, self.v = np.linalg.eigh(cov)
         # Check the smallest singular value for a bad solution
-        sigma_n = np.min(w)
+        sigma_n = np.min(self.w)
         if sigma_n < 0.00000001:
             for i in range(self.x_vals.shape[0]):
                 cov[i, i] = cov[i, i] + 0.00000001 - sigma_n
-            w, v = np.linalg.eigh(cov)
+            self.w, self.v = np.linalg.eigh(cov)
         # Calculate RHS
         rhs = self.f_vals.copy()
         self.mean[:] = np.sum(rhs, axis=0) / rhs.shape[0]
@@ -158,8 +162,8 @@ def fit(self, x, f):
         # Finish the solve
         self.weights = np.zeros((self.m, rhs.shape[0]))
         for i in range(self.m):
-            tmp = np.dot(v.transpose(), rhs[:, i]) / w[:]
-            self.weights[i, :] = np.dot(v, tmp)
+            tmp = np.dot(self.v.transpose(), rhs[:, i]) / self.w[:]
+            self.weights[i, :] = np.dot(self.v, tmp)
         return
 
     def update(self, x, f):
@@ -204,13 +208,15 @@ def update(self, x, f):
             for i in range(self.x_vals.shape[0]):
                 cov[i, i] = cov[i, i] + self.nugget
         # Get eigenvalue decomp to solve the SPD system with multiple RHS
-        w, v = np.linalg.eigh(cov)
+        self.w = np.zeros(cov.shape[0])
+        self.v = np.zeros(cov.shape)
+        self.w, self.v = np.linalg.eigh(cov)
         # Check the smallest singular value for a bad solution
-        sigma_n = np.min(w)
+        sigma_n = np.min(self.w)
         if sigma_n < 0.00000001:
             for i in range(self.x_vals.shape[0]):
                 cov[i, i] = cov[i, i] + 0.00000001 - sigma_n
-            w, v = np.linalg.eigh(cov)
+            self.w, self.v = np.linalg.eigh(cov)
         # Calculate RHS
         rhs = self.f_vals.copy()
         self.mean[:] = np.sum(rhs, axis=0) / rhs.shape[0]
@@ -219,21 +225,10 @@ def update(self, x, f):
         # Finish the solve
         self.weights = np.zeros((self.m, rhs.shape[0]))
         for i in range(self.m):
-            tmp = np.dot(v.transpose(), rhs[:, i]) / w[:]
-            self.weights[i, :] = np.dot(v, tmp)
+            tmp = np.dot(self.v.transpose(), rhs[:, i]) / self.w[:]
+            self.weights[i, :] = np.dot(self.v, tmp)
         return
 
-    def setCenter(self, center):
-        """ This is a dummy subroutine, that does nothing for this class.
-
-        Returns:
-            float: The max of ub - lb, which could be used as the trust region
-            radius for a local optimizer, i.e., the entire design space.
-
-        """
-
-        return max(self.ub - self.lb)
-
     def evaluate(self, x):
         """ Evaluate the Gaussian RBF at a design point.
 
@@ -289,19 +284,48 @@ def gradient(self, x):
             outs[i, :] = 2.0 * (xi - x) * dists[i] / (self.std_dev ** 2.0)
         return np.dot(self.weights, outs)
 
-    def improve(self, x, global_improv):
-        """ Suggests a design to evaluate to improve the RBF model near x.
+    def stdDev(self, x):
+        """ Evaluate the standard deviation (uncertainty) of the Gaussian RBF at x.
 
         Args:
             x (numpy.ndarray): A 1d array containing the design point at
-                which the RBF should be improved.
+                which the standard deviation should be evaluated.
 
-            global_improv (Boolean): When True, returns a point for global
-                improvement, ignoring the value of x.
+        Returns:
+            numpy.ndarray: A 1d array containing the standard deviation at x.
+
+        """
+
+        # Check that the x is legal
+        if not isinstance(x, np.ndarray):
+            raise TypeError("x must be a numpy array")
+        else:
+            if x.size != self.n:
+                raise ValueError("x must have length n")
+            elif (np.any(x < self.lb - self.eps) or
+                  np.any(x > self.ub + self.eps)):
+                raise ValueError("x cannot be infeasible")
+        # Get vector of Gaussian-transformed distances
+        dists = self.__gaussian(cdist(self.x_vals, [x])).flatten()
+        # Solve using previously factored Kernel matrix
+        stdd_weights = np.zeros(self.v.shape[0])
+        tmp = np.dot(self.v.transpose(), dists) / self.w[:]
+        stdd_weights[:] = np.dot(self.v, tmp)
+        # Evaluate stddev of all m surrogates at x
+        return (np.sqrt(max(self.__gaussian(0.0) -
+                            np.dot(stdd_weights, dists), 0)
+                * np.ones(self.m)))
+
+    def stdDevGrad(self, x):
+        """ Evaluate the gradient of the standard deviation of the GaussRBF at x.
+ 
+        Args:
+            x (numpy.ndarray): A 1d array containing the design point at
+                which the gradient of standard deviation should be evaluated.
 
         Returns:
-            numpy.ndarray: A 2d array containing the list of design points
-            that should be evaluated to improve the RBF models.
+            numpy.ndarray: A 2d array containing the Jacobian matrix of the
+            standard deviation at x.
 
         """
 
@@ -314,49 +338,29 @@ def improve(self, x, global_improv):
             elif (np.any(x < self.lb - self.eps) or
                   np.any(x > self.ub + self.eps)):
                 raise ValueError("x cannot be infeasible")
-        # Allocate the output array.
-        x_new = np.zeros(self.n)
-        if global_improv:
-            # If global improvement has been specified, randomly select a
-            # point from within the bound constraints.
-            x_new[:] = self.lb[:] + (np.random.random(self.n)
-                                     * (self.ub[:] - self.lb[:]))
-            while any([np.all(np.abs(x_new - xj) < self.eps)
-                       for xj in self.x_vals]):
-                x_new[:] = self.lb[:] + (np.random.random(self.n)
-                                         * (self.ub[:] - self.lb[:]))
+        # Get vector of Gaussian-transformed distances
+        dists = self.__gaussian(cdist(self.x_vals, [x])).flatten()
+        # Solve using previously factored Kernel matrix
+        stdd_weights = np.zeros(self.v.shape[0])
+        tmp = np.dot(self.v.transpose(), dists) / self.w[:]
+        stdd_weights[:] = np.dot(self.v, tmp)
+        # Evaluate stddev of all m surrogates at x
+        stdd = np.sqrt(max(self.__gaussian(0.0) - np.dot(stdd_weights, dists),
+                           0))
+        # Evaluate all m gradients at x
+        kgrad = np.zeros((self.x_vals.shape[0], self.n))
+        for i, xi in enumerate(self.x_vals):
+            kgrad[i, :] = 2.0 * (xi - x) * dists[i] / (self.std_dev ** 2.0)
+        # Just return 0 when derivative is undefined (at training points)
+        if stdd < 1.0e-4:
+            result = np.zeros(self.n)
         else:
-            # Find the n+1 closest points to x in the current database
-            diffs = np.asarray([np.abs(x - xj) / self.eps
-                                for xj in self.x_vals])
-            dists = np.asarray([np.amax(dj) for dj in diffs])
-            inds = np.argsort(dists)
-            diffs = diffs[inds]
-            if dists[inds[self.n]] > 1.5:
-                # Calculate the normalized sample std dev along each axis
-                stddev = np.asarray(tstd(diffs[:self.n+1], axis=0))
-                stddev[:] = np.maximum(stddev, np.ones(self.n))
-                stddev[:] = stddev[:] / np.amin(stddev)
-                # Sample within B(x, dists[inds[self.n]] / stddev)
-                rad = (dists[inds[self.n]] * self.eps) / stddev
-                x_new = np.fmin(np.fmax(2.0 * (np.random.random(self.n) - 0.5)
-                                        * rad[:] + x, self.lb), self.ub)
-                while any([np.all(np.abs(x_new - xj) < self.eps)
-                           for xj in self.x_vals]):
-                    x_new = np.fmin(np.fmax(2.0 *
-                                            (np.random.random(self.n) - 0.5)
-                                            * rad[:] + x, self.lb), self.ub)
-            else:
-                # If the n+1st nearest point is too close, use global_improv.
-                x_new[:] = self.lb[:] + np.random.random(self.n) \
-                           * (self.ub[:] - self.lb[:])
-                # If the nearest point is too close, resample.
-                while any([np.all(np.abs(x_new - xj) < self.eps)
-                           for xj in self.x_vals]):
-                    x_new[:] = self.lb[:] + (np.random.random(self.n)
-                                             * (self.ub[:] - self.lb[:]))
-        # Return the point to be sampled in a 2d array.
-        return np.asarray([x_new])
+            result = -np.dot(kgrad.T, stdd_weights).flatten() / stdd
+        # Build Jacobian (all m rows identical)
+        jac = np.zeros((self.m, self.n))
+        for i in range(self.m):
+            jac[i, :] = result[:]
+        return jac
 
     def save(self, filename):
         """ Save important data from this class so that it can be reloaded.
@@ -382,6 +386,8 @@ def save(self, filename):
         gp_state['eps'] = self.eps.tolist()
         gp_state['weights'] = self.weights.tolist()
         gp_state['mean'] = self.mean.tolist()
+        gp_state['v'] = self.v.tolist()
+        gp_state['w'] = self.w.tolist()
         # Save file
         with open(filename, 'w') as fp:
             json.dump(gp_state, fp)
@@ -414,6 +420,8 @@ def load(self, filename):
         self.eps = np.array(gp_state['eps'])
         self.weights = np.array(gp_state['weights'])
         self.mean = np.array(gp_state['mean'])
+        self.v = np.array(gp_state['v'])
+        self.w = np.array(gp_state['w'])
         return
 
 
@@ -428,7 +436,7 @@ class LocalGaussRBF(SurrogateFunction):
     # Slots for the UniformRandom class
     __slots__ = ['m', 'n', 'lb', 'ub', 'x_vals', 'f_vals', 'eps', 'std_dev',
                  'nugget', 'n_loc', 'loc_inds', 'tr_center', 'weights',
-                 'mean']
+                 'mean', 'v', 'w']
 
     def __init__(self, m, lb, ub, hyperparams):
         """ Constructor for the LocalGaussRBF class.
@@ -468,6 +476,8 @@ def __init__(self, m, lb, ub, hyperparams):
         self.f_vals = np.zeros((0, self.m))
         self.weights = np.zeros((0, 0))
         self.mean = np.zeros(self.m)
+        self.v = np.zeros((0, 0))
+        self.w = np.zeros((0, 0))
         # Initialize trust-region settings
         self.tr_center = np.zeros(0)
         self.loc_inds = []
@@ -626,13 +636,15 @@ def setCenter(self, center):
                 for i in range(len(self.loc_inds)):
                     cov[i, i] = cov[i, i] + self.nugget
             # Get eigenvalue decomp to solve the SPD system with multiple RHS
-            w, v = np.linalg.eigh(cov)
+            self.w = np.zeros(cov.shape[0])
+            self.v = np.zeros(cov.shape)
+            self.w, self.v = np.linalg.eigh(cov)
             # Check the smallest singular value for a bad solution
-            sigma_n = np.min(w)
+            sigma_n = np.min(self.w)
             if sigma_n < 0.00000001:
                 for i in range(len(self.loc_inds)):
                     cov[i, i] = cov[i, i] + 0.00000001 - sigma_n
-                w, v = np.linalg.eigh(cov)
+                self.w, self.v = np.linalg.eigh(cov)
             # Calculate RHS
             rhs = self.f_vals[self.loc_inds, :].copy()
             self.mean[:] = np.sum(rhs, axis=0) / rhs.shape[0]
@@ -641,8 +653,8 @@ def setCenter(self, center):
             # Finish the solve
             self.weights = np.zeros((self.m, rhs.shape[0]))
             for i in range(self.m):
-                tmp = np.dot(v.transpose(), rhs[:, i]) / w[:]
-                self.weights[i, :] = np.dot(v, tmp)
+                tmp = np.dot(self.v.transpose(), rhs[:, i]) / self.w[:]
+                self.weights[i, :] = np.dot(self.v, tmp)
         return self.std_dev
 
     def evaluate(self, x):
@@ -702,6 +714,86 @@ def gradient(self, x):
             outs[i, :] = 2.0 * (xi - x) * dists[i] / (self.std_dev ** 2.0)
         return np.dot(self.weights, outs)
 
+    def stdDev(self, x):
+        """ Evaluate the standard deviation (uncertainty) of the Gaussian RBF at x.
+
+        Args:
+            x (numpy.ndarray): A 1d array containing the design point at
+                which the standard deviation should be evaluated.
+
+        Returns:
+            numpy.ndarray: A 1d array containing the standard deviation at x.
+
+        """
+
+        # Check that the x is legal
+        if not isinstance(x, np.ndarray):
+            raise TypeError("x must be a numpy array")
+        else:
+            if x.size != self.n:
+                raise ValueError("x must have length n")
+            elif (np.any(x < self.lb - self.eps) or
+                  np.any(x > self.ub + self.eps)):
+                raise ValueError("x cannot be infeasible")
+        # Get vector of Gaussian-transformed distances
+        dists = self.__gaussian(cdist(self.x_vals[self.loc_inds],
+                                [x])).flatten()
+        # Solve using previously factored Kernel matrix
+        stdd_weights = np.zeros(self.v.shape[0])
+        tmp = np.dot(self.v.transpose(), dists) / self.w[:]
+        stdd_weights[:] = np.dot(self.v, tmp)
+        # Evaluate standard deviation of all m surrogates at x
+        return (np.sqrt(max(self.__gaussian(0.0) -
+                            np.dot(stdd_weights, dists), 0)
+                * np.ones(self.m)))
+
+    def stdDevGrad(self, x):
+        """ Evaluate the gradient of the standard deviation of the GaussRBF at x.
+ 
+        Args:
+            x (numpy.ndarray): A 1d array containing the design point at
+                which the gradient of standard deviation should be evaluated.
+
+        Returns:
+            numpy.ndarray: A 2d array containing the Jacobian matrix of the
+            standard deviation at x.
+
+        """
+
+        # Check that the x is legal
+        if not isinstance(x, np.ndarray):
+            raise TypeError("x must be a numpy array")
+        else:
+            if x.size != self.n:
+                raise ValueError("x must have length n")
+            elif (np.any(x < self.lb - self.eps) or
+                  np.any(x > self.ub + self.eps)):
+                raise ValueError("x cannot be infeasible")
+        # Get vector of Gaussian-transformed distances
+        dists = self.__gaussian(cdist(self.x_vals[self.loc_inds],
+                                      [x])).flatten()
+        # Solve using previously factored Kernel matrix
+        stdd_weights = np.zeros(self.v.shape[0])
+        tmp = np.dot(self.v.transpose(), dists) / self.w[:]
+        stdd_weights[:] = np.dot(self.v, tmp)
+        # Evaluate standard deviation of all m surrogates at x
+        stdd = np.sqrt(max(self.__gaussian(0.0) - np.dot(stdd_weights, dists),
+                           0))
+        # Evaluate all m gradients at x
+        kgrad = np.zeros((self.v.shape[0], self.n))
+        for i, xi in enumerate(self.x_vals[self.loc_inds]):
+            kgrad[i, :] = 2.0 * (xi - x) * dists[i] / (self.std_dev ** 2.0)
+        # Just return 0 when derivative is undefined (at training points)
+        if stdd < 1.0e-4:
+            result = np.zeros(self.n)
+        else:
+            result = -np.dot(kgrad.T, stdd_weights).flatten() / stdd
+        # Build Jacobian (all m rows identical)
+        jac = np.zeros((self.m, self.n))
+        for i in range(self.m):
+            jac[i, :] = result[:]
+        return jac
+
     def improve(self, x, global_improv):
         """ Suggests a design to evaluate to improve the RBF model near x.
 
@@ -746,7 +838,7 @@ def improve(self, x, global_improv):
             inds = np.argsort(dists)
             diffs = diffs[inds]
             if dists[inds[self.n_loc - 1]] > 1.5:
-                # Calculate the normalized sample std dev along each axis
+                # Calculate the normalized sample stddev along each axis
                 stddev = np.asarray(tstd(diffs[:self.n_loc], axis=0))
                 stddev[:] = np.maximum(stddev, np.ones(self.n))
                 stddev[:] = stddev[:] / np.amin(stddev)
@@ -798,6 +890,8 @@ def save(self, filename):
         gp_state['tr_center'] = self.tr_center.tolist()
         gp_state['weights'] = self.weights.tolist()
         gp_state['mean'] = self.mean.tolist()
+        gp_state['v'] = self.v.tolist()
+        gp_state['w'] = self.w.tolist()
         # Save file
         with open(filename, 'w') as fp:
             json.dump(gp_state, fp)
@@ -833,4 +927,6 @@ def load(self, filename):
         self.tr_center = np.array(gp_state['tr_center'])
         self.weights = np.array(gp_state['weights'])
         self.mean = np.array(gp_state['mean'])
+        self.v = np.array(gp_state['v'])
+        self.w = np.array(gp_state['w'])
         return
diff --git a/parmoo/version.py b/parmoo/version.py
index 020ed73..0404d81 100644
--- a/parmoo/version.py
+++ b/parmoo/version.py
@@ -1,1 +1,1 @@
-__version__ = '0.2.2'
+__version__ = '0.3.0'
diff --git a/setup.py b/setup.py
index b60a690..45705a8 100644
--- a/setup.py
+++ b/setup.py
@@ -80,7 +80,7 @@ def run_tests(self):
     tests_require=["pytest", "pytest-cov", "flake8"],
 
     extras_require={
-        'extras': ["libensemble", "plotly", "dash", "kaleido"],
+        'extras': ["libensemble==0.9.3", "plotly", "dash", "kaleido"],
         'docs': ["sphinx", "sphinxcontrib.bibtex", "sphinx_rtd_theme"]},
 
     classifiers=[


Test patch:
diff --git a/parmoo/tests/libe_tests/test_libe_gen.py b/parmoo/tests/libe_tests/test_libe_gen.py
index 20d23c0..526113b 100644
--- a/parmoo/tests/libe_tests/test_libe_gen.py
+++ b/parmoo/tests/libe_tests/test_libe_gen.py
@@ -51,7 +51,7 @@
         moop.addAcquisition({'acquisition': RandomConstraint})
     
     # Solve
-    moop.solve()
+    moop.solve(sim_max=200)
     assert(moop.getObjectiveData()['x_vals'].shape[0] == 200)
     
     #print(moop.getPF())
@@ -80,5 +80,5 @@
         moop.addAcquisition({'acquisition': RandomConstraint})
     
     # Solve
-    moop.solve()
+    moop.solve(sim_max=200)
     assert(moop.getObjectiveData()['x1'].shape[0] == 200)
diff --git a/parmoo/tests/regression_tests/test_dtlz2_sim.py b/parmoo/tests/regression_tests/test_dtlz2_sim.py
index d0fdfa5..74f3780 100644
--- a/parmoo/tests/regression_tests/test_dtlz2_sim.py
+++ b/parmoo/tests/regression_tests/test_dtlz2_sim.py
@@ -57,8 +57,8 @@
 for i in range(10):
     moop.addAcquisition({'acquisition': RandomConstraint, 'hyperparams': {}})
 
-# Solve the problem with 5 iterations
-moop.solve(5)
+# Solve the problem with the equivalent of 5 iterations
+moop.solve(sim_max=150)
 
 # Check that 150 simulations were evaluated
 assert(moop.getObjectiveData()['x_vals'].shape[0] == 150)
diff --git a/parmoo/tests/unit_tests/test_epsilon_constraint.py b/parmoo/tests/unit_tests/test_epsilon_constraint.py
index a8fcc52..08a5f22 100644
--- a/parmoo/tests/unit_tests/test_epsilon_constraint.py
+++ b/parmoo/tests/unit_tests/test_epsilon_constraint.py
@@ -42,7 +42,7 @@ def obj_f(x):
     with pytest.raises(TypeError):
         acqu.setTarget(data, 5, {})
     with pytest.raises(ValueError):
-        acqu.setTarget(data, lambda x, y: np.zeros(0), {})
+        acqu.setTarget(data, lambda x, y, z: np.zeros(0), {})
     with pytest.raises(ValueError):
         acqu.setTarget({}, lambda x: np.ones(1), {})
     with pytest.raises(ValueError):
@@ -59,9 +59,9 @@ def obj_f(x):
     assert (acqu.setTarget(data, lambda x: np.zeros(3), {}) in data['x_vals'])
     # Try some bad scalarizations to test error handling
     with pytest.raises(TypeError):
-        acqu.scalarize(5)
+        acqu.scalarize(5, 1, 2, 3)
     with pytest.raises(ValueError):
-        acqu.scalarize(np.ones(2))
+        acqu.scalarize(np.ones(2), np.ones(2), np.ones(2), np.ones(2))
     # Generate a random scalarization target and check the scalarization
     acqu = RandomConstraint(3, np.zeros(3), np.ones(3), {})
     acqu.setTarget({'x_vals': None, 'f_vals': None},
@@ -71,8 +71,8 @@ def obj_f(x):
     pf = updatePF(data, {})
     # Check that the scalar value is either less than the sum of fi or bad
     for fi in pf['f_vals']:
-        assert (acqu.scalarize(fi) <= np.sum(fi) or
-                np.any(fi > acqu.f_ub))
+        assert (acqu.scalarize(fi, np.zeros(3), np.zeros(3), np.zeros(3))
+                <= np.sum(fi) or np.any(fi > acqu.f_ub))
     # Try some bad gradient scalarizations to test error handling
     with pytest.raises(TypeError):
         acqu.scalarizeGrad(5, np.zeros((3, 4))[0])
@@ -90,5 +90,111 @@ def obj_f(x):
     return
 
 
+def test_EI_RandomConstraint():
+    """ Test the EI_RandomConstraint class in acquisitions.py.
+
+    Use the EI_RandomConstraint class to randomly select a target point and
+    improvement direction, and check that exactly one point was targeted.
+
+    """
+
+    from parmoo.acquisitions import EI_RandomConstraint
+    from parmoo.util import updatePF
+    import numpy as np
+    import pytest
+
+    # Define the objective function
+    def obj_f(x):
+        return np.asarray([np.dot(x - np.eye(3)[i],
+                                  x - np.eye(3)[i]) for i in range(3)])
+
+    # Generate a database
+    x_vals = np.random.random_sample((10, 3))
+    data = {'x_vals': x_vals,
+            'f_vals': np.asarray([obj_f(x) for x in x_vals]),
+            'c_vals': np.ones((10, 1))}
+
+    # Initialize a good instance
+    acqu = EI_RandomConstraint(3, np.zeros(3), np.ones(3), {})
+    # Try some bad targets to test error handling
+    with pytest.raises(TypeError):
+        acqu.setTarget(5, lambda x: np.zeros(1), {})
+    with pytest.raises(AttributeError):
+        acqu.setTarget({'x_vals': []}, lambda x: np.zeros(0), {})
+    with pytest.raises(ValueError):
+        acqu.setTarget({'x_vals': np.ones(1), 'f_vals': np.ones(2)},
+                       lambda x: np.zeros(0), {})
+    with pytest.raises(ValueError):
+        acqu.setTarget({'x_vals': np.ones((1, 1)), 'f_vals': np.ones((1, 3))},
+                       lambda x: np.zeros(0), {})
+    with pytest.raises(ValueError):
+        acqu.setTarget({'x_vals': np.ones((1, 3)), 'f_vals': np.ones((1, 1))},
+                       lambda x: np.zeros(0), {})
+    with pytest.raises(TypeError):
+        acqu.setTarget(data, 5, {})
+    with pytest.raises(ValueError):
+        acqu.setTarget(data, lambda x, y, z: np.zeros(0), {})
+    with pytest.raises(ValueError):
+        acqu.setTarget({}, lambda x: np.ones(1), {})
+    with pytest.raises(ValueError):
+        acqu.setTarget(data, lambda x: np.ones(1), {})
+    data['c_vals'] = np.zeros((10, 1))
+    # Set a few good target
+    assert (np.all(acqu.setTarget({}, lambda x, sx=0: np.zeros(3), {}) < 1.0))
+    assert (np.all(acqu.setTarget({}, lambda x, sx=0: np.zeros(3), {}) > 0.0))
+    assert (np.all(acqu.setTarget({'x_vals': np.zeros((1, 3)),
+                                   'f_vals': np.zeros((1, 3)),
+                                   'c_vals': np.zeros((1, 1))},
+                                  lambda x, sx=0: np.ones(3) * (0.01 - sum(x)),
+                                  {}) < 1.0))
+    assert (acqu.setTarget(data, lambda x, sx=0: np.zeros(3), {})
+            in data['x_vals'])
+    # Try some bad scalarizations to test error handling
+    with pytest.raises(TypeError):
+        acqu.scalarize(5, 1, 2, 3)
+    with pytest.raises(ValueError):
+        acqu.scalarize(np.ones(2), np.ones(2), np.ones(2), np.ones(2))
+    # Generate a random 1D scalarization target and check the scalarization
+    acqu = EI_RandomConstraint(3, np.zeros(3), np.ones(3), {})
+    acqu.setTarget({'x_vals': None, 'f_vals': None},
+                   lambda x, sx=0: np.zeros(3), {})
+    acqu.setTarget(data, lambda x, sx=0: np.zeros(3), {})
+    # Get a copy of the Pareto front for checking correctness
+    pf = updatePF(data, {})
+    # Check that the scalar value is either less than the sum of fi or bad
+    for fi in pf['f_vals']:
+        assert (acqu.scalarize(fi, np.zeros(3), np.zeros(1), np.ones(1))
+                <= np.sum(fi) or np.any(fi > acqu.f_ub))
+    # Generate a random 2D scalarization target and check the scalarization
+    acqu = EI_RandomConstraint(3, np.zeros(3), np.ones(3), {})
+    acqu.setTarget({'x_vals': None, 'f_vals': None},
+                   lambda x, sx=0: np.zeros(3), {})
+    acqu.setTarget(data, lambda x, sx=0: np.zeros(3), {})
+    # Get a copy of the Pareto front for checking correctness
+    pf = updatePF(data, {})
+    # Check that the scalar value is either less than the sum of fi or bad
+    for fi in pf['f_vals']:
+        assert (acqu.scalarize(fi, np.zeros(3), np.zeros(2), np.ones(2))
+                <= np.sum(fi) or np.any(fi > acqu.f_ub))
+    # Generate a random 3D scalarization target and check the scalarization
+    acqu = EI_RandomConstraint(3, np.zeros(3), np.ones(3), {})
+    acqu.setTarget({'x_vals': None, 'f_vals': None},
+                   lambda x, sx=0: np.zeros(3), {})
+    acqu.setTarget(data, lambda x, sx=0: np.zeros(3), {})
+    # Get a copy of the Pareto front for checking correctness
+    pf = updatePF(data, {})
+    # Check that the scalar value is either less than the sum of fi or bad
+    for fi in pf['f_vals']:
+        assert (acqu.scalarize(fi, np.zeros(3), np.zeros(3), np.ones(3))
+                <= np.sum(fi) or np.any(fi > acqu.f_ub))
+    # Check that the scalar grad is either less than the sum of fi or bad
+    fi = np.random.random_sample(3)
+    gi = np.random.random_sample((3, 3))
+    with pytest.raises(NotImplementedError):
+        acqu.scalarizeGrad(fi, gi)
+    return
+
+
 if __name__ == "__main__":
     test_RandomConstraint()
+    test_EI_RandomConstraint()
diff --git a/parmoo/tests/unit_tests/test_gaussian_proc.py b/parmoo/tests/unit_tests/test_gaussian_proc.py
index 3341a15..11e24fe 100644
--- a/parmoo/tests/unit_tests/test_gaussian_proc.py
+++ b/parmoo/tests/unit_tests/test_gaussian_proc.py
@@ -84,16 +84,22 @@ def test_GaussRBF():
     # Check that the RBFs match on a random evaluation point
     x = np.random.random_sample((3))
     assert (all(rbf1.evaluate(x) == rbf2.evaluate(x)))
+    assert (all(rbf1.stdDev(x) == rbf2.stdDev(x)))
     # Check that the RBFs interpolate, up to 8 decimal digits of precision
     for i in range(x_vals_full.shape[0]):
         assert (np.linalg.norm(rbf1.evaluate(x_vals_full[i])-y_vals_full[i])
                 < 0.00000001)
         assert (np.linalg.norm(rbf2.evaluate(x_vals_full[i])-y_vals_full[i])
                 < 0.00000001)
+        assert (np.max(rbf1.stdDev(x_vals_full[i])) < 1.0e-4)
+        assert (np.max(rbf2.stdDev(x_vals_full[i])) < 1.0e-4)
     # Check that the RBFs compute the same grad, up to 8 digits of precision
     for i in range(x_vals_full.shape[0]):
         assert (np.linalg.norm(rbf1.gradient(x_vals_full[i]) -
                                rbf2.gradient(x_vals_full[i])) < 0.00000001)
+    for i in range(x_vals_full.shape[0]):
+        assert (np.linalg.norm(rbf1.stdDevGrad(x_vals_full[i]) -
+                               rbf2.stdDevGrad(x_vals_full[i])) < 1.0e-4)
     # Check that the RBF gradient evaluates correctly on a known dataset
     x_vals3 = np.eye(3)
     x_vals3 = np.append(x_vals3, [[0.5, 0.5, 0.5]], axis=0)
@@ -104,6 +110,10 @@ def test_GaussRBF():
     y_grad_vals3 = -0.03661401 * np.ones((1, 3))
     assert (np.linalg.norm(rbf3.gradient(x_vals3[-1]) - y_grad_vals3[-1])
             < 1.0e-4)
+    # Check standard deviation calculations
+    xi = np.random.random_sample(3)
+    assert (np.all(rbf3.stdDev(xi) >= 0))
+    assert (np.any(rbf3.stdDevGrad(xi) != 0))
     # Check that the RBF generates feasible local improvement points
     for i in range(4):
         x_improv = rbf3.improve(np.zeros(3), False)
@@ -164,6 +174,25 @@ def test_GaussRBF():
     xx = np.random.random_sample(3)
     assert (np.all(rbf6.evaluate(xx) == rbf7.evaluate(xx)))
     os.remove("parmoo.surrogate")
+    # Generate a simple 1D RBF and check its stdDev and stdDevGrad are accurate
+    x_vals4 = np.array([[0], [1]])
+    y_vals4 = np.array([[1], [1]])
+    rbf8 = GaussRBF(1, np.zeros(1), np.ones(1), {})
+    rbf8.fit(x_vals4, y_vals4)
+    assert (np.linalg.norm(rbf8.evaluate(np.array([0.5])) - 1.0) < 1.0e-8)
+    assert (np.linalg.norm(rbf8.stdDev(np.array([0.5]))) > 1.0e-1)
+    assert (np.linalg.norm(rbf8.gradient(np.array([0.5]))) < 1.0e-8)
+    assert (np.linalg.norm(rbf8.stdDevGrad(np.array([0.5]))) < 1.0e-4)
+    xx = np.linspace(0, 1).reshape((50, 1))
+    maxind = 0
+    for i, xi in enumerate(xx):
+        if np.all(rbf8.stdDev(xi) > rbf8.stdDev(xx[maxind])):
+            maxind = i
+        if i < 25:
+            assert (np.all(rbf8.stdDevGrad(xi) >= 0))
+        else:
+            assert (np.all(rbf8.stdDevGrad(xi) <= 0))
+    assert (maxind in [24, 25])
     return
 
 
@@ -265,6 +294,7 @@ def test_LocalGaussRBF():
     # Check that the RBFs match on a random evaluation point
     x = np.random.random_sample((3))
     assert (all(rbf1.evaluate(x) == rbf2.evaluate(x)))
+    assert (all(rbf1.stdDev(x) == rbf2.stdDev(x)))
     # Check that the RBFs interpolate, up to 8 decimal digits of precision
     for i in range(x_vals_full.shape[0]):
         rbf1.setCenter(x_vals_full[i])
@@ -273,12 +303,16 @@ def test_LocalGaussRBF():
                 < 0.00000001)
         assert (np.linalg.norm(rbf2.evaluate(x_vals_full[i])-y_vals_full[i])
                 < 0.00000001)
+        assert (np.linalg.norm(rbf1.stdDev(x_vals_full[i]) < 1.0e-4))
+        assert (np.linalg.norm(rbf2.stdDev(x_vals_full[i]) < 1.0e-4))
     # Check that the RBFs compute the same grad, up to 8 digits of precision
     rbf1.setCenter(0.5 * np.ones(3))
     rbf2.setCenter(0.5 * np.ones(3))
     for i in range(x_vals_full.shape[0]):
         assert (np.linalg.norm(rbf1.gradient(x_vals_full[i]) -
                                rbf2.gradient(x_vals_full[i])) < 0.00000001)
+        assert (np.linalg.norm(rbf1.stdDevGrad(x_vals_full[i]) -
+                               rbf2.stdDevGrad(x_vals_full[i])) < 1.0e-4)
     # Check that the RBF gradient evaluates correctly on a known dataset
     x_vals3 = np.eye(3)
     x_vals3 = np.append(x_vals3, [[0.5, 0.5, 0.5]], axis=0)
@@ -289,6 +323,11 @@ def test_LocalGaussRBF():
     y_grad_vals3 = -0.08798618 * np.ones((1, 3))
     assert (np.linalg.norm(rbf3.gradient(x_vals3[-1]) - y_grad_vals3[-1])
             < 1.0e-4)
+    assert (np.linalg.norm(rbf3.stdDevGrad(x_vals3[-1]) >= 0))
+    # Check standard deviation calculations
+    xi = np.random.random_sample(3)
+    assert (np.all(rbf3.stdDev(xi) >= 0))
+    assert (np.any(rbf3.stdDevGrad(xi) != 0))
     # Check that the RBF generates feasible local improvement points
     for i in range(4):
         x_improv = rbf3.improve(np.zeros(3), False)
@@ -355,6 +394,26 @@ def test_LocalGaussRBF():
     xx = np.random.random_sample(3)
     assert (np.all(rbf6.evaluate(xx) == rbf7.evaluate(xx)))
     os.remove("parmoo.surrogate")
+    # Generate a simple 1D RBF and check its stdDev and stdDevGrad are accurate
+    x_vals4 = np.array([[0], [1]])
+    y_vals4 = np.array([[1], [1]])
+    rbf8 = LocalGaussRBF(1, np.zeros(1), np.ones(1), {})
+    rbf8.fit(x_vals4, y_vals4)
+    rbf8.setCenter(np.array([0.5]))
+    assert (np.linalg.norm(rbf8.evaluate(np.array([0.5])) - 1.0) < 1.0e-8)
+    assert (np.linalg.norm(rbf8.stdDev(np.array([0.5]))) > 1.0e-1)
+    assert (np.linalg.norm(rbf8.gradient(np.array([0.5]))) < 1.0e-8)
+    assert (np.linalg.norm(rbf8.stdDevGrad(np.array([0.5]))) < 1.0e-4)
+    xx = np.linspace(0, 1).reshape((50, 1))
+    maxind = 0
+    for i, xi in enumerate(xx):
+        if np.all(rbf8.stdDev(xi) > rbf8.stdDev(xx[maxind])):
+            maxind = i
+        if i < 25:
+            assert (np.all(rbf8.stdDevGrad(xi) >= 0))
+        else:
+            assert (np.all(rbf8.stdDevGrad(xi) <= 0))
+    assert (maxind in [24, 25])
     return
 
 
diff --git a/parmoo/tests/unit_tests/test_gps_search.py b/parmoo/tests/unit_tests/test_gps_search.py
index 0be25da..141d8a8 100644
--- a/parmoo/tests/unit_tests/test_gps_search.py
+++ b/parmoo/tests/unit_tests/test_gps_search.py
@@ -31,7 +31,9 @@ def test_LocalGPS():
     ub = np.ones(n)
     # Create the biobjective function
     def f(z): return np.asarray([-z[0] + z[1] + z[2], z[0] - z[1] + z[2]])
-    def L(z): return np.ones(2)
+    def L(z, sz=1): return f(z) + 5 * (max(0.1 - z[2], 0) + max(z[2] - 0.6, 0))
+    def S(z): return np.ones(2)
+    def SD(z): return np.zeros(2)
     def g(z): return np.ones((2, 3))
     # Create 2 acquisition functions targeting 2 "pure" solutions
     acqu1 = UniformWeights(o, lb, ub, {})
@@ -68,6 +70,7 @@ def g(z): return np.ones((2, 3))
     # Add the correct objective and constraints
     opt.setObjective(f)
     opt.setConstraints(lambda z: np.asarray([0.1 - z[2], z[2] - 0.6]))
+    opt.setSimulation(S, SD)
     opt.setPenalty(L, g)
     opt.addAcquisition(acqu1, acqu2, acqu3)
     opt.setReset(lambda x: 100.0)
@@ -78,8 +81,6 @@ def g(z): return np.ones((2, 3))
         opt.solve(np.zeros((3, n-1)))
     with pytest.raises(ValueError):
         opt.solve(np.zeros((4, n)))
-    with pytest.raises(ValueError):
-        opt.solve(-np.ones((3, n)))
     # Solve the surrogate problem with LocalGPS, starting from the centroid
     x = np.zeros((3, n))
     x[:] = 0.5
@@ -130,7 +131,9 @@ def test_GlobalGPS():
     ub = np.ones(n)
     # Create the biobjective function
     def f(z): return np.asarray([-z[0] + z[1] + z[2], z[0] - z[1] + z[2]])
-    def L(z): return np.ones(2)
+    def L(z, sz=1): return f(z) + 5 * (max(0.1 - z[2], 0) + max(z[2] - 0.6, 0))
+    def S(z): return np.ones(2)
+    def SD(z): return np.zeros(2)
     def g(z): return np.ones((2, 3))
     # Create 2 acquisition functions targeting 2 "pure" solutions
     acqu1 = UniformWeights(o, lb, ub, {})
@@ -177,6 +180,7 @@ def g(z): return np.ones((2, 3))
     # Add the correct objective and constraints
     opt.setObjective(f)
     opt.setConstraints(lambda z: np.asarray([0.1 - z[2], z[2] - 0.6]))
+    opt.setSimulation(S, SD)
     opt.setPenalty(L, g)
     opt.addAcquisition(acqu1, acqu2, acqu3)
     opt.setReset(lambda x: 100.0)
@@ -187,8 +191,6 @@ def g(z): return np.ones((2, 3))
         opt.solve(np.zeros((3, n-1)))
     with pytest.raises(ValueError):
         opt.solve(np.zeros((4, n)))
-    with pytest.raises(ValueError):
-        opt.solve(-np.ones((3, n)))
     # Solve the surrogate problem with GlobalGPS, starting from the centroid
     x = np.zeros((3, n))
     x[:, :] = 0.5
diff --git a/parmoo/tests/unit_tests/test_lbfgsb.py b/parmoo/tests/unit_tests/test_lbfgsb.py
index 8d91df6..f70398b 100644
--- a/parmoo/tests/unit_tests/test_lbfgsb.py
+++ b/parmoo/tests/unit_tests/test_lbfgsb.py
@@ -34,7 +34,11 @@ def test_LBFGSB():
     def f(z):
         return np.asarray([-z[0] + z[1] + z[2], z[0] - z[1] + z[2]])
 
-    def L(z):
+    def S(z): return np.ones(1)
+
+    def SD(z): return np.zeros(1)
+
+    def L(z, sz=1):
         res = np.asarray([-z[0] + z[1] + z[2], z[0] - z[1] + z[2]])
         if z[2] < 0.1:
             res[:] = res[:] + 2.0 * (0.1 - z[2])
@@ -83,7 +87,7 @@ def g(z):
     with pytest.raises(TypeError):
         opt.setPenalty(5, lambda z: np.zeros(1))
     with pytest.raises(ValueError):
-        opt.setPenalty(lambda z1, z2: np.zeros(1), lambda z: np.zeros(1))
+        opt.setPenalty(lambda z1, z2, z3: np.zeros(1), lambda z: np.zeros(1))
     with pytest.raises(TypeError):
         opt.setPenalty(lambda z: np.zeros(1), 5)
     with pytest.raises(ValueError):
@@ -93,6 +97,7 @@ def g(z):
     # Add the correct objective and constraints
     opt.setObjective(f)
     opt.setConstraints(lambda z: np.asarray([0.1 - z[2], z[2] - 0.6]))
+    opt.setSimulation(S, SD)
     opt.setPenalty(L, g)
     opt.addAcquisition(acqu1, acqu2, acqu3)
     opt.setReset(lambda x: 100.0)
@@ -158,7 +163,11 @@ def test_TR_LBFGSB():
     def f(z):
         return np.asarray([-z[0] + z[1] + z[2], z[0] - z[1] + z[2]])
 
-    def L(z):
+    def S(z): return np.ones(1)
+
+    def SD(z): return np.zeros(1)
+
+    def L(z, sz=1):
         res = np.asarray([-z[0] + z[1] + z[2], z[0] - z[1] + z[2]])
         if z[2] < 0.1:
             res[:] = res[:] + 2.0 * (0.1 - z[2])
@@ -207,7 +216,7 @@ def g(z):
     with pytest.raises(TypeError):
         opt.setPenalty(5, lambda z: np.zeros(1))
     with pytest.raises(ValueError):
-        opt.setPenalty(lambda z1, z2: np.zeros(1), lambda z: np.zeros(1))
+        opt.setPenalty(lambda z1, z2, z3: np.zeros(1), lambda z: np.zeros(1))
     with pytest.raises(TypeError):
         opt.setPenalty(lambda z: np.zeros(1), 5)
     with pytest.raises(ValueError):
@@ -221,6 +230,7 @@ def g(z):
     # Add the correct objective and constraints
     opt.setObjective(f)
     opt.setConstraints(lambda z: np.asarray([0.1 - z[2], z[2] - 0.6]))
+    opt.setSimulation(S, SD)
     opt.setPenalty(L, g)
     opt.addAcquisition(acqu1, acqu2, acqu3)
     opt.setReset(lambda x: 100.0)
diff --git a/parmoo/tests/unit_tests/test_libe.py b/parmoo/tests/unit_tests/test_libe.py
index 791f421..d6c8659 100644
--- a/parmoo/tests/unit_tests/test_libe.py
+++ b/parmoo/tests/unit_tests/test_libe.py
@@ -46,14 +46,14 @@ def test_libE_MOOP():
 
     """
 
+    import os
+    import pytest
     from parmoo import MOOP
     from parmoo.searches import LatinHypercube
     from parmoo.surrogates import GaussRBF
     from parmoo.acquisitions import RandomConstraint
     from parmoo.optimizers import LocalGPS
     import numpy as np
-    import os
-    import pytest
 
     try:
         from parmoo.extras.libe import libE_MOOP
@@ -191,10 +191,18 @@ def obj1(x, s): return s[0]
     sys.argv.append("--nworkers")
     sys.argv.append("1")
 
-    # Solve with bad CL args
+    # Solve with unset budget vars
     with pytest.raises(ValueError):
         moop.solve()
 
+    # Solve with bad type
+    with pytest.raises(TypeError):
+        moop.solve(sim_max="100")
+
+    # Solve with bad CL args
+    with pytest.raises(ValueError):
+        moop.solve(sim_max=101)
+
 
 if __name__ == "__main__":
     test_libE_parmoo_persis_gen()
diff --git a/parmoo/tests/unit_tests/test_moop.py b/parmoo/tests/unit_tests/test_moop.py
index 7600921..5cc2626 100644
--- a/parmoo/tests/unit_tests/test_moop.py
+++ b/parmoo/tests/unit_tests/test_moop.py
@@ -536,7 +536,7 @@ def test_MOOP_evaluateSimulation():
 
 
 def test_MOOP_evaluateSurrogates():
-    """ Check that the MOOP class handles evaluating objectives properly.
+    """ Check that the MOOP class handles evaluating surrogate models properly.
 
     Initialize a MOOP object and check that the evaluateSurrogates() function
     works correctly.
@@ -598,29 +598,164 @@ def test_MOOP_evaluateSurrogates():
         moop1.evaluateSurrogates(np.zeros(1))
     # Now do some good evaluations and check the results
     assert (np.linalg.norm(moop1.evaluateSurrogates(np.zeros(3)) -
+                           np.asarray([0.0, np.sqrt(3.0), np.sqrt(0.75)]))
+            < 0.00000001)
+    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([0.5,
+                                                                0.5, 0.5]))
+                           - np.asarray([np.sqrt(0.75), np.sqrt(0.75), 0.0]))
+            < 0.00000001)
+    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([1.0, 0.0,
+                                                                0.0]))
+                           - np.asarray([1.0, np.sqrt(2.0), np.sqrt(0.75)]))
+            < 0.00000001)
+    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([0.0, 1.0,
+                                                                0.0]))
+                           - np.asarray([1.0, np.sqrt(2.0), np.sqrt(0.75)]))
+            < 0.00000001)
+    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([0.0, 0.0,
+                                                                1.0]))
+                           - np.asarray([1.0, np.sqrt(2.0), np.sqrt(0.75)]))
+            < 0.00000001)
+    assert (np.linalg.norm(moop1.evaluateSurrogates(np.ones(3)) -
+                           np.asarray([np.sqrt(3.0), 0.0, np.sqrt(0.75)]))
+            < 0.00000001)
+    assert (np.linalg.norm(moop1.surrogateUncertainty(np.zeros(3)))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.surrogateUncertainty(np.asarray([0.5,
+                                                                  0.5, 0.5])))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.surrogateUncertainty(np.asarray([1.0,
+                                                                  0.0, 0.0])))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.surrogateUncertainty(np.asarray([0.0,
+                                                                  1.0, 0.0])))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.surrogateUncertainty(np.asarray([0.0,
+                                                                  0.0, 1.0])))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.surrogateUncertainty(np.ones(3))) < 1.0e-4)
+    xi = np.random.random_sample(3)
+    assert (np.linalg.norm(moop1.surrogateUncertainty(xi)) > 1.0e-4)
+    # Adjust the scale and try again
+    moop2 = MOOP(LocalGPS)
+    moop2.addDesign({'lb': -1.0, 'ub': 1.0},
+                    {'lb': 0.0, 'ub': 2.0},
+                    {'lb': -0.5, 'ub': 1.5})
+    moop2.addObjective({'obj_func': lambda x, s: x[0]},
+                       {'obj_func': lambda x, s: s[0]},
+                       {'obj_func': lambda x, s: s[1] + s[2]})
+    moop2.addSimulation(g1, g2)
+    # Evaluate some data points and fit the surrogates
+    moop2.evaluateSimulation(np.zeros(3), 0)
+    moop2.evaluateSimulation(np.zeros(3), 1)
+    moop2.evaluateSimulation(np.asarray([0.5, 0.5, 0.5]), 0)
+    moop2.evaluateSimulation(np.asarray([0.5, 0.5, 0.5]), 1)
+    moop2.evaluateSimulation(np.asarray([1.0, 0.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.asarray([1.0, 0.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.asarray([0.0, 1.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.asarray([0.0, 1.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.asarray([0.0, 0.0, 1.0]), 0)
+    moop2.evaluateSimulation(np.asarray([0.0, 0.0, 1.0]), 1)
+    moop2.evaluateSimulation(np.ones(3), 0)
+    moop2.evaluateSimulation(np.ones(3), 1)
+    moop2.fitSurrogates()
+    # Now compare evaluations against the original surrogate
+    x = moop1.__embed__(np.zeros(3))
+    xx = moop2.__embed__(np.zeros(3))
+    assert (np.linalg.norm(moop1.evaluateSurrogates(x) -
+                           moop2.evaluateSurrogates(xx)) < 0.00000001)
+    x = moop1.__embed__(np.ones(3))
+    xx = moop2.__embed__(np.ones(3))
+    assert (np.linalg.norm(moop1.evaluateSurrogates(x) -
+                           moop2.evaluateSurrogates(xx)) < 0.00000001)
+
+
+def test_MOOP_evaluateObjectives():
+    """ Check that the MOOP class handles evaluating objectives properly.
+
+    Initialize a MOOP object and check that the evaluateObjectives() function
+    works correctly.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+    import pytest
+
+    # Create 2 SimGroups for later
+    g1 = {'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x)],
+          'surrogate': GaussRBF}
+    g2 = {'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x-1.0), np.linalg.norm(x-0.5)],
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'obj_func': lambda x, s: x[0]},
+                       {'obj_func': lambda x, s: s[0]},
+                       {'obj_func': lambda x, s: s[1] + s[2]})
+    # Try some bad evaluations
+    with pytest.raises(TypeError):
+        moop1.evaluateSimulation(np.zeros(3), 0.0)
+    with pytest.raises(ValueError):
+        moop1.evaluateSimulation(np.zeros(3), -1)
+    # Evaluate some data points and fit the surrogates
+    moop1.evaluateSimulation(np.zeros(3), 0)
+    moop1.evaluateSimulation(np.zeros(3), 1)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop1.evaluateSimulation(np.ones(3), 0)
+    moop1.evaluateSimulation(np.ones(3), 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Now try some bad evaluations
+    with pytest.raises(TypeError):
+        moop1.evaluateObjectives(10.0)
+    with pytest.raises(ValueError):
+        moop1.evaluateObjectives(np.zeros(1))
+    # Now do some good evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.zeros(3)) -
                            np.asarray([0.0, 0.0, np.sqrt(3.0) +
                                        np.sqrt(0.75)]))
             < 0.00000001)
-    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([0.5,
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.5,
                                                                 0.5, 0.5]))
                            - np.asarray([0.5, np.sqrt(0.75), np.sqrt(0.75)]))
             < 0.00000001)
-    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([1.0, 0.0,
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([1.0, 0.0,
                                                                 0.0]))
                            - np.asarray([1.0, 1.0, np.sqrt(2.0) +
                                          np.sqrt(0.75)]))
             < 0.00000001)
-    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([0.0, 1.0,
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.0, 1.0,
                                                                 0.0]))
                            - np.asarray([0.0, 1.0, np.sqrt(2.0) +
                                          np.sqrt(0.75)]))
             < 0.00000001)
-    assert (np.linalg.norm(moop1.evaluateSurrogates(np.asarray([0.0, 0.0,
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.0, 0.0,
                                                                 1.0]))
                            - np.asarray([0.0, 1.0, np.sqrt(2.0) +
                                          np.sqrt(0.75)]))
             < 0.00000001)
-    assert (np.linalg.norm(moop1.evaluateSurrogates(np.ones(3)) -
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.ones(3)) -
                            np.asarray([1.0, np.sqrt(3.0), np.sqrt(0.75)]))
             < 0.00000001)
     # Adjust the scale and try again
@@ -649,12 +784,12 @@ def test_MOOP_evaluateSurrogates():
     # Now compare evaluations against the original surrogate
     x = moop1.__embed__(np.zeros(3))
     xx = moop2.__embed__(np.zeros(3))
-    assert (np.linalg.norm(moop1.evaluateSurrogates(x) -
-                           moop2.evaluateSurrogates(xx)) < 0.00000001)
+    assert (np.linalg.norm(moop1.evaluateObjectives(x) -
+                           moop2.evaluateObjectives(xx)) < 0.00000001)
     x = moop1.__embed__(np.ones(3))
     xx = moop2.__embed__(np.ones(3))
-    assert (np.linalg.norm(moop1.evaluateSurrogates(x) -
-                           moop2.evaluateSurrogates(xx)) < 0.00000001)
+    assert (np.linalg.norm(moop1.evaluateObjectives(x) -
+                           moop2.evaluateObjectives(xx)) < 0.00000001)
 
 
 def test_MOOP_evaluateConstraints():
@@ -1385,19 +1520,19 @@ def f3(x, sim): return np.linalg.norm(x - np.eye(4)[2, :]) ** 2.0
                  'f_vals': np.zeros((5, 3)),
                  'c_vals': np.zeros((5, 1))}
     moop.data['x_vals'][0, :] = np.asarray([0.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][0, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][0, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][1, :] = np.asarray([1.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][1, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][1, :] = moop.evaluateObjectives(
                                    np.asarray([1.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][2, :] = np.asarray([0.0, 1.0, 0.0, 0.0])
-    moop.data['f_vals'][2, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][2, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 1.0, 0.0, 0.0]))
     moop.data['x_vals'][3, :] = np.asarray([0.0, 0.0, 1.0, 0.0])
-    moop.data['f_vals'][3, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][3, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 1.0, 0.0]))
     moop.data['x_vals'][4, :] = np.asarray([0.0, 0.0, 0.0, 1.0])
-    moop.data['f_vals'][4, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][4, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 1.0]))
     moop.n_dat = 5
     soln = moop.getPF()
@@ -1428,27 +1563,27 @@ def f3(x, sim):
                  'f_vals': np.zeros((5, 3)),
                  'c_vals': np.zeros((5, 1))}
     moop.data['x_vals'][0, :] = np.asarray([0.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][0, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][0, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 0.0]))
     moop.data['c_vals'][0, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][1, :] = np.asarray([1.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][1, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][1, :] = moop.evaluateObjectives(
                                    np.asarray([1.0, 0.0, 0.0, 0.0]))
     moop.data['c_vals'][1, :] = moop.evaluateConstraints(
                                    np.asarray([1.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][2, :] = np.asarray([0.0, 1.0, 0.0, 0.0])
-    moop.data['f_vals'][2, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][2, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 1.0, 0.0, 0.0]))
     moop.data['c_vals'][2, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 1.0, 0.0, 0.0]))
     moop.data['x_vals'][3, :] = np.asarray([0.0, 0.0, 1.0, 0.0])
-    moop.data['f_vals'][3, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][3, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 1.0, 0.0]))
     moop.data['c_vals'][3, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 1.0, 0.0]))
     moop.data['x_vals'][4, :] = np.asarray([0.0, 0.0, 0.0, 1.0])
-    moop.data['f_vals'][4, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][4, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 1.0]))
     moop.data['c_vals'][4, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 0.0, 1.0]))
@@ -1582,27 +1717,27 @@ def f3(x, sim): return np.linalg.norm(x - np.eye(4)[2, :]) ** 2.0
                  'f_vals': np.zeros((5, 3)),
                  'c_vals': np.zeros((5, 1))}
     moop.data['x_vals'][0, :] = np.asarray([0.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][0, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][0, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 0.0]))
     moop.data['c_vals'][0, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][1, :] = np.asarray([1.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][1, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][1, :] = moop.evaluateObjectives(
                                    np.asarray([1.0, 0.0, 0.0, 0.0]))
     moop.data['c_vals'][1, :] = moop.evaluateConstraints(
                                    np.asarray([1.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][2, :] = np.asarray([0.0, 1.0, 0.0, 0.0])
-    moop.data['f_vals'][2, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][2, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 1.0, 0.0, 0.0]))
     moop.data['c_vals'][2, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 1.0, 0.0, 0.0]))
     moop.data['x_vals'][3, :] = np.asarray([0.0, 0.0, 1.0, 0.0])
-    moop.data['f_vals'][3, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][3, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 1.0, 0.0]))
     moop.data['c_vals'][3, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 1.0, 0.0]))
     moop.data['x_vals'][4, :] = np.asarray([0.0, 0.0, 0.0, 1.0])
-    moop.data['f_vals'][4, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][4, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 1.0]))
     moop.data['c_vals'][4, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 0.0, 1.0]))
@@ -1635,27 +1770,27 @@ def f3(x, sim):
                  'f_vals': np.zeros((5, 3)),
                  'c_vals': np.zeros((5, 1))}
     moop.data['x_vals'][0, :] = np.asarray([0.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][0, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][0, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 0.0]))
     moop.data['c_vals'][0, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][1, :] = np.asarray([1.0, 0.0, 0.0, 0.0])
-    moop.data['f_vals'][1, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][1, :] = moop.evaluateObjectives(
                                    np.asarray([1.0, 0.0, 0.0, 0.0]))
     moop.data['c_vals'][1, :] = moop.evaluateConstraints(
                                    np.asarray([1.0, 0.0, 0.0, 0.0]))
     moop.data['x_vals'][2, :] = np.asarray([0.0, 1.0, 0.0, 0.0])
-    moop.data['f_vals'][2, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][2, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 1.0, 0.0, 0.0]))
     moop.data['c_vals'][2, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 1.0, 0.0, 0.0]))
     moop.data['x_vals'][3, :] = np.asarray([0.0, 0.0, 1.0, 0.0])
-    moop.data['f_vals'][3, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][3, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 1.0, 0.0]))
     moop.data['c_vals'][3, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 1.0, 0.0]))
     moop.data['x_vals'][4, :] = np.asarray([0.0, 0.0, 0.0, 1.0])
-    moop.data['f_vals'][4, :] = moop.evaluateSurrogates(
+    moop.data['f_vals'][4, :] = moop.evaluateObjectives(
                                    np.asarray([0.0, 0.0, 0.0, 1.0]))
     moop.data['c_vals'][4, :] = moop.evaluateConstraints(
                                    np.asarray([0.0, 0.0, 0.0, 1.0]))
@@ -1991,6 +2126,7 @@ def check_moops(moop1, moop2):
     test_MOOP_getTypes()
     test_MOOP_evaluateSimulation()
     test_MOOP_evaluateSurrogates()
+    test_MOOP_evaluateObjectives()
     test_MOOP_evaluateConstraints()
     test_MOOP_addData()
     test_MOOP_iterate()
diff --git a/parmoo/tests/unit_tests/test_moop_ev.py b/parmoo/tests/unit_tests/test_moop_ev.py
new file mode 100644
index 0000000..58f4ad3
--- /dev/null
+++ b/parmoo/tests/unit_tests/test_moop_ev.py
@@ -0,0 +1,1675 @@
+
+def test_MOOP_evaluateExpectedValue_unnamed():
+    """ Check that the MOOP class handles evaluating objective EVs properly.
+
+    Initialize a MOOP object and check that the evaluateObjectives() function
+    works correctly for expected values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            return np.eye(x.size)[0]
+        elif der == 2:
+            return np.zeros(s.size)
+        else:
+            return x[0]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * np.eye(ev_s.size)[0] * ev_s[0]
+        elif der == 3:
+            return 2.0 * np.eye(sd_s.size)[0] * sd_s[0]
+        else:
+            return ev_s[0] ** 2 + sd_s[0] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * (np.eye(ev_s.size)[1] * ev_s[1] +
+                          np.eye(ev_s.size)[2] * ev_s[2])
+        elif der == 3:
+            return 2.0 * (np.eye(sd_s.size)[1] * sd_s[1] +
+                          np.eye(sd_s.size)[2] * sd_s[2])
+        else:
+            return ev_s[1] ** 2 + sd_s[1] ** 2 + ev_s[2] ** 2 + sd_s[2] ** 2
+
+    # Create 2 SimGroups for later
+    g1 = {'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x)],
+          'surrogate': GaussRBF}
+    g2 = {'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x-1.0), np.linalg.norm(x-0.5)],
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'obj_func': obj_1},
+                       {'exp_func': ev_1},
+                       {'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    moop1.evaluateSimulation(np.zeros(3), 0)
+    moop1.evaluateSimulation(np.zeros(3), 1)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop1.evaluateSimulation(np.ones(3), 0)
+    moop1.evaluateSimulation(np.ones(3), 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    moop2.addObjective({'obj_func': obj_1},
+                       {'obj_func': lambda x, s, der=0: ev_1(x, s,
+                                                             np.zeros(s.size),
+                                                             der=der)},
+                       {'obj_func': lambda x, s, der=0: ev_2(x, s,
+                                                             np.zeros(s.size),
+                                                             der=der)})
+    # Evaluate some data points and fit the surrogates
+    moop2.evaluateSimulation(np.zeros(3), 0)
+    moop2.evaluateSimulation(np.zeros(3), 1)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop2.evaluateSimulation(np.ones(3), 0)
+    moop2.evaluateSimulation(np.ones(3), 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.zeros(3)) -
+                           np.asarray([0.0, 0.0, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.5,
+                                                                0.5, 0.5]))
+                           - np.asarray([0.5, 0.75, 0.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([1.0, 0.0,
+                                                                0.0]))
+                           - np.asarray([1.0, 1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.0, 1.0,
+                                                                0.0]))
+                           - np.asarray([0.0, 1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.0, 0.0,
+                                                                1.0]))
+                           - np.asarray([0.0, 1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.ones(3)) -
+                           np.asarray([1.0, 3.0, 0.75])) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        assert (np.all(moop1.evaluateObjectives(xi) >=
+                       moop2.evaluateObjectives(xi)))
+
+
+def test_MOOP_evaluateExpectedValuePenalty_unnamed():
+    """ Check that the MOOP class handles evaluating penalized EVs properly.
+
+    Initialize a MOOP object and check that the evaluatePenalty() function
+    works correctly for expected values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            return np.eye(x.size)[0]
+        elif der == 2:
+            return np.zeros(s.size)
+        else:
+            return x[0]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * np.eye(ev_s.size)[0] * ev_s[0]
+        elif der == 3:
+            return 2.0 * np.eye(sd_s.size)[0] * sd_s[0]
+        else:
+            return ev_s[0] ** 2 + sd_s[0] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * (np.eye(ev_s.size)[1] * ev_s[1] +
+                          np.eye(ev_s.size)[2] * ev_s[2])
+        elif der == 3:
+            return 2.0 * (np.eye(sd_s.size)[1] * sd_s[1] +
+                          np.eye(sd_s.size)[2] * sd_s[2])
+        else:
+            return ev_s[1] ** 2 + sd_s[1] ** 2 + ev_s[2] ** 2 + sd_s[2] ** 2
+
+    def c2(x, s, der=0): return ev_2(x, s, np.zeros(s.size), der=der)
+
+    # Create 2 SimGroups for later
+    g1 = {'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x)],
+          'surrogate': GaussRBF}
+    g2 = {'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x-1.0), np.linalg.norm(x-0.5)],
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'obj_func': obj_1},
+                       {'exp_func': ev_1})
+    moop1.addConstraint({'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    moop1.evaluateSimulation(np.zeros(3), 0)
+    moop1.evaluateSimulation(np.zeros(3), 1)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop1.evaluateSimulation(np.ones(3), 0)
+    moop1.evaluateSimulation(np.ones(3), 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    moop2.addObjective({'obj_func': obj_1},
+                       {'obj_func': lambda x, s, der=0: ev_1(x, s,
+                                                             np.zeros(s.size),
+                                                             der=der)})
+    moop2.addConstraint({'constraint': c2})
+    # Evaluate some data points and fit the surrogates
+    moop2.evaluateSimulation(np.zeros(3), 0)
+    moop2.evaluateSimulation(np.zeros(3), 1)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop2.evaluateSimulation(np.ones(3), 0)
+    moop2.evaluateSimulation(np.ones(3), 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.zeros(3)) -
+                           np.asarray([3.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([0.5, 0.5, 0.5]))
+                           - np.asarray([1.25, 1.5])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([1.0, 0.0, 0.0]))
+                           - np.asarray([3.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([0.0, 1.0, 0.0]))
+                           - np.asarray([2.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([0.0, 0.0, 1.0]))
+                           - np.asarray([2.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.ones(3)) -
+                           np.asarray([1.75, 3.75])) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        assert (np.all(moop1.evaluatePenalty(xi) >=
+                       moop2.evaluatePenalty(xi)))
+
+
+def test_MOOP_expectedValueGradient_unnamed():
+    """ Check that the MOOP class evaluates the expected value gradient.
+
+    Initialize a MOOP object and check that the evaluateGradients() function
+    works correctly for objectives.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            return np.eye(x.size)[0]
+        elif der == 2:
+            return np.zeros(s.size)
+        else:
+            return x[0]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * np.eye(ev_s.size)[0] * ev_s[0]
+        elif der == 3:
+            return 2.0 * np.eye(sd_s.size)[0] * sd_s[0]
+        else:
+            return ev_s[0] ** 2 + sd_s[0] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * (np.eye(ev_s.size)[1] * ev_s[1] +
+                          np.eye(ev_s.size)[2] * ev_s[2])
+        elif der == 3:
+            return 2.0 * (np.eye(sd_s.size)[1] * sd_s[1] +
+                          np.eye(sd_s.size)[2] * sd_s[2])
+        else:
+            return ev_s[1] ** 2 + sd_s[1] ** 2 + ev_s[2] ** 2 + sd_s[2] ** 2
+
+    # Create 2 SimGroups for later
+    g1 = {'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x)],
+          'surrogate': GaussRBF}
+    g2 = {'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x-1.0), np.linalg.norm(x-0.5)],
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'obj_func': obj_1},
+                       {'exp_func': ev_1},
+                       {'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    moop1.evaluateSimulation(np.zeros(3), 0)
+    moop1.evaluateSimulation(np.zeros(3), 1)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop1.evaluateSimulation(np.ones(3), 0)
+    moop1.evaluateSimulation(np.ones(3), 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    moop2.addObjective({'obj_func': obj_1},
+                       {'obj_func': lambda x, s, der=0: ev_1(x, s,
+                                                             np.zeros(s.size),
+                                                             der=der)},
+                       {'obj_func': lambda x, s, der=0: ev_2(x, s,
+                                                             np.zeros(s.size),
+                                                             der=der)})
+    # Evaluate some data points and fit the surrogates
+    moop2.evaluateSimulation(np.zeros(3), 0)
+    moop2.evaluateSimulation(np.zeros(3), 1)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop2.evaluateSimulation(np.ones(3), 0)
+    moop2.evaluateSimulation(np.ones(3), 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateGradients(np.zeros(3)) -
+                           moop2.evaluateGradients(np.zeros(3))) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(0.5 * np.ones(3)) -
+                           moop2.evaluateGradients(0.5 * np.ones(3)))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[0]) -
+                           moop2.evaluateGradients(np.eye(3)[0])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[1]) -
+                           moop2.evaluateGradients(np.eye(3)[1])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[2]) -
+                           moop2.evaluateGradients(np.eye(3)[2])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.ones(3)) -
+                           moop2.evaluateGradients(np.ones(3))) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        dsig_dxi = moop1.surrogateUncertainty(xi, grad=True)
+        df_dsig = np.zeros((3, 3))
+        df_dsig[1, :] = ev_1(xi, np.zeros(3),
+                             moop1.surrogateUncertainty(xi), der=3)
+        df_dsig[2, :] = ev_2(xi, np.zeros(3),
+                             moop1.surrogateUncertainty(xi), der=3)
+        assert (np.linalg.norm(moop1.evaluateGradients(xi) -
+                               np.dot(df_dsig, dsig_dxi) -
+                               moop2.evaluateGradients(xi)) < 1.0e-4)
+
+
+def test_MOOP_evaluateConstraintExpectedValue_unnamed():
+    """ Check that the MOOP class handles evaluating constraint EVs properly.
+
+    Initialize a MOOP object and check that the evaluateConstraints() function
+    works correctly for expected-values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            return np.eye(x.size)[0]
+        elif der == 2:
+            return np.zeros(s.size)
+        else:
+            return x[0]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * np.eye(ev_s.size)[0] * ev_s[0]
+        elif der == 3:
+            return 2.0 * np.eye(sd_s.size)[0] * sd_s[0]
+        else:
+            return ev_s[0] ** 2 + sd_s[0] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * (np.eye(ev_s.size)[1] * ev_s[1] +
+                          np.eye(ev_s.size)[2] * ev_s[2])
+        elif der == 3:
+            return 2.0 * (np.eye(sd_s.size)[1] * sd_s[1] +
+                          np.eye(sd_s.size)[2] * sd_s[2])
+        else:
+            return ev_s[1] ** 2 + sd_s[1] ** 2 + ev_s[2] ** 2 + sd_s[2] ** 2
+
+    # Create 2 SimGroups for later
+    g1 = {'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x)],
+          'surrogate': GaussRBF}
+    g2 = {'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x-1.0), np.linalg.norm(x-0.5)],
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'obj_func': obj_1})
+    moop1.addConstraint({'exp_func': ev_1},
+                        {'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    moop1.evaluateSimulation(np.zeros(3), 0)
+    moop1.evaluateSimulation(np.zeros(3), 1)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop1.evaluateSimulation(np.ones(3), 0)
+    moop1.evaluateSimulation(np.ones(3), 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    moop2.addObjective({'obj_func': obj_1})
+    moop2.addConstraint({'constraint': lambda x, s, der=0: ev_1(x, s,
+                                                                np.zeros(3),
+                                                                der=der)},
+                        {'constraint': lambda x, s, der=0: ev_2(x, s,
+                                                                np.zeros(3),
+                                                                der=der)})
+    # Evaluate some data points and fit the surrogates
+    moop2.evaluateSimulation(np.zeros(3), 0)
+    moop2.evaluateSimulation(np.zeros(3), 1)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop2.evaluateSimulation(np.ones(3), 0)
+    moop2.evaluateSimulation(np.ones(3), 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.zeros(3)) -
+                           np.asarray([0.0, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([0.5,
+                                                                 0.5, 0.5]))
+                           - np.asarray([0.75, 0.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([1.0, 0.0,
+                                                                 0.0]))
+                           - np.asarray([1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([0.0, 1.0,
+                                                                 0.0]))
+                           - np.asarray([1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([0.0, 0.0,
+                                                                 1.0]))
+                           - np.asarray([1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.ones(3)) -
+                           np.asarray([3.0, 0.75])) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        assert (np.all(moop1.evaluateConstraints(xi) >=
+                       moop2.evaluateConstraints(xi)))
+
+
+def test_MOOP_expectedValueConstraintGradient_unnamed():
+    """ Check that the MOOP class evaluates the expected value gradient.
+
+    Initialize a MOOP object and check that the evaluateGradients() function
+    works correctly for constraint expected values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            return np.eye(x.size)[0]
+        elif der == 2:
+            return np.zeros(s.size)
+        else:
+            return x[0]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * np.eye(ev_s.size)[0] * ev_s[0]
+        elif der == 3:
+            return 2.0 * np.eye(sd_s.size)[0] * sd_s[0]
+        else:
+            return ev_s[0] ** 2 + sd_s[0] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(x.size)
+        elif der == 2:
+            return 2.0 * (np.eye(ev_s.size)[1] * ev_s[1] +
+                          np.eye(ev_s.size)[2] * ev_s[2])
+        elif der == 3:
+            return 2.0 * (np.eye(sd_s.size)[1] * sd_s[1] +
+                          np.eye(sd_s.size)[2] * sd_s[2])
+        else:
+            return ev_s[1] ** 2 + sd_s[1] ** 2 + ev_s[2] ** 2 + sd_s[2] ** 2
+
+    # Create 2 SimGroups for later
+    g1 = {'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x)],
+          'surrogate': GaussRBF}
+    g2 = {'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': lambda x: [np.linalg.norm(x-1.0), np.linalg.norm(x-0.5)],
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'obj_func': obj_1})
+    moop1.addConstraint({'exp_func': ev_1},
+                        {'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    moop1.evaluateSimulation(np.zeros(3), 0)
+    moop1.evaluateSimulation(np.zeros(3), 1)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop1.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop1.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop1.evaluateSimulation(np.ones(3), 0)
+    moop1.evaluateSimulation(np.ones(3), 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    moop2.addObjective({'obj_func': obj_1})
+    moop2.addConstraint({'constraint': lambda x, s, der=0: ev_1(x, s,
+                                                                np.zeros(3),
+                                                                der=der)},
+                        {'constraint': lambda x, s, der=0: ev_2(x, s,
+                                                                np.zeros(3),
+                                                                der=der)})
+    # Evaluate some data points and fit the surrogates
+    moop2.evaluateSimulation(np.zeros(3), 0)
+    moop2.evaluateSimulation(np.zeros(3), 1)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 0)
+    moop2.evaluateSimulation(np.array([0.5, 0.5, 0.5]), 1)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([1.0, 0.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 1.0, 0.0]), 1)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 0)
+    moop2.evaluateSimulation(np.array([0.0, 0.0, 1.0]), 1)
+    moop2.evaluateSimulation(np.ones(3), 0)
+    moop2.evaluateSimulation(np.ones(3), 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateGradients(np.zeros(3)) -
+                           moop2.evaluateGradients(np.zeros(3))) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(0.5 * np.ones(3)) -
+                           moop2.evaluateGradients(0.5 * np.ones(3)))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[0]) -
+                           moop2.evaluateGradients(np.eye(3)[0])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[1]) -
+                           moop2.evaluateGradients(np.eye(3)[1])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[2]) -
+                           moop2.evaluateGradients(np.eye(3)[2])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.ones(3)) -
+                           moop2.evaluateGradients(np.ones(3))) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        dsig_dxi = moop1.surrogateUncertainty(xi, grad=True)
+        df_dsig = np.zeros((3, 3))
+        df_dsig[1, :] = ev_1(xi, np.zeros(3),
+                             moop1.surrogateUncertainty(xi), der=3)
+        df_dsig[2, :] = ev_2(xi, np.zeros(3),
+                             moop1.surrogateUncertainty(xi), der=3)
+        if all(moop1.evaluateConstraints(xi) > 0) and \
+           all(moop2.evaluateConstraints(xi) > 0):
+            assert (np.linalg.norm(moop1.evaluateGradients(xi) -
+                                   np.sum(np.dot(df_dsig, dsig_dxi), axis=0) -
+                                   moop2.evaluateGradients(xi)) < 1.0e-4)
+
+
+def test_MOOP_evaluateExpectedValue_named():
+    """ Check that the MOOP class handles evaluating objective EVs properly.
+
+    Initialize a MOOP object and check that the evaluateObjectives() function
+    works correctly for expected values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            dx = np.zeros(1, dtype=x.dtype)[0]
+            dx["x1"] = 1
+            return dx
+        elif der == 2:
+            return np.zeros(1, dtype=s.dtype)[0]
+        else:
+            return x["x1"]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)[0]
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s1"] = 2.0 * ev_s["s1"]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=sd_s.dtype)[0]
+            dsd["s1"] = 2.0 * sd_s["s1"]
+            return dsd
+        else:
+            return ev_s["s1"] ** 2 + sd_s["s1"] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s2"][0] = 2.0 * ev_s["s2"][0]
+            ds["s2"][1] = 2.0 * ev_s["s2"][1]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=ev_s.dtype)[0]
+            dsd["s2"][0] = 2.0 * sd_s["s2"][0]
+            dsd["s2"][1] = 2.0 * sd_s["s2"][1]
+            return dsd
+        else:
+            return (ev_s["s2"][0] ** 2 + sd_s["s2"][0] ** 2 +
+                    ev_s["s2"][1] ** 2 + sd_s["s2"][1] ** 2)
+
+    # Create 2 sims for later
+
+    def sim1(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx)]
+
+    def sim2(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx-1.0), np.linalg.norm(xx-0.5)]
+
+    # Create 2 SimGroups for later
+    g1 = {'name': "s1",
+          'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim1,
+          'surrogate': GaussRBF}
+    g2 = {'name': "s2",
+          'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim2,
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'name': "f1", 'obj_func': obj_1},
+                       {'name': "f2", 'exp_func': ev_1},
+                       {'name': "f3", 'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop1.getDesignType())[0]
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    sds = np.zeros(1, dtype=moop2.getSimulationType())[0]
+    moop2.addObjective({'name': "f1", 'obj_func': obj_1},
+                       {'name': "f2",
+                        'obj_func': lambda x, s, der=0: ev_1(x, s, sds,
+                                                             der=der)},
+                       {'name': "f3",
+                        'obj_func': lambda x, s, der=0: ev_2(x, s, sds,
+                                                             der=der)})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop2.getDesignType())[0]
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.zeros(3)) -
+                           np.asarray([0.0, 0.0, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.5,
+                                                                0.5, 0.5]))
+                           - np.asarray([0.5, 0.75, 0.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([1.0, 0.0,
+                                                                0.0]))
+                           - np.asarray([1.0, 1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.0, 1.0,
+                                                                0.0]))
+                           - np.asarray([0.0, 1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.asarray([0.0, 0.0,
+                                                                1.0]))
+                           - np.asarray([0.0, 1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateObjectives(np.ones(3)) -
+                           np.asarray([1.0, 3.0, 0.75])) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        assert (np.all(moop1.evaluateObjectives(xi) >=
+                       moop2.evaluateObjectives(xi)))
+
+
+def test_MOOP_evaluateExpectedValuePenalty_named():
+    """ Check that the MOOP class handles evaluating penalized EVs properly.
+
+    Initialize a MOOP object and check that the evaluatePenalty() function
+    works correctly for expected values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            dx = np.zeros(1, dtype=x.dtype)[0]
+            dx["x1"] = 1
+            return dx
+        elif der == 2:
+            return np.zeros(1, dtype=s.dtype)[0]
+        else:
+            return x["x1"]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)[0]
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s1"] = 2.0 * ev_s["s1"]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=sd_s.dtype)[0]
+            dsd["s1"] = 2.0 * sd_s["s1"]
+            return dsd
+        else:
+            return ev_s["s1"] ** 2 + sd_s["s1"] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s2"][0] = 2.0 * ev_s["s2"][0]
+            ds["s2"][1] = 2.0 * ev_s["s2"][1]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=ev_s.dtype)[0]
+            dsd["s2"][0] = 2.0 * sd_s["s2"][0]
+            dsd["s2"][1] = 2.0 * sd_s["s2"][1]
+            return dsd
+        else:
+            return (ev_s["s2"][0] ** 2 + sd_s["s2"][0] ** 2 +
+                    ev_s["s2"][1] ** 2 + sd_s["s2"][1] ** 2)
+
+    def c2(x, s, der=0): return ev_2(x, s, sds, der=der)
+
+    # Create 2 sims for later
+
+    def sim1(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx)]
+
+    def sim2(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx-1.0), np.linalg.norm(xx-0.5)]
+
+    # Create 2 SimGroups for later
+    g1 = {'name': "s1",
+          'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim1,
+          'surrogate': GaussRBF}
+    g2 = {'name': "s2",
+          'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim2,
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'name': "f1", 'obj_func': obj_1},
+                       {'name': "f2", 'exp_func': ev_1})
+    moop1.addConstraint({'name': "f3", 'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop1.getDesignType())[0]
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    sds = np.zeros(1, dtype=moop2.getSimulationType())[0]
+    moop2.addObjective({'name': "f1", 'obj_func': obj_1},
+                       {'name': "f2",
+                        'obj_func': lambda x, s, der=0: ev_1(x, s, sds,
+                                                             der=der)})
+    moop2.addConstraint({'name': "f3", 'constraint': c2})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop2.getDesignType())[0]
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.zeros(3)) -
+                           np.asarray([3.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([0.5, 0.5, 0.5]))
+                           - np.asarray([1.25, 1.5])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([1.0, 0.0, 0.0]))
+                           - np.asarray([3.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([0.0, 1.0, 0.0]))
+                           - np.asarray([2.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.asarray([0.0, 0.0, 1.0]))
+                           - np.asarray([2.75, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluatePenalty(np.ones(3)) -
+                           np.asarray([1.75, 3.75])) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        assert (np.all(moop1.evaluatePenalty(xi) >=
+                       moop2.evaluatePenalty(xi)))
+
+
+def test_MOOP_expectedValueGradient_named():
+    """ Check that the MOOP class evaluates the expected value gradient.
+
+    Initialize a MOOP object and check that the evaluateGradients() function
+    works correctly for objectives.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            dx = np.zeros(1, dtype=x.dtype)[0]
+            dx["x1"] = 1
+            return dx
+        elif der == 2:
+            return np.zeros(1, dtype=s.dtype)[0]
+        else:
+            return x["x1"]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)[0]
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s1"] = 2.0 * ev_s["s1"]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=sd_s.dtype)[0]
+            dsd["s1"] = 2.0 * sd_s["s1"]
+            return dsd
+        else:
+            return ev_s["s1"] ** 2 + sd_s["s1"] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s2"][0] = 2.0 * ev_s["s2"][0]
+            ds["s2"][1] = 2.0 * ev_s["s2"][1]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=ev_s.dtype)[0]
+            dsd["s2"][0] = 2.0 * sd_s["s2"][0]
+            dsd["s2"][1] = 2.0 * sd_s["s2"][1]
+            return dsd
+        else:
+            return (ev_s["s2"][0] ** 2 + sd_s["s2"][0] ** 2 +
+                    ev_s["s2"][1] ** 2 + sd_s["s2"][1] ** 2)
+
+    # Create 2 sims for later
+
+    def sim1(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx)]
+
+    def sim2(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx-1.0), np.linalg.norm(xx-0.5)]
+
+    # Create 2 SimGroups for later
+    g1 = {'name': "s1",
+          'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim1,
+          'surrogate': GaussRBF}
+    g2 = {'name': "s2",
+          'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim2,
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'name': "f1", 'obj_func': obj_1},
+                       {'name': "f2", 'exp_func': ev_1},
+                       {'name': "f3", 'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop1.getDesignType())[0]
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    sds = np.zeros(1, dtype=moop2.getSimulationType())[0]
+    moop2.addObjective({'name': "f1", 'obj_func': obj_1},
+                       {'name': "f2",
+                        'obj_func': lambda x, s, der=0: ev_1(x, s, sds,
+                                                             der=der)},
+                       {'name': "f3",
+                        'obj_func': lambda x, s, der=0: ev_2(x, s, sds,
+                                                             der=der)})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop2.getDesignType())[0]
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateGradients(np.zeros(3)) -
+                           moop2.evaluateGradients(np.zeros(3))) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(0.5 * np.ones(3)) -
+                           moop2.evaluateGradients(0.5 * np.ones(3)))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[0]) -
+                           moop2.evaluateGradients(np.eye(3)[0])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[1]) -
+                           moop2.evaluateGradients(np.eye(3)[1])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[2]) -
+                           moop2.evaluateGradients(np.eye(3)[2])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.ones(3)) -
+                           moop2.evaluateGradients(np.ones(3))) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        xxi = moop1.__extract__(xi)
+        sds = moop1.surrogateUncertainty(xi)
+        dsig_dxi = moop1.surrogateUncertainty(xi, grad=True)
+        df_dsig = np.zeros((3, 3))
+        df_dsig_tmp = ev_1(xxi, moop1.__unpack_sim__(np.zeros(3)),
+                           moop1.__unpack_sim__(sds), der=3)
+        df_dsig[1, :] = moop1.__pack_sim__(df_dsig_tmp)
+        df_dsig_tmp = ev_2(xxi, moop1.__unpack_sim__(np.zeros(3)),
+                           moop1.__unpack_sim__(sds), der=3)
+        df_dsig[2, :] = moop1.__pack_sim__(df_dsig_tmp)
+        assert (np.linalg.norm(moop1.evaluateGradients(xi) -
+                               np.dot(df_dsig, dsig_dxi) -
+                               moop2.evaluateGradients(xi)) < 1.0e-4)
+
+
+def test_MOOP_evaluateConstraintExpectedValue_named():
+    """ Check that the MOOP class handles evaluating constraint EVs properly.
+
+    Initialize a MOOP object and check that the evaluateConstraints() function
+    works correctly for expected-values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            dx = np.zeros(1, dtype=x.dtype)[0]
+            dx["x1"] = 1
+            return dx
+        elif der == 2:
+            return np.zeros(1, dtype=s.dtype)[0]
+        else:
+            return x["x1"]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)[0]
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s1"] = 2.0 * ev_s["s1"]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=sd_s.dtype)[0]
+            dsd["s1"] = 2.0 * sd_s["s1"]
+            return dsd
+        else:
+            return ev_s["s1"] ** 2 + sd_s["s1"] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s2"][0] = 2.0 * ev_s["s2"][0]
+            ds["s2"][1] = 2.0 * ev_s["s2"][1]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=ev_s.dtype)[0]
+            dsd["s2"][0] = 2.0 * sd_s["s2"][0]
+            dsd["s2"][1] = 2.0 * sd_s["s2"][1]
+            return dsd
+        else:
+            return (ev_s["s2"][0] ** 2 + sd_s["s2"][0] ** 2 +
+                    ev_s["s2"][1] ** 2 + sd_s["s2"][1] ** 2)
+
+    # Create 2 sims for later
+
+    def sim1(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx)]
+
+    def sim2(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx-1.0), np.linalg.norm(xx-0.5)]
+
+    # Create 2 SimGroups for later
+    g1 = {'name': "s1",
+          'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim1,
+          'surrogate': GaussRBF}
+    g2 = {'name': "s2",
+          'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim2,
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'name': "f1", 'obj_func': obj_1})
+    moop1.addConstraint({'name': "c1", 'exp_func': ev_1},
+                        {'name': "c2", 'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop1.getDesignType())[0]
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    sds = np.zeros(1, dtype=moop2.getSimulationType())[0]
+    moop2.addObjective({'name': "f1", 'obj_func': obj_1})
+    moop2.addConstraint({'name': "c1",
+                         'constraint': lambda x, s, der=0: ev_1(x, s, sds,
+                                                                der=der)},
+                        {'name': "c2",
+                         'constraint': lambda x, s, der=0: ev_2(x, s, sds,
+                                                                der=der)})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop2.getDesignType())[0]
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.zeros(3)) -
+                           np.asarray([0.0, 3.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([0.5,
+                                                                 0.5, 0.5]))
+                           - np.asarray([0.75, 0.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([1.0, 0.0,
+                                                                 0.0]))
+                           - np.asarray([1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([0.0, 1.0,
+                                                                 0.0]))
+                           - np.asarray([1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.asarray([0.0, 0.0,
+                                                                 1.0]))
+                           - np.asarray([1.0, 2.75])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateConstraints(np.ones(3)) -
+                           np.asarray([3.0, 0.75])) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        assert (np.all(moop1.evaluateConstraints(xi) >=
+                       moop2.evaluateConstraints(xi)))
+
+
+def test_MOOP_expectedValueConstraintGradient_named():
+    """ Check that the MOOP class evaluates the expected value gradient.
+
+    Initialize a MOOP object and check that the evaluateGradients() function
+    works correctly for constraint expected values.
+
+    """
+
+    from parmoo import MOOP
+    from parmoo.surrogates import GaussRBF
+    from parmoo.searches import LatinHypercube
+    from parmoo.optimizers import LocalGPS
+    import numpy as np
+
+    # Create 3 objectives for later
+
+    def obj_1(x, s, der=0):
+        if der == 1:
+            dx = np.zeros(1, dtype=x.dtype)[0]
+            dx["x1"] = 1
+            return dx
+        elif der == 2:
+            return np.zeros(1, dtype=s.dtype)[0]
+        else:
+            return x["x1"]
+
+    def ev_1(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)[0]
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s1"] = 2.0 * ev_s["s1"]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=sd_s.dtype)[0]
+            dsd["s1"] = 2.0 * sd_s["s1"]
+            return dsd
+        else:
+            return ev_s["s1"] ** 2 + sd_s["s1"] ** 2
+
+    def ev_2(x, ev_s, sd_s, der=0):
+        if der == 1:
+            return np.zeros(1, dtype=x.dtype)
+        elif der == 2:
+            ds = np.zeros(1, dtype=ev_s.dtype)[0]
+            ds["s2"][0] = 2.0 * ev_s["s2"][0]
+            ds["s2"][1] = 2.0 * ev_s["s2"][1]
+            return ds
+        elif der == 3:
+            dsd = np.zeros(1, dtype=ev_s.dtype)[0]
+            dsd["s2"][0] = 2.0 * sd_s["s2"][0]
+            dsd["s2"][1] = 2.0 * sd_s["s2"][1]
+            return dsd
+        else:
+            return (ev_s["s2"][0] ** 2 + sd_s["s2"][0] ** 2 +
+                    ev_s["s2"][1] ** 2 + sd_s["s2"][1] ** 2)
+
+    # Create 2 sims for later
+
+    def sim1(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx)]
+
+    def sim2(x):
+        xx = np.zeros(3)
+        xx[0] = x["x1"]
+        xx[1] = x["x2"]
+        xx[2] = x["x3"]
+        return [np.linalg.norm(xx-1.0), np.linalg.norm(xx-0.5)]
+
+    # Create 2 SimGroups for later
+    g1 = {'name': "s1",
+          'n': 3,
+          'm': 1,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim1,
+          'surrogate': GaussRBF}
+    g2 = {'name': "s2",
+          'n': 3,
+          'm': 2,
+          'hyperparams': {},
+          'search': LatinHypercube,
+          'sim_func': sim2,
+          'surrogate': GaussRBF}
+    # Initialize a MOOP with 2 SimGroups and 3 objectives
+    moop1 = MOOP(LocalGPS)
+    for i in range(3):
+        moop1.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop1.addSimulation(g1, g2)
+    moop1.addObjective({'name': "f1", 'obj_func': obj_1})
+    moop1.addConstraint({'name': "c1", 'exp_func': ev_1},
+                        {'name': "c2", 'exp_func': ev_2})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop1.getDesignType())[0]
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop1.evaluateSimulation(xi, 0)
+    moop1.evaluateSimulation(xi, 1)
+    moop1.fitSurrogates()
+    moop1.resetSurrogates(np.ones(3) * 0.5)
+    # Initialize a second identical MOOP without UQ functions for comparison
+    moop2 = MOOP(LocalGPS)
+    for i in range(3):
+        moop2.addDesign({'name': f"x{i+1}", 'lb': 0.0, 'ub': 1.0})
+    moop2.addSimulation(g1, g2)
+    sds_tt = np.zeros(1, dtype=moop2.getSimulationType())[0]
+    moop2.addObjective({'name': "f1", 'obj_func': obj_1})
+    moop2.addConstraint({'name': "c1",
+                         'constraint': lambda x, s, der=0: ev_1(x, s, sds_tt,
+                                                                der=der)},
+                        {'name': "c2",
+                         'constraint': lambda x, s, der=0: ev_2(x, s, sds_tt,
+                                                                der=der)})
+    # Evaluate some data points and fit the surrogates
+    xi = np.zeros(1, dtype=moop2.getDesignType())[0]
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0.5
+    xi['x2'] = 0.5
+    xi['x3'] = 0.5
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 0
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 1
+    xi['x3'] = 0
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 0
+    xi['x2'] = 0
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    xi['x1'] = 1
+    xi['x2'] = 1
+    xi['x3'] = 1
+    moop2.evaluateSimulation(xi, 0)
+    moop2.evaluateSimulation(xi, 1)
+    moop2.fitSurrogates()
+    moop2.resetSurrogates(np.ones(3) * 0.5)
+
+    # Do some objective evaluations and check the results
+    assert (np.linalg.norm(moop1.evaluateGradients(np.zeros(3)) -
+                           moop2.evaluateGradients(np.zeros(3))) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(0.5 * np.ones(3)) -
+                           moop2.evaluateGradients(0.5 * np.ones(3)))
+            < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[0]) -
+                           moop2.evaluateGradients(np.eye(3)[0])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[1]) -
+                           moop2.evaluateGradients(np.eye(3)[1])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.eye(3)[2]) -
+                           moop2.evaluateGradients(np.eye(3)[2])) < 1.0e-4)
+    assert (np.linalg.norm(moop1.evaluateGradients(np.ones(3)) -
+                           moop2.evaluateGradients(np.ones(3))) < 1.0e-4)
+    # Try some random evals
+    for i in range(10):
+        xi = np.random.random_sample(3)
+        xxi = moop1.__extract__(xi)
+        sds = moop1.surrogateUncertainty(xi)
+        dsig_dxi = moop1.surrogateUncertainty(xi, grad=True)
+        df_dsig = np.zeros((3, 3))
+        df_dsig_tmp = ev_1(xxi, moop1.__unpack_sim__(np.zeros(3)),
+                           moop1.__unpack_sim__(sds), der=3)
+        df_dsig[1, :] = moop1.__pack_sim__(df_dsig_tmp)
+        df_dsig_tmp = ev_2(xxi, moop1.__unpack_sim__(np.zeros(3)),
+                           moop1.__unpack_sim__(sds), der=3)
+        df_dsig[2, :] = moop1.__pack_sim__(df_dsig_tmp)
+        if all(moop1.evaluateConstraints(xi) > 0) and \
+           all(moop2.evaluateConstraints(xi) > 0):
+            assert (np.linalg.norm(moop1.evaluateGradients(xi) -
+                                   np.sum(np.dot(df_dsig, dsig_dxi), axis=0) -
+                                   moop2.evaluateGradients(xi)) < 1.0e-4)
+
+
+if __name__ == "__main__":
+    test_MOOP_evaluateExpectedValue_unnamed()
+    test_MOOP_evaluateExpectedValuePenalty_unnamed()
+    test_MOOP_expectedValueGradient_unnamed()
+    test_MOOP_evaluateConstraintExpectedValue_unnamed()
+    test_MOOP_expectedValueConstraintGradient_unnamed()
+    test_MOOP_evaluateExpectedValue_named()
+    test_MOOP_evaluateExpectedValuePenalty_named()
+    test_MOOP_expectedValueGradient_named()
+    test_MOOP_evaluateConstraintExpectedValue_named()
+    test_MOOP_expectedValueConstraintGradient_named()
diff --git a/parmoo/tests/unit_tests/test_moop_grads.py b/parmoo/tests/unit_tests/test_moop_grads.py
index 855b76b..96b697c 100644
--- a/parmoo/tests/unit_tests/test_moop_grads.py
+++ b/parmoo/tests/unit_tests/test_moop_grads.py
@@ -454,7 +454,9 @@ def c2(x, s, der=0):
     moop2.addAcquisition({'acquisition': FixedWeights})
     np.random.seed(0)
     moop2.solve(0)
+    np.random.seed(0)
     b1 = moop1.iterate(1)
+    np.random.seed(0)
     b2 = moop2.iterate(1)
     # Check that same solutions were found
     for x1, x2 in zip(b1, b2):
diff --git a/parmoo/tests/unit_tests/test_random_search.py b/parmoo/tests/unit_tests/test_random_search.py
index 770af45..9f04705 100644
--- a/parmoo/tests/unit_tests/test_random_search.py
+++ b/parmoo/tests/unit_tests/test_random_search.py
@@ -31,7 +31,9 @@ def test_RandomSearch():
     ub = np.ones(n)
     # Create the biobjective function
     def f(z): return np.asarray([-z[0] + z[1] + z[2], z[0] - z[1] + z[2]])
-    def L(z): return np.ones(2)
+    def L(z, sz=1): return f(z)
+    def S(z): return np.ones(2)
+    def SD(z): return np.zeros(2)
     def g(z): return np.ones((2, 3))
     # Create 2 acquisition functions targeting 2 "pure" solutions
     acqu1 = UniformWeights(o, lb, ub, {})
@@ -68,6 +70,7 @@ def g(z): return np.ones((2, 3))
     # Add the correct objective and constraints
     opt.setObjective(f)
     opt.setConstraints(lambda z: np.asarray([0.1 - z[2], z[2] - 0.6]))
+    opt.setSimulation(S, SD)
     opt.setPenalty(L, g)
     opt.addAcquisition(acqu1, acqu2, acqu3)
     opt.setReset(lambda x: 100.0)
@@ -78,8 +81,6 @@ def g(z): return np.ones((2, 3))
         opt.solve(np.zeros((3, n-1)))
     with pytest.raises(ValueError):
         opt.solve(np.zeros((4, n)))
-    with pytest.raises(ValueError):
-        opt.solve(-np.ones((3, n)))
     # Solve the surrogate problem with RandomSearch, starting from the centroid
     x = np.zeros((3, n))
     x[:] = 0.5
diff --git a/parmoo/tests/unit_tests/test_weighted_sum.py b/parmoo/tests/unit_tests/test_weighted_sum.py
index 71b82e8..8c31fd9 100644
--- a/parmoo/tests/unit_tests/test_weighted_sum.py
+++ b/parmoo/tests/unit_tests/test_weighted_sum.py
@@ -31,16 +31,16 @@ def test_UniformWeights():
     with pytest.raises(TypeError):
         acqu.setTarget({}, 4, {})
     with pytest.raises(ValueError):
-        acqu.setTarget({}, lambda x, y: np.zeros(3), {})
+        acqu.setTarget({}, lambda x, y, z: np.zeros(3), {})
     # Set a good target for future usage
     x0 = acqu.setTarget({}, lambda x: np.zeros(3), {})
     assert (abs(sum(acqu.weights) - 1.0) < 0.00000001)
     assert (np.all(x0[:] <= acqu.ub) and np.all(x0[:] >= acqu.lb))
     # Try some bad scalarizations to test error handling
     with pytest.raises(TypeError):
-        acqu.scalarize(5)
+        acqu.scalarize(5, 5, 5, 5)
     with pytest.raises(ValueError):
-        acqu.scalarize(np.ones(2))
+        acqu.scalarize(np.ones(2), np.ones(2), np.ones(2), np.ones(2))
     # Generate 3 random weight vector
     acqu1 = UniformWeights(3, np.zeros(4), np.ones(4), {})
     acqu1.setTarget({}, lambda x: np.zeros(3), {})
@@ -54,15 +54,27 @@ def test_UniformWeights():
     assert (all(acqu2.weights[:] >= 0.0))
     assert (all(acqu3.weights[:] >= 0.0))
     # Use the scalarization function to check that the weights sum to 1
-    assert (np.abs(acqu1.scalarize(np.eye(3)[0])
-                   + acqu1.scalarize(np.eye(3)[1])
-                   + acqu1.scalarize(np.eye(3)[2]) - 1.0) < 0.00000001)
-    assert (np.abs(acqu2.scalarize(np.eye(3)[0])
-                   + acqu2.scalarize(np.eye(3)[1])
-                   + acqu2.scalarize(np.eye(3)[2]) - 1.0) < 0.00000001)
-    assert (np.abs(acqu3.scalarize(np.eye(3)[0])
-                   + acqu3.scalarize(np.eye(3)[1])
-                   + acqu3.scalarize(np.eye(3)[2]) - 1.0) < 0.00000001)
+    assert (np.abs(acqu1.scalarize(np.eye(3)[0], np.ones(2),
+                                   np.ones(2), np.ones(2))
+                   + acqu1.scalarize(np.eye(3)[1], np.ones(2),
+                                     np.ones(2), np.ones(2))
+                   + acqu1.scalarize(np.eye(3)[2], np.ones(2),
+                                     np.ones(2), np.ones(2)) - 1.0)
+            < 0.00000001)
+    assert (np.abs(acqu2.scalarize(np.eye(3)[0], np.ones(2),
+                                   np.ones(2), np.ones(2))
+                   + acqu2.scalarize(np.eye(3)[1], np.ones(2),
+                                     np.ones(2), np.ones(2))
+                   + acqu2.scalarize(np.eye(3)[2], np.ones(2),
+                                     np.ones(2), np.ones(2)) - 1.0)
+            < 0.00000001)
+    assert (np.abs(acqu3.scalarize(np.eye(3)[0], np.ones(2),
+                                   np.ones(2), np.ones(2))
+                   + acqu3.scalarize(np.eye(3)[1], np.ones(2),
+                                     np.ones(2), np.ones(2))
+                   + acqu3.scalarize(np.eye(3)[2], np.ones(2),
+                                     np.ones(2), np.ones(2)) - 1.0)
+            < 0.00000001)
     # Try some bad gradient scalarizations to test error handling
     with pytest.raises(TypeError):
         acqu.scalarizeGrad(5, np.zeros((3, 4))[0])
@@ -122,7 +134,7 @@ def test_FixedWeights():
     with pytest.raises(TypeError):
         acqu.setTarget({}, 4, {})
     with pytest.raises(ValueError):
-        acqu.setTarget({}, lambda x, y: np.zeros(3), {})
+        acqu.setTarget({}, lambda x, y, z: np.zeros(3), {})
     # Set some good targets
     x0 = acqu.setTarget({'x_vals': None, 'f_vals': None},
                         lambda x: np.zeros(3), {})
@@ -136,12 +148,17 @@ def test_FixedWeights():
     assert (np.all(x0[:] <= acqu.ub) and np.all(x0[:] >= acqu.lb))
     # Try some bad scalarizations to test error handling
     with pytest.raises(TypeError):
-        acqu.scalarize(5)
+        acqu.scalarize(5, 5, 5, 5)
     with pytest.raises(ValueError):
-        acqu.scalarize(np.ones(2))
+        acqu.scalarize(np.ones(2), np.ones(2), np.ones(2), np.ones(2))
     # Use the scalarization function to check that the weights sum to 1
-    assert (np.abs(acqu.scalarize(np.eye(3)[0]) + acqu.scalarize(np.eye(3)[1])
-                   + acqu.scalarize(np.eye(3)[2]) - 1.0) < 0.00000001)
+    assert (np.abs(acqu.scalarize(np.eye(3)[0], np.ones(2),
+                                  np.ones(2), np.ones(2))
+                   + acqu.scalarize(np.eye(3)[1], np.ones(2),
+                                    np.ones(2), np.ones(2))
+                   + acqu.scalarize(np.eye(3)[2], np.ones(2),
+                                    np.ones(2), np.ones(2)) - 1.0)
+            < 0.00000001)
     # Try some bad gradient scalarizations to test error handling
     with pytest.raises(TypeError):
         acqu.scalarizeGrad(5, np.zeros((3, 4))[0])