Repository: watts-dev/watts (language: python)

Issue:
Added unit conversion capability
This PR adds the unit-conversion capability to WATTS:

- `params` can now accept dictionary where the `value`, the `current_unit`, and the `new_unit` of a parameter are specified. 
- `new_unit` is optional. If not specified, S.I. units will be used.
- Changes are also made to MOOSE and OpenMC plugins to automatically convert the units of a parameter to S.I and CGS, respectively. With the changes, there is no need to define the same parameter twice in different units to accommodate for the different units requirements in SAM/MOOSE and OpenMC.


Discussion / hints:
@nstauff @paulromano Please have a look. Thank you.
@zhieejhia93 thanks for this work! I like the approach. Could you please update the documentation in `usage.rst`? 
Thanks for the comments @nstauff @paulromano . I will try to implement the suggestions by @paulromano and let you both know if I have any questions.
Hi @nstauff  @paulromano I made some changes based on your comments:

- The implementation still relies on dictionary to allow users to input the `value` and `current_unit` of a parameter. I removed the capability to specify `new_unit` because as Paul mentioned previously, it didn't add much value to the code as users could simply perform the unit conversion in the Python input file.
- The parameter that needs to be converted is now stored as `astropy.units.Quantity` in the `Parameter` class and is converted to SI or CGS depending on whether it's being used by MOOSE or openmc. 
- As Paul mentioned previously, ideally `params` should remain unchanged. In this implementation, unit conversion is performed on a copy of `params`, which is then passed to the template generator.
- I kept the unit conversion at the MOOSE and OpenMC plugins level instead of the `plugin` level as suggested by Nicolas because I wasn't sure if that was possible without changing `params`.
- I added a unit test to `test_results.py`. The unit test simply checks whether the unit conversion is performed correctly, I am not sure if that is sufficient or more checks are required.
- I have been testing the implementation with example1a and example2. I will update the remaining example files once this is finalized.

Thank you.
Thanks @zhieejhia93! my main comment to push some of the logic to the `plugin` level (to avoid duplication in different plugins) was not addressed - maybe @paulromano can help with this improvement.
Otherwise this looks good to me - but I will let @paulromano do a detailed review. 
Thanks @nstauff  and @paulromano. I will work on the comments and let you both know if I have any questions. 
Hi @nstauff  @paulromano, I modified the implementation according to your comments:

- The unit conversion implementation has been moved to the general `plugin` level as recommended.
- Removed the dictionary input style. Users can now use the `Quantity` class from Astropy directly to input the value and units of parameters.
- Updated the unit test to check the unit conversion in both MOOSE and OpenMC.

Thank you
@zhieejhia93 It looks like CI tests are failing due to an update of the OpenMC docker image -- don't worry about that, I can fix it in a follow-on PR
@paulromano I made the changes that you suggested. Please have a look. Also, do I have to do anything on my end regarding the failed CI tests? Thanks.
@nstauff @paulromano Thanks for the feedback and comments on this PR. 
@zhieejhia93 thanks for your work on this PR! This looks good to me. A few additional things that will need addressing in future PR:
- documentation for future users
- extend this to the new PyARC plugin
@nstauff Sure, I can do that. Just to double check, does PyARC use SI units as default? 

Fix patch:
diff --git a/doc/source/user/plugins.rst b/doc/source/user/plugins.rst
index b9aed799..5bff071e 100644
--- a/doc/source/user/plugins.rst
+++ b/doc/source/user/plugins.rst
@@ -253,3 +253,98 @@ At this point, we recommend using the `serpentTools
 files. For example::
 
     results_reader = serpentTools.ResultsReader(str(result.outputs[-1]))
+
+Dakota Plugin
++++++++++++++
+
+The :class:`~watts.PluginDakota` class handles execution of Dakota. Dakota uses
+text-based input files that can be templated as follows:
+
+.. code-block:: jinja
+
+    real = {{ real }}
+    work_directory named = {{ workdir }}
+
+Note that the execution of the Dakota plugin is slightly different and involves
+more steps than the execution of the other plugins. Dakota is an
+optimization and uncertainty quantification tool that needs to be coupled to
+other external tools or software.
+
+The execution of Dakota with WATTS is a two-step process. In the
+first step, WATTS creates Dakota's input file using the user-provided template
+and runs Dakota. In the second step, Dakota drives the execution of the coupled
+code (PyARC, SAM, SAS, etc.) via a Python script known as the "Dakota driver".
+The Dakota driver also facilitates the exchange of information between Dakota
+and the coupled code. Note that this is done through Dakota's `interfacing`
+library. The user needs to ensure that this library is available prior to running
+Dakota with WATTS.
+
+To run Dakota with WATTS, the user needs to provide a number of files including
+the input file for Dakota, the WATTS Python script for executing Dakota,
+the input file for the coupled code, the WATTS script for executing the coupled
+code (note that this can involve complex workflows with several codes or iterations), 
+and the Dakota driver Python script, in addition to any file necessary to
+run the coupled code. Note that all of these files could be templated automatically
+by WATTS using the `template_file` and `extra_template_inputs` options, provided
+they are text-based.
+
+If the templated Dakota input file is `dakota_watts_opt.in`, then the Dakota
+plugin can be instantiated with the following command line::
+
+    dakota_plugin = watts.PluginDakota('dakota_watts_opt.in')
+
+If the coupled code has a text-based input file, users can also template
+this file (or other necessary files) with the `extra_template_inputs` options::
+
+    dakota_plugin = watts.PluginDakota(
+        template_file='dakota_watts_opt.in',
+        extra_template_inputs=['extra_template_file_name', 'other_necessary_files'])
+
+During the execution of WATTS, the working directory is switched to a temporary
+location. Non-templated files needed by the coupled code (license file, data file,
+etc.) can be copied to the temporary location with the `extra_inputs` option::
+
+    dakota_plugin = watts.PluginDakota(
+        template_file='dakota_watts_opt.in',
+        extra_template_inputs=['extra_template_file_name', 'other_necessary_files'],
+        extra_inputs=['file_1', 'file_2'])
+
+In the Dakota input file, users need to provide the names of required files to the
+`link_files` or the `copy_files` options where these files will be copied by Dakota
+to the working directory during each iteration. Users can choose to input the names of
+these files manually or they can choose to have WATTS automatically include all
+file names in the `extra_template_inputs` and `extra_inputs` options. To do so, simply use
+the `auto_link_files` option::
+
+    dakota_plugin = watts.PluginDakota(
+        template_file='dakota_watts_opt.in',
+        extra_template_inputs=['extra_template_file_name', 'other_necessary_files'],
+        auto_link_files='<string_name_for_files>',
+        extra_inputs=['file_1', 'file_2'])
+
+And set::
+
+    link_files = {{ <string_name_for_files> }}
+
+in the Dakota input file. Note that the same `<string_name_for_files>` must be used
+in the two locations mentioned above.
+
+As mentioned earlier, Dakota drives the execution of the coupled code through a
+Python script known as the Dakota driver. A template for the Dakota driver is
+provided in the example. Just like the other files mentioned earlier, the Dakota
+driver can also be templated using the approach described above.
+
+Furthermore, the path to the 'dakota.sh' shell script
+needs to be provided either by setting the :envvar:`DAKOTA_DIR` environment
+variable to the directory containing `dakota.sh` or by adding it through the
+input file as::
+
+    dakota_plugin.dakota_exec = "path/to/dakota.sh"
+
+Once the execution is complete, WATTS saves the results from all iterations as
+individual objects and the final results as a separate object known as `finaldata1`
+in the :class:`~watts.Parameters` class.
+
+The setup of WATTS-Dakota coupling is more involved than other codes. Users are
+strongly encouraged to visit the example case `Optimization_PyARC_DAKOTA` for
+detailed explanation on how to prepare the input files.
diff --git a/examples/Optimization_PyARC_DAKOTA/README.md b/examples/Optimization_PyARC_DAKOTA/README.md
new file mode 100644
index 00000000..3a41b161
--- /dev/null
+++ b/examples/Optimization_PyARC_DAKOTA/README.md
@@ -0,0 +1,25 @@
+# 1App_SAS_SodiumLoop
+
+## Purpose
+
+This example provides a demonstration on how to use WATTS to run Dakota by coupling it with PyArc.
+
+## Code(s)
+
+- Dakota
+- Dakota's Interfacing library
+- PyArc
+
+## Keywords
+
+- Optimization
+
+## File descriptions
+
+- [__watts_dakota_exec.py__](watts_dakota_exec.py): This is the main file to execute to run the problem described above.
+- [__watts_pyarc_exec.py__](watts_pyarc_exec.py): This is the file to run PyArc.
+- [__dakota_watts_opt.in__](dakota_watts_opt.in): Templated Dakota input.
+- [__pyarc_template__](pyarc_template): Templated PyArc input.
+- [__dakota_driver.py__](dakota_driver.py): Python script that Dakota uses drive the execution of the coupled code (PyArc).
+- [__pyarc_input.isotxs](pyarc_input.isotxs): PyArc input data.
+- [__lumped.son](lumped.son): PyArc input data.
diff --git a/examples/Optimization_PyARC_DAKOTA/dakota_driver.py b/examples/Optimization_PyARC_DAKOTA/dakota_driver.py
new file mode 100755
index 00000000..98777e1b
--- /dev/null
+++ b/examples/Optimization_PyARC_DAKOTA/dakota_driver.py
@@ -0,0 +1,6 @@
+#!/usr/bin/env python
+import sys
+from watts.plugin_dakota import run_dakota_driver
+
+sys.path.insert(0, '{{ dakota_path }}')
+run_dakota_driver(coupled_code_exec='{{  coupled_code_exec  }}')
diff --git a/examples/Optimization_PyARC_DAKOTA/dakota_watts_opt.in b/examples/Optimization_PyARC_DAKOTA/dakota_watts_opt.in
new file mode 100755
index 00000000..7935c1ed
--- /dev/null
+++ b/examples/Optimization_PyARC_DAKOTA/dakota_watts_opt.in
@@ -0,0 +1,75 @@
+# Dakota Input File: for mogas
+
+environment
+  tabular_data
+    tabular_data_file = '{{dakota_out_file}}'
+
+method
+    moga
+    
+    fitness_type domination_count              # another option is layer_rank. they are similar
+    replacement_type below_limit = 5.0         # designs those are dominated by 10 other designs or less are kept
+											   # designs dominated by more than 10 other designs are discarded.
+    shrinkage_fraction = 0.95                  # optional, the population size of next generation >= 0.95 * population size of current generation
+	                                           # this is to prevent a quick reduce in population size in some extreme case. 
+    
+#    niching_type distance 0.05 0.05 0.05      # In output space. if two data points have close outputs, one will be buffered and re-inserted in next generation.
+                                               # corresponses to three objectives 
+											   # this could be useful capability, but I didn't test it, and the values here are default. 
+    convergence_type metric_tracker			   # the rules are quite strict, and I recommend to use convergence criteria of our own.
+                     percent_change = 0.05     # when the change between generations is less than 5% 
+                     num_generations = 5       # for 5 consecutive generations, the optimization reach convergence.
+
+#    postprocessor_type orthogonal_distance = 0.0 0.0 0.0   # Pareto Frontier is the last generation, give by population_#.dat
+															# the postprocessor_type defines the post processing of the last generation.
+															# and it will generate the data file called finaldata.dat -  this is processed data.
+															# it is based on the normalized distance to the ideal/Utopia points
+    max_function_evaluations = 20    					    # The maximum number of external code evaluations
+    population_size = 5                                     # the inital population size. was 500 for ABTR, but I think 100 should be already sufficient.
+    log_file = "test"
+    print_each_pop											# please enable this to print out the information at all generations, then we could manually calcualte the change to determine the convergence. 
+    initialization_type unique_random						# random sampling to obtained initial population.
+
+    crossover_type shuffle_random							# inputs from parents are shuffled first, and then randomly mix to generate offsprings.
+                   num_offspring = 2      					# or  5 recommended by Laura
+                   num_parents = 4							# number of parants
+                   crossover_rate = 0.8						# specifies the probability of a crossover operation being performed to generate a new offspring.
+    mutation_type replace_uniform							# random variation by first randomly choosing a design variable of a randomly selected design  
+                  mutation_rate = 0.1						# the probability of a mutation operation,  the number of mutations = mutation_rate * population_size
+    
+variables   
+    discrete_design_set
+        real = {{ real }}
+            elements_per_variable =     5   5   
+            initial_point         =     13  20
+            elements =  10 11 12 13 14 
+                        16 18 20 22 24
+            descriptors   =   "AP" "AL" 
+
+
+interface
+    asynchronous  evaluation_concurrency = 1
+    analysis_drivers = 'dakota_driver.py'
+        fork 
+            parameters_file = 'params.in'
+            results_file    = 'results.out'
+            work_directory named = "workdir"
+            directory_tag 
+            directory_save
+            link_files = {{ auto_link_file_string_name }} # Users can automatically add files to the link files (refer to watts_dakota_exec.py for more details).
+            # link_files = "dakota_driver.py" "pyarc_template" "watts_pyarc_exec.py" "lumped.son" "pyarc_input.isotxs"  # Users can choose to manually input the linked files
+            file_save
+    failure_capture continuation
+
+responses
+  objective_functions = 2
+  sense = "minimization"  "minimization"
+   nonlinear_inequality_constraints = 1
+   nonlinear_inequality_upper_bounds = 2 
+   nonlinear_inequality_lower_bounds = 1 
+   no_gradients
+    descriptors =  
+                    "{{ dakota_descriptor_1 }}"            # Obj Function = Keff
+                    "{{ dakota_descriptor_2 }}"             # Obj Function = Core weight
+                    "{{ dakota_descriptor_3 }}"             # Constraint =  Keff
+   no_hessians
diff --git a/examples/Optimization_PyARC_DAKOTA/lumped.son b/examples/Optimization_PyARC_DAKOTA/lumped.son
new file mode 100644
index 00000000..d7e6db09
--- /dev/null
+++ b/examples/Optimization_PyARC_DAKOTA/lumped.son
@@ -0,0 +1,6 @@
+lumped_element_external_list{
+
+    afrac(Mo92) = 0.5
+    afrac(Mo95) = 0.5
+
+}
diff --git a/examples/Optimization_PyARC_DAKOTA/pyarc_input.isotxs b/examples/Optimization_PyARC_DAKOTA/pyarc_input.isotxs
new file mode 100644
index 00000000..f1ac072b
Binary files /dev/null and b/examples/Optimization_PyARC_DAKOTA/pyarc_input.isotxs differ
diff --git a/examples/Optimization_PyARC_DAKOTA/pyarc_template b/examples/Optimization_PyARC_DAKOTA/pyarc_template
new file mode 100644
index 00000000..90a74716
--- /dev/null
+++ b/examples/Optimization_PyARC_DAKOTA/pyarc_template
@@ -0,0 +1,72 @@
+=arc
+geometry{
+
+    materials{
+        material ( LEU ) {
+            temp    = {{ temp }}
+            adens{ 
+                 aden ( u235 ) = 2.0e-2  % atom density in at/barn-cm
+                 aden ( u238 ) = 2.0e-2  % atom density in at/barn-cm
+                 aden ( o16 ) = 8.0e-2  % atom density in at/barn-cm
+            }
+            lumped_element_aden( lu35 ) = 1e-3
+        }
+        material ( LEU2 ) {
+            temp    = {{ temp }}
+            adens{ 
+                 aden ( u236 ) = 2.0e-2  % atom density in at/barn-cm
+                 aden ( u237 ) = 2.0e-2  % atom density in at/barn-cm
+                 aden ( o16 ) = 8.0e-2  % atom density in at/barn-cm
+            }
+        }
+    }
+    surfaces{
+        hexagon ( hex ){ orientation=y   normal = z  pitch = {{ assembly_pitch }} }
+        plane ( z0 ) { z = 0.0  }
+        plane ( z10 ) { z = {{ assembly_length }} }
+    }
+
+    reactor{
+        lower_axial_surf=z0
+        upper_axial_surf=z10
+        
+        lower_boundary_condition=reflective
+        upper_boundary_condition=reflective
+        reactor_hex_lattice {
+            pitch         = hex
+            num_ring      = 1
+            fill          = [ assembly ]
+        }
+        assembly ( assembly ) {
+            sub_assembly ( sub_assembly_name ) {
+                lower_axial_surf=z0
+                upper_axial_surf=z10
+                material=LEU
+            }
+        }
+    }
+}
+calculations{
+
+    mcc3{
+       xslib            = "endf7.0"
+       egroupname       = ANL33
+       scattering_order = 1
+       lumped_element_text_file( lu35 ) = "lumped.son"
+        cell( a ){
+            associated_sub_assembly     = sub_assembly_name
+        }
+    }
+    dif3d{
+        power           = 1.0
+        geometry_type   = hexagonal_infinite_lattice
+        isotxs          = "pyarc_input.isotxs"
+        run_dif3d       = true
+        max_axial_mesh_size = 0.05
+        dif_fd_options{}
+    }
+}
+
+
+
+end
\ No newline at end of file
diff --git a/examples/Optimization_PyARC_DAKOTA/watts_dakota_exec.py b/examples/Optimization_PyARC_DAKOTA/watts_dakota_exec.py
new file mode 100644
index 00000000..59a5c2f8
--- /dev/null
+++ b/examples/Optimization_PyARC_DAKOTA/watts_dakota_exec.py
@@ -0,0 +1,103 @@
+# SPDX-FileCopyrightText: 2022 UChicago Argonne, LLC
+# SPDX-License-Identifier: MIT
+
+"""
+This example demonstrates how to use DAKOTA to perform 
+optimization with WATTS. This example uses PyARC as the
+coupled code for Dakota. There are a number of files in 
+this example. The primary files are 'watts_dakota_exec.py',
+'watts_pyarc_exec.py', 'dakota_watts_opt.in', 'pyarc_template',
+and 'dakota_driver.py', with several other secondary files
+needed to run PyARC. Note that all of the files mentioned above 
+can be templated with the 'extra_template_inputs' option in 
+the Dakota plugin (refer below for details).
+
+The execution of Dakota with WATTS is essentially a two-step process. 
+When the user runs the 'watts_dakota_exec.py' script, it generates 
+an input file for Dakota based on the 'dakota_watts_opt.in' template. 
+It then runs Dakota which in turn drives the execution of the coupled 
+code (PyARC) through 'dakota_driver.py' by running 'watts_pyarc_exec.py'.
+The Dakota driver is responsible for facilitating the communication
+between Dakota and the coupled code. Note that this is done through 
+Dakota's `interfacing` library. The user needs to ensure that this 
+library is available prior to running Dakota with WATTS.
+
+The user first needs to identify the parameters that will be optimized 
+by Dakota (These are the parameters that will be varied in PyARC).
+In this example, the parameters are the 'assembly_pitch' and 
+'assembly_length' and are represented as 'AP' and 'AL' in the 
+'dakota_watts_opt.in' file. The input values of 'assembly_pitch' 
+and 'assembly_length' are set to the values generated by Dakota in
+'watts_pyarc_exec.py'.
+
+The user then needs to identify the response parameters that Dakota
+will use to judge the optimization. In this example, 
+'KeffOpt', 'CoreWeight', and 'KeffCrit' are chosen as the response 
+parameters, where they are represented as 'KO', 'CW', and 'KC', respectively.
+Then, in the 'watts_pyarc_exec.py', the user has to set an object known as 
+'dakota_descriptors' to 'params'. The 'dakota_descriptors' is a dictionary
+that links 'KeffOpt' to 'KO', 'CoreWeight' to 'CW', and so on. The order of 
+the descriptors MUST match the order of the response descriptors in 
+'dakota_watts_opt.in'. 
+
+After that, the user needs to save the results from PyARC as objects 
+to 'params'. Note that the names of these objects must match the names 
+of the response descriptors described above ('KeffOpt', 'CoreWeight', etc).
+Lastly, 'params', MUST be saved as a pickle file with the name 'opt_res.out'
+which will be loaded and returned to Dakota.
+"""
+
+import watts
+
+# watts.Database.set_default_path('/default/directory') # Set default save directory if necessary
+
+params = watts.Parameters()
+
+# Provide optional parameters to generate the dakota_driver.py file
+params['dakota_path'] = '/software/Workbench/dakota/share/dakota/Python/dakota' # Specify path to Dakota's 'interface' library
+params['coupled_code_exec'] = 'watts_pyarc_exec.py' # Specify the script of the coupled code
+params['dakota_driver_name'] = 'dakota_driver.py'   # Specify the file name of Dakota driver.
+                                                    # If user wishes to template the Dakota driver file, 
+                                                    # make sure to set the key as 'dakota_driver_name'
+                                                    # because WATTS relies on this key name to do 
+                                                    # additional internal checks to ensure Dakota
+                                                    # has the right permission to run this file.
+                                                    # An error will be raised if a different key is used here.
+                                                    # Note: If users do not wish to template the Dakota driver file,
+                                                    # make sure that the mode of path of the file is correct.
+                                                    # If not, users can use os.chmod(<dakota_driver_file_name>, 0o755)
+                                                    # to change it to the right mode.
+
+# Dakota parameters
+params['real'] = 2
+params['temp'] = 26.85
+
+# Response descriptors for Dakota (Make sure the descriptors match those in the watts_pyarc_exec.py)
+params['dakota_descriptor_1'] = 'KO'
+params['dakota_descriptor_2'] = 'CW'
+params['dakota_descriptor_3'] = 'KC'
+
+# File name of the final output from Dakota.
+# The default name of the output file is 'dakota_opt.dat'
+# If the user wishes to change the name of the output file, make sure to use the 'dakota_out_file' key.
+params['dakota_out_file'] = 'dakota_opt.dat'
+
+params.show_summary(show_metadata=False, sort_by='key')
+
+# Dakota Workflow
+# Use 'extra_template_inputs' to template optional extra files.
+# Use 'extra_inputs' to copy files to the temporary directory for WATTS operation.
+# Set 'auto_link_files = <string_name_for_files>' if users wish to automatically add all files 
+# in 'extra_template_inputs' and 'extra_inputs' to the 'link_files' option in the Dakota 
+# input file. In the Dakota input file, users must set "link_files = {{ <string_name_for_files> }}".
+dakota_plugin = watts.PluginDakota(
+    template_file='dakota_watts_opt.in',
+    extra_template_inputs=['watts_pyarc_exec.py', 'dakota_driver.py'],
+    extra_inputs=['pyarc_input.isotxs', 'pyarc_template', 'lumped.son', 'watts_dakota_exec.py'],
+    auto_link_files = 'auto_link_file_string_name',
+    show_stdout=True) # show all the output
+
+dakota_result = dakota_plugin(params)
+
+for key in dakota_result.output_data:
+    print(key, dakota_result.output_data[key])
\ No newline at end of file
diff --git a/examples/Optimization_PyARC_DAKOTA/watts_pyarc_exec.py b/examples/Optimization_PyARC_DAKOTA/watts_pyarc_exec.py
new file mode 100644
index 00000000..ee6fb9da
--- /dev/null
+++ b/examples/Optimization_PyARC_DAKOTA/watts_pyarc_exec.py
@@ -0,0 +1,64 @@
+# SPDX-FileCopyrightText: 2022 UChicago Argonne, LLC
+# SPDX-License-Identifier: MIT
+
+"""
+This example demonstrates how to use DAKOTA to perform 
+optimization of a WATTS workflow. 
+"""
+import shutil
+import watts
+import json
+from astropy.units import Quantity
+
+# TH params
+params = watts.Parameters()
+
+# Load Dakota's output and read Dakota's output from 'params.json' and directly 
+# input them to WATTS' params()
+f = open("params.json") 
+dakota_output = json.load(f)
+
+# Set PyARC variables ('assembly_pitch' and 'assembly_length') to the
+# values generated by Dakota. Make sure that keys ('AP' and 'AL' in this example) match the
+# variable descriptors in the Dakota input file.
+params['assembly_pitch'] = Quantity(dakota_output['AP'], "cm")  
+params['assembly_length'] = Quantity(dakota_output['AL'], "cm")  
+params['temp'] = Quantity({{ temp }}, "Celsius")  # 300 K
+
+# Store the Dakota response descriptors as dictionary.
+# Make sure that the ORDER of the descriptors matches the order in the Dakota input file
+# because WATTS uses the order to identify the descriptors. 
+# MUST use the 'dakota_descriptors' key word here. 
+params['dakota_descriptors'] = {
+    '{{ dakota_descriptor_1 }}': 'keff-dif3d', 
+    '{{ dakota_descriptor_2 }}': 'core_weight', 
+    '{{ dakota_descriptor_3 }}': 'keff-dif3d'
+}
+
+# PyARC Workflow
+pyarc_plugin = watts.PluginPyARC(
+    template_file='pyarc_template', show_stdout=True, 
+    extra_inputs=['lumped.son', 'pyarc_input.isotxs']) # show all the output
+pyarc_result = pyarc_plugin(params)
+
+# Store results to params.
+# Make sure the keys for params match the keys for 'dakota_descriptors' above.
+params['keff-dif3d'] = pyarc_result.results_data["keff_dif3d"][0.0]
+params['core_weight'] = pyarc_result.results_data["rebus_inventory"][0][('CORE', 'TOTAL')]
+
+# Generate output file for the next iteration.
+for p in pyarc_result.inputs:
+    if "pyarc_input.son" in str(p):
+        shutil.copyfile(str(p), "pyarc_input.son")
+for p in pyarc_result.outputs:
+    if "pyarc_input.summary" in str(p):
+        shutil.copyfile(str(p), "pyarc_input.summary")
+    if "pyarc_input.out" in str(p):
+        shutil.copyfile(str(p), "pyarc_input.out")
+
+# Save params() as a pickle file for data transfer between WATTS and Dakota.
+# The file MUST be named as 'opt_res.out'.
+# If this file is missing, an error will be generated.
+params.save("opt_res.out")
+
+print(f"results: KeffOpt = {params['keff-dif3d']} - HNinventory = {params['core_weight']} ")
diff --git a/examples/README.md b/examples/README.md
index de505480..73963459 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -20,6 +20,8 @@ Training on WATTS is enabled with a suite of simple examples:
 * [__Optimization_SAM-OpenMC_VHTR-scipy__](Optimization_SAM-OpenMC_VHTR-scipy/README.md) - 1-criteria optimization of SAM/OpenMC workflow using scipy
 * [__Optimization_SAM-OpenMC_VHTR-pymoo__](Optimization_SAM-OpenMC_VHTR-pymoo/README.md) - Multi-criteria optimization of SAM/OpenMC workflow using pymoo
 ## Examples with Sensitivity studies
+* [__Optimization_PyARC_DAKOTA__](Optimization_PyARC_DAKOTA/README.md) - Multi-criteria optimization of PyARC workflow using Dakota
+## Examples with Sensitivity studies
 * [__Sensitivity_SAM-OpenMC_VHTR-scipy-LHS__](Sensitivity_SAM-OpenMC_VHTR-scipy-LHS/README.md) - Sampling with LHS of SAM/OpenMC workflow using scipy
 ## Examples with Parameteric studies
 * [__ParamStudy_SAM_VHTR__](ParamStudy_SAM_VHTR/README.md) - Showcasing SAM with parametric study
diff --git a/src/watts/__init__.py b/src/watts/__init__.py
index a3903baa..9355b8d8 100644
--- a/src/watts/__init__.py
+++ b/src/watts/__init__.py
@@ -9,6 +9,7 @@
 from .plugin_relap5 import *
 from .plugin_mcnp import *
 from .plugin_serpent import *
+from .plugin_dakota import *
 from .template import *
 from .parameters import *
 from .database import *
diff --git a/src/watts/plugin_dakota.py b/src/watts/plugin_dakota.py
new file mode 100644
index 00000000..b7706f90
--- /dev/null
+++ b/src/watts/plugin_dakota.py
@@ -0,0 +1,256 @@
+# SPDX-FileCopyrightText: 2022 UChicago Argonne, LLC
+# SPDX-License-Identifier: MIT
+
+import os
+from datetime import datetime
+from pathlib import Path
+import pickle
+from typing import List, Optional
+
+import json
+import csv
+import numpy as np
+import pandas as pd
+import subprocess
+
+from .fileutils import PathLike
+from .parameters import Parameters
+from .plugin import TemplatePlugin
+from .results import Results
+
+
+class ResultsDakota(Results):
+    """Dakota simulation results
+
+    Parameters
+    ----------
+    params
+        Parameters used to generate inputs
+    name
+        Name of workflow producing results
+    time
+        Time at which workflow was run
+    inputs
+        List of input files
+    outputs
+        List of output files
+
+    Attributes
+    ----------
+    stdout
+        Standard output from Dakota run
+    output_data
+        Dictionary with data from .dat files
+    """
+    def __init__(self, params: Parameters, name: str, time: datetime,
+                 inputs: List[PathLike], outputs: List[PathLike]):
+        super().__init__(params, name, time, inputs, outputs)
+        self.output_data = self._get_Dakota_output(params)
+
+    def _get_Dakota_output(self, params: Parameters) -> dict:
+        """Read all Dakota '.dat' files and return results in a dictionary
+
+        Returns
+        -------
+        Results from Dakota .dat files
+
+        """
+
+        dakota_out_file_name = params.get('dakota_out_file', 'dakota_opt.dat')
+
+        # Save Dakota's main output '.dat' files
+        output_data = {}
+        
+        if Path(dakota_out_file_name).exists():
+            with open(dakota_out_file_name) as f:
+                col_names = f.readline().split()
+            df = pd.read_csv(dakota_out_file_name, sep="\s+", skiprows=1, names=col_names)
+
+            for name in col_names:
+                output_data[name] = np.array(df[name])
+
+        # Save Dakota's final output '.dat' files
+        if Path('finaldata1.dat').exists():
+            with open('finaldata1.dat') as fd:
+                reader = csv.reader(fd)
+                rows = [row for idx, row in enumerate(reader) if idx == 0]
+
+            new_rows = str(rows[0][0]).split()
+            output_data['finaldata1'] = np.array([float(i) for i in new_rows])
+
+        return output_data
+
+
+class PluginDakota(TemplatePlugin):
+    """Plugin for running Dakota
+
+    Parameters
+    ----------
+    template_file
+        Templated Dakota input
+    extra_inputs
+        List of extra (non-templated) input files that are needed
+    extra_template_inputs
+        Extra templated input files
+    show_stdout
+        Whether to display output from stdout when Dakota is run
+    show_stderr
+        Whether to display output from stderr when Dakota is run
+
+    """
+
+    def __init__(self, template_file: str,  
+                 extra_inputs: Optional[List[str]] = None,
+                 extra_template_inputs: Optional[List[PathLike]] = None,
+                 auto_link_files: Optional[str] = None,
+                 show_stdout: bool = False, show_stderr: bool = False):
+        super().__init__(template_file, extra_inputs, extra_template_inputs,
+                         show_stdout, show_stderr)
+
+        dakota_dir = Path(os.environ.get("DAKOTA_DIR", ""))
+        self._executable = dakota_dir / f"dakota.sh"
+        self.input_name = template_file
+        self._auto_link_files = auto_link_files
+
+        # Setup to automatically include all 'extra_inputs' and 'extra_template_inputs' 
+        # to Dakota's "link files" option. Create a string of the file names in
+        # 'extra_inputs' and 'extra_template_inputs'.
+        if self._auto_link_files is not None:
+            dakota_link_files = []
+            if extra_inputs is not None:
+                dakota_link_files.extend(list(extra_inputs))
+
+            if extra_template_inputs is not None:
+                dakota_link_files.extend(list(extra_template_inputs))
+
+            self.dakota_link_files_string = " ".join(f"'{item}'" for item in dakota_link_files)
+
+    def prerun(self, params: Parameters, filename: Optional[str] = None):
+        """ Change the permisison of the Dakota driver file
+
+        Parameters
+        ----------
+        params
+            Parameters used to render template
+        """
+
+        # Store the string for the "link_files" in params.
+        if self._auto_link_files is not None:
+            params[self._auto_link_files] = self.dakota_link_files_string
+
+        super().prerun(params)
+        if 'dakota_driver_name' in params.keys():
+            os.chmod(params['dakota_driver_name'], 0o755)
+
+    @property
+    def execute_command(self):
+        return [str(self.executable), "-i", self.input_name]
+
+
+def run_dakota_driver(coupled_code_exec: str):
+    """ Function to execute the workflow for data
+    exchange between Dakota and the coupled code
+    in the Dakota driver script.
+
+    Parameters
+    ----------
+    coupled_code_exec
+        The name of the WATTS python script of the
+        coupled code.
+    """
+    results = _parse_dakota_input()
+    retval = _run_coupled_code(coupled_code_exec)
+    _return_dakota_input(results, retval)
+
+
+def _parse_dakota_input() -> Results:
+    """Parse Dakota input
+
+    Parameters
+    ----------
+
+    Returns
+    -------
+    results
+        Results from Dakota's output file
+    """
+    
+    from interfacing import interfacing as di # Dakota's interface module        
+
+    params, results = di.read_parameters_file()
+
+    # Dump params to external params.json file for future use by the template engine
+    params_for_template_engine_file_path = "params.json"
+    with open(params_for_template_engine_file_path, 'w') as outfile:
+        f = json.dump(params._variables,  outfile, default=lambda o: o.__dict__)
+    return(results)
+
+
+def _run_coupled_code(coupled_code_exec: str) -> dict:
+    """ Run the coupled code
+
+    Parameters
+    ----------
+    coupled_code_exec
+        The name of the WATTS python script of the
+        coupled code.
+
+    Returns
+    -------
+    retval
+        Processed output from the coupled code.
+    """
+
+    if not os.path.exists(coupled_code_exec):
+        raise FileNotFoundError("Coupled-code script missing.")
+
+    subprocess.check_output(["python", coupled_code_exec])
+
+    # Read the 'opt_res.out' pickle file and 
+    # store the results to 'res_output' for data
+    # transfer with Dakota.
+    if os.path.exists('opt_res.out'): 
+        db = pickle.load(open('opt_res.out', 'rb'))
+        if 'dakota_descriptors' in db.keys():
+            res_output = []
+            for key in db['dakota_descriptors']:
+                res_output.append(db[db['dakota_descriptors'][key]])
+    else:
+        raise RuntimeError("'opt_res.out' file is missing.")
+
+    return {'fns': res_output}
+
+
+def _return_dakota_input(results: Results, retval: dict):
+    """ Return the output of the coupled code
+    to Dakota.
+
+    Parameters
+    ----------
+    results
+        Results from Dakota's output file
+    retval
+        Processed output from the coupled code.
+    """
+
+    # Insert extracted values into results
+    # Results iterator provides an index, response name, and response
+    try:
+        for i, n, r in results:
+            if r.asv.function:
+                # Returns the response function value from the coupled 
+                # code to Dakota (for pre Dakota v6.9). 
+                try:
+                    r.function = retval['fns'][i]
+                except:
+                    pass
+    # Catch Dakota 6.9 exception where results interface has changed
+    # ValueError: too many values to unpack
+    except ValueError:
+        for i, (n, r) in enumerate(results.items()):
+            r.function = retval['fns'][i]
+    results.write()
+
+    # Dump to external results.json file
+    with open('results.json', 'w') as outfile:
+        rst = json.dump(results, outfile, default=lambda o: o.__dict__)
\ No newline at end of file


Test patch:
diff --git a/tests/test_results.py b/tests/test_results.py
index dda19062..b04db925 100755
--- a/tests/test_results.py
+++ b/tests/test_results.py
@@ -215,3 +215,56 @@ def test_results_relap5(run_in_tmpdir):
     assert new_results.inputs == results.inputs
     assert new_results.outputs == results.outputs
     assert new_results.stdout == results.stdout
+
+
+def test_results_dakota(run_in_tmpdir):
+    params = watts.Parameters(city='Chicago', population=2.7e6)
+    name = 'North_Dakota'
+    now = datetime.now()
+
+    # Create some fake input files
+    dakota_inp = Path('dakota.inp')
+    dakota_inp.touch()
+    inputs = [dakota_inp]
+
+    # Create fake output files
+    csv = Path('dakota_opt.dat')
+    csv.write_text("""\
+prop1  prop2
+3.5  1
+4.0  2
+5.0  3
+    """)
+    stdout = Path('Dakota_log.txt')
+    stdout.write_text('DAKOTA standard out\n')
+    outputs = [csv, stdout]
+
+    results = watts.ResultsDakota(params, name, now, inputs, outputs)
+
+    # Sanity checks
+    assert results.plugin == 'Dakota'
+    assert results.parameters == params
+    assert results.name == name
+    assert results.time == now
+    assert results.inputs == inputs
+    assert results.outputs == outputs
+
+    # Other attributes
+    assert results.stdout == 'DAKOTA standard out\n'
+    np.testing.assert_equal(results.output_data['prop1'], [3.5, 4.0, 5.0])
+    np.testing.assert_equal(results.output_data['prop2'], [1, 2, 3])
+
+    # Saving
+    p = Path('myresults.pkl')
+    results.save(p)
+    assert p.is_file()
+
+    # Ensure results read from file match
+    new_results = watts.Results.from_pickle(p)
+    assert isinstance(new_results, watts.ResultsDakota)
+    assert new_results.parameters == results.parameters
+    assert new_results.name == results.name
+    assert new_results.time == results.time
+    assert new_results.inputs == results.inputs
+    assert new_results.outputs == results.outputs
+    assert new_results.stdout == results.stdout
\ No newline at end of file