diff --git a/CHANGELOG.md b/CHANGELOG.md
index be9520e..854a7df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
## [Unreleased]
+### Added
+
+- **`QUANTUI_ENABLE_SLURM` site gate** — SLURM batch mode stays hidden and
+ undispatchable until an operator sets this environment variable. Student CPU
+ images and JupyterHub profiles can omit it (default off); instructors enable
+ cluster testing with `QUANTUI_ENABLE_SLURM=1` without any extra student
+ command.
+
### Fixed
- **`apptainer/quantui-gpu.def` never installed the `xtb` extra**, so GFN-FF
diff --git a/apptainer/slurm/README.md b/apptainer/slurm/README.md
index 3b3a400..8975805 100644
--- a/apptainer/slurm/README.md
+++ b/apptainer/slurm/README.md
@@ -19,6 +19,7 @@ See the [NCShare SLURM batch runbook](https://github.com/The-Schultz-Lab/QuantUI
| Variable | Default | Purpose |
|----------|---------|---------|
+| `QUANTUI_ENABLE_SLURM` | *(unset — off)* | Show **SLURM batch (cluster)** in Settings and allow cluster dispatch. Requires `sbatch` on PATH. Leave unset in student CPU images; set on instructor/test profiles when validating NCShare. |
| `QUANTUI_MAX_CONCURRENT_JOBS` | `2` | Active SLURM job cap |
| `QUANTUI_SLURM_SUBMIT_COOLDOWN_S` | `30` | Min seconds between submits (`0` disables) |
| `QUANTUI_SLURM_STALE_NO_ID_S` | `600` | Stale registry rows without SLURM id |
diff --git a/docs/CLI.md b/docs/CLI.md
index 8976689..a593c0a 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -251,6 +251,7 @@ successfully; only the auto-open is best-effort.
| --- | --- |
| `QUANTUI_LOG_DIR` | Override the default `~/.quantui/logs/` location. The dashboard's default output (`~/.quantui/dashboard.html`) follows: it lives one level up from the active `QUANTUI_LOG_DIR`. |
| `QUANTUI_DISABLE_GPU` | Force CPU mode even when gpu4pyscf is installed. `quantui gpu check` reports this as the reason. Accepted truthy values: `1`, `true`, `True`. |
+| `QUANTUI_ENABLE_SLURM` | Opt in to **SLURM batch (cluster)** on the System Settings → Execution backend dropdown. Requires `sbatch` on PATH. Off by default so student deployments stay in-kernel until an operator enables cluster mode in the Apptainer image, JupyterHub spawner, or shell. Accepted truthy values: `1`, `true`, `yes`, `on`. |
| `QUANTUI_FREQ_PARALLEL` | Opt in to parallel **CPU** workers for the IR-intensity finite-difference loop in frequency calculations (`6N` displaced SCFs). Same effect as the **Parallelize IR intensity displacements** checkbox on the System Settings tab; when this env var is set it overrides the saved setting. Reference SCF and Hessian still use gpu4pyscf when available. Requires ≥4 cores and ≥2 atoms. Off by default. Accepted truthy values: `1`, `true`, `yes`, `on`. |
---
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index bc0ba84..f66fdb5 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -18,6 +18,7 @@
sync_textarea_from_table,
)
from quantui.backends.cluster_config import max_concurrent_jobs
+from quantui.backends.dispatch import slurm_unavailable_note
from quantui.help_content import HELP_TOPICS
from quantui.live_log import LiveLog
from quantui.orbital_visualization import (
@@ -349,7 +350,7 @@ def _render_status(gpu_state: Any) -> str:
if not slurm_available:
exec_backend_note = widgets.HTML(
f'
'
- "SLURM unavailable here (sbatch not found).
"
+ f"{slurm_unavailable_note()}"
)
else:
limit = max_concurrent_jobs()
diff --git a/quantui/app_slurm.py b/quantui/app_slurm.py
index f4b66db..c1294b9 100644
--- a/quantui/app_slurm.py
+++ b/quantui/app_slurm.py
@@ -25,6 +25,7 @@
calc_type_key_from_app,
is_slurm_available,
slurm_backend_for_app,
+ slurm_unavailable_user_message,
)
from quantui.backends.registry import JobRecord, JobRegistry
from quantui.backends.slurm_errors import format_error_html
@@ -140,9 +141,7 @@ def startup_slurm_check(app: Any) -> None:
def submit_slurm_run(app: Any) -> None:
"""Submit the current configuration to SLURM and start the monitor thread."""
if not is_slurm_available():
- app.run_status.value = (
- "SLURM is not available on this system (sbatch not found)."
- )
+ app.run_status.value = slurm_unavailable_user_message()
return
calc_type = calc_type_key_from_app(app)
diff --git a/quantui/backends/dispatch.py b/quantui/backends/dispatch.py
index 0bd4b6b..4cdfd27 100644
--- a/quantui/backends/dispatch.py
+++ b/quantui/backends/dispatch.py
@@ -4,6 +4,7 @@
from __future__ import annotations
+import os
import shutil
from pathlib import Path
from typing import Any
@@ -17,12 +18,53 @@
# Calc types that may run a DFT geometry optimization before the main step.
_PREOPT_CALC_TYPES = frozenset({"frequency", "tddft"})
+_TRUTHY_ENV = frozenset({"1", "true", "yes", "on"})
-def is_slurm_available() -> bool:
- """Return True when ``sbatch`` is on PATH."""
+
+def _env_truthy(name: str) -> bool:
+ """Return True when ``name`` is set to a truthy value in the environment."""
+ return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV
+
+
+def is_slurm_cli_present() -> bool:
+ """Return True when the SLURM CLI (``sbatch``) is on PATH."""
return shutil.which("sbatch") is not None
+def is_slurm_site_enabled() -> bool:
+ """Return True when the operator has opted in via ``QUANTUI_ENABLE_SLURM``."""
+ return _env_truthy("QUANTUI_ENABLE_SLURM")
+
+
+def is_slurm_available() -> bool:
+ """Return True when SLURM batch mode is available for the UI and dispatch."""
+ return is_slurm_cli_present() and is_slurm_site_enabled()
+
+
+def slurm_unavailable_note() -> str:
+ """Return a short Settings-tab note when SLURM batch mode is not offered."""
+ if is_slurm_available():
+ return ""
+ if not is_slurm_cli_present():
+ return "SLURM unavailable here (sbatch not found)."
+ return (
+ "SLURM batch mode is off for this deployment "
+ "(operator: set QUANTUI_ENABLE_SLURM=1 to enable)."
+ )
+
+
+def slurm_unavailable_user_message() -> str:
+ """Return a user-facing run-status message when SLURM dispatch is blocked."""
+ if is_slurm_available():
+ return ""
+ if not is_slurm_cli_present():
+ return "SLURM is not available on this system (sbatch not found)."
+ return (
+ "SLURM batch mode is disabled on this deployment. "
+ "Ask your instructor to set QUANTUI_ENABLE_SLURM=1."
+ )
+
+
def calc_type_key_from_app(app: Any) -> str:
from quantui.app_runflow import calc_type_key
diff --git a/tests/test_app_slurm.py b/tests/test_app_slurm.py
index ca013d9..fbec24a 100644
--- a/tests/test_app_slurm.py
+++ b/tests/test_app_slurm.py
@@ -20,6 +20,10 @@
build_calculation_request,
calc_type_key_from_app,
is_slurm_available,
+ is_slurm_cli_present,
+ is_slurm_site_enabled,
+ slurm_unavailable_note,
+ slurm_unavailable_user_message,
)
from quantui.backends.registry import JobRegistry
from quantui.security import SecurityError
@@ -60,12 +64,36 @@ def test_calc_type_key_from_app(self):
assert calc_type_key_from_app(app) == "frequency"
@patch("quantui.backends.dispatch.shutil.which")
- def test_is_slurm_available(self, mock_which):
+ def test_is_slurm_available(self, mock_which, monkeypatch):
mock_which.return_value = "/usr/bin/sbatch"
+ monkeypatch.delenv("QUANTUI_ENABLE_SLURM", raising=False)
+ assert is_slurm_cli_present() is True
+ assert is_slurm_site_enabled() is False
+ assert is_slurm_available() is False
+
+ monkeypatch.setenv("QUANTUI_ENABLE_SLURM", "1")
+ assert is_slurm_site_enabled() is True
assert is_slurm_available() is True
+
mock_which.return_value = None
+ assert is_slurm_cli_present() is False
assert is_slurm_available() is False
+ @patch("quantui.backends.dispatch.shutil.which")
+ def test_slurm_unavailable_messages(self, mock_which, monkeypatch):
+ mock_which.return_value = None
+ monkeypatch.delenv("QUANTUI_ENABLE_SLURM", raising=False)
+ assert "sbatch not found" in slurm_unavailable_note()
+ assert "sbatch not found" in slurm_unavailable_user_message()
+
+ mock_which.return_value = "/usr/bin/sbatch"
+ assert "QUANTUI_ENABLE_SLURM=1" in slurm_unavailable_note()
+ assert "QUANTUI_ENABLE_SLURM=1" in slurm_unavailable_user_message()
+
+ monkeypatch.setenv("QUANTUI_ENABLE_SLURM", "yes")
+ assert slurm_unavailable_note() == ""
+ assert slurm_unavailable_user_message() == ""
+
class TestUseSlurmExecution:
@patch("quantui.app_slurm.is_slurm_available", return_value=True)