diff --git a/.github/workflows/run-dumux-benchmark.yml b/.github/workflows/run-dumux-benchmark.yml new file mode 100644 index 0000000..3c00e6f --- /dev/null +++ b/.github/workflows/run-dumux-benchmark.yml @@ -0,0 +1,55 @@ +name: DuMux-CI +on: + push: + + pull_request: + branches: [ main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + + # Runs the workflow once per day at 3:15am + schedule: + - cron: '3 16 * * *' + +env: + CACHE_NUMBER: 1 # increase to reset cache manually + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: checkout repo content + uses: actions/checkout@v2 + + - name: Setup Mambaforge + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: latest + activate-environment: model-validation + use-mamba: true + + - name: Set strict channel priority + run: conda config --set channel_priority strict + + - name: Setup Apptainer + uses: eWaterCycle/setup-apptainer@v2 + with: + apptainer-version: 1.4.5 + + - name: Update environment + run: mamba env update -n model-validation -f environment_benchmarks.yml + + - name: generate-rc-config + shell: bash -l {0} + run: | + cd $GITHUB_WORKSPACE/benchmarks/rotating-cylinders + python3 generate_rc_config.py + + - name: run-rotating-cylinders-snakemake + shell: bash -l {0} + run: | + cd $GITHUB_WORKSPACE/benchmarks/rotating-cylinders + # Note: --use-singularity is aliased to --use-apptainer in modern Snakemake + snakemake --use-apptainer --cores all --resources serial_run=1 --apptainer-args "--bind $(pwd):/dumux/shared" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f684468..bfbec91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ -.snakemake -site \ No newline at end of file +site + +# macOS system files +.DS_Store + +# Snakemake hidden folders +.snakefile/ +.snakemake/ + +# Sentinel / temporary files +*.done +.simulation_done diff --git a/benchmarks/rotating-cylinders/Snakefile b/benchmarks/rotating-cylinders/Snakefile new file mode 100644 index 0000000..63d7e95 --- /dev/null +++ b/benchmarks/rotating-cylinders/Snakefile @@ -0,0 +1,63 @@ +# benchmarks/rotating-cylinders/Snakefile +import json +import os + +if not os.path.exists("rotating-cylinders_config.json"): + os.system("python3 generate_rc_config.py") + +configfile: "rotating-cylinders_config.json" + +# Variables +tools = config["tools"] +configs = config["configurations"] +benchmark = config["benchmark"] +benchmark_uri = config["benchmark_uri"] +result_dir = f"snakemake_results/{benchmark}" +shared_dir = os.getcwd() + +rule all: + input: + expand(f"{result_dir}/{{tool}}/summary.json", tool=tools) + +for tool in tools: + include: f"{tool}/Snakefile" + +rule summary: + input: + script = "../common/summarize_results.py", + + parameters=lambda wc: expand( + f"{shared_dir}/{wc.tool}/grid_files/grid_{{conf}}.json", + conf=configs + ), + + mesh=lambda wc: expand( + f"{shared_dir}/{wc.tool}/grid_files/grid_{{conf}}.json", + conf=configs + ), + + metrics=lambda wc: expand( + f"{result_dir}/{wc.tool}/solution_metrics_{{conf}}.json", + conf=configs + ), + + solution_field_data=lambda wc: expand( + f"{result_dir}/{wc.tool}/solution_field_data_{{conf}}.zip", + conf=configs + ) + + output: + summary_json=f"{result_dir}/{{tool}}/summary.json" + + shell: + """ + python3 {input.script} \ + --input_configuration {configs} \ + --input_parameter_file {input.parameters} \ + --input_mesh_file {input.mesh} \ + --input_solution_metrics {input.metrics} \ + --input_solution_field_data {input.solution_field_data} \ + --input_benchmark {benchmark} \ + --input_benchmark_uri {benchmark_uri} \ + --output_summary_json {output.summary_json} + """ \ No newline at end of file diff --git a/benchmarks/rotating-cylinders/dumux/Snakefile b/benchmarks/rotating-cylinders/dumux/Snakefile new file mode 100644 index 0000000..cb03796 --- /dev/null +++ b/benchmarks/rotating-cylinders/dumux/Snakefile @@ -0,0 +1,60 @@ +# benchmarks/rotating-cylinders/dumux/Snakefile +import os + +# Setup and Config +tool = "dumux" +container_image = config["container_image"] +dumux_dir = f"{shared_dir}/dumux" + +# Rule 1: Input generation +rule generate_dumux_inputs: + input: + grid_t = f"{dumux_dir}/grid_files/grid_template.json", + dumux_t = f"{dumux_dir}/dumux_input_files/dumux_config.json", + input_gen_script = f"{dumux_dir}/dumux_input_gen.py" + output: + params = expand(f"{dumux_dir}/dumux_input_files/params_{{conf}}.json", conf=configs), + grids = expand(f"{dumux_dir}/grid_files/grid_{{conf}}.json", conf=configs) + shell: + "python3 {input.input_gen_script} --grid_template {input.grid_t} --dumux_template {input.dumux_t}" + +# Rule 2: Simulation +rule run_dumux_simulation: + input: + # Lambda prevents KeyError before the JSON is generated + params = lambda wildcards: f"{dumux_dir}/dumux_input_files/{config['configuration_to_parameter_file'][wildcards.configuration]}" + output: + vtu_files = [ + f"{dumux_dir}/test_rotatingcylinders_{{configuration}}-00000.vtu", + f"{dumux_dir}/test_rotatingcylinders_{{configuration}}-00001.vtu" + ] + resources: + serial_run=1 + singularity: + f"docker://{container_image}" + shell: + """ + set -euo pipefail + cd /dumux/rotating-cylinders/build-cmake/test/freeflow/navierstokes/rotatingcylinders + ./test_ff_navierstokes_rotatingcylinders JsonParameterFile={input.params} + """ + +# Rule 3: Post-processing +rule postprocess_dumux: + input: + sim_data = [ + f"{dumux_dir}/test_rotatingcylinders_{{configuration}}-00000.vtu", + f"{dumux_dir}/test_rotatingcylinders_{{configuration}}-00001.vtu" + ], + postprocess_script = f"{dumux_dir}/run_dumux_postprocessing.py" + output: + metrics = f"{result_dir}/{tool}/solution_metrics_{{configuration}}.json", + fields = f"{result_dir}/{tool}/solution_field_data_{{configuration}}.zip" + shell: + """ + python3 {input.postprocess_script} \ + --input_dumux_output_dir {dumux_dir} \ + --input_configuration {wildcards.configuration} \ + --output_solution_file_zip {output.fields} \ + --output_metrics_file {output.metrics} + """ \ No newline at end of file diff --git a/benchmarks/rotating-cylinders/dumux/dumux_input_files/dumux_config.json b/benchmarks/rotating-cylinders/dumux/dumux_input_files/dumux_config.json new file mode 100644 index 0000000..ea30c5a --- /dev/null +++ b/benchmarks/rotating-cylinders/dumux/dumux_input_files/dumux_config.json @@ -0,0 +1,60 @@ +{ + "Problem": { + "Name": "test_rotatingcylinders", + "Omega1": 1e2, + "Omega2": 0, + "EnableGravity": false, + "EnableInertiaTerms": true + }, + "FreeFlow": { + "EnableUnsymmetrizedVelocityGradient": true + }, + "Flux": { + "UpwindWeight": 0.5 + }, + "Component": { + "LiquidDensity": 1, + "LiquidDynamicViscosity": 1 + }, + "Mass": { + "Assembly": { + "NumericDifference": { + "PriVarMagnitude": "1e-2", + "BaseEpsilon": 0.01 + } + } + }, + "Momentum": { + "Assembly": { + "NumericDifference": { + "PriVarMagnitude": "0.2 0.2", + "BaseEpsilon": 0.01 + } + } + }, + "LinearSolver": { + "MaxIterations": 500, + "ResidualReduction": "1e-10", + "SymmetrizeDirichlet": true, + "DirectSolverForVelocity": false, + "GMResRestart": 500, + "Type": "gmres", + "Verbosity": 1, + "Preconditioner": { + "Mode": "Triangular", + "Iterations": 5, + "AmgSmootherIterations": 2, + "AmgDefaultAggregationDimension": 2, + "AmgMinAggregateSize": 2, + "AmgMaxAggregateSize": 2, + "AmgAdditive": false, + "AmgGamma": 1, + "AmgCriterionSymmetric": true + } + }, + "Newton": { + "MinSteps": 1, + "EnableAbsoluteResidualCriterion": true, + "MaxAbsoluteResidual": "4e-6" + } +} \ No newline at end of file diff --git a/benchmarks/rotating-cylinders/dumux/dumux_input_gen.py b/benchmarks/rotating-cylinders/dumux/dumux_input_gen.py new file mode 100644 index 0000000..70a18f7 --- /dev/null +++ b/benchmarks/rotating-cylinders/dumux/dumux_input_gen.py @@ -0,0 +1,86 @@ +import json +import argparse +from pathlib import Path + +def generate_grid_files(grid_template_path, grid_dir, base_cells0, base_cells1, num_files): + grid_dir.mkdir(parents=True, exist_ok=True) + + if not grid_template_path.exists(): + print(f"Error: {grid_template_path} not found.") + return [] + + with open(grid_template_path, "r") as f: + grid_template = json.load(f) + + generated_configs = [] + + for i in range(num_files): + scale = 2 ** i + c0 = base_cells0 * scale + c1 = base_cells1 * scale + config_id = f"{c0}_{c1}" + + current_grid = json.loads(json.dumps(grid_template)) + current_grid["Grid"]["Cells0"] = f"{c0} {c0}" + current_grid["Grid"]["Cells1"] = c1 + + grid_file_path = grid_dir / f"grid_{config_id}.json" + with open(grid_file_path, "w") as f: + json.dump(current_grid, f, indent=4) + + generated_configs.append((config_id, current_grid)) + print(f"Generated Grid JSON: {grid_file_path}") + + return generated_configs + + +def write_dumux_inputs_json(grid_template, dumux_template, grid_out, input_out): + problem_name_base = "/dumux/shared/dumux/test_rotatingcylinders" + base_cells0, base_cells1 = 10, 80 + num_files = 3 + + if not dumux_template.exists(): + print(f"Error: dumux template not found at {dumux_template}") + return + + with open(dumux_template, "r") as f: + dumux_config = json.load(f) + + grid_configs = generate_grid_files( + grid_template, grid_out, base_cells0, base_cells1, num_files + ) + + input_out.mkdir(parents=True, exist_ok=True) + + for config_id, grid_data in grid_configs: + full_config = json.loads(json.dumps(dumux_config)) + + full_config["Problem"]["Name"] = f"{problem_name_base}_{config_id}" + full_config.update(grid_data) + + output_file = input_out / f"params_{config_id}.json" + + with open(output_file, "w") as f: + json.dump(full_config, f, indent=4) + + print(f"Generated JSON Input: {output_file}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Generate DuMuX JSON input files from templates." + ) + + parser.add_argument("--grid_template", type=str, required=True) + parser.add_argument("--dumux_template", type=str, required=True) + parser.add_argument("--grid_dir", type=str, default="./dumux/grid_files") + parser.add_argument("--input_dir", type=str, default="./dumux/dumux_input_files") + + args = parser.parse_args() + + write_dumux_inputs_json( + grid_template=Path(args.grid_template).resolve(), + dumux_template=Path(args.dumux_template).resolve(), + grid_out=Path(args.grid_dir).resolve(), + input_out=Path(args.input_dir).resolve() + ) \ No newline at end of file diff --git a/benchmarks/rotating-cylinders/dumux/grid_files/grid_template.json b/benchmarks/rotating-cylinders/dumux/grid_files/grid_template.json new file mode 100644 index 0000000..7c5021f --- /dev/null +++ b/benchmarks/rotating-cylinders/dumux/grid_files/grid_template.json @@ -0,0 +1,10 @@ +{ + "Grid": { + "Cells0": "{cells0_x} {cells0_y}", + "Cells1": "{cells1}", + "Grading0": "1.1 -1.1", + "Grading1": "1.0", + "Radial0": "1.0 1.5 2.0", + "Angular1": "0.0 360.0" + } +} \ No newline at end of file diff --git a/benchmarks/rotating-cylinders/dumux/run_dumux_postprocessing.py b/benchmarks/rotating-cylinders/dumux/run_dumux_postprocessing.py new file mode 100644 index 0000000..e3c06ff --- /dev/null +++ b/benchmarks/rotating-cylinders/dumux/run_dumux_postprocessing.py @@ -0,0 +1,86 @@ +import json +import zipfile +from argparse import ArgumentParser +from pathlib import Path +import meshio +import numpy as np + +def run_dumux_postprocessing( + dumux_output_dir: str, + configuration: str, + metrics_file: str, + solution_file_zip: str, +) -> None: + dumux_output_dir = Path(dumux_output_dir) + + # ---- Targeted Discovery ---- + # Only find files that belong to THIS configuration to avoid race conditions + # DuMuX files usually look like: test_rotatingcylinders_10_80-00001.vtu + vtk_files = list(dumux_output_dir.glob(f"*{configuration}*.vtu")) + \ + list(dumux_output_dir.glob(f"*{configuration}*.pvtu")) + + pvd_files = list(dumux_output_dir.glob(f"*{configuration}*.pvd")) + + if not vtk_files: + raise RuntimeError(f"No VTU/PVTU files found for configuration: {configuration}") + + # Sort files to ensure we process the last timestep (usually the highest index) + vtk_files.sort() + + # Read the last VTU file for metrics (represents the final state) + mesh = meshio.read(vtk_files[-1]) + + metrics = {} + + # ---- Example metrics (keep your original logic) ---- + if "p" in mesh.cell_data_dict: + p = mesh.cell_data_dict["p"] + p_vals = np.concatenate([np.asarray(v).ravel() for v in p.values()]) + metrics["min_pressure"] = float(np.min(p_vals)) + metrics["max_pressure"] = float(np.max(p_vals)) + metrics["mean_pressure"] = float(np.mean(p_vals)) + + if "velocity_liq (m/s)" in mesh.cell_data_dict: + v = mesh.cell_data_dict["velocity_liq (m/s)"] + v_vals = np.concatenate([np.asarray(vv) for vv in v.values()]) + speed = np.linalg.norm(v_vals, axis=1) + metrics["max_velocity_magnitude"] = float(np.max(speed)) + metrics["mean_velocity_magnitude"] = float(np.mean(speed)) + + # ---- Write metrics JSON ---- + metrics_path = Path(metrics_file) + metrics_path.parent.mkdir(parents=True, exist_ok=True) + with open(metrics_path, "w") as f: + json.dump(metrics, f, indent=4) + + # ---- Zip solution field data ---- + # We include VTUs and the PVD specifically for this configuration + with zipfile.ZipFile(solution_file_zip, "w", zipfile.ZIP_DEFLATED) as zipf: + for file in vtk_files + pvd_files: + zipf.write(file, arcname=file.name) + + # ---- Targeted Cleanup ---- + # Only delete files we just zipped. This allows other concurrent + # configurations to finish safely. + for file in vtk_files + pvd_files: + try: + file.unlink() + except FileNotFoundError: + pass # Already removed or handled + + +if __name__ == "__main__": + parser = ArgumentParser(description="Post-process DuMuX results") + parser.add_argument("--input_dumux_output_dir", required=True) + parser.add_argument("--input_configuration", required=True) + parser.add_argument("--output_metrics_file", required=True) + parser.add_argument("--output_solution_file_zip", required=True) + + args = parser.parse_args() + + run_dumux_postprocessing( + args.input_dumux_output_dir, + args.input_configuration, + args.output_metrics_file, + args.output_solution_file_zip, + ) \ No newline at end of file diff --git a/benchmarks/rotating-cylinders/generate_rc_config.py b/benchmarks/rotating-cylinders/generate_rc_config.py new file mode 100644 index 0000000..b64b861 --- /dev/null +++ b/benchmarks/rotating-cylinders/generate_rc_config.py @@ -0,0 +1,33 @@ +import json +from pathlib import Path + +def write_benchmark_config(): + base_cells0, base_cells1 = 10, 80 + num_files = 3 + + configurations = [] + config_to_param = {} + + for i in range(num_files): + cells0 = base_cells0 * (2 ** i) + cells1 = base_cells1 * (2 ** i) + config_name = f"{cells0}_{cells1}" + + configurations.append(config_name) + config_to_param[config_name] = f"params_{config_name}.json" + + benchmark_json = { + "benchmark": "rotating-cylinders", + "benchmark_uri": "https://www.openfoam.com/documentation/guides/latest/doc/verification-validation-rotating-cylinders-2d.html", + "tools": ["dumux"], + "configuration_to_parameter_file": config_to_param, + "configurations": configurations, + "container_image": "git.iws.uni-stuttgart.de:4567/benchmarks/rotating-cylinders:3.1" + } + + with open("rotating-cylinders_config.json", "w") as f: + json.dump(benchmark_json, f, indent=4) + print("rotating-cylinders_config.json generated.") + +if __name__ == "__main__": + write_benchmark_config() \ No newline at end of file