From adc73fd7e76841932d8eec4433b883f1fe5dc2b5 Mon Sep 17 00:00:00 2001 From: Ruben Horn <> Date: Tue, 13 Aug 2024 17:30:44 +0200 Subject: [PATCH] config-generator: non-interactive mode --- examples/config-generator/README.md | 6 +- .../assets/configuration_template.json | 13 +- examples/config-generator/run | 4 +- .../generators/{domain.py => domain_size.py} | 11 +- .../src/generators/filtering.py | 2 +- .../src/generators/simulation.py | 5 +- .../src/generators/solver_md.py | 4 +- .../src/generators/use_checkpoint.py | 7 +- examples/config-generator/src/main.py | 198 ++++++++++++++---- examples/plotting/plot-couette-profiles.py | 20 +- 10 files changed, 195 insertions(+), 75 deletions(-) rename examples/config-generator/src/generators/{domain.py => domain_size.py} (85%) diff --git a/examples/config-generator/README.md b/examples/config-generator/README.md index 003122b6f..a6360c2eb 100644 --- a/examples/config-generator/README.md +++ b/examples/config-generator/README.md @@ -42,6 +42,8 @@ This file has the following structure: ### Usage 1. Navigate to the target directory for the `couette.xml` file (most likely the MaMiCo build folder) -2. Execute `$MAMICO_DIR/examples/config-generator/run` +2. Execute `$MAMICO_DIR/examples/config-generator/run` +Specific values can be fixed by providing them as a command line argument, e.g. `--override=domain_size=large,use_checkpoint=False,multi_md=2`. +The corresponding entries will be removed from the interactive menu. If all configurations are overwritten, the script will run non-interactively. (May be used for automated testing.) 3. Customize the generator settings and generate the `couette.xml` file -3. Customize the generated configuration even further according to the [documentation](https://github.com/HSU-HPC/MaMiCo/wiki/couette.xml) (optional) +4. Customize the generated configuration even further according to the [documentation](https://github.com/HSU-HPC/MaMiCo/wiki/couette.xml) (optional) diff --git a/examples/config-generator/assets/configuration_template.json b/examples/config-generator/assets/configuration_template.json index 0dd1b176c..6c818da1f 100644 --- a/examples/config-generator/assets/configuration_template.json +++ b/examples/config-generator/assets/configuration_template.json @@ -1,22 +1,19 @@ [ { - "key": "domain", + "key": "domain_size", "label": "domain size", "options": [ { "selected": true, - "value": 1, - "label": "small", + "value": "small", "description": "MD/CFD domain size = 30\u00B3/50\u00B3" }, { - "value": 2, - "label": "medium", + "value": "medium", "description": "MD/CFD domain size = 60\u00B3/100\u00B3" }, { - "value": 3, - "label": "large", + "value": "large", "description": "MD/CFD domain size = 120\u00B3/200\u00B3" } ] @@ -188,4 +185,4 @@ } ] } -] \ No newline at end of file +] diff --git a/examples/config-generator/run b/examples/config-generator/run index f2b100015..2d280c8dd 100755 --- a/examples/config-generator/run +++ b/examples/config-generator/run @@ -4,7 +4,7 @@ # Any dependencies are automatically installed. # The output of the script is saved to the current working directory. -OUTPUT="$(pwd)/couette.xml" +OUTPUT="$(pwd)" cd "$(dirname "$0")" || exit @@ -22,4 +22,4 @@ fi source .venv/bin/activate # Run the config builder -python3 src/main.py "$OUTPUT" +python3 src/main.py --output "$OUTPUT" "$@" diff --git a/examples/config-generator/src/generators/domain.py b/examples/config-generator/src/generators/domain_size.py similarity index 85% rename from examples/config-generator/src/generators/domain.py rename to examples/config-generator/src/generators/domain_size.py index 1b7d8cfe4..c39ca44af 100644 --- a/examples/config-generator/src/generators/domain.py +++ b/examples/config-generator/src/generators/domain_size.py @@ -2,19 +2,22 @@ from generators.mpi_ranks import get_ranks_xyz -def _get_domain_size(get_config_value) -> int: +def get_domain_size(get_config_value) -> int: key = __name__.split(".")[-1] - return get_config_value(key) + domain_size_name = get_config_value(key) + domain_size_names = ["small", "medium", "large"] + return domain_size_names.index(domain_size_name) + 1 def get_domain_sizes(get_config_value) -> int: - size = _get_domain_size(get_config_value) + size = get_domain_size(get_config_value) md_base_size = 30 cfd_base_size = 50 md_domain_size = md_base_size * size cfd_domain_size = cfd_base_size * size if size == 3: md_domain_size = 120 # instead of 90 + cfd_domain_size = 200 # instead of 150 return md_domain_size, cfd_domain_size @@ -35,7 +38,7 @@ def validate(get_config_value) -> str: def apply(partial_xml, get_config_value) -> None: equilibration_steps = 10000 equilibration_steps_max = 20000 - size = _get_domain_size(get_config_value) + size = get_domain_size(get_config_value) md_domain_size, cfd_domain_size = get_domain_sizes(get_config_value) domain_offset_xy = (cfd_domain_size - md_domain_size) / 2 # Centered partial_xml.substitute("md-size", md_domain_size) diff --git a/examples/config-generator/src/generators/filtering.py b/examples/config-generator/src/generators/filtering.py index 62bc56a3c..32f2cba37 100644 --- a/examples/config-generator/src/generators/filtering.py +++ b/examples/config-generator/src/generators/filtering.py @@ -25,7 +25,7 @@ def apply(partial_xml, get_config_value) -> None: """.strip() if filtering == False: - per_instance_filtering_xml = "" + per_instance_filtering_xml = '' elif filtering == "gauss": per_instance_filtering_xml = xml_2d_gaussian elif filtering == "nlm": diff --git a/examples/config-generator/src/generators/simulation.py b/examples/config-generator/src/generators/simulation.py index f6a9e788c..b42cdf1b3 100644 --- a/examples/config-generator/src/generators/simulation.py +++ b/examples/config-generator/src/generators/simulation.py @@ -1,7 +1,10 @@ +from generators.domain_size import get_domain_size + + def apply(partial_xml, get_config_value) -> None: key = __name__.split(".")[-1] simulation_type = get_config_value(key) - domain_size = get_config_value("domain") + domain_size = get_domain_size(get_config_value) match simulation_type: case "test": partial_xml.substitute("coupling-cycles", 50) diff --git a/examples/config-generator/src/generators/solver_md.py b/examples/config-generator/src/generators/solver_md.py index 048d3f84d..6c1416409 100644 --- a/examples/config-generator/src/generators/solver_md.py +++ b/examples/config-generator/src/generators/solver_md.py @@ -1,7 +1,7 @@ from pathlib import Path from generators.cell_size import get_molecules_per_direction -from generators.domain import get_domain_sizes +from generators.domain_size import get_domain_sizes from utils import get_asset_text from xml_templating import PartialXml @@ -15,7 +15,7 @@ def _create_ls1_config(get_config_value) -> None: xml.substitute("grid-filler-lattice", grid_filler_lattice) boundary_condition = get_config_value("boundary") xml.substitute("boundary-condition", boundary_condition) - filename = Path(get_config_value("output_filename")).parent / "ls1config.xml" + filename = Path(get_config_value("output_dir")) / "ls1config.xml" with open(filename, "w") as file: file.write(xml.get()) diff --git a/examples/config-generator/src/generators/use_checkpoint.py b/examples/config-generator/src/generators/use_checkpoint.py index 0e51c0bc0..95f0c490a 100644 --- a/examples/config-generator/src/generators/use_checkpoint.py +++ b/examples/config-generator/src/generators/use_checkpoint.py @@ -1,6 +1,8 @@ import shutil from pathlib import Path +from generators.domain_size import get_domain_size + def validate(get_config_value) -> str: """MaMiCo config validation: @@ -8,7 +10,7 @@ def validate(get_config_value) -> str: """ key = __name__.split(".")[-1] use_checkpoint = get_config_value(key) - domain_size = get_config_value("domain") + domain_size = get_domain_size(get_config_value) solver_md = get_config_value("solver_md") if use_checkpoint and (domain_size > 1 or solver_md != "md"): return f"Example checkpoint file only provided for small Simple MD simulation." @@ -29,8 +31,7 @@ def apply(partial_xml, get_config_value) -> None: / f"CheckpointSimpleMD_10000_{boundary_condition}_0.checkpoint" ) checkpoint_dst_path = ( - Path(get_config_value("output_filename")).parent - / "CheckpointSimpleMD.checkpoint" + Path(get_config_value("output_dir")) / "CheckpointSimpleMD.checkpoint" ) # Avoid reading and writing the contents of the file, because it is rather large shutil.copyfile(checkpoint_src_path, checkpoint_dst_path) diff --git a/examples/config-generator/src/main.py b/examples/config-generator/src/main.py index 59f6f8b12..d77f38000 100644 --- a/examples/config-generator/src/main.py +++ b/examples/config-generator/src/main.py @@ -1,10 +1,11 @@ #! /usr/bin/env python3 """ -Utility script to generate basic couette.xml configuration for MaMiCo. +Utility script to generate basic couette.xml configurations for MaMiCo. Do NOT run this script directly! Use ../run instead. """ +import argparse import importlib import json import os @@ -16,16 +17,47 @@ from xml_templating import PartialXml -def select_option(config: dict) -> None: +def select_option(configs: list, key: str, value: str) -> None: + """Select an option based on a serialized key value pair without user input. + + Keyword arguments: + configs -- All configurations in which to update the configuration with the specified key + key -- The key by which to select the configuration + value -- The serialized representation of the value which should be selected + """ + for config in configs: + if config["key"] == key: + get_selected(config)["selected"] = False + for option in config["options"]: + if str(option["value"]) == value: + option["selected"] = True + return + raise ValueError( + f'No option with the value "{value}" exists for the configuration with the key "{key}"' + ) + raise ValueError(f'No configuration with the key "{key}" exists') + + +def select_option_interactive(configs: list, label: str) -> None: """Show a terminal menu for a single option of the configuration and apply the selection of the user to the current configurations. Keyword arguments: - config -- The configuration from all configurations (by reference) which should be updated + configs -- All configurations in which to update the configuration with the specified label + label -- The menu label by which to select the configuration """ - print(config) + selected_config = None + for config in configs: + if config["label"] == label: + if selected_config is not None: + raise RuntimeError( + f'Ambiguous selection. Multiple configs match the label "{label}"' + ) + else: + selected_config = config + del config # Avoid bugs by re-using variable pre_selected = None option_labels = [] - for i, option in enumerate(config["options"]): + for i, option in enumerate(selected_config["options"]): if "selected" in option and option["selected"]: pre_selected = i option["selected"] = False @@ -34,9 +66,9 @@ def select_option(config: dict) -> None: label += "\t" + option["description"] option_labels.append(label) selected = term.select( - option_labels, title=config["label"], pre_selected=pre_selected + option_labels, title=selected_config["label"], pre_selected=pre_selected ) - config["options"][selected]["selected"] = True + selected_config["options"][selected]["selected"] = True def get_selected(config: dict) -> object: @@ -77,27 +109,32 @@ def load_generator(generator: str) -> object: exit(1) -def generate(configs: list, filename: str) -> None: +def generate(configs: list, output_dir: str) -> None: """Create the configuration by loading the template, applying the generators, and saving the resulting XML file. + This function terminates the process. Keyword arguments: configs -- The list of configurations which to apply using the generators corresponding to the keys - filename -- The output XML filename + output_dir -- The directory where couette.xml and other files will be created """ xml = PartialXml(get_asset_text("couette.xml.template")) print("Loaded configuration template") for config in configs: generator = load_generator(config["key"]) # Make output path accessible to generators - config_output_filename = [ - dict(key="output_filename", options=[dict(selected=True, value=filename)]) + config_output_dir = [ + dict(key="output_dir", options=[dict(selected=True, value=output_dir)]) ] - generator.apply( - xml, lambda k: get_config_value(configs + config_output_filename, k) - ) - with open(filename, "w") as file: + generator.apply(xml, lambda k: get_config_value(configs + config_output_dir, k)) + path = output_dir / "couette.xml" + with open(path, "w") as file: file.write(xml.get()) - print(f"\nWrote configuration to {filename}") + print(f"\nWrote configuration to {path}") + ranks = get_config_value(configs, "mpi_ranks") * get_config_value( + configs, "multi_md" + ) + print(f'\nRun simulation using "mpirun -n {ranks} $MAMICO_DIR/build/couette"') + exit(0) def validate(configs: list) -> str: @@ -115,7 +152,8 @@ def validate(configs: list) -> str: ) except AttributeError: print( - f"Could not invoke validation for generator \"{config['key']}\"", file=sys.stderr, + f"Could not invoke validation for generator \"{config['key']}\"", + file=sys.stderr, ) continue if validation_error is not None: @@ -123,10 +161,76 @@ def validate(configs: list) -> str: return validation_errors.strip() +def parse_args(argv: dict = sys.argv[1:], configs: list = []) -> object: + """Parse the arguments (from the command line) and return them as a dictionary. + If the usage should be printed, the program is terminated instead. + + Keyword arguments: + argv -- The command line arguments excluding the name of the script itself or the interpreter + configs -- All configurations which may be overwritten using command line arguments + """ + # Extract all possible key=value overrides for help text and validation + all_override_kvs = set() + all_overrides = "" + for config in configs: + key = config["key"] + all_overrides += f"\n\t{key}\t=\t<" + for i, option in enumerate(config["options"]): + value = str(option["value"]) + all_override_kvs.add(f"{key}={value}") + if i > 0: + all_overrides += "|" + all_overrides += value + all_overrides += ">" + # Parse arguments + arg_parser = argparse.ArgumentParser( + description="A simple utility to generate basic couette.xml configurations for MaMiCo.", + epilog="Available overrides:" + all_overrides, + formatter_class=argparse.RawTextHelpFormatter, + ) + arg_parser.add_argument( + "-o", + "--output", + type=Path, + required=True, + help="The directory where couette.xml and other files will be created", + ) + arg_parser.add_argument( + "--override", + type=str, + default="", + help="Comma separated list of key-value (key=value) config overrides. (Will be removed from interactive menu)", + ) + # Parse key=value overrides + args = arg_parser.parse_args(argv) + override = {} + for kv in args.override.split(","): + if len(kv.strip()) == 0: + continue + parts = kv.split("=") + if len(parts) != 2: + arg_parser.error( + "The value of --override must be a comma separated list of key-value pairs (key=value)." + ) + k, v = (p.strip() for p in parts) + override[k] = v + args.override = override + # Check if every key=value override is valid + for key, value in args.override.items(): + if f"{key}={value}" not in all_override_kvs: + arg_parser.error(f'Invalid override "{key}={value}"') + if not Path(args.output).is_dir(): + arg_parser.error( + "The value of --output must be a path to a directory, not a file." + ) + return args + + def main() -> None: os.chdir(Path(__file__).parent) # 1. Load configuration options configs = json.loads(get_asset_text("configuration_template.json")) + args = parse_args(configs=configs) # Fill missing labels for config in configs: if "label" not in config: @@ -140,36 +244,46 @@ def main() -> None: title = "MaMiCo couette.xml Generator\n" main_menu = [] for config in configs: - main_menu.append(config["label"] + "\t" + get_selected(config)["label"]) + # Apply overrides from command line + key = config["key"] + if key in args.override: + select_option(configs, key, args.override[key]) + else: + main_menu.append(config["label"] + "\t" + get_selected(config)["label"]) # 3. Validate validation_errors = validate(configs) is_valid = len(validation_errors.strip()) == 0 - if is_valid: - main_menu[-1] += "\n" - main_menu.append("Generate") + is_interactive = ( + len(main_menu) > 0 + ) # Skip interactive mode if there are no options + if not is_interactive: + if is_valid: + generate(configs, args.output) + else: + print(validation_errors, file=sys.stderr) + exit(1) else: - title += "\n" + validation_errors + "\n" - # 4. Show and the menu - selected = term.select( - main_menu, - title=title, - pre_selected=-1 if is_valid else None, - ) - if selected == -1: - exit(1) - elif is_valid and selected == len(main_menu) - 1: - # Generate - generate(configs, sys.argv[1]) - ranks = get_config_value(configs, "mpi_ranks") * get_config_value( - configs, "multi_md" + if is_valid: + main_menu[-1] += "\n" + main_menu.append("Generate") + else: + title += "\n" + validation_errors + "\n" + # 4. Show and the menu + selected = term.select( + main_menu, + title=title, + pre_selected=-1 if is_valid else None, ) - print( - f'\nRun simulation using "mpirun -n {ranks} $MAMICO_DIR/build/couette"' - ) - exit(0) - else: - # Update configuration - select_option(configs[selected]) + if selected == -1: + exit(1) + elif is_valid and selected == len(main_menu) - 1: + # Generate + generate(configs, args.output) + else: + # Update configuration + # Remove explanation behind label + selected_label = main_menu[selected].split("\t")[0] + select_option_interactive(configs, selected_label) if __name__ == "__main__": diff --git a/examples/plotting/plot-couette-profiles.py b/examples/plotting/plot-couette-profiles.py index a6267bdb5..ae3a6a210 100644 --- a/examples/plotting/plot-couette-profiles.py +++ b/examples/plotting/plot-couette-profiles.py @@ -1,7 +1,7 @@ #! /usr/bin/env python3 """ -Simple but versitile script to plot couette flow profiles +Simple but versatile script to plot couette flow profiles (Replaces https://github.com/HSU-HPC/MaMiCo/commit/42ad244c75640a692ae1b70d56c7060431fdab0d) """ @@ -95,18 +95,18 @@ def parse_args(argv=sys.argv[1:]): global args arg_parser = argparse.ArgumentParser() # Scenario parameters - arg_parser.add_argument("--offset", default=2.5) - arg_parser.add_argument("--wall-velocity", default=0.5) - arg_parser.add_argument("--channel-height", default=50.0) - arg_parser.add_argument("--density", default=0.813037037) - arg_parser.add_argument("--viscosity", default=2.4) - arg_parser.add_argument("--coupling-cells", default=6) + arg_parser.add_argument("--offset", default=2.5, type=float) + arg_parser.add_argument("--wall-velocity", default=0.5, type=float) + arg_parser.add_argument("--channel-height", default=50.0, type=float) + arg_parser.add_argument("--density", default=0.813037037, type=float) + arg_parser.add_argument("--viscosity", default=2.4, type=float) + arg_parser.add_argument("--coupling-cells", default=6, type=int) arg_parser.add_argument( - "--overlap-size", default=3, help="In number of coupling cells" + "--overlap-size", default=3, help="In number of coupling cells", type=int ) - arg_parser.add_argument("--coupling-cell-size", default=2.5) + arg_parser.add_argument("--coupling-cell-size", default=2.5, type=float) # Script parameters - arg_parser.add_argument("--workdir", default=Path(__file__).parent.parent) + arg_parser.add_argument("--workdir", default=Path(), type=Path) arg_parser.add_argument( "--coupling-cycles", default="",