From 02e832b421e9dbec668fe3ee4b58ad730e958c04 Mon Sep 17 00:00:00 2001
From: Gregor Weiss <52161555+gregorweiss@users.noreply.github.com>
Date: Tue, 11 Aug 2026 20:22:05 +0200
Subject: [PATCH 1/5] chore: open PR for issue 38
From 63d88b1c89fdde502191c0dda15827d83ccaf5dd Mon Sep 17 00:00:00 2001
From: Gregor Weiss <52161555+gregorweiss@users.noreply.github.com>
Date: Tue, 11 Aug 2026 20:38:03 +0200
Subject: [PATCH 2/5] feat: add --clean flag to simulate and fix stale-cpt
detection
Add clean_simulation_outputs() that removes stage outputs (tpr, cpt, log,
edr, gro, xtc/trr, rescue MDPs, GROMACS backups, mdout.mdp) while
preserving build inputs. Respects --stages filter and integrates with
--dry-run for preview.
Fix _detect_stage_state: trajectory stages (Production) with cpt+tpr but
no trajectory file now return not_started instead of partial, preventing
the stale-cpt-append crash where GROMACS refuses -append without a
trajectory to append to.
---
mdfactory/cli.py | 9 +
mdfactory/orchestration/__init__.py | 3 +-
mdfactory/orchestration/simulate.py | 102 ++++++++
.../tests/test_orchestration_cli_simulate.py | 55 +++++
.../tests/test_orchestration_simulate.py | 217 ++++++++++++++++++
5 files changed, 385 insertions(+), 1 deletion(-)
diff --git a/mdfactory/cli.py b/mdfactory/cli.py
index ab65115..e6e3863 100644
--- a/mdfactory/cli.py
+++ b/mdfactory/cli.py
@@ -574,6 +574,10 @@ def simulate_systems(
bool,
Parameter(help="Preview plan without executing"),
] = False,
+ clean: Annotated[
+ bool,
+ Parameter(help="Remove simulation outputs before running"),
+ ] = False,
max_rescue: Annotated[
int,
Parameter(
@@ -609,6 +613,10 @@ def simulate_systems(
mdfactory simulate output_dir/ --slurm gpu.yaml --dry-run
+ Clean before running::
+
+ mdfactory simulate output_dir/ --clean --stages Production
+
"""
source = source.resolve()
@@ -646,6 +654,7 @@ def simulate_systems(
stages=stages,
checkpoint_mode=checkpoint,
dry_run=dry_run,
+ clean=clean,
max_rescue=max_rescue,
)
except ValueError as exc:
diff --git a/mdfactory/orchestration/__init__.py b/mdfactory/orchestration/__init__.py
index 2b32ec2..c5d40e5 100644
--- a/mdfactory/orchestration/__init__.py
+++ b/mdfactory/orchestration/__init__.py
@@ -4,13 +4,14 @@
from .build import build_systems
from .config import ExecutorConfig, SlurmExecutorConfig
-from .simulate import find_structure_file, run_simulations
+from .simulate import clean_simulation_outputs, find_structure_file, run_simulations
from .tui import configure_and_save_slurm, configure_slurm_interactive
__all__ = [
"ExecutorConfig",
"SlurmExecutorConfig",
"build_systems",
+ "clean_simulation_outputs",
"find_structure_file",
"run_simulations",
"configure_and_save_slurm",
diff --git a/mdfactory/orchestration/simulate.py b/mdfactory/orchestration/simulate.py
index d14bdcd..6b4ea7c 100644
--- a/mdfactory/orchestration/simulate.py
+++ b/mdfactory/orchestration/simulate.py
@@ -70,6 +70,7 @@ def run_simulations(
stages: list[str] | None = None,
wait: bool = True,
dry_run: bool = False,
+ clean: bool = False,
checkpoint_mode: str = "auto",
max_rescue: int = 3,
) -> list[dict]:
@@ -87,6 +88,11 @@ def run_simulations(
Wait for completion (default: True).
dry_run : bool
Preview plan without executing (default: False).
+ clean : bool
+ Remove simulation outputs before running (default: False).
+ Respects ``stages`` filter — only files belonging to requested
+ stages are deleted. Combined with ``dry_run``, previews what
+ would be deleted without acting.
checkpoint_mode : str
- "auto": Skip stages with valid outputs (default)
- "skip": Never re-run completed stages
@@ -148,6 +154,20 @@ def run_simulations(
return skipped_results
sim_paths = ready_paths
+ # 1b. Clean outputs (if requested) before checkpoint detection.
+ if clean:
+ for sim_dir in sim_paths:
+ deleted = clean_simulation_outputs(sim_dir, stages, dry_run=dry_run)
+ if dry_run and deleted:
+ logger.info(
+ f"Would delete {len(deleted)} file(s) from {sim_dir.name}: "
+ + ", ".join(p.name for p in deleted)
+ )
+ if dry_run:
+ # After previewing deletions, still show the dry-run execution plan
+ # (which will report all stages as needed since nothing was deleted).
+ pass
+
# 2. Checkpoint detection (includes restart info for -cpi -append support)
work_plan = []
for sim_dir in sim_paths:
@@ -279,6 +299,83 @@ def _missing_build_files(sim_dir: Path, stages: list[str]) -> list[str]:
return [f for f in required if not (sim_dir / f).exists()]
+def clean_simulation_outputs(
+ sim_dir: Path,
+ stages: list[str],
+ *,
+ dry_run: bool = False,
+) -> list[Path]:
+ """Remove simulation outputs for the given stages, preserving build inputs.
+
+ Derives deletable files from :data:`STAGE_BY_NAME` (tpr, cpt, log, edr,
+ gro, trr, xtc) plus ``mdout.mdp``, rescue-tier MDPs, and GROMACS backup
+ files (``#*#``).
+
+ Parameters
+ ----------
+ sim_dir : Path
+ Simulation directory.
+ stages : list[str]
+ Stage names whose outputs should be removed.
+ dry_run : bool
+ If ``True``, collect and return the list of files that *would* be
+ deleted without actually removing them.
+
+ Returns
+ -------
+ list[Path]
+ Paths that were deleted (or would be deleted in dry-run mode).
+
+ """
+ to_delete: list[Path] = []
+
+ for stage_name in stages:
+ spec = STAGE_BY_NAME[stage_name]
+ deffnm = spec.deffnm
+
+ # Named output files: tpr, cpt, log, edr
+ for ext in ("tpr", "cpt", "log", "edr"):
+ to_delete.append(sim_dir / f"{deffnm}.{ext}")
+
+ # Structure output (EM/NVT/NPT)
+ if spec.gro_out:
+ to_delete.append(sim_dir / spec.gro_out)
+
+ # Trajectory outputs (Production)
+ for traj in spec.traj_files:
+ to_delete.append(sim_dir / traj)
+
+ # Rescue-tier MDPs (e.g. em_rescue_t1.mdp, em_rescue_t2.mdp)
+ mdp_stem = spec.mdp_file.rsplit(".", 1)[0]
+ to_delete.extend(sim_dir.glob(f"{mdp_stem}_rescue_t*.mdp"))
+
+ # GROMACS backup files (#deffnm.*#)
+ to_delete.extend(sim_dir.glob(f"#{deffnm}.*#"))
+
+ # mdout.mdp — grompp output, always regenerated
+ to_delete.append(sim_dir / "mdout.mdp")
+
+ # Deduplicate (glob results may overlap with named files) and filter to
+ # files that actually exist.
+ seen: set[Path] = set()
+ existing: list[Path] = []
+ for p in to_delete:
+ if p not in seen and p.exists():
+ seen.add(p)
+ existing.append(p)
+
+ if not dry_run:
+ for p in existing:
+ p.unlink()
+ logger.debug(f"Deleted {p}")
+ if existing:
+ logger.info(
+ f"Cleaned {len(existing)} file(s) from {sim_dir.name}"
+ )
+
+ return existing
+
+
def _validate_simulation_dir(sim_dir: Path, stages: list[str]) -> None:
"""Raise if any required build output files are missing.
@@ -496,6 +593,11 @@ def _detect_stage_state(sim_dir: Path, stage: str, mode: str = "auto") -> dict:
# Check partial progress (checkpoint exists, output doesn't).
if cpt_file.exists() and tpr_file.exists():
+ if spec.traj_files:
+ # Trajectory stage (Production): stale checkpoint without a
+ # trajectory file cannot use -append — GROMACS would crash.
+ # Treat as not_started so grompp+mdrun run from scratch.
+ return {"status": "not_started", "cpt_file": None, "restart": False}
return {"status": "partial", "cpt_file": cpt_file, "restart": True}
return {"status": "not_started", "cpt_file": None, "restart": False}
diff --git a/mdfactory/tests/test_orchestration_cli_simulate.py b/mdfactory/tests/test_orchestration_cli_simulate.py
index de2afda..f1197ec 100644
--- a/mdfactory/tests/test_orchestration_cli_simulate.py
+++ b/mdfactory/tests/test_orchestration_cli_simulate.py
@@ -275,3 +275,58 @@ def test_simulate_systems_max_rescue_default_is_3(mock_slurm, mock_config, mock_
mock_run.assert_called_once()
_, kwargs = mock_run.call_args
assert kwargs["max_rescue"] == 3
+
+
+@patch("mdfactory.orchestration.run_simulations", return_value=_SUCCESS_RESULTS)
+@patch("mdfactory.cli._load_executor_config", return_value=MagicMock(provider="local"))
+@patch("mdfactory.cli._resolve_slurm_flag", return_value=None)
+def test_simulate_systems_forwards_clean_flag(mock_slurm, mock_config, mock_run, tmp_path):
+ """--clean is forwarded from CLI to run_simulations."""
+ from mdfactory.cli import simulate_systems
+
+ sim_dir = tmp_path / "abc123"
+ sim_dir.mkdir()
+ (sim_dir / "system.pdb").touch()
+
+ simulate_systems(source=tmp_path, clean=True)
+
+ mock_run.assert_called_once()
+ _, kwargs = mock_run.call_args
+ assert kwargs["clean"] is True
+
+
+@patch("mdfactory.orchestration.run_simulations", return_value=_SUCCESS_RESULTS)
+@patch("mdfactory.cli._load_executor_config", return_value=MagicMock(provider="local"))
+@patch("mdfactory.cli._resolve_slurm_flag", return_value=None)
+def test_simulate_systems_clean_default_is_false(mock_slurm, mock_config, mock_run, tmp_path):
+ """--clean defaults to False when not specified."""
+ from mdfactory.cli import simulate_systems
+
+ sim_dir = tmp_path / "abc123"
+ sim_dir.mkdir()
+ (sim_dir / "system.pdb").touch()
+
+ simulate_systems(source=tmp_path)
+
+ mock_run.assert_called_once()
+ _, kwargs = mock_run.call_args
+ assert kwargs["clean"] is False
+
+
+@patch("mdfactory.orchestration.run_simulations", return_value=[])
+@patch("mdfactory.cli._load_executor_config", return_value=MagicMock(provider="local"))
+@patch("mdfactory.cli._resolve_slurm_flag", return_value=None)
+def test_simulate_systems_clean_with_dry_run(mock_slurm, mock_config, mock_run, tmp_path):
+ """--clean combined with --dry-run passes both flags through."""
+ from mdfactory.cli import simulate_systems
+
+ sim_dir = tmp_path / "abc123"
+ sim_dir.mkdir()
+ (sim_dir / "system.pdb").touch()
+
+ simulate_systems(source=tmp_path, clean=True, dry_run=True)
+
+ mock_run.assert_called_once()
+ _, kwargs = mock_run.call_args
+ assert kwargs["clean"] is True
+ assert kwargs["dry_run"] is True
diff --git a/mdfactory/tests/test_orchestration_simulate.py b/mdfactory/tests/test_orchestration_simulate.py
index bc2f3ac..cbbc2d4 100644
--- a/mdfactory/tests/test_orchestration_simulate.py
+++ b/mdfactory/tests/test_orchestration_simulate.py
@@ -1849,3 +1849,220 @@ def test_extract_resource_hints_divides_by_max_workers():
hints = _extract_resource_hints(cfg)
assert hints.ntasks == 6 # 12 // 2
+
+
+# --- clean_simulation_outputs tests ---
+
+
+def _populate_sim_dir(sim_dir):
+ """Create a fake simulation directory with build inputs and all stage outputs."""
+ # Build inputs (must be preserved)
+ (sim_dir / "system.pdb").write_bytes(b"pdb")
+ (sim_dir / "topology.top").write_bytes(b"top")
+ (sim_dir / "em.mdp").write_bytes(b"mdp")
+ (sim_dir / "nvt.mdp").write_bytes(b"mdp")
+ (sim_dir / "npt.mdp").write_bytes(b"mdp")
+ (sim_dir / "md.mdp").write_bytes(b"mdp")
+ (sim_dir / "posres.itp").write_bytes(b"itp")
+ (sim_dir / "config.yaml").write_bytes(b"yaml")
+
+ # Simulation outputs per stage
+ for deffnm in ("min", "nvt", "npt", "prod"):
+ for ext in ("tpr", "cpt", "log", "edr"):
+ (sim_dir / f"{deffnm}.{ext}").write_bytes(b"x")
+
+ # Structure outputs (EM/NVT/NPT)
+ for deffnm in ("min", "nvt", "npt"):
+ (sim_dir / f"{deffnm}.gro").write_bytes(b"x")
+
+ # Trajectory outputs (Production)
+ (sim_dir / "prod.xtc").write_bytes(b"x")
+ (sim_dir / "prod.trr").write_bytes(b"x")
+
+ # Rescue MDPs
+ (sim_dir / "em_rescue_t1.mdp").write_bytes(b"x")
+ (sim_dir / "em_rescue_t2.mdp").write_bytes(b"x")
+ (sim_dir / "nvt_rescue_t1.mdp").write_bytes(b"x")
+
+ # GROMACS backup files
+ (sim_dir / "#min.tpr.1#").write_bytes(b"x")
+ (sim_dir / "#prod.xtc.1#").write_bytes(b"x")
+
+ # grompp output
+ (sim_dir / "mdout.mdp").write_bytes(b"x")
+
+
+def test_clean_simulation_outputs_all_stages(tmp_path):
+ """All stage outputs removed; build inputs preserved."""
+ from mdfactory.orchestration.simulate import clean_simulation_outputs
+
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ _populate_sim_dir(sim_dir)
+
+ all_stages = ["EM", "NVT", "NPT", "Production"]
+ deleted = clean_simulation_outputs(sim_dir, all_stages)
+
+ # Build inputs must survive
+ assert (sim_dir / "system.pdb").exists()
+ assert (sim_dir / "topology.top").exists()
+ assert (sim_dir / "em.mdp").exists()
+ assert (sim_dir / "nvt.mdp").exists()
+ assert (sim_dir / "npt.mdp").exists()
+ assert (sim_dir / "md.mdp").exists()
+ assert (sim_dir / "posres.itp").exists()
+ assert (sim_dir / "config.yaml").exists()
+
+ # Simulation outputs must be gone
+ for deffnm in ("min", "nvt", "npt", "prod"):
+ for ext in ("tpr", "cpt", "log", "edr"):
+ assert not (sim_dir / f"{deffnm}.{ext}").exists()
+
+ for deffnm in ("min", "nvt", "npt"):
+ assert not (sim_dir / f"{deffnm}.gro").exists()
+
+ assert not (sim_dir / "prod.xtc").exists()
+ assert not (sim_dir / "prod.trr").exists()
+
+ # Rescue MDPs and backups must be gone
+ assert not (sim_dir / "em_rescue_t1.mdp").exists()
+ assert not (sim_dir / "em_rescue_t2.mdp").exists()
+ assert not (sim_dir / "nvt_rescue_t1.mdp").exists()
+ assert not (sim_dir / "#min.tpr.1#").exists()
+ assert not (sim_dir / "#prod.xtc.1#").exists()
+ assert not (sim_dir / "mdout.mdp").exists()
+
+ assert len(deleted) > 0
+
+
+def test_clean_simulation_outputs_filtered_stages(tmp_path):
+ """Clean only Production: prod.* removed; min/nvt/npt files preserved."""
+ from mdfactory.orchestration.simulate import clean_simulation_outputs
+
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ _populate_sim_dir(sim_dir)
+
+ deleted = clean_simulation_outputs(sim_dir, ["Production"])
+
+ # Production outputs must be gone
+ assert not (sim_dir / "prod.tpr").exists()
+ assert not (sim_dir / "prod.cpt").exists()
+ assert not (sim_dir / "prod.log").exists()
+ assert not (sim_dir / "prod.edr").exists()
+ assert not (sim_dir / "prod.xtc").exists()
+ assert not (sim_dir / "prod.trr").exists()
+ assert not (sim_dir / "#prod.xtc.1#").exists()
+
+ # Other stages must survive
+ assert (sim_dir / "min.tpr").exists()
+ assert (sim_dir / "min.gro").exists()
+ assert (sim_dir / "nvt.cpt").exists()
+ assert (sim_dir / "npt.gro").exists()
+
+ # EM rescue MDPs must survive (not in requested stages)
+ assert (sim_dir / "em_rescue_t1.mdp").exists()
+ assert (sim_dir / "em_rescue_t2.mdp").exists()
+
+ # mdout.mdp is always deleted
+ assert not (sim_dir / "mdout.mdp").exists()
+
+ deleted_names = {p.name for p in deleted}
+ assert "prod.tpr" in deleted_names
+ assert "min.tpr" not in deleted_names
+
+
+def test_clean_simulation_outputs_missing_files(tmp_path):
+ """Partial outputs (some absent) — no errors, remaining files cleaned."""
+ from mdfactory.orchestration.simulate import clean_simulation_outputs
+
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ # Only create a subset of files
+ (sim_dir / "system.pdb").write_bytes(b"pdb")
+ (sim_dir / "topology.top").write_bytes(b"top")
+ (sim_dir / "min.tpr").write_bytes(b"x")
+ # min.cpt, min.gro, etc. intentionally absent
+
+ deleted = clean_simulation_outputs(sim_dir, ["EM"])
+
+ assert not (sim_dir / "min.tpr").exists()
+ assert (sim_dir / "system.pdb").exists()
+ assert len(deleted) == 1
+
+
+def test_clean_simulation_outputs_preserves_build_inputs(tmp_path):
+ """Explicitly verify all build input types survive cleaning."""
+ from mdfactory.orchestration.simulate import clean_simulation_outputs
+
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ _populate_sim_dir(sim_dir)
+
+ clean_simulation_outputs(sim_dir, ["EM", "NVT", "NPT", "Production"])
+
+ # Every build input must still exist
+ for name in ("system.pdb", "topology.top", "em.mdp", "nvt.mdp", "npt.mdp",
+ "md.mdp", "posres.itp", "config.yaml"):
+ assert (sim_dir / name).exists(), f"{name} should be preserved"
+
+
+def test_clean_simulation_outputs_dry_run(tmp_path):
+ """Dry-run mode returns files that would be deleted but doesn't remove them."""
+ from mdfactory.orchestration.simulate import clean_simulation_outputs
+
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ _populate_sim_dir(sim_dir)
+
+ would_delete = clean_simulation_outputs(
+ sim_dir, ["EM", "NVT", "NPT", "Production"], dry_run=True
+ )
+
+ # Files must still exist
+ assert (sim_dir / "min.tpr").exists()
+ assert (sim_dir / "prod.xtc").exists()
+ assert (sim_dir / "mdout.mdp").exists()
+
+ # But the return value should list them
+ assert len(would_delete) > 0
+ deleted_names = {p.name for p in would_delete}
+ assert "min.tpr" in deleted_names
+ assert "prod.xtc" in deleted_names
+
+
+# --- _detect_stage_state trajectory stale-cpt fix tests ---
+
+
+def test_detect_stage_state_trajectory_stale_cpt(tmp_path):
+ """Production with cpt+tpr but no trajectory → not_started (stale-cpt fix)."""
+ from mdfactory.orchestration.simulate import _detect_stage_state
+
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ (sim_dir / "prod.cpt").write_bytes(b"x")
+ (sim_dir / "prod.tpr").write_bytes(b"x")
+ # prod.xtc / prod.trr intentionally absent
+
+ state = _detect_stage_state(sim_dir, "Production", "auto")
+
+ assert state["status"] == "not_started"
+ assert state["restart"] is False
+ assert state["cpt_file"] is None
+
+
+def test_detect_stage_state_structure_partial_cpt_unchanged(tmp_path):
+ """EM with cpt+tpr but no gro → still partial (structure stages unchanged)."""
+ from mdfactory.orchestration.simulate import _detect_stage_state
+
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ (sim_dir / "min.cpt").write_bytes(b"x")
+ (sim_dir / "min.tpr").write_bytes(b"x")
+ # min.gro intentionally absent
+
+ state = _detect_stage_state(sim_dir, "EM", "auto")
+
+ assert state["status"] == "partial"
+ assert state["restart"] is True
+ assert state["cpt_file"] == sim_dir / "min.cpt"
From dc847d43868ef4dd3b51b2e48b16c3e8b64f1b30 Mon Sep 17 00:00:00 2001
From: Gregor Weiss <52161555+gregorweiss@users.noreply.github.com>
Date: Tue, 11 Aug 2026 20:43:57 +0200
Subject: [PATCH 3/5] docs: add --clean flag to CLI reference, troubleshooting,
and HPC guide
---
.../content/docs/user-guide/cli-reference.mdx | 10 ++++++++
.../docs/user-guide/running-on-hpc.mdx | 8 +++++++
.../docs/user-guide/troubleshooting.mdx | 23 +++++++++++++++++++
3 files changed, 41 insertions(+)
diff --git a/docs/content/docs/user-guide/cli-reference.mdx b/docs/content/docs/user-guide/cli-reference.mdx
index b351691..b16a7d6 100644
--- a/docs/content/docs/user-guide/cli-reference.mdx
+++ b/docs/content/docs/user-guide/cli-reference.mdx
@@ -58,6 +58,7 @@ Options:
- `--checkpoint` — checkpoint mode: `auto` (default), `skip`, `force`
- `--hash` — filter by hash prefix (can specify multiple)
- `--dry-run` — preview plan without executing
+- `--clean` — remove simulation outputs before running (respects `--stages` filter)
- `--max-rescue` — max rescue tiers for physics failures (default: 3; set to 0 to disable)
Usage examples:
@@ -80,6 +81,15 @@ mdfactory simulate output_dir/ --hash abc123 def456
# Dry-run preview
mdfactory simulate output_dir/ --slurm gpu.yaml --dry-run
+
+# Clean outputs and re-run (e.g. after a killed job left stale checkpoints)
+mdfactory simulate output_dir/ --clean --slurm gpu.yaml
+
+# Clean only Production stage before re-running it
+mdfactory simulate output_dir/ --clean --stages Production --slurm gpu.yaml
+
+# Preview what --clean would delete without acting
+mdfactory simulate output_dir/ --clean --dry-run
```
## Configuration commands
diff --git a/docs/content/docs/user-guide/running-on-hpc.mdx b/docs/content/docs/user-guide/running-on-hpc.mdx
index 0eec38b..88f8fac 100644
--- a/docs/content/docs/user-guide/running-on-hpc.mdx
+++ b/docs/content/docs/user-guide/running-on-hpc.mdx
@@ -205,6 +205,14 @@ Simulations automatically resume from checkpoints. If a job is interrupted, re-r
mdfactory simulate results/ --slurm slurm_executor.yaml --checkpoint auto
```
+If a job was killed mid-flight and left stale checkpoints (e.g. `.cpt` files without matching trajectory files), use `--clean` to reset before re-running:
+
+```bash
+mdfactory simulate results/ --clean --slurm slurm_executor.yaml
+```
+
+This removes simulation outputs while preserving build inputs. Use `--clean --dry-run` to preview what would be deleted. The `--stages` filter is respected — `--clean --stages Production` only cleans Production files.
+
## Submit analyses with submitit
For a full walkthrough of running analyses, see the
diff --git a/docs/content/docs/user-guide/troubleshooting.mdx b/docs/content/docs/user-guide/troubleshooting.mdx
index 7034668..7a03d33 100644
--- a/docs/content/docs/user-guide/troubleshooting.mdx
+++ b/docs/content/docs/user-guide/troubleshooting.mdx
@@ -108,6 +108,29 @@ To skip validation and rely only on file existence:
mdfactory simulate output_dir/ --slurm gpu.yaml --checkpoint skip
```
+
+
+
+When a SLURM job is cancelled mid-flight, checkpoint files (`.cpt`) may be left behind
+without corresponding trajectory files. Use `--clean` to reset simulation directories to
+post-build state before re-running:
+
+```bash
+# Clean all stages and re-run
+mdfactory simulate output_dir/ --clean --slurm gpu.yaml
+
+# Clean only Production and re-run it
+mdfactory simulate output_dir/ --clean --stages Production --slurm gpu.yaml
+
+# Preview what would be deleted
+mdfactory simulate output_dir/ --clean --dry-run
+```
+
+The `--clean` flag removes simulation outputs (`.tpr`, `.cpt`, `.log`, `.edr`, `.gro`,
+`.xtc`, `.trr`, rescue MDPs, GROMACS backup files) while preserving build inputs
+(`system.pdb`, `topology.top`, template `.mdp` files, `.itp`, `.yaml`). It respects
+the `--stages` filter — only files belonging to requested stages are deleted.
+
From a2ec675864e1cb55fd34881a6c1a16e83d8d1f49 Mon Sep 17 00:00:00 2001
From: Gregor Weiss <52161555+gregorweiss@users.noreply.github.com>
Date: Tue, 11 Aug 2026 21:04:23 +0200
Subject: [PATCH 4/5] test: add integration tests for clean=True wiring in
run_simulations
---
.../tests/test_orchestration_simulate.py | 55 +++++++++++++++++++
1 file changed, 55 insertions(+)
diff --git a/mdfactory/tests/test_orchestration_simulate.py b/mdfactory/tests/test_orchestration_simulate.py
index cbbc2d4..5bb5b1e 100644
--- a/mdfactory/tests/test_orchestration_simulate.py
+++ b/mdfactory/tests/test_orchestration_simulate.py
@@ -2066,3 +2066,58 @@ def test_detect_stage_state_structure_partial_cpt_unchanged(tmp_path):
assert state["status"] == "partial"
assert state["restart"] is True
assert state["cpt_file"] == sim_dir / "min.cpt"
+
+
+# --- run_simulations clean integration tests ---
+
+
+@patch("mdfactory.orchestration.simulate.parsl_session")
+@patch("mdfactory.orchestration.simulate._execute_stage_list")
+def test_run_simulations_clean_deletes_before_checkpoint_detection(
+ mock_execute, mock_session, tmp_path
+):
+ """run_simulations(clean=True) deletes outputs then detects all stages needed."""
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ _populate_sim_dir(sim_dir)
+
+ mock_future = MagicMock()
+ mock_future.done.return_value = True
+ mock_future.result.return_value = {"status": "success"}
+ mock_execute.return_value = mock_future
+ mock_session.return_value.__enter__.return_value = MagicMock()
+
+ config = ExecutorConfig()
+ run_simulations([sim_dir], config, clean=True)
+
+ # Verify files were actually deleted before execution
+ assert not (sim_dir / "min.tpr").exists()
+ assert not (sim_dir / "prod.xtc").exists()
+ assert not (sim_dir / "mdout.mdp").exists()
+ # Build inputs preserved
+ assert (sim_dir / "system.pdb").exists()
+ assert (sim_dir / "topology.top").exists()
+
+ # All stages should have been submitted (clean removed all outputs)
+ mock_execute.assert_called_once()
+ stages_arg = mock_execute.call_args[0][1]
+ assert set(stages_arg) == {"EM", "NVT", "NPT", "Production"}
+
+
+def test_run_simulations_clean_dry_run_preserves_files(tmp_path):
+ """run_simulations(clean=True, dry_run=True) previews without removing files."""
+ sim_dir = tmp_path / "sim"
+ sim_dir.mkdir()
+ _populate_sim_dir(sim_dir)
+
+ config = ExecutorConfig()
+ results = run_simulations([sim_dir], config, clean=True, dry_run=True)
+
+ # dry_run=True propagates to clean_simulation_outputs — files NOT deleted
+ assert (sim_dir / "min.gro").exists()
+ assert (sim_dir / "min.tpr").exists()
+ assert (sim_dir / "prod.xtc").exists()
+
+ # Still returns a work plan (dry-run shows what would run)
+ plan_items = [r for r in results if "sim_dir" in r]
+ assert len(plan_items) == 1
From d702b672162aa0b5d1c83cd28160a0cbce905529 Mon Sep 17 00:00:00 2001
From: Gregor Weiss <52161555+gregorweiss@users.noreply.github.com>
Date: Tue, 11 Aug 2026 21:06:19 +0200
Subject: [PATCH 5/5] style: apply ruff formatting
---
mdfactory/orchestration/simulate.py | 17 +++------
.../tests/test_orchestration_simulate.py | 36 ++++++++++---------
2 files changed, 24 insertions(+), 29 deletions(-)
diff --git a/mdfactory/orchestration/simulate.py b/mdfactory/orchestration/simulate.py
index 6b4ea7c..a72e109 100644
--- a/mdfactory/orchestration/simulate.py
+++ b/mdfactory/orchestration/simulate.py
@@ -236,8 +236,7 @@ def _run_pipeline(item):
with ThreadPoolExecutor() as pool:
thread_futs = [
- (item["hash"], pool.submit(_run_pipeline, item))
- for item in active_items
+ (item["hash"], pool.submit(_run_pipeline, item)) for item in active_items
]
for h, tf in thread_futs:
try:
@@ -369,9 +368,7 @@ def clean_simulation_outputs(
p.unlink()
logger.debug(f"Deleted {p}")
if existing:
- logger.info(
- f"Cleaned {len(existing)} file(s) from {sim_dir.name}"
- )
+ logger.info(f"Cleaned {len(existing)} file(s) from {sim_dir.name}")
return existing
@@ -771,11 +768,7 @@ def _execute_stage_list(
cpt_file = restarts.get(stage, "")
# Use rescue loop for eligible stages without checkpoint restart
- if (
- max_rescue > 0
- and stage in RESCUE_ELIGIBLE_STAGES
- and not cpt_file
- ):
+ if max_rescue > 0 and stage in RESCUE_ELIGIBLE_STAGES and not cpt_file:
prev_future = execute_stage_with_rescue(
sim_dir,
stage,
@@ -788,9 +781,7 @@ def _execute_stage_list(
else:
# Standard execution: chain futures without waiting
stage_cfg = (
- config.get_stage_config(stage)
- if hasattr(config, "get_stage_config")
- else None
+ config.get_stage_config(stage) if hasattr(config, "get_stage_config") else None
)
cfg_kwarg = {"stage_config": stage_cfg} if stage_cfg is not None else {}
prev_future = run_stage(
diff --git a/mdfactory/tests/test_orchestration_simulate.py b/mdfactory/tests/test_orchestration_simulate.py
index 5bb5b1e..ca458df 100644
--- a/mdfactory/tests/test_orchestration_simulate.py
+++ b/mdfactory/tests/test_orchestration_simulate.py
@@ -797,17 +797,13 @@ def test_execute_stage_list_partial_pipeline_from_checkpoint(mock_run_stage):
@patch("mdfactory.orchestration.rescue.execute_stage_with_rescue")
@patch("mdfactory.orchestration.simulate.run_stage")
-def test_execute_stage_list_routes_to_rescue_when_enabled(
- mock_run_stage, mock_rescue
-):
+def test_execute_stage_list_routes_to_rescue_when_enabled(mock_run_stage, mock_rescue):
"""Rescue-eligible stages dispatch to execute_stage_with_rescue when max_rescue > 0."""
rescue_future = MagicMock()
mock_rescue.return_value = rescue_future
sim_dir = Path("/tmp/test")
- result = _execute_stage_list(
- sim_dir, ["EM"], MagicMock(), MagicMock(), max_rescue=3
- )
+ result = _execute_stage_list(sim_dir, ["EM"], MagicMock(), MagicMock(), max_rescue=3)
mock_rescue.assert_called_once()
mock_run_stage.assert_not_called()
@@ -816,17 +812,13 @@ def test_execute_stage_list_routes_to_rescue_when_enabled(
@patch("mdfactory.orchestration.rescue.execute_stage_with_rescue")
@patch("mdfactory.orchestration.simulate.run_stage")
-def test_execute_stage_list_production_bypasses_rescue(
- mock_run_stage, mock_rescue
-):
+def test_execute_stage_list_production_bypasses_rescue(mock_run_stage, mock_rescue):
"""Production stage uses run_stage even when max_rescue > 0."""
prod_future = MagicMock()
mock_run_stage.return_value = prod_future
sim_dir = Path("/tmp/test")
- result = _execute_stage_list(
- sim_dir, ["Production"], MagicMock(), MagicMock(), max_rescue=3
- )
+ result = _execute_stage_list(sim_dir, ["Production"], MagicMock(), MagicMock(), max_rescue=3)
mock_run_stage.assert_called_once()
mock_rescue.assert_not_called()
@@ -1416,8 +1408,12 @@ def test_execute_stage_list_no_stage_config_for_local(mock_run_stage):
mock_run_stage.return_value = MagicMock()
_execute_stage_list(
- Path("/tmp/test"), ["EM"], MagicMock(), MagicMock(),
- config=ExecutorConfig(), max_rescue=0,
+ Path("/tmp/test"),
+ ["EM"],
+ MagicMock(),
+ MagicMock(),
+ config=ExecutorConfig(),
+ max_rescue=0,
)
_, call_kwargs = mock_run_stage.call_args
@@ -2002,8 +1998,16 @@ def test_clean_simulation_outputs_preserves_build_inputs(tmp_path):
clean_simulation_outputs(sim_dir, ["EM", "NVT", "NPT", "Production"])
# Every build input must still exist
- for name in ("system.pdb", "topology.top", "em.mdp", "nvt.mdp", "npt.mdp",
- "md.mdp", "posres.itp", "config.yaml"):
+ for name in (
+ "system.pdb",
+ "topology.top",
+ "em.mdp",
+ "nvt.mdp",
+ "npt.mdp",
+ "md.mdp",
+ "posres.itp",
+ "config.yaml",
+ ):
assert (sim_dir / name).exists(), f"{name} should be preserved"