Repository: ORNL-MDF/Myna

Issue:
Enable Peregrine CLI to use workspace file instead of explicit parameter passing
The Peregrine CLI currently passes simulation parameters via a structured command line call. This should be updated in two ways:

1) Use a workspace file to populate specific parameters for simulation. Once developed, a default workspace file will be distributed within Peregrine (the default file should also be stored in this repo in [cli/peregrine_launcher](https://github.com/ORNL-MDF/Myna/tree/main/cli/peregrine_launcher). Peregrine users will also have an option to specify a custom workspace file. The workspace file path will be passed via command line argument.
2) Implement `argparse` to handle the command line arguments, instead of requiring specific ordering.

Example of the new Peregrine command line call:
```shell
myna launch_peregrine --build server/2024/08/example_build --parts [P5,P21] --layers [1,4,10] --workspace peregrine_default.myna-workspace --mode gv
```

Discussion:
gknapp1: I will clean up the "fix" commits before merging. All of the tests were passing locally, but not on the CI so it took me a few iterations to figure out what was wrong.

Patch:
diff --git a/cli/peregrine_launcher/config.yaml b/cli/peregrine_launcher/config.yaml
deleted file mode 100644
index fe73540..0000000
--- a/cli/peregrine_launcher/config.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-3DTHESIS_EXEC: /home/cloud/3DThesis/build/application/3DThesis
-3DTHESIS_RESOLUTION: 25e-6
-MYNA_INPUT_FILE: input_configured.yaml
-MARKER: $$
diff --git a/cli/peregrine_launcher/input_gv.yaml b/cli/peregrine_launcher/input_gv.yaml
index 4085b41..4a5e1ea 100644
--- a/cli/peregrine_launcher/input_gv.yaml
+++ b/cli/peregrine_launcher/input_gv.yaml
@@ -1,9 +1,4 @@
 steps:
 - 3dthesis:
     class: solidification_part
-    execute: [python $MYNA_APP_PATH/solidification_part/3dthesis/execute.py
-        --input $$MYNA_INPUT_FILE$$
-        --exec $$3DTHESIS_EXEC$$
-        --res $$3DTHESIS_RESOLUTION$$]
-data:
-  build:
+    application: thesis
diff --git a/cli/peregrine_launcher/input_meltpool_geometry.yaml b/cli/peregrine_launcher/input_meltpool_geometry.yaml
new file mode 100644
index 0000000..32344eb
--- /dev/null
+++ b/cli/peregrine_launcher/input_meltpool_geometry.yaml
@@ -0,0 +1,4 @@
+steps:
+- 3dthesis:
+    class: melt_pool_geometry_part
+    application: thesis
diff --git a/cli/peregrine_launcher/peregrine_default_workspace.yaml b/cli/peregrine_launcher/peregrine_default_workspace.yaml
new file mode 100644
index 0000000..31d9a9f
--- /dev/null
+++ b/cli/peregrine_launcher/peregrine_default_workspace.yaml
@@ -0,0 +1,33 @@
+additivefoam:
+  solidification_region_reduced:
+    configure:
+      coarse: 80e-6
+      pad-sub: 1e-3
+      pad-xy: 0.00125
+      pad-z: 0.0005
+      refine-layer: 1
+      refine-region: 1
+      rx: 1e-3
+      ry: 1e-3
+      rz: 0.0003
+exaca:
+  microstructure_region:
+    configure:
+      cell-size: 2.5
+      nd: 250
+      sub-size: 12.3
+    executable: ExaCA
+thesis:
+  melt_pool_geometry_part:
+    configure:
+      nout: 1000
+      res: 20e-6
+    executable: 3DThesis
+    execute:
+      batch: true
+  solidification_part:
+    configure:
+      res: 25e-6
+    executable: 3DThesis
+    execute:
+      batch: true
diff --git a/cli/peregrine_launcher/readme.md b/cli/peregrine_launcher/readme.md
index c28d276..f53adf2 100644
--- a/cli/peregrine_launcher/readme.md
+++ b/cli/peregrine_launcher/readme.md
@@ -1,20 +1,19 @@
 # Peregrine Launcher
 
 This interface is designed to facilitate launching Myna workflows from
-Peregrine via the `launch_from_peregrine` script. The modes available to
+Peregrine via the `myna launch_peregrine` script. The modes available to
 Peregrine users are defined by the input files following the notation
-`input_<mode>.yaml`.
-
-Any variables defined in `config.yaml` can be used in double brackets
-as variables in the input file templates. For example, in `input_gv.yaml`
-the executable path for 3DThesis is specified by `{{3DTHESIS_EXEC}}`` and
-will be replaced by the correct path at runtime.
+`input_<mode>.yaml` and the specified workspace.
 
 ## Example
 
-To run an example using the resources provided with the `myna` repository, first
-open `config.yaml` and set the path to your local installation of 3DThesis. Then use:
+To run an example using the resources provided with the `myna` repository, run the
+following command:
 
 ```bash
-myna_peregrine_launcher /home/cloud/myna/resources [51] [P5] 20e-6 gv
+# set to the directory where Myna repository was cloned
+MYNA_PATH="."
+
+# Launch the melt pool geometry simulation
+myna launch_peregrine --build "$MYNA_PATH/resources" --parts [P5] --layers [50,51,52] --workspace "$MYNA_PATH/cli/peregrine_launcher/peregrine_default_workspace.yaml" --mode "meltpool_geometry"
 ```
diff --git a/myna/core/workflow/all.py b/myna/core/workflow/all.py
index 3f2636d..d1b168d 100644
--- a/myna/core/workflow/all.py
+++ b/myna/core/workflow/all.py
@@ -30,7 +30,7 @@ def main():
     if "status" in args.type:
         myna.core.workflow.status.write_codebase_status_to_file(parser)
     elif "launch_peregrine" in args.type:
-        myna.core.workflow.launch_from_peregrine.launch_from_peregrine()
+        myna.core.workflow.launch_from_peregrine(parser)
     else:
         if "config" in args.type:
             myna.core.workflow.config.main(parser)
diff --git a/myna/core/workflow/launch_from_peregrine.py b/myna/core/workflow/launch_from_peregrine.py
index be526cc..51826e8 100644
--- a/myna/core/workflow/launch_from_peregrine.py
+++ b/myna/core/workflow/launch_from_peregrine.py
@@ -34,94 +34,127 @@ def working_directory(path):
         os.chdir(prev_cwd)
 
 
-def launch_from_peregrine(argv=sys.argv):
+def launch_from_peregrine(parser):
     # Set working directory to the peregrine_launcher interface
     peregrine_launcher_path = os.path.join(
         os.environ["MYNA_INSTALL_PATH"], "cli", "peregrine_launcher"
     )
-    with working_directory(peregrine_launcher_path):
-        # Parse the arguments passed from Peregrine
-        build_path = argv[1]
-        layers = [int(x) for x in myna.core.utils.strlist_to_list(argv[2])]
-        exported_parts = myna.core.utils.strlist_to_list(argv[3])
-        resolution = float(argv[4])
-        mode = argv[5].lower()
-
-        # Write Peregrine input to log file
-        lines = []
-        lines.append(f"""argv = {argv[0]} {' '.join([f'"{x}"' for x in argv[1:]])}\n""")
-        lines.append(f"{build_path=}\n")
-        lines.append(f"{layers=}\n")
-        lines.append(f"{exported_parts=}\n")
-        lines.append(f"{resolution=}\n")
-        lines.append(f"{mode=}\n")
-
-        # Get yyyy-mm-dd_hh-mm format for the current time and add to log file
-        now = datetime.datetime.now()
-        now_str_pretty = now.strftime("%Y-%m-%d %H:%M:%S")
-        now_str_id = now.strftime("%Y-%m-%d-%Hh-%Mm")
-        lines.append(f"\nSimulation started at {now_str_pretty}\n")
-
-        # Set input file paths
-        input_file = f"input_{mode}.yaml"
-        output_basedir = "myna_output"
-        input_file_configured = os.path.join(
-            output_basedir, f"input_{mode}_{now_str_id}.yaml"
+
+    parser.add_argument(
+        "--build",
+        default=None,
+        type=str,
+        help="path to the desired build to simulate",
+    )
+    parser.add_argument(
+        "--parts",
+        default=None,
+        type=lambda s: [str(item) for item in s.strip()[1:-1].split(",")],
+        help="list of part names to simulate",
+    )
+    parser.add_argument(
+        "--layers",
+        default=None,
+        type=lambda s: [int(item) for item in s.strip()[1:-1].split(",")],
+        help="list of layers to simulate",
+    )
+    parser.add_argument(
+        "--workspace",
+        default=None,
+        type=str,
+        help="path to the desired workspace to use",
+    )
+    parser.add_argument(
+        "--mode",
+        default=None,
+        type=str,
+        help="simulation mode to use",
+    )
+
+    args = parser.parse_args()
+
+    # Parse the arguments passed from Peregrine
+    build_path = os.path.abspath(args.build)
+    layers = args.layers
+    exported_parts = args.parts
+    mode = args.mode
+    workspace = os.path.abspath(args.workspace)
+
+    # Check if build path includes Peregrine as the last directory
+    if any([build_path[-1] == sep for sep in [os.path.sep, os.path.altsep]]):
+        build_path = build_path[:-1]
+    if os.path.basename(build_path) == "Peregrine":
+        build_path = os.path.dirname(build_path)
+
+    # Create Myna directory if it doesn't already exist
+    myna_working_dir = os.path.join(build_path, "Myna")
+    os.makedirs(myna_working_dir, exist_ok=True)
+
+    # Write Peregrine input to log file
+    lines = []
+    lines.append(f"Peregrine inputs:\n")
+    lines.append(f"- {build_path=}\n")
+    lines.append(f"- {layers=}\n")
+    lines.append(f"- {exported_parts=}\n")
+    lines.append(f"- {mode=}\n")
+    lines.append(f"- {workspace=}\n")
+
+    # Get yyyy-mm-dd_hh-mm format for the current time and add to log file
+    now = datetime.datetime.now()
+    now_str_pretty = now.strftime("%Y-%m-%d %H:%M:%S")
+    now_str_id = now.strftime("%Y-%m-%d-%Hh-%Mm")
+    lines.append(f"\nSimulation started at {now_str_pretty}\n")
+
+    # Set input file paths
+    input_file = os.path.join(peregrine_launcher_path, f"input_{mode}.yaml")
+    input_file_configured = os.path.join(
+        myna_working_dir, f"input_{mode}_{now_str_id}.yaml"
+    )
+
+    # Read and update input dictionary
+    output_dir = os.path.basename(build_path).replace(" ", "_") + f"_{now_str_id}"
+    with open(input_file, "r") as f:
+        input_dict = yaml.safe_load(f)
+    input_dict["data"] = {}
+    input_dict["data"]["build"] = {}
+    input_dict["data"]["build"]["datatype"] = "Peregrine"
+    input_dict["data"]["build"]["name"] = output_dir
+    input_dict["data"]["build"]["path"] = build_path
+    input_dict["data"]["build"]["parts"] = {}
+    for part in exported_parts:
+        input_dict["data"]["build"]["parts"][part] = {"layers": layers}
+    input_dict["myna"] = {}
+    input_dict["myna"]["workspace"] = workspace
+
+    # Export updated input dictionary
+    with open(input_file_configured, "w") as f:
+        yaml.dump(input_dict, f, default_flow_style=False)
+
+    # Set working directory to run all myna scripts
+    with working_directory(myna_working_dir):
+
+        # Construct myna config command
+        lines.append("\nStarting configuration of simulation cases:\n")
+        cmd = f"myna config --input {input_file_configured}"
+        p = subprocess.run(
+            cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
         )
-        os.makedirs(output_basedir, exist_ok=True)
-
-        # Get configurable key words
-        config_file = "config.yaml"
-        with open(config_file, "r") as f:
-            config_dict = yaml.safe_load(f)
-
-        # Set case-specific configurable key words
-        config_dict["3DTHESIS_RESOLUTION"] = resolution
-        config_dict["MYNA_INPUT_FILE"] = os.path.basename(input_file_configured)
-
-        # Update key words
-        marker = str(config_dict["MARKER"])
-        with open(input_file, "r") as f:
-            input_lines = f.readlines()
-        for i in range(len(input_lines)):
-            for key in config_dict.keys():
-                if key != "MARKER":
-                    old = marker + key + marker
-                    new = str(config_dict[key])
-                    old_line = input_lines[i]
-                    new_line = input_lines[i].replace(old, new)
-                    input_lines[i] = new_line
-                    if old_line == new_line:
-                        print(key)
-                        print("\t" + old + " --> " + new)
-                        print("\told: " + old_line)
-                        print("\tnew: " + new_line)
-
-        with open(input_file_configured, "w") as f:
-            f.writelines(input_lines)
-
-        # Read and update input dictionary
-        output_dir = os.path.basename(build_path).replace(" ", "_") + f"_{now_str_id}"
-        with open(input_file_configured, "r") as f:
-            input_dict = yaml.safe_load(f)
-        input_dict["data"] = {}
-        input_dict["data"]["build"] = {}
-        input_dict["data"]["build"]["name"] = output_dir
-        input_dict["data"]["build"]["path"] = build_path
-        input_dict["data"]["build"]["parts"] = {}
-        for part in exported_parts:
-            input_dict["data"]["build"]["parts"][part] = {"layers": layers}
-
-        # Export updated input dictionary
-        with open(input_file_configured, "w") as f:
-            yaml.dump(input_dict, f, default_flow_style=False)
-
-    # Set working directory to the peregrine_launcher interface output directory
-    # to run all myna scripts
-    with working_directory(os.path.join(peregrine_launcher_path, output_basedir)):
-        # Construct myna_config command
-        input_file_configured = os.path.basename(input_file_configured)
-        cmd = f"myna_config --input {input_file_configured}"
+        out = p.stdout.decode()
+
+        # Add time to log file
+        now_str_pretty = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        lines.append(f"\nSync to Peregrine completed at {now_str_pretty}\n")
+
+        # Parse output to store in log file
+        lines.append(f"{cmd=}\n\n")
+        for line in out.split("\r\n"):
+            print(line)
+            lines.append(line + "\n")
+        lines.append("\n")
+
+        # Construct myna run command
+        lines.append("\nStarting simulation pipeline execution:\n")
+        cmd = f"myna run --input {input_file_configured}"
         p = subprocess.run(
             cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
         )
@@ -134,8 +167,13 @@ def launch_from_peregrine(argv=sys.argv):
             lines.append(line + "\n")
         lines.append("\n")
 
-        # Construct myna_run command
-        cmd = f"myna_run --input {input_file_configured}"
+        # Add time to log file
+        now_str_pretty = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        lines.append(f"\nSimulation completed at {now_str_pretty}\n")
+
+        # Construct myna sync command
+        lines.append("Syncing simulation results to Peregrine:\n")
+        cmd = f"myna sync --input {input_file_configured}"
         p = subprocess.run(
             cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
         )
@@ -148,12 +186,9 @@ def launch_from_peregrine(argv=sys.argv):
             lines.append(line + "\n")
         lines.append("\n")
 
-        # Get yyyy-mm-dd_hh-mm format for the current time
-        now = datetime.datetime.now()
-        now_str_pretty = now.strftime("%Y-%m-%d %H:%M:%S")
-
         # Add time to log file
-        lines.append(f"\nSimulation completed at {now_str_pretty}\n")
+        now_str_pretty = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        lines.append(f"\nSync to Peregrine completed at {now_str_pretty}\n")
 
         # Write log file
         with open(f"launch_from_peregrine_{now_str_id}.log", "w") as f:


Test patch:
diff --git a/tests/test_peregrine_cli.py b/tests/test_peregrine_cli.py
new file mode 100644
index 0000000..45b1555
--- /dev/null
+++ b/tests/test_peregrine_cli.py
@@ -0,0 +1,42 @@
+#
+# Copyright (c) 2024 Oak Ridge National Laboratory.
+#
+# This file is part of Myna. For details, see the top-level license
+# at https://github.com/ORNL-MDF/Myna/LICENSE.md.
+#
+# License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause.
+#
+import pytest
+import os
+import shutil
+import argparse
+import myna
+import sys
+
+
+# This test checks that the Peregrine CLI is behaving as expected for a test call
+def test_peregrine_cli():
+    test_path = os.path.dirname(os.path.abspath(__file__))
+    myna_path = os.path.abspath(os.path.join(test_path, ".."))
+    build_dir = os.path.join(myna_path, "resources")
+    output_dir = os.path.join(build_dir, "Myna")
+
+    # Set up command line argument
+    parser = argparse.ArgumentParser("test")
+    sys.argv = [
+        "test",
+        "--build",
+        f"{build_dir}",
+        "--parts",
+        "[P5]",
+        "--layers",
+        "[50]",
+        "--workspace",
+        f"{myna_path}/cli/peregrine_launcher/peregrine_default_workspace.yaml",
+        "--mode",
+        "meltpool_geometry",
+    ]
+    myna.core.workflow.launch_from_peregrine(parser)
+
+    assert os.path.exists(output_dir)
+    shutil.rmtree(output_dir)