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

Issue:
Move some logic in plugins to base Plugin class to avoid duplication
This PR makes some simplifications in each of the concrete Plugin classes by:
- Moving the logic for redirection of stdout/stderr to the base `Plugin` class
- Also move the `print()` statements for prerun, run, and postrun to the base `Plugin` class

I've also updated the CHANGELOG, which was missing updates from previous PRs.


Discussion / hints:
Added a few more updates in this PR:
- The `params` argument to `Plugin.__call__` is now optional. This means you can create and run a plugin without having to use parameters if you simply want to execute a code with an input file that is not templated
- Combined redundant logic for `prerun` into the `TemplatePlugin` class. Many of the plugins now don't need specialization of the prerun method and can use the default provided by `TemplatePlugin`

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bf50367..1509dca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
   indicating extra template files that should be rendered
 * The `PluginOpenMC` class now takes an optional `function` argument that
   specifies an arbitrary execution sequence
+* All plugins consistently use an attribute `executable` for specifying the path
+  to an executable
 
 ## [0.2.0]
 
diff --git a/doc/source/user/usage.rst b/doc/source/user/usage.rst
index 22b92619..231c0045 100755
--- a/doc/source/user/usage.rst
+++ b/doc/source/user/usage.rst
@@ -111,9 +111,9 @@ file (mesh or cross-section files).
 
 The SAM executable defaults to ``sam-opt`` (assumed to be present on your
 :envvar:`PATH`) but can also be specified explicitly with the
-:attr:`~watts.PluginMOOSE.moose_exec` attribute::
+:attr:`~watts.PluginMOOSE.executable` attribute::
 
-    moose_plugin.moose_exec = "/path/to/sam-opt"
+    moose_plugin.executable = "/path/to/sam-opt"
 
 To execute SAM, the :class:`~watts.PluginMOOSE` instance is called as a function
 and expects to receive an instance of :class:`~watts.Parameters`. For the above
@@ -229,9 +229,9 @@ instantiated with following command line::
     pyarc_plugin = watts.PluginPyARC('pyarc_template', show_stdout=True, extra_inputs=['lumped_test5.son'])
 
 The path to PyARC directory must be specified explicitly with the
-:attr:`~watts.PluginPyARC.pyarc_exec` attribute::
+:attr:`~watts.PluginPyARC.executable` attribute::
 
-    pyarc_plugin.pyarc_exec  = "/path/to/PyARC"
+    pyarc_plugin.executable  = "/path/to/PyARC"
 
 To execute PyARC, the :meth:`~watts.PluginPyARC` instance is called directly the
 same way as other plugins.
@@ -247,8 +247,8 @@ SAS4A/SASSY-1 Plugin
 ~~~~~~~~~~~~~~~~~~~~
 
 The :class:`~watts.PluginSAS` class handles SAS4A/SASSY-1 execution in a similar
-manner to the :class:`~watts.PluginMOOSE` class for MOOSE. SAS4A/SASSY-1 uses text-based
-input files which can be templated as follows:
+manner to the :class:`~watts.PluginMOOSE` class for MOOSE. SAS4A/SASSY-1 uses
+text-based input files which can be templated as follows:
 
 .. code-block:: jinja
 
@@ -256,17 +256,17 @@ input files which can be templated as follows:
     3     1 {{ total_reactor_power }}
     7     1                {{ tmax }}
 
-If the templated input file is `sas_template`, then the SAS4A/SASSY-1 plugin can be
-instantiated with the following command line::
+If the templated input file is `sas_template`, then the SAS4A/SASSY-1 plugin can
+be instantiated with the following command line::
 
     sas_plugin = watts.PluginSAS('sas_template', show_stdout=True)
 
 The SAS executable is OS-dependent. It defaults to ``sas.x`` (assumed to be
 present on your :envvar:`PATH`) for Linux and macOS, and ``sas.exe`` for
 Windows. However, the executable can also be specified explicitly with the
-:attr:`~watts.PluginSAS.sas_exec` attribute::
+:attr:`~watts.PluginSAS.executable` attribute::
 
-    sas_plugin.sas_exec = "/path/to/sas-exec"
+    sas_plugin.executable = "/path/to/sas-exec"
 
 Furthermore, the paths to the SAS utilities that convert the ".dat" files to
 ".csv" files must be specified with the :attr:`~watts.PluginSAS.conv_channel`
@@ -282,10 +282,10 @@ other plugins.
 RELAP5-3D Plugin
 ~~~~~~~~~~~~~~~~~~~~
 
-The :class:`~watts.PluginRELAP5` class handles execution of RELAP5-3D. Note that the
-plugin is designed for the execution of RELAP5-3D v4.3.4 and thus may not be compatible
-with other version of RELAP5-3D. RELAP5-3D uses text-based input files that can be
-templated as follows:
+The :class:`~watts.PluginRELAP5` class handles execution of RELAP5-3D. Note that
+the plugin is designed for the execution of RELAP5-3D v4.3.4 and thus may not be
+compatible with other version of RELAP5-3D. RELAP5-3D uses text-based input
+files that can be templated as follows:
 
 .. code-block:: jinja
 
@@ -299,11 +299,11 @@ instantiated with the following command line::
 
     relap5_plugin = watts.PluginRELAP5('relap5_template', show_stdout=True)
 
-RELAP5-3D requires the executable, license key, and the input file to be in the same
-directory to run. Thus, before running the RELAP5-3D plugin, user needs to specify the
-directory that the executable and the license key are in (must be in the same directory).
-This can be done by adding the ``RELAP5_DIR`` variable to the environment or by
-explicitly specifying the path in the Python script as::
+RELAP5-3D requires the executable, license key, and the input file to be in the
+same directory to run. Thus, before running the RELAP5-3D plugin, user needs to
+specify the directory that the executable and the license key are in (must be in
+the same directory). This can be done by adding the ``RELAP5_DIR`` variable to
+the environment or by explicitly specifying the path in the Python script as::
 
     relap5_plugin.relap5_dir = "\path\to\executable\and\license"
 
@@ -311,23 +311,27 @@ The RELAP5 executable is OS-dependent. It defaults to ``relap5.x`` (assumed to b
 present on your :envvar:`PATH`) for Linux and macOS, and ``relap5.exe`` for
 Windows.
 
-The plugin also supports extra input options to RELAP5-3D. User simply needs to add the
-extra options as a string to the ``extra_option`` argument when instantiating the plugin as follows::
+The plugin also supports extra input options to RELAP5-3D. You simply need to
+add the extra options as a list of strings to the ``extra_args`` argument when
+calling the plugin as follows::
 
-    relap5_plugin = watts.PluginRELAP5('relap5_template',
-                                        extra_options = ['-w', 'tpfh2o', '-e', 'tpfn2', '-tpfdir', 'location\of\fluid\property\files'])
+    relap5_plugin = watts.PluginRELAP5('relap5_template')
+    relap5_plugin(params, extra_args=['-w', 'tpfh2o', '-e', 'tpfn2', '-tpfdir', 'location\of\fluid\property\files'])
 
-Note that the fluid property files can be specified as extra input options, as shown above. Another
-approach is simply put them in the same directory as the executable and license key before running the plugin.
+Note that the fluid property files can be specified via ``extra_args``, as shown
+above. Another approach is to simply put them in the same directory as the
+executable and license key before running the plugin.
 
-For the postprocessing of RELAP5-3D results, the plugin converts the default "plotfl" plot file generated
-by RELAP5-3D into a ".CSV" file. Card-104 must be specified as "ascii" in the RELAP5-3D input file as::
+For the postprocessing of RELAP5-3D results, the plugin converts the default
+"plotfl" plot file generated by RELAP5-3D into a ".CSV" file. Card-104 must be
+specified as "ascii" in the RELAP5-3D input file as::
 
     104          ascii
 
-to ensure that the "plotfl" is in ASCII format instead of the default binary format. As the conversion
-process could be computationally expensive, user can turn it off by omitting Card-104 in the RELAP5-3D input
-file and adding ``plotfl_to_csv=False`` when instantiating the plugin as follows::
+to ensure that the "plotfl" is in ASCII format instead of the default binary
+format. As the conversion process could be computationally expensive, user can
+turn it off by omitting Card-104 in the RELAP5-3D input file and adding
+``plotfl_to_csv=False`` when instantiating the plugin as follows::
 
     relap5_plugin = watts.PluginRELAP5('relap5_template', plotfl_to_csv=False)
 
diff --git a/examples/1App_BISON_MetalFuel/watts_exec.py b/examples/1App_BISON_MetalFuel/watts_exec.py
index 336d5ed7..69f1204d 100644
--- a/examples/1App_BISON_MetalFuel/watts_exec.py
+++ b/examples/1App_BISON_MetalFuel/watts_exec.py
@@ -42,9 +42,9 @@
 
 moose_app_type = "bison"
 app_dir = os.environ[moose_app_type.upper() + "_DIR"]
-moose_plugin = watts.PluginMOOSE('bison_template', show_stdout=True, n_cpu=2) # show all the output
-moose_plugin.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
-moose_result = moose_plugin(params)
+moose_plugin = watts.PluginMOOSE('bison_template', show_stdout=True) # show all the output
+moose_plugin.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
+moose_result = moose_plugin(params, mpi_args=['mpiexec', '-n', '2'])
 for key in moose_result.csv_data:
     print(key, moose_result.csv_data[key])
 print(moose_result.inputs)
diff --git a/examples/1App_MOOSE-MultiApp_Simple/watts_exec.py b/examples/1App_MOOSE-MultiApp_Simple/watts_exec.py
index ada2c0c6..713f388c 100644
--- a/examples/1App_MOOSE-MultiApp_Simple/watts_exec.py
+++ b/examples/1App_MOOSE-MultiApp_Simple/watts_exec.py
@@ -23,7 +23,7 @@
 moose_app_type = "bison"
 app_dir = os.environ[moose_app_type.upper() + "_DIR"]
 moose_plugin = watts.PluginMOOSE('main.tmpl', extra_inputs=['main_in.e', 'sub.i'])
-moose_plugin.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
+moose_plugin.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
 moose_result = moose_plugin(params)
 for key in moose_result.csv_data:
     print(key, moose_result.csv_data[key])
diff --git a/examples/1App_RELAP5_SingleChannel/watts_exec.py b/examples/1App_RELAP5_SingleChannel/watts_exec.py
index 21526921..721a208a 100755
--- a/examples/1App_RELAP5_SingleChannel/watts_exec.py
+++ b/examples/1App_RELAP5_SingleChannel/watts_exec.py
@@ -21,7 +21,7 @@
 
 # Channel params
 params['inlet_pressure'] = 3.0e5
-params['outlet_pressure'] = 1.013e5 
+params['outlet_pressure'] = 1.013e5
 params['heater_power'] = Quantity(20, "kW")
 
 
@@ -32,8 +32,8 @@
 
 # Example of input with extra options, including explicitly specifying the
 # locations of fluid files.
-# relap5_plugin = watts.PluginRELAP5('relap5_template', 
-#                                     extra_options = ['-w', 'tpfh2o', '-e', 'tpfn2', '-tpfdir', 'location\of\fluid\property\files'])
+# relap5_plugin = watts.PluginRELAP5('relap5_template')
+# relap5_plugin(params, extra_args=['-w', 'tpfh2o', '-e', 'tpfn2', '-tpfdir', 'location\of\fluid\property\files'])
 
 # If RELAP5_DIR is not added to the environment, the directory where the
 # executable and license key are stored can be specified explicitly.
@@ -43,4 +43,4 @@
 for key in relap5_result.csv_data:
     print(key, relap5_result.csv_data[key])
 print(relap5_result.inputs)
-print(relap5_result.outputs)
\ No newline at end of file
+print(relap5_result.outputs)
diff --git a/examples/1App_SAM_VHTR/watts_exec.py b/examples/1App_SAM_VHTR/watts_exec.py
index c341f68b..dd85d392 100644
--- a/examples/1App_SAM_VHTR/watts_exec.py
+++ b/examples/1App_SAM_VHTR/watts_exec.py
@@ -2,7 +2,7 @@
 # SPDX-License-Identifier: MIT
 
 """
-This example demonstrates how to use WATTS to perform 
+This example demonstrates how to use WATTS to perform
 SAM calculations. This example uses a simple VHTR unit-cell
 model with 1 coolant channel surrounded by graphite and fuel.
 The demonstration includes the application of unit-conversion
@@ -57,7 +57,7 @@
 moose_app_type = "SAM"
 app_dir = os.environ[moose_app_type.upper() + "_DIR"]
 moose_plugin = watts.PluginMOOSE(moose_app_type.lower() + '_template') # show all the output
-moose_plugin.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
+moose_plugin.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
 moose_result = moose_plugin(params)
 for key in moose_result.csv_data:
     print(key, moose_result.csv_data[key])
diff --git a/examples/Main_SAM-OpenMC_VHTR/watts_main.py b/examples/Main_SAM-OpenMC_VHTR/watts_main.py
index cf9b44cd..eb290588 100644
--- a/examples/Main_SAM-OpenMC_VHTR/watts_main.py
+++ b/examples/Main_SAM-OpenMC_VHTR/watts_main.py
@@ -3,7 +3,7 @@
 
 """
 This example just uses the workflow in `MultiApp_SAM-OpenMC_VHTR`
-and applies it in a procedure for re-use in optimization and 
+and applies it in a procedure for re-use in optimization and
 sensitivity analyses.
 """
 
@@ -68,7 +68,7 @@ def calc_workflow(X):
     moose_app_type = "SAM"
     app_dir = os.environ[moose_app_type.upper() + "_DIR"]
     sam_plugin = watts.PluginMOOSE('../1App_SAM_VHTR/sam_template', show_stderr=False) # does not show anything
-    sam_plugin.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
+    sam_plugin.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
     sam_result = sam_plugin(params)
     max_Tf = max(sam_result.csv_data[f'max_Tf_{i}'][-1] for i in range(1, 6))
     avg_Tf = mean(sam_result.csv_data[f'avg_Tf_{i}'][-1] for i in range(1, 6))
diff --git a/examples/MultiApp_SAM-OpenMC_VHTR/watts_exec.py b/examples/MultiApp_SAM-OpenMC_VHTR/watts_exec.py
index 95de6c30..7612e306 100644
--- a/examples/MultiApp_SAM-OpenMC_VHTR/watts_exec.py
+++ b/examples/MultiApp_SAM-OpenMC_VHTR/watts_exec.py
@@ -2,8 +2,8 @@
 # SPDX-License-Identifier: MIT
 
 """
-This example demonstrates how to use WATTS to perform 
-SAM calculations followed by OpenMC calculation. 
+This example demonstrates how to use WATTS to perform
+SAM calculations followed by OpenMC calculation.
 This example uses a simple VHTR unit-cell
 model with 1 coolant channel surrounded by graphite and fuel.
 The demonstration includes the application of unit-conversion
@@ -75,7 +75,7 @@
 moose_app_type = "SAM"
 app_dir = os.environ[moose_app_type.upper() + "_DIR"]
 moose_plugin = watts.PluginMOOSE('../1App_SAM_VHTR/sam_template', show_stderr=True) # show only error
-moose_plugin.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
+moose_plugin.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
 moose_result = moose_plugin(params)
 for key in moose_result.csv_data:
     print(key, moose_result.csv_data[key])
diff --git a/examples/MultiStep_Griffin-BISON-Sockeye_MR/watts_exec.py b/examples/MultiStep_Griffin-BISON-Sockeye_MR/watts_exec.py
index e93045cc..04475c79 100644
--- a/examples/MultiStep_Griffin-BISON-Sockeye_MR/watts_exec.py
+++ b/examples/MultiStep_Griffin-BISON-Sockeye_MR/watts_exec.py
@@ -41,9 +41,10 @@
 
 # MOOSE Workflow for steady state
 print("Steady-state calculation")
-moose_plugin_ss = watts.PluginMOOSE('MP_ss_griffin.tmpl', n_cpu=40, extra_inputs=['MP_ss_moose.i', 'MP_ss_sockeye.i', '3D_unit_cell_FY21_level-1_bison.e', '3D_unit_cell_FY21_supersimple.e', 'unitcell_nogap_hom_xml_G11_df_MP.xml'])
-moose_plugin_ss.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
-moose_result_ss = moose_plugin_ss(params_ss)
+mpi_args = ['mpiexec', '-n', '40']
+moose_plugin_ss = watts.PluginMOOSE('MP_ss_griffin.tmpl', extra_inputs=['MP_ss_moose.i', 'MP_ss_sockeye.i', '3D_unit_cell_FY21_level-1_bison.e', '3D_unit_cell_FY21_supersimple.e', 'unitcell_nogap_hom_xml_G11_df_MP.xml'])
+moose_plugin_ss.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
+moose_result_ss = moose_plugin_ss(params_ss, mpi_args=mpi_args)
 for key in moose_result_ss.csv_data:
     print(key, moose_result_ss.csv_data[key])
 print(moose_result_ss.inputs)
@@ -57,9 +58,9 @@
 
 # MOOSE Workflow for Null transient
 print("Null transient calculation")
-moose_plugin_trN = watts.PluginMOOSE('MP_trN_griffin.tmpl', n_cpu=40, show_stdout=False, extra_inputs=['MP_trN_moose.i', 'MP_trN_sockeye.i', 'unitcell_nogap_hom_xml_G11_df_MP.xml'])
-moose_plugin_trN.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
-moose_result_trN = moose_plugin_trN(params_trN)
+moose_plugin_trN = watts.PluginMOOSE('MP_trN_griffin.tmpl', show_stdout=False, extra_inputs=['MP_trN_moose.i', 'MP_trN_sockeye.i', 'unitcell_nogap_hom_xml_G11_df_MP.xml'])
+moose_plugin_trN.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
+moose_result_trN = moose_plugin_trN(params_trN, mpi_args=mpi_args)
 for key in moose_result_trN.csv_data:
     print(key, moose_result_trN.csv_data[key])
 print(moose_result_trN.inputs)
@@ -77,9 +78,9 @@
 
 # MOOSE Workflow for transient
 print("Transient calculation")
-moose_plugin_tr = watts.PluginMOOSE('MP_tr_griffin.tmpl', n_cpu=40, show_stdout=True, extra_inputs=['MP_tr_moose.i', 'MP_tr_sockeye.i', 'unitcell_nogap_hom_xml_G11_df_MP.xml'])
-moose_plugin_tr.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
-moose_result_tr = moose_plugin_tr(params_tr)
+moose_plugin_tr = watts.PluginMOOSE('MP_tr_griffin.tmpl', show_stdout=True, extra_inputs=['MP_tr_moose.i', 'MP_tr_sockeye.i', 'unitcell_nogap_hom_xml_G11_df_MP.xml'])
+moose_plugin_tr.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
+moose_result_tr = moose_plugin_tr(params_tr, mpi_args=mpi_args)
 for key in moose_result_tr.csv_data:
     print(key, moose_result_tr.csv_data[key])
 print(moose_result_tr.inputs)
diff --git a/examples/ParamStudy_SAM_VHTR/watts_exec.py b/examples/ParamStudy_SAM_VHTR/watts_exec.py
index 55675e44..da8accf7 100644
--- a/examples/ParamStudy_SAM_VHTR/watts_exec.py
+++ b/examples/ParamStudy_SAM_VHTR/watts_exec.py
@@ -2,16 +2,16 @@
 # SPDX-License-Identifier: MIT
 
 """
-This example demonstrates how to use WATTS to perform 
-multiple runs and extract selected results from the runs. 
-The demonstration includes an approach to save selected 
-results from different runs into a single CSV file for 
+This example demonstrates how to use WATTS to perform
+multiple runs and extract selected results from the runs.
+The demonstration includes an approach to save selected
+results from different runs into a single CSV file for
 ease of comparison, visualization, and post-processing.
 This demonstration uses SAM with a single PbCoreChannel
-with inlet and outlet boundary conditions. The input 
-power of the channel is varied. The simulation is run 
+with inlet and outlet boundary conditions. The input
+power of the channel is varied. The simulation is run
 as transient where the end time is varied to artificially
-create results of different lengths to show that the 
+create results of different lengths to show that the
 output CSV file can accept columns of different lengths.
 """
 
@@ -57,7 +57,7 @@
 
     # Execute WATTS
     moose_plugin = watts.PluginMOOSE(moose_app_type.lower() + '_template') # show all the output
-    moose_plugin.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
+    moose_plugin.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
     moose_result = moose_plugin(params)
 
     # Add items to dictionary.
@@ -69,4 +69,4 @@
 df = pd.DataFrame({k: pd.Series(v) for k, v in results_dict.items()})
 
 # Sort column names alphabetically and save as CSV file
-df.sort_index(axis=1).to_csv('results.csv') 
\ No newline at end of file
+df.sort_index(axis=1).to_csv('results.csv')
diff --git a/examples/PicardIterations_SAM-OpenMC_VHTR/watts_exec.py b/examples/PicardIterations_SAM-OpenMC_VHTR/watts_exec.py
index 777bfe5b..2c78d1f7 100644
--- a/examples/PicardIterations_SAM-OpenMC_VHTR/watts_exec.py
+++ b/examples/PicardIterations_SAM-OpenMC_VHTR/watts_exec.py
@@ -3,7 +3,7 @@
 
 """
 This example demonstrates how to use WATTS to perform several
-iterations where SAM calculations are followed by OpenMC 
+iterations where SAM calculations are followed by OpenMC
 calculation and temperature/power information are exchanged
 until convergence. This example uses a simple VHTR unit-cell
 model with 1 coolant channel surrounded by graphite and fuel.
@@ -70,7 +70,7 @@
     moose_app_type = "SAM"
     app_dir = os.environ[moose_app_type.upper() + "_DIR"]
     sam_plugin = watts.PluginMOOSE('../1App_SAM_VHTR/sam_template', show_stderr=True) # show only error
-    sam_plugin.moose_exec = app_dir + "/" + moose_app_type.lower() + "-opt"
+    sam_plugin.executable = app_dir + "/" + moose_app_type.lower() + "-opt"
     sam_result = sam_plugin(params)
 
     # get temperature from SAM results
diff --git a/src/watts/__init__.py b/src/watts/__init__.py
old mode 100755
new mode 100644
diff --git a/src/watts/plugin.py b/src/watts/plugin.py
index 6972ded3..f3881755 100644
--- a/src/watts/plugin.py
+++ b/src/watts/plugin.py
@@ -11,10 +11,11 @@
 from typing import Optional, List
 
 from .database import Database
-from .fileutils import cd_tmpdir, PathLike, tee_stdout, tee_stderr
+from .fileutils import cd_tmpdir, PathLike, tee_stdout, tee_stderr, run as run_proc
 from .parameters import Parameters
 from .results import Results
 from .template import TemplateRenderer
+import watts
 
 
 class Plugin(ABC):
@@ -142,6 +143,10 @@ class TemplatePlugin(Plugin):
     show_stderr
         Whether to display output from stderr when :meth:`run` is called
 
+    Attributes
+    ----------
+    executable
+        Path to plugin executable
 
     """
     def __init__(self, template_file: PathLike,
@@ -155,25 +160,15 @@ def __init__(self, template_file: PathLike,
         if extra_template_inputs is not None:
             self.extra_render_templates = [TemplateRenderer(f, '') for f in extra_template_inputs]
 
-    def _get_result_input(self, input_filename: str):
-        """Get the data needed to create the postrun results object
-
-        Parameters
-        ----------
-        input_filename
-            Name of the input file for the plugin code
+    @property
+    def executable(self) -> Path:
+        return self._executable
 
-        Returns
-        -------
-        tuple of data used to create the results object
-        """
-        time = datetime.fromtimestamp(self._run_time * 1e-9)
-        inputs = [p.name for p in self.extra_inputs]
-        inputs.append(input_filename)
-        for renderer in self.extra_render_templates:
-            inputs.append(renderer.template_file.name)
-        outputs = [p for p in Path.cwd().iterdir() if p.name not in inputs]
-        return time, inputs, outputs
+    @executable.setter
+    def executable(self, exe: PathLike):
+        if shutil.which(exe) is None:
+            raise RuntimeError(f"{self.plugin_name} executable '{exe}' is missing.")
+        self._executable = Path(exe)
 
     def prerun(self, params: Parameters, filename: Optional[str] = None):
         """Render the template based on model parameters
@@ -198,3 +193,50 @@ def prerun(self, params: Parameters, filename: Optional[str] = None):
         self.render_template(params_copy, filename=filename)
         for render_template in self.extra_render_templates:
             render_template(params_copy)
+
+    def postrun(self, params: Parameters, name: str, **kwargs) -> Results:
+        """Read simulation results and create results object
+
+        Parameters
+        ----------
+        params
+            Parameters used to generate input files
+        name
+            Name of the plugin
+        **kwargs
+            Keyword arguments for Results subclasses
+
+        Returns
+        -------
+        Results object
+        """
+
+        # Determine time, inputs and outputs
+        time = datetime.fromtimestamp(self._run_time * 1e-9)
+        inputs = [self.input_name] + [p.name for p in self.extra_inputs]
+        for renderer in self.extra_render_templates:
+            inputs.append(renderer.template_file.name)
+        outputs = [p for p in Path.cwd().iterdir() if p.name not in inputs]
+
+        # Get correct Results subclass and return instance
+        results_cls = getattr(watts, f'Results{self.plugin_name}')
+        return results_cls(params, name, time, inputs, outputs, **kwargs)
+
+    def run(self, mpi_args: Optional[List[str]] = None,
+            extra_args: Optional[List[str]] = None):
+        """Run plugin
+
+        Parameters
+        ----------
+        mpi_args
+            MPI execute command and any additional MPI arguments to pass,
+            e.g. ['mpiexec', '-n', '8'].
+        extra_args
+            Additional command-line arguments to append after the main command
+
+        """
+        if mpi_args is None:
+            mpi_args = []
+        if extra_args is None:
+            extra_args = []
+        run_proc(mpi_args + self.execute_command + extra_args)
diff --git a/src/watts/plugin_moose.py b/src/watts/plugin_moose.py
index 98d70844..a33ddcab 100644
--- a/src/watts/plugin_moose.py
+++ b/src/watts/plugin_moose.py
@@ -3,13 +3,12 @@
 
 from datetime import datetime
 from pathlib import Path
-import shutil
 from typing import List, Optional
 
 import numpy as np
 import pandas as pd
 
-from .fileutils import PathLike, run as run_proc
+from .fileutils import PathLike
 from .parameters import Parameters
 from .plugin import TemplatePlugin
 from .results import Results
@@ -40,13 +39,9 @@ class ResultsMOOSE(Results):
     """
     def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[PathLike], outputs: List[PathLike]):
-        super().__init__('MOOSE', params, name, time, inputs, outputs)
+        super().__init__(params, name, time, inputs, outputs)
         self.csv_data = self._save_MOOSE_csv()
 
-    @property
-    def stdout(self) -> str:
-        return (self.base_path / "MOOSE_log.txt").read_text()
-
     def _save_MOOSE_csv(self) -> dict:
         """Read all MOOSE '.csv' files and return results in a dictionary
 
@@ -89,8 +84,6 @@ class PluginMOOSE(TemplatePlugin):
     ----------
     template_file
         Templated MOOSE input
-    n_cpu
-        Number of processors to be used to run MOOSE application
     extra_inputs
         List of extra (non-templated) input files that are needed
     extra_template_inputs
@@ -102,62 +95,20 @@ class PluginMOOSE(TemplatePlugin):
 
     Attributes
     ----------
-    moose_exec
+    executable
         Path to MOOSE executable
 
     """
 
-    def __init__(self, template_file: str, n_cpu: int = 1,
+    def __init__(self, template_file: str,
                  extra_inputs: Optional[List[str]] = None,
                  extra_template_inputs: Optional[List[PathLike]] = None,
                  show_stdout: bool = False, show_stderr: bool = False):
         super().__init__(template_file, extra_inputs, extra_template_inputs,
                          show_stdout, show_stderr)
-        self._moose_exec = Path('moose-opt')
+        self._executable = Path('moose-opt')
         self.input_name = "MOOSE.i"
-        if n_cpu < 1:
-            raise RuntimeError("The CPU number used to run MOOSE app must be a natural number.")
-        self.n_cpu = n_cpu
 
     @property
-    def moose_exec(self) -> Path:
-        return self._moose_exec
-
-    @moose_exec.setter
-    def moose_exec(self, exe: PathLike):
-        if shutil.which(exe) is None:
-            raise RuntimeError(f"MOOSE executable '{exe}' is missing.")
-        self._moose_exec = Path(exe)
-
-    def options(self, moose_exec):
-        """Input MOOSE user-specified options
-
-        Parameters
-        ----------
-        MOOSE_exec
-            Path to MOOSE executable
-        """
-        self.moose_exec = moose_exec
-
-    def run(self):
-        """Run MOOSE"""
-        run_proc(["mpiexec", "-n", str(self.n_cpu), self.moose_exec,
-                    "-i", self.input_name])
-
-    def postrun(self, params: Parameters, name: str) -> ResultsMOOSE:
-        """Read MOOSE results and create results object
-
-        Parameters
-        ----------
-        params
-            Parameters used to create MOOSE model
-        name
-            Name of the workflow
-
-        Returns
-        -------
-        MOOSE results object
-        """
-
-        time, inputs, outputs = self._get_result_input(self.input_name)
-        return ResultsMOOSE(params, name, time, inputs, outputs)
+    def execute_command(self):
+        return [self.executable, "-i", self.input_name]
diff --git a/src/watts/plugin_openmc.py b/src/watts/plugin_openmc.py
index ee94b9fb..fd3600a3 100644
--- a/src/watts/plugin_openmc.py
+++ b/src/watts/plugin_openmc.py
@@ -42,7 +42,7 @@ class ResultsOpenMC(Results):
 
     def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[Path], outputs: List[Path]):
-        super().__init__('OpenMC', params, name, time, inputs, outputs)
+        super().__init__(params, name, time, inputs, outputs)
 
     @property
     def statepoints(self) -> List[Path]:
@@ -69,10 +69,6 @@ def tallies(self) -> List:
         with openmc.StatePoint(last_statepoint) as sp:
             return list(sp.tallies.values())
 
-    @property
-    def stdout(self) -> str:
-        return (self.base_path / "OpenMC_log.txt").read_text()
-
 
 class PluginOpenMC(Plugin):
     """Plugin for running OpenMC
diff --git a/src/watts/plugin_pyarc.py b/src/watts/plugin_pyarc.py
index f834eaba..fea786e5 100644
--- a/src/watts/plugin_pyarc.py
+++ b/src/watts/plugin_pyarc.py
@@ -36,13 +36,9 @@ class ResultsPyARC(Results):
 
     def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[Path], outputs: List[Path], results_data: dict):
-        super().__init__('PyARC', params, name, time, inputs, outputs)
+        super().__init__(params, name, time, inputs, outputs)
         self.results_data = results_data
 
-    @property
-    def stdout(self) -> str:
-        return (self.base_path / "PyARC_log.txt").read_text()
-
 
 class PluginPyARC(TemplatePlugin):
     """Plugin for running PyARC
@@ -62,7 +58,7 @@ class PluginPyARC(TemplatePlugin):
 
     Attributes
     ----------
-    pyarc_exec
+    executable
         Path to PyARC executable
 
     """
@@ -73,18 +69,14 @@ def __init__(self, template_file: str,
                  show_stdout: bool = False, show_stderr: bool = False):
         super().__init__(template_file, extra_inputs, extra_template_inputs,
                          show_stdout, show_stderr)
-        self._pyarc_exec = Path(os.environ.get('PyARC_DIR', 'PyARC.py'))
+        self._executable = Path(os.environ.get('PyARC_DIR', 'PyARC.py'))
         self.input_name = "pyarc_input.son"
 
-    @property
-    def pyarc_exec(self) -> Path:
-        return self._pyarc_exec
-
-    @pyarc_exec.setter
-    def pyarc_exec(self, exe: PathLike):
-        if os.path.exists(exe) is False:
-            raise RuntimeError(f"PyARC executable '{exe}' is missing.")
-        self._pyarc_exec = Path(exe)
+    @TemplatePlugin.executable.setter
+    def executable(self, exe: PathLike):
+        if Path(exe).exists():
+            raise RuntimeError(f"{self.plugin_name} executable '{exe}' is missing.")
+        self._executable = Path(exe)
 
     def run(self, **kwargs: Mapping):
         """Run PyARC
@@ -94,7 +86,7 @@ def run(self, **kwargs: Mapping):
         **kwargs
             Keyword arguments passed on to :func:`pyarc.execute`
         """
-        sys.path.insert(0, f'{self._pyarc_exec}')
+        sys.path.insert(0, f'{self.executable}')
         import PyARC
         self.pyarc = PyARC.PyARC()
         self.pyarc.user_object.do_run = True
@@ -104,7 +96,7 @@ def run(self, **kwargs: Mapping):
         with tempfile.TemporaryDirectory() as tmpdir:
             self.pyarc.execute(["-i", self.input_name, "-w", tmpdir, "-o", str(od)], **kwargs)
         sys.path.pop(0)  # Restore sys.path to original state
-        os.chdir(od)  # TODO: I don't know why but I keep going to self._pyarc_exec after execution - this is very wierd!
+        os.chdir(od)  # TODO: I don't know why but I keep going to self.executable after execution - this is very wierd!
 
     def postrun(self, params: Parameters, name: str) -> ResultsPyARC:
         """Collect information from PyARC and create results object
@@ -120,6 +112,4 @@ def postrun(self, params: Parameters, name: str) -> ResultsPyARC:
         -------
         PyARC results object
         """
-
-        time, inputs, outputs = self._get_result_input(self.input_name)
-        return ResultsPyARC(params, name, time, inputs, outputs, self.pyarc.user_object.results)
+        return super().postrun(params, name, results_data=self.pyarc.user_object.results)
diff --git a/src/watts/plugin_relap5.py b/src/watts/plugin_relap5.py
old mode 100755
new mode 100644
index b4193a0b..51539626
--- a/src/watts/plugin_relap5.py
+++ b/src/watts/plugin_relap5.py
@@ -43,13 +43,9 @@ class ResultsRELAP5(Results):
     """
     def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[PathLike], outputs: List[PathLike]):
-        super().__init__('RELAP5-3D', params, name, time, inputs, outputs)
+        super().__init__(params, name, time, inputs, outputs)
         self.csv_data = self._get_relap5_csv_data()
 
-    @property
-    def stdout(self) -> str:
-        return (self.base_path / "RELAP5_log.txt").read_text()
-
     def _get_relap5_csv_data(self) -> dict:
         """Read relap5 '.csv' file and return results in a dictionary
 
@@ -59,7 +55,7 @@ def _get_relap5_csv_data(self) -> dict:
 
         """
         csv_data = {}
-        if os.path.exists('R5-out.csv'):
+        if Path('R5-out.csv').exists():
             csv_file_df = pd.read_csv('R5-out.csv')
             for column_name in csv_file_df.columns:
                 csv_data[column_name] =  np.array(csv_file_df[column_name])
@@ -73,13 +69,11 @@ class PluginRELAP5(TemplatePlugin):
     template_file
         Templated RELAP5 input
     plotfl_to_csv
-        Whether to convert RELAP5-3D's plotfl file to CSV file
+        Whether to convert RELAP5's plotfl file to CSV file
     extra_inputs
         List of extra (non-templated) input files that are needed
     extra_template_inputs
         Extra templated input files
-    extra_options
-        Extra options for running RELAP5
     show_stdout
         Whether to display output from stdout when RELAP5 is run
     show_stderr
@@ -88,14 +82,13 @@ class PluginRELAP5(TemplatePlugin):
     Attributes
     ----------
     relap5_dir
-        Path to RELAP5 executable
+        Path to directory containing RELAP5 executable
 
     """
 
     def  __init__(self, template_file: str, plotfl_to_csv: bool = True,
                   extra_inputs: Optional[List[str]] = None,
                   extra_template_inputs: Optional[List[PathLike]] = None,
-                  extra_options: Optional[List[str]] = None,
                   show_stdout: bool = False, show_stderr: bool = False):
         super().__init__(template_file, extra_inputs, extra_template_inputs,
                          show_stdout, show_stderr)
@@ -106,56 +99,52 @@ def  __init__(self, template_file: str, plotfl_to_csv: bool = True,
 
         self._relap5_dir = Path(os.environ.get("RELAP5_DIR", ""))
         self.ext = "exe" if platform.system() == "Windows" else "x"
-        self._relap5_exec = f"relap5.{self.ext}"
+        self._executable = f"relap5.{self.ext}"
 
         self.input_name = "RELAP5.i"
         self.plotfl_to_csv = plotfl_to_csv
-        self.extra_options = extra_options
 
     @property
     def relap5_dir(self) -> Path:
         return self._relap5_dir
 
     @relap5_dir.setter
-    def relap5_dir(self, relap5_directory:PathLike):
+    def relap5_dir(self, relap5_directory: PathLike):
         if shutil.which(Path(relap5_directory) / f"relap5.{self.ext}") is None:
-            raise RuntimeError("RELAP5-3D executable is missing.")
+            raise RuntimeError("RELAP5 executable is missing.")
         self._relap5_dir = Path(relap5_directory)
 
-    def prerun(self, params: Parameters):
-        """Generate the RELAP5 input based on the template
+    @property
+    def execute_command(self):
+        return [self.executable, '-i', self.input_name]
+
+    def run(self, extra_args: Optional[List[str]] = None):
+        """Run RELAP5
 
         Parameters
         ----------
-        params
-            Parameters used when rendering template
+        extra_args
+            Extra arguments to be appended when running the RELAP5 executable
         """
-        super().prerun(params)
-
-        # Copy all necessary files to the temporary directory.
-        # RELAP5 requires the executable file and the license key
-        # to be in the same directory as the input file to run.
-        # Users can also add all fluid property files here.
 
-        files = os.listdir(self._relap5_dir)
-        for fname in files:
-            shutil.copy2(os.path.join(self._relap5_dir, fname), os.getcwd())
+        # Copy all necessary files to the temporary directory. RELAP5 requires
+        # the executable file and the license key to be in the same directory as
+        # the input file to run. Users can also add all fluid property files
+        # here.
+        cwd = Path.cwd()
+        for fname in self._relap5_dir.iterdir():
+            shutil.copy2(str(fname), str(cwd))
 
         # Create a list for RELAP5 input command and append any extra
         # options to it.
-
-        self._relap5_input = [self._relap5_exec, '-i', self.input_name]
-        if isinstance(self.extra_options, list):
-            for options in self.extra_options:
-                self._relap5_input.append(options)
-
-    def run(self):
-        """Run RELAP5"""
+        if extra_args is None:
+            extra_args = []
+        command = self.execute_command + extra_args
 
         # run_proc() does not work with RELAP5-3D.
         # The extra argument of 'stdout' to subprocess.Popen() in run_proc() somehow prevents RELAP5 from running.
         # As a work-around, we explicitly use subprocess.Popen() here without specifying 'stdout=subprocess.PIPE')
-        p = subprocess.Popen(self._relap5_input)
+        p = subprocess.Popen(command)
         stdout, stderr = p.communicate()
 
     def postrun(self, params: Parameters, name: str) -> ResultsRELAP5:
@@ -175,13 +164,16 @@ def postrun(self, params: Parameters, name: str) -> ResultsRELAP5:
 
         # Convert RELAP5's plotfl file to CSV file for processing
         if self.plotfl_to_csv:
-            if os.path.exists('plotfl'):
+            if Path('plotfl').exists():
                 self._plotfl_to_csv()
             else:
-                raise RuntimeError("Output plot file 'plotfl' is missing. Please make sure you are running the correct version of RELAP5-3D or the plot file is named correctly.")
+                raise RuntimeError(
+                    "Output plot file 'plotfl' is missing. Please make sure you "
+                    "are running the correct version of RELAP5-3D or the plot "
+                    "file is named correctly."
+                )
 
-        time, inputs, outputs = self._get_result_input(self.input_name)
-        return ResultsRELAP5(params, name, time, inputs, outputs)
+        return super().postrun(params, name)
 
     # The RELAP5-3D version used here does not generate csv output files.
     # It generates a text file with a particular format that needs to
@@ -250,11 +242,7 @@ def _check_string(self, file, keyword: str) -> list:
         A list of line number of the keyword markers in the plotfl file
 
         """
-        n_line = []
-        for i in range(len(file)):
-            if keyword in file[i]:
-                n_line.append(i)
-        return n_line
+        return [i for i, file_i in enumerate(file) if keyword in file_i]
 
     def _extract_value(self, contents):
         """Extracts values from the plotfl file according to the keyword markers.
@@ -282,7 +270,7 @@ def _extract_value(self, contents):
         value_list.append(s.strip())
 
         # Remove all spaces
-        while('' in value_list):
+        while '' in value_list:
             value_list.remove('')
 
         # Remove first element, i.e. markers
diff --git a/src/watts/plugin_sas.py b/src/watts/plugin_sas.py
index aa7339f7..ae6aba60 100644
--- a/src/watts/plugin_sas.py
+++ b/src/watts/plugin_sas.py
@@ -44,13 +44,9 @@ class ResultsSAS(Results):
     """
     def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[PathLike], outputs: List[PathLike]):
-        super().__init__('SAS', params, name, time, inputs, outputs)
+        super().__init__(params, name, time, inputs, outputs)
         self.csv_data = self._get_sas_csv_data()
 
-    @property
-    def stdout(self) -> str:
-        return (self.base_path / "SAS_log.txt").read_text()
-
     def _get_sas_csv_data(self) -> dict:
         """Read all sas '.csv' files and return results in a dictionary
 
@@ -88,8 +84,12 @@ class PluginSAS(TemplatePlugin):
 
     Attributes
     ----------
-    sas_exec
+    executable
         Path to SAS executable
+    conv_channel
+        Path to CHANNELtoCSV utility executable
+    conv_primar4
+        Path to PRIMAR4toCSV utility executable
 
     """
 
@@ -105,16 +105,11 @@ def  __init__(self, template_file: str,
         # The Windows executable is ".exe".
         sas_dir = Path(os.environ.get("SAS_DIR", ""))
         ext = "exe" if platform.system() == "Windows" else "x"
-        self._sas_exec = sas_dir / f"sas.{ext}"
+        self._executable = sas_dir / f"sas.{ext}"
         self._conv_channel = sas_dir / f"CHANNELtoCSV.{ext}"
         self._conv_primar4 = sas_dir / f"PRIMAR4toCSV.{ext}"
-
         self.input_name = "SAS.inp"
 
-    @property
-    def sas_exec(self) -> Path:
-        return self._sas_exec
-
     @property
     def conv_channel(self) -> Path:
         return self._conv_channel
@@ -123,12 +118,6 @@ def conv_channel(self) -> Path:
     def conv_primar4(self) -> Path:
         return self._conv_primar4
 
-    @sas_exec.setter
-    def sas_exec(self, exe: PathLike):
-        if shutil.which(exe) is None:
-            raise RuntimeError(f"SAS executable '{exe}' is missing.")
-        self._sas_exec = Path(exe)
-
     @conv_channel.setter
     def conv_channel(self, exe: PathLike):
         if shutil.which(exe) is None:
@@ -141,9 +130,9 @@ def conv_primar4(self, exe: PathLike):
             raise RuntimeError(f"PRIMAR4toCSV utility executable '{exe}' is missing.")
         self._conv_primar4 = Path(exe)
 
-    def run(self):
-        """Run SAS"""
-        run_proc([self.sas_exec, "-i", self.input_name, "-o", "out.txt"])
+    @property
+    def execute_command(self):
+        return [self.executable, "-i", self.input_name, "-o", "out.txt"]
 
     def postrun(self, params: Parameters, name: str) -> ResultsSAS:
         """Read SAS results and create results object
@@ -171,5 +160,4 @@ def postrun(self, params: Parameters, name: str) -> ResultsSAS:
             with open("PRIMAR4.dat", "r") as file_in, open("PRIMAR4.csv", "w") as file_out:
                 subprocess.run(str(self.conv_primar4), stdin=file_in, stdout=file_out)
 
-        time, inputs, outputs = self._get_result_input(self.input_name)
-        return ResultsSAS(params, name, time, inputs, outputs)
+        return super().postrun(params, name)
diff --git a/src/watts/results.py b/src/watts/results.py
index b5d13542..8b08bece 100644
--- a/src/watts/results.py
+++ b/src/watts/results.py
@@ -17,8 +17,6 @@ class Results:
 
     Parameters
     ----------
-    plugin
-        Name of the plugin that created the results
     params
         Parameters used to generate inputs
     time
@@ -29,16 +27,23 @@ class Results:
         List of output files
     """
 
-    def __init__(self, plugin: str, params: Parameters, name: str, time: datetime,
+    def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[PathLike], outputs: List[PathLike]):
         self.base_path = Path.cwd()
-        self.plugin = plugin
         self.name = name
         self.parameters = Parameters(params)
         self.time = time
         self.inputs = [Path(p) for p in inputs]
         self.outputs = [Path(p) for p in outputs]
 
+    @property
+    def plugin(self):
+        return type(self).__name__[7:]
+
+    @property
+    def stdout(self) -> str:
+        return (self.base_path / f"{self.plugin}_log.txt").read_text()
+
     def move_files(self, dst: PathLike):
         """Move input/output files to different directory
 


Test patch:
diff --git a/tests/test_results.py b/tests/test_results.py
index 8be835d6..dda19062 100755
--- a/tests/test_results.py
+++ b/tests/test_results.py
@@ -189,7 +189,7 @@ def test_results_relap5(run_in_tmpdir):
     results = watts.ResultsRELAP5(params, name, now, inputs, outputs)
 
     # Sanity checks
-    assert results.plugin == 'RELAP5-3D'
+    assert results.plugin == 'RELAP5'
     assert results.parameters == params
     assert results.name == name
     assert results.time == now