diff --git a/CHANGELOG.md b/CHANGELOG.md index 854a7df..832b385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht images and JupyterHub profiles can omit it (default off); instructors enable cluster testing with `QUANTUI_ENABLE_SLURM=1` without any extra student command. +- **CPU teaching SIF defaults** — `quantui.def` sets `QUANTUI_FREQ_PARALLEL=1` + so NCShare classroom sessions parallelize IR-intensity displacements on + multi-core nodes. The Settings checkbox reflects the deployment value and is + locked while the variable is set; use `QUANTUI_FREQ_PARALLEL=0` to force off + site-wide. ### Fixed diff --git a/apptainer/README.md b/apptainer/README.md index 276941b..1a98809 100644 --- a/apptainer/README.md +++ b/apptainer/README.md @@ -35,6 +35,13 @@ every layer (CUDA base image, all-pip install, a pinned PyPI release rather than the working tree). Merging them would ship a multi-GB CUDA stack to students who will never have a GPU. See [GPU image](#gpu-image) below. +**CPU image environment defaults** (set in `quantui.def` `%environment`): + +| Variable | CPU `quantui.sif` | GPU `quantui-gpu.sif` | +| --- | --- | --- | +| `QUANTUI_FREQ_PARALLEL` | `1` — parallel CPU workers for IR-intensity displacements on frequency calcs (≥4 cores, ≥2 atoms). Overrides the Settings checkbox while set. | unset (off unless you set it) | +| `QUANTUI_ENABLE_SLURM` | unset (class stays in-kernel) | unset | + The compiled `.sif` image is **not** committed to git — it is too large (~4–5 GB). Build it locally (see below) or download the latest release asset from the [GitHub Releases page](https://github.com/The-Schultz-Lab/QuantUI/releases). diff --git a/apptainer/quantui.def b/apptainer/quantui.def index cffa3be..17f7339 100644 --- a/apptainer/quantui.def +++ b/apptainer/quantui.def @@ -49,6 +49,12 @@ FROM: condaforge/miniforge3:26.1.1-3 # across kernel restarts. The home dir is bind-mounted by Apptainer. export QUANTUI_RESULTS_DIR="${HOME}/.quantui/results" + # Parallel CPU workers for IR-intensity finite-difference SCFs (6N displacements) + # on multi-core hosts. Reference SCF + Hessian stay in-kernel. Students can still + # see the preference in Settings; when this variable is set it overrides the + # saved checkbox (set QUANTUI_FREQ_PARALLEL=0 to force off site-wide). + export QUANTUI_FREQ_PARALLEL=1 + %files # Source paths are relative to where you run `apptainer build` (repo root). # Run the build command from the repo root, not from apptainer/. diff --git a/quantui/app.py b/quantui/app.py index 0c03e0f..008f56c 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -574,6 +574,10 @@ SUPPORTED_BASIS_SETS, SUPPORTED_METHODS, ) +from quantui.freq_ir_workers import ( + freq_parallel_env_configured, + freq_parallel_opt_in, +) from quantui.help_content import HELP_TOPICS from quantui.molecule import Molecule, parse_xyz_input from quantui.progress import StepProgress @@ -1964,7 +1968,12 @@ def _build_status_panel(self) -> None: vib_framerate_fps=self._user_settings.viz.vib_framerate_fps, gpu_enabled=self._user_settings.compute.gpu_enabled, density_fit_enabled=self._user_settings.compute.density_fit, - freq_parallel_enabled=self._user_settings.compute.freq_parallel, + freq_parallel_enabled=( + freq_parallel_opt_in() + if freq_parallel_env_configured() + else self._user_settings.compute.freq_parallel + ), + freq_parallel_env_locked=freq_parallel_env_configured(), execution_backend=self._user_settings.compute.execution_backend, slurm_available=is_slurm_available(), ) @@ -3748,10 +3757,12 @@ def _on_density_fit_enabled_changed(self, change) -> None: def _on_freq_parallel_enabled_changed(self, change) -> None: """Persist the parallel IR finite-difference preference. - The freq_calc driver reads this via :func:`freq_ir_workers._freq_parallel_opt_in` + The freq_calc driver reads this via :func:`freq_ir_workers.freq_parallel_opt_in` on each run (unless ``QUANTUI_FREQ_PARALLEL`` overrides in the environment). Refresh the time estimate because parallel divides the IR term in the model. """ + if freq_parallel_env_configured(): + return new_val = bool(change["new"]) if new_val == self._user_settings.compute.freq_parallel: return diff --git a/quantui/app_builders.py b/quantui/app_builders.py index f66fdb5..31876f1 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -123,6 +123,7 @@ def build_status_panel( gpu_enabled: bool = True, density_fit_enabled: bool = False, freq_parallel_enabled: bool = False, + freq_parallel_env_locked: bool = False, execution_backend: str = "local", slurm_available: bool = False, ) -> None: @@ -327,8 +328,17 @@ def _render_status(gpu_state: Any) -> str: value=freq_parallel_enabled, description="Parallelize IR intensity displacements (CPU)", indent=False, + disabled=freq_parallel_env_locked, layout=layout_fn(width="320px", margin="2px 0 0 0"), ) + if freq_parallel_env_locked: + fp_env_note = widgets.HTML( + f'
' + "Controlled by deployment (QUANTUI_FREQ_PARALLEL in the " + "container or session environment).
" + ) + else: + fp_env_note = None exec_backend_label = widgets.HTML( f'
" ) + fp_settings_rows: list[Any] = [ + fp_toggle_label, + app.freq_parallel_enabled_cb, + ] + if fp_env_note is not None: + fp_settings_rows.append(fp_env_note) + settings_box = widgets.VBox( [ settings_html, @@ -370,8 +387,7 @@ def _render_status(gpu_state: Any) -> str: app.gpu_enabled_cb, df_toggle_label, app.density_fit_enabled_cb, - fp_toggle_label, - app.freq_parallel_enabled_cb, + *fp_settings_rows, exec_backend_label, app.execution_backend_dd, exec_backend_note, diff --git a/quantui/freq_ir_workers.py b/quantui/freq_ir_workers.py index 9e29fcf..cbc2fbe 100644 --- a/quantui/freq_ir_workers.py +++ b/quantui/freq_ir_workers.py @@ -169,6 +169,16 @@ def run_displaced_scf(coords_bohr_flat) -> Any: return np.array(mf.dip_moment(verbose=0)) +def freq_parallel_opt_in() -> bool: + """Return whether parallel IR displacements are enabled for the next run.""" + return _freq_parallel_opt_in() + + +def freq_parallel_env_configured() -> bool: + """Return True when ``QUANTUI_FREQ_PARALLEL`` is set in the environment.""" + return os.environ.get("QUANTUI_FREQ_PARALLEL") is not None + + def _freq_parallel_opt_in() -> bool: """Whether parallel IR displacements are opted in. diff --git a/tests/test_cpu_container_def.py b/tests/test_cpu_container_def.py new file mode 100644 index 0000000..ce7becb --- /dev/null +++ b/tests/test_cpu_container_def.py @@ -0,0 +1,39 @@ +"""CPU Apptainer image deployment defaults (quantui.def). + +Lightweight text assertions — no Apptainer build required. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +CPU_DEF = REPO / "apptainer" / "quantui.def" +GPU_DEF = REPO / "apptainer" / "quantui-gpu.def" + + +@pytest.fixture(scope="module") +def cpu_def_text() -> str: + return CPU_DEF.read_text(encoding="utf-8") + + +def _section(text: str, name: str) -> str: + m = re.search(rf"^{re.escape(name)}\s*$", text, re.M) + assert m is not None, f"no {name} section" + rest = text[m.end() :] + nxt = re.search(r"^%[a-z]+\s*$", rest, re.M) + return rest[: nxt.start()] if nxt else rest + + +class TestCpuTeachingImageDefaults: + def test_cpu_image_enables_ir_parallel_by_default(self, cpu_def_text): + env = _section(cpu_def_text, "%environment") + assert re.search(r"export\s+QUANTUI_FREQ_PARALLEL=1\b", env) + + def test_gpu_image_does_not_inherit_cpu_ir_parallel_default(self): + gpu_text = GPU_DEF.read_text(encoding="utf-8") + env = _section(gpu_text, "%environment") + assert "QUANTUI_FREQ_PARALLEL" not in env diff --git a/tests/test_freq_ir_workers.py b/tests/test_freq_ir_workers.py index 29d5326..5aa55e0 100644 --- a/tests/test_freq_ir_workers.py +++ b/tests/test_freq_ir_workers.py @@ -17,6 +17,8 @@ from quantui.freq_ir_workers import ( _truthy, + freq_parallel_env_configured, + freq_parallel_opt_in, parallel_enabled_for_run, pick_worker_count, threads_per_worker, @@ -138,3 +140,15 @@ def test_recognised_falsy(self, value): def test_whitespace_stripped(self): assert _truthy(" 1 ") is True assert _truthy("\ttrue\n") is True + + +class TestEnvConfiguredHelpers: + def test_freq_parallel_env_configured(self, monkeypatch): + monkeypatch.delenv("QUANTUI_FREQ_PARALLEL", raising=False) + assert freq_parallel_env_configured() is False + monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "0") + assert freq_parallel_env_configured() is True + + def test_freq_parallel_opt_in_public_alias(self, monkeypatch): + monkeypatch.setenv("QUANTUI_FREQ_PARALLEL", "yes") + assert freq_parallel_opt_in() is True