From c211b7cf915785079dea16a2e83006b4e5e50e06 Mon Sep 17 00:00:00 2001 From: tyler-g-hudson Date: Wed, 8 Nov 2023 18:36:49 +0000 Subject: [PATCH] Workflow command backend functionality + Added the functionalities necessary to run workflows ~ Assumes that all data for the workflow has already been located ~ Does not implement the full workflow command yet, which is dependant on the `data search` functionality. --- wigwam/_exceptions.py | 7 + wigwam/_workflows.py | 434 ++++++++++++++++++++++++++++++++++++++++++ wigwam/defaults.py | 5 + workflowtests.json | 311 ++++++++++++++++++++++++++++++ 4 files changed, 757 insertions(+) create mode 100644 wigwam/_workflows.py create mode 100644 workflowtests.json diff --git a/wigwam/_exceptions.py b/wigwam/_exceptions.py index 962290e..b3e9fbc 100644 --- a/wigwam/_exceptions.py +++ b/wigwam/_exceptions.py @@ -38,3 +38,10 @@ def __init__(self, tag_or_id: str): """ self.tag_or_id = tag_or_id super().__init__(f'Docker image "{tag_or_id}" not found.') + + +class TestFailedError(Exception): + """Raised when a test fails.""" + + def __init__(self, message: str = ""): + super().__init__(message) diff --git a/wigwam/_workflows.py b/wigwam/_workflows.py new file mode 100644 index 0000000..ecc9759 --- /dev/null +++ b/wigwam/_workflows.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +import json +import os +import shutil +import tempfile +from abc import ABC, abstractmethod +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from subprocess import CalledProcessError +from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple + +from ._bind_mount import BindMount +from ._exceptions import TestFailedError +from ._image import Image +from .defaults import default_workflowtest_path, install_prefix + + +class Workflow(ABC): + """A workflow handler. Abstract.""" + + @abstractmethod + def get_command(self, runconfig: os.PathLike[str] | str) -> str: + ... + + +class GenericSASWorkflow(Workflow): + """A handler for typical SAS workflows.""" + + def __init__(self, module: str) -> None: + self.module = module + + def get_command(self, runconfig: os.PathLike[str] | str) -> str: + return f"python -m nisar.workflows.{self.module} {runconfig}" + + +class InSARWorkflow(GenericSASWorkflow): + """A handler for the InSAR workflow.""" + + def get_command(self, runconfig: os.PathLike[str] | str) -> str: + return super().get_command(runconfig) + " -- restart" + + +class TextRunconfigWorkflow(GenericSASWorkflow): + """A handler for workflows whose runconfig is a text file.""" + + def get_command(self, runconfig: os.PathLike[str] | str) -> str: + return f"python -m nisar.workflows.{self.module} @{runconfig}" + + +class SoilMoistureWorkflow(GenericSASWorkflow): + """A handler for the SoilMoisture workflow.""" + + def get_command(self, runconfig: os.PathLike[str] | str) -> str: + return f"micromamba run -n SoilMoisture NISAR_SM_SAS {runconfig}" + + +def get_workflow_object(workflow_name: str) -> Workflow: + """ + Returns a Workflow object given the name of the workflow. + + Parameters + ---------- + workflow_name : str + The name of the workflow. + + Returns + ------- + Workflow + The associated Workflow object. + + Raises + ------ + ValueError + If the given workflow name is not recognized. + """ + if workflow_name in ["gslc", "gcov", "insar"]: + return GenericSASWorkflow(workflow_name) + elif workflow_name == "rslc": + return GenericSASWorkflow("focus") + elif workflow_name == "insar": + return InSARWorkflow(workflow_name) + elif workflow_name in ["el_edge", "el_null"]: + return TextRunconfigWorkflow(workflow_name) + else: + raise ValueError(f"Workflow {workflow_name} not recognized") + + +def get_test_info( + workflow_name: str, test_name: str, filename: Path = default_workflowtest_path() +) -> Tuple[Dict[str, str | List[Dict[str, str]] | Dict[str, str]], str]: + """ + Get test data from the given file. + + Parameters + ---------- + workflow_name : str + The name of the workflow in the test file. + test_name : str + The name of the test under the given workflow. + filename : Path + The path to the workflowtests file. Defaults to the default workflowtest path. + + Returns + ------- + Tuple[Dict[str, str | List[str] | Dict[str, str]], str] + A dictionary representing information about the test. + + Raises: + ------- + ValueError: + If the workflow is not found, or the supplied test is not a test under the + given workflow. + """ + with open(str(filename)) as file: + file_dict = json.load(fp=file) + + if workflow_name not in file_dict: + raise ValueError(f"Workflow {workflow_name} not found.") + workflow_dict = file_dict[workflow_name] + + if test_name not in workflow_dict["tests"]: + raise ValueError(f"Test {test_name} not found in workflow {workflow_name}") + test_type = workflow_dict["type"] + if not isinstance(test_type, str): + raise ValueError( + '"type" field of workflow in given test database returned ' + f"{type(test_name)}, expected string." + ) + return workflow_dict["tests"][test_name], test_type + + +def workflow_mounts( + test_params: WorkflowParams, +) -> List[BindMount]: + """Generates a list of workflow bind mounts. + + Parameters + ---------- + test_params: WorkflowParams + The workflow parameters object containing information about the file mount + locations on the host. + + Returns + ------- + List[BindMount] + The generated bind mounts. + """ + input_dict: Dict[str, Path] = dict(test_params.input_dict) + output_dir: Path = test_params.output_dir + scratch_dir: Path = test_params.scratch_dir + install_path: Path = Path(install_prefix()) + + # Create the output directory bind mount, place it in the bind mounts basic list. + # This list will be copied and used for all tests that are run. + bind_mounts = [ + BindMount( + dst=str(install_path / "output"), + src=str((output_dir).absolute()), + permissions="rw", + ) + ] + + # Create input files + for repo in input_dict: + host_path = input_dict[repo].absolute() + image_path = install_path / "input" / repo + bind_mounts.append( + BindMount( + dst=str(image_path), + src=str(host_path), + permissions="ro", + ) + ) + + # Create the scratch file bind mount + bind_mounts.append( + BindMount( + dst=str(install_path / "scratch"), + src=str(scratch_dir.absolute()), + permissions="rw", + ) + ) + + return bind_mounts + + +@contextmanager +def prepare_scratch_dir(scratch_dir: Optional[Path]) -> Iterator[Path]: + """ + A context manager that returns a scratch directory absolute path. + + If none is given, generates a temporary one, and deletes it on exit. + + Parameters + ---------- + scratch_dir : Optional[Path] + The location of the scratch directory, or None if a temp directory is desired. + + Returns + ------- + Path + An absolute path to a scratch directory. + + Yields + ------ + Iterator[Path] + An absolute path to a scratch directory. + """ + # If requested, add a path to the scratch directory. + # Otherwise, create a temporary one so the Docker container can get rw permissions. + temp_scratch: bool = scratch_dir is None + + try: + if temp_scratch: + # Create the temp scratch file + return_dir: Path = Path(tempfile.mkdtemp()).absolute() + else: + assert isinstance(scratch_dir, Path) + return_dir = scratch_dir.absolute() + + yield return_dir + + finally: + # If a temporary scratch file was created, remove it. + if temp_scratch: + shutil.rmtree(str(return_dir)) + + +def prepare_subdirectories( + workflow_name: str, test: Optional[str], test_params: WorkflowParams +) -> None: + """ + Generates output and scratch subdirectories for a workflow test. + + Parameters + ---------- + workflow_name : str + The name of this workflow. + test : Optional[str] + The name of this subtest, if it exists. + test_params : WorkflowParams + The test parameters. + """ + test_subdir = ( + Path(workflow_name) / test if test is not None else Path(workflow_name) + ) + output_dir: Path = test_params.output_dir / test_subdir + scratch_dir: Path = test_params.scratch_dir / test_subdir + + # If the output directory doesn't exist on the host, make it. + if not os.path.isdir(str(output_dir)): + os.makedirs(str(output_dir)) + + # Create the scratch file if it doesn't already exist + if not os.path.isdir(str(scratch_dir)): + os.makedirs(str(scratch_dir)) + + +def prepare_runconfig( + workflow_name: str, runconfig: str, runconfig_dir: str = "runconfigs" +) -> BindMount: + """ + Checks for a local runconfig for a given test and returns a bind mount for it. + + Parameters + ---------- + workflow_name : str + The name of the workflow. + runconfig : str + The name of the runconfig. + runconfig_dir : str, optional + The name of the directory on the system to search for runconfigs. + Defaults to "runconfigs". + + Returns + ------- + BindMount + A BindMount object with the local and container locations of the runconfig, in + read-only mode. + + Raises + ------ + ValueError + If the given runconfig could not be found. + """ + runconfig_path: Path = Path(runconfig_dir) + install_path: Path = Path(install_prefix()) + + # Add the runconfig mount to the install prefix directory + runconfig_lookup_path: Path = runconfig_path / workflow_name + # Get the runconfig path on the host and image + runconfig_host_path: Path = (runconfig_lookup_path / runconfig).absolute() + runconfig_image_path: Path = install_path / runconfig + # If the runconfig doesn't exist at the expected location, this is an error + if not os.path.isfile(str(runconfig_host_path)): + raise ValueError( + f"Runconfig {runconfig} not found at {str(runconfig_host_path)}." + ) + + # Create the runconfig file bind mount + return BindMount( + src=str(runconfig_host_path), + dst=str(runconfig_image_path), + permissions="ro", + ) + + +def run_series_workflow( + test_params: WorkflowParams, + main_test_name: str, + test_sequence_info: Sequence[Mapping[str, Any]], + bind_mounts: Sequence[BindMount], +) -> None: + """ + Run a series of workflow tests in order. + + Parameters + ---------- + test_params : WorkflowParams + The workflow parameter object containing the image and its host mount points. + main_test_name : str + The name of the overall workflow test that the series is running on. + test_series_info : Sequence[Mapping[str, Any]] + A sequence of information dictionaries which contain information about subtests + to be run in series. + bind_mounts : Sequence[BindMount] + The basic file binds associated with this test, including the input, output, + and scratch directories. + """ + for test_info in test_sequence_info: + workflow_name = test_info["workflow"] + + # Get the location of the runconfig + runconfig = test_info["runconfig"] + runconfig_path = Path("runconfigs") / main_test_name + + # If the test is tagged, this tag will be a subdirectory under the test + # directory. This is done by passing "test" into workflow_mounts. + test_name = test_info["label"] if "label" in test_info.keys() else None + + # Print a message announcing the running of this workflow. + test_msg: str = f"\nRunning workflow: {main_test_name} {workflow_name} " + if test_name is not None: + test_msg += f"{test_name} " + test_msg += f"on image: {test_params.image_tag}.\n" + print(test_msg) + + # Run the workflow. + run_workflow( + test_params=test_params, + workflow_name=workflow_name, + test=test_name, + basic_mounts=bind_mounts, + runconfig=runconfig, + runconfig_dir=str(runconfig_path), + ) + + +def run_workflow( + test_params: WorkflowParams, + workflow_name: str, + test: Optional[str], + basic_mounts: Sequence[BindMount], + runconfig: str, + runconfig_dir: str = "runconfigs", +) -> None: + """ + Runs a workflow test on the given image. + + Parameters + ---------- + test_params : WorkflowParams + The workflow parameter object containing the image and its host mount points. + workflow_name : str + The name of the workflow. + test : str, optional + The name of the test. If None is given, then a test subdirectory will not be + added to the output and scratch directories. + basic_mounts : Sequence[BindMount] + The basic file binds associated with this test, including the input, output, + and scratch directories. + runconfig : str + The location of the runconfig. + runconfig_dir : str, optional + The location of the directory in which runconfigs are held. + Default is "runconfig". + + Raises + ------ + CalledProcessError + If the workflow fails. + """ + + # Get the test command. + workflow_obj: Workflow = get_workflow_object(workflow_name=workflow_name) + command = workflow_obj.get_command(runconfig=runconfig) + + mounts: List[BindMount] = [ + prepare_runconfig( + workflow_name=workflow_name, + runconfig=runconfig, + runconfig_dir=runconfig_dir, + ) + ] + list(basic_mounts) + + prepare_subdirectories( + workflow_name=workflow_name, test=test, test_params=test_params + ) + + # Run the test on the image. + try: + test_params.image.run(command, bind_mounts=mounts, host_user=True) + except CalledProcessError as err: + raise TestFailedError( + "Workflow test failed with stderr:\n" + str(err.stderr) + ) from err + + +@dataclass(frozen=True) +class WorkflowParams: + """A data container holding parameters for a workflow.""" + + # The image that the workflow will be run on. + image: Image + # The tag of the above image. + image_tag: str + # A mapping of input data repositories to their host paths. + input_dict: Mapping[str, Path] + # The host location of the output directory for the workflow. + output_dir: Path + # The host location of the scratch directory for the workflow. + scratch_dir: Path diff --git a/wigwam/defaults.py b/wigwam/defaults.py index 449ea38..a019622 100644 --- a/wigwam/defaults.py +++ b/wigwam/defaults.py @@ -14,3 +14,8 @@ def install_prefix() -> Path: def build_prefix() -> Path: """Returns the build system's default build prefix path.""" return Path("/tmp/build") + + +def default_workflowtest_path() -> Path: + """The default workflowtests.json path""" + return Path("workflowtests.json") diff --git a/workflowtests.json b/workflowtests.json new file mode 100644 index 0000000..fffe597 --- /dev/null +++ b/workflowtests.json @@ -0,0 +1,311 @@ +{ + "rslc": { + "type": "single", + "tests": { + "REE1": { + "runconfig": "REE1.yaml", + "inputs": "L0B_RRSD_REE1" + }, + + "REE2": { + "runconfig": "REE2.yaml", + "inputs": "L0B_RRSD_REE2" + }, + + "REE_NISAR_dithered": { + "runconfig": "REE_NISAR_dithered.yaml", + "inputs": "L0B_RRSD_REE_NISAR_dithered" + }, + + "DIST1": { + "runconfig": "DIST1.yaml", + "inputs": "L0B_RRSD_DIST1" + }, + + "DIST2": { + "runconfig": "DIST2.yaml", + "inputs": "L0B_RRSD_DIST2" + }, + + "ALPSRP037370690": { + "runconfig": "ALPSRP037370690.yaml", + "inputs": "L0B_RRSD_ALPSRP037370690" + }, + + "ALPSRP271200680": { + "runconfig": "ALPSRP271200680.yaml", + "inputs": "L0B_RRSD_ALPSRP271200680" + } + } + }, + + "doppler": { + "type": "single", + "tests": { + "ALPSRP264757150_Amazon": { + "runconfig": "ALPSRP264757150_Amazon.txt", + "inputs": "L0B_RRSD_ALPSRP264757150_Amazon" + }, + + "DM2_REE_AMAZON_PASS1": { + "runconfig": "DM2_REE_AMAZON_PASS1.txt", + "inputs": "L0B_RRSD_DM2_REE_AMAZON_PASS1" + } + } + }, + + "el_edge": { + "type": "single", + "tests": { + "ALPSRP264757150_Amazon": { + "runconfig": "ALPSRP264757150_Amazon.txt", + "inputs": "L0B_RRSD_ALPSRP264757150_Amazon" + } + } + }, + + "el_null": { + "type": "single", + "tests": { + "REE_CHANNEL4_EXTSCENE_PASS1": { + "runconfig": "REE_CHANNEL4_EXTSCENE_PASS1.txt", + "inputs": "L0B_RRSD_REE_CHANNEL4_EXTSCENE_PASS1" + }, + + "DM2_REE_AMAZON_PASS1": { + "runconfig": "DM2_REE_AMAZON_PASS1.txt", + "inputs": "L0B_RRSD_DM2_REE_AMAZON_PASS1" + } + } + }, + + "gslc": { + "type": "single", + "tests": { + "UAVSAR_SanAnd_05024_18038_006_180730_L090_CX_129_05": { + "runconfig": "UAVSAR_SanAnd_05024_18038_006_180730_L090_CX_129_05.yaml", + "inputs": "L1_RSLC_UAVSAR_SanAnd_05024_18038_006_180730_L090_CX_129_05" + }, + + "UAVSAR_NISARP_32039_19049_005_190717_L090_CX_129_0": { + "runconfig": "UAVSAR_NISARP_32039_19049_005_190717_L090_CX_129_0.yaml", + "inputs": "L1_RSLC_UAVSAR_NISARP_32039_19049_005_190717_L090_CX_129_03" + }, + + "UAVSAR_NISARP_32039_19052_004_190726_L090_CX_129_02": { + "runconfig": "UAVSAR_NISARP_32039_19052_004_190726_L090_CX_129_02.yaml", + "inputs": "L1_RSLC_UAVSAR_NISARP_32039_19052_004_190726_L090_CX_129_02" + }, + + "UAVSAR_SanAnd_05518_12018_000_120419_L090_CX_143_03": { + "runconfig": "UAVSAR_SanAnd_05518_12018_000_120419_L090_CX_143_03.yaml", + "inputs": "L1_RSLC_UAVSAR_SanAnd_05518_12018_000_120419_L090_CX_143_03" + }, + + "UAVSAR_SanAnd_05518_12128_008_121105_L090_CX_143_02": { + "runconfig": "UAVSAR_SanAnd_05518_12128_008_121105_L090_CX_143_02.yaml", + "inputs": "L1_RSLC_UAVSAR_SanAnd_05518_12128_008_121105_L090_CX_143_02" + }, + + "UAVSAR_Snjoaq_14511_18034_014_180720_L090_CX_143_02": { + "runconfig": "UAVSAR_Snjoaq_14511_18034_014_180720_L090_CX_143_02.yaml", + "inputs": "L1_RSLC_UAVSAR_Snjoaq_14511_18034_014_180720_L090_CX_143_02" + }, + + "UAVSAR_Snjoaq_14511_18044_015_180814_L090_CX_143_02": { + "runconfig": "UAVSAR_Snjoaq_14511_18044_015_180814_L090_CX_143_02.yaml", + "inputs": "L1_RSLC_UAVSAR_Snjoaq_14511_18044_015_180814_L090_CX_143_02" + } + } + }, + + "gcov": { + "type": "single", + "tests": { + "UAVSAR_SanAnd_05024_18038_006_180730_L090_CX_129_05": { + "runconfig": "UAVSAR_SanAnd_05024_18038_006_180730_L090_CX_129_05.yaml", + "inputs": "L1_RSLC_UAVSAR_SanAnd_05024_18038_006_180730_L090_CX_129_05" + }, + + "UAVSAR_NISARP_32039_19049_005_190717_L090_CX_129_03": { + "runconfig": "UAVSAR_NISARP_32039_19049_005_190717_L090_CX_129_03.yaml", + "inputs": "L1_RSLC_UAVSAR_NISARP_32039_19049_005_190717_L090_CX_129_03" + }, + + "S1B_IW_SLC__1SDV_20180504T104507_20180504T104535_010770_013AEE_919F": { + "runconfig": "S1B_IW_SLC__1SDV_20180504T104507_20180504T104535_010770_013AEE_919F.yaml", + "inputs": "L1_RSLC_S1B_IW_SLC__1SDV_20180504T104507_20180504T104535_010770_013AEE_919F" + }, + + "UAVSAR_SanAnd_05518_12018_000_120419_L090_CX_143_03": { + "runconfig": "UAVSAR_SanAnd_05518_12018_000_120419_L090_CX_143_03.yaml", + "inputs": "L1_RSLC_UAVSAR_SanAnd_05518_12018_000_120419_L090_CX_143_03" + }, + + "UAVSAR_SanAnd_05518_12128_008_121105_L090_CX_143_02": { + "runconfig": "UAVSAR_SanAnd_05518_12128_008_121105_L090_CX_143_02.yaml", + "inputs": "L1_RSLC_UAVSAR_SanAnd_05518_12128_008_121105_L090_CX_143_02" + }, + + "UAVSAR_Snjoaq_14511_18034_014_180720_L090_CX_143_02": { + "runconfig": "UAVSAR_Snjoaq_14511_18034_014_180720_L090_CX_143_02.yaml", + "inputs": "L1_RSLC_UAVSAR_Snjoaq_14511_18034_014_180720_L090_CX_143_02" + }, + + "UAVSAR_Snjoaq_14511_18044_015_180814_L090_CX_143_02": { + "runconfig": "UAVSAR_Snjoaq_14511_18044_015_180814_L090_CX_143_02.yaml", + "inputs": "L1_RSLC_UAVSAR_Snjoaq_14511_18044_015_180814_L090_CX_143_02" + } + } + }, + + "noisest": { + "type": "single", + "tests": { + "REE_BF_NET": { + "runconfig": "REE_BF_NET.txt", + "inputs": "L0B_RRSD_REE_BF_NET" + } + } + }, + + "pta": { + "type": "single", + "tests": { + "REE_PTA": { + "runconfig": "REE_PTA.txt", + "inputs": "L1_RSLC_REE_PTA" + } + } + }, + + "soilm": { + "type": "single", + "tests": { + "023_045_A_017_4020_HH_20220926T135152_20220926T135219_P01101_P_P_J_001": { + "runconfig": "023_045_A_017_4020_HH_20220926T135152_20220926T135219_P01101_P_P_J_001.txt", + "inputs": "L2_GCOV_023_045_A_017_4020_HH_20220926T135152_20220926T135219_P01101_P_P_J_001" + } + } + }, + + "insar": { + "type": "single", + "tests": { + "UAVSAR_NISARP_32039_19049-005_19052-004_129": { + "runconfig": "UAVSAR_NISARP_32039_19049-005_19052-004_129.yaml", + "inputs": { + "ref": "L1_RSLC_UAVSAR_NISARP_32039_19049_005_190717_L090_CX_129_03", + "sec": "L1_RSLC_UAVSAR_NISARP_32039_19052_004_190726_L090_CX_129_02" + } + }, + + "UAVSAR_SanAnd_05518_12018-000_12128-008_143": { + "runconfig": "UAVSAR_SanAnd_05518_12018-000_12128-008_143.yaml", + "inputs": { + "ref": "L1_RSLC_UAVSAR_SanAnd_05518_12018_000_120419_L090_CX_143_03", + "sec": "L1_RSLC_UAVSAR_SanAnd_05518_12128_008_121105_L090_CX_143_02" + } + }, + + "UAVSAR_Snjoaq_14511_18034-014_18044-015_143": { + "runconfig": "UAVSAR_Snjoaq_14511_18034-014_18044-015_143.yaml", + "inputs": { + "ref": "L1_RSLC_UAVSAR_Snjoaq_14511_18034_014_180720_L090_CX_143_02", + "sec": "L1_RSLC_UAVSAR_Snjoaq_14511_18044_015_180814_L090_CX_143_02" + } + } + } + }, + + "end2end": { + "type": "multi", + "tests": { + "ALPSRP262866750_ALPSRP269576750_Chile": { + "inputs": { + "ref": "L0B_RRSD_ALPSRP262866750_Chile", + "sec": "L0B_RRSD_ALPSRP269576750_Chile" + }, + "series": [ + { + "workflow": "rslc", + "label": "ref", + "runconfig": "ALPSRP262866750_ALPSRP269576750_Chile_ref.yaml" + }, + { + "workflow": "rslc", + "label": "sec", + "runconfig": "ALPSRP262866750_ALPSRP269576750_Chile_sec.yaml" + }, + { + "workflow": "gslc", + "label": "ref", + "runconfig": "ALPSRP262866750_ALPSRP269576750_Chile_ref.yaml" + }, + { + "workflow": "gslc", + "label": "sec", + "runconfig": "ALPSRP262866750_ALPSRP269576750_Chile_sec.yaml" + }, + { + "workflow": "gcov", + "label": "ref", + "runconfig": "ALPSRP262866750_ALPSRP269576750_Chile_ref.yaml" + }, + { + "workflow": "gcov", + "label": "sec", + "runconfig": "ALPSRP262866750_ALPSRP269576750_Chile_sec.yaml" + }, + { + "workflow": "insar", + "runconfig": "ALPSRP262866750_ALPSRP269576750_Chile.yaml" + } + ] + }, + + "ALPSRP110160680_ALPSRP116870680_Rosamond": { + "inputs": { + "ref": "L0B_RRSD_ALPSRP110160680_Rosamond", + "sec": "L0B_RRSD_ALPSRP116870680_Rosamond" + }, + "series": [ + { + "workflow": "rslc", + "label": "ref", + "runconfig": "ALPSRP110160680_ALPSRP116870680_Rosamond_ref.yaml" + }, + { + "workflow": "rslc", + "label": "sec", + "runconfig": "ALPSRP110160680_ALPSRP116870680_Rosamond_sec.yaml" + }, + { + "workflow": "gslc", + "label": "ref", + "runconfig": "ALPSRP110160680_ALPSRP116870680_Rosamond_ref.yaml" + }, + { + "workflow": "gslc", + "label": "sec", + "runconfig": "ALPSRP110160680_ALPSRP116870680_Rosamond_sec.yaml" + }, + { + "workflow": "gcov", + "label": "ref", + "runconfig": "ALPSRP110160680_ALPSRP116870680_Rosamond_ref.yaml" + }, + { + "workflow": "gcov", + "label": "sec", + "runconfig": "ALPSRP110160680_ALPSRP116870680_Rosamond_sec.yaml" + }, + { + "workflow": "insar", + "runconfig": "ALPSRP110160680_ALPSRP116870680_Rosamond.yaml" + } + ] + } + } + } +}