From ac06306ac97885d69557b1550daa0fcca806fdb4 Mon Sep 17 00:00:00 2001 From: David Ormrod Morley Date: Mon, 13 Jul 2026 14:53:24 +0200 Subject: [PATCH] Add view_orbital function SO107 --- CHANGELOG.md | 2 + src/scm/plams/__init__.py | 3 +- src/scm/plams/tools/view.py | 233 +++++++++++++++++++++++++++++++--- unit_tests/test_tools_view.py | 72 +++++++++-- 4 files changed, 277 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff7d54d1..5925914dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,14 @@ This changelog is effective from the 2025 releases. ### Added * MultiJob now supports generic Job types for the self.children attribute +* Function `view_orbital` to visualize orbitals of completed AMS jobs ### Changed * `Molecule.readmol2` can now read non-integer bond orders * `Molecule.writepdb` now writes default values for res, resnum, fix, and occ columns * `view` function uses stdin mode for AMSview, reducing overhead for image creation * `vasp_output_to_ams` now reads the MD time step from `POTIM` in the OUTCAR and labels molecular-dynamics runs (`IBRION = 0`) as such, instead of using a fixed default time step +* `view` function supports AMSJob and rkf as inputs ### Fixed * Xvfb backend for `view` function with 0 display value diff --git a/src/scm/plams/__init__.py b/src/scm/plams/__init__.py index a786aba7b..a7723713e 100644 --- a/src/scm/plams/__init__.py +++ b/src/scm/plams/__init__.py @@ -161,7 +161,7 @@ plot_msd, plot_work_function, ) -from scm.plams.tools.view import view, ViewConfig +from scm.plams.tools.view import view, view_orbital, ViewConfig from scm.plams.tools.reaction import ReactionEquation from scm.plams.tools.reaction_energies import ( balance_equation, @@ -335,6 +335,7 @@ "plot_msd", "plot_work_function", "view", + "view_orbital", "ViewConfig", "SDFTrajectoryFile", "create_sdf_string", diff --git a/src/scm/plams/tools/view.py b/src/scm/plams/tools/view.py index 35d238d17..b60dba42c 100644 --- a/src/scm/plams/tools/view.py +++ b/src/scm/plams/tools/view.py @@ -28,10 +28,12 @@ import shutil from abc import ABC, abstractmethod from tempfile import NamedTemporaryFile +from pathlib import Path from scm.plams.core.functions import requires_optional_package, log from scm.plams.interfaces.adfsuite.errors import AMSExecutionError from scm.plams.interfaces.adfsuite.utils import requires_ams +from scm.plams.interfaces.adfsuite.ams import AMSJob from scm.plams.mol.molecule import Molecule from scm.plams.core.private import run_with_timeout from scm.plams.tools.units import Units @@ -46,7 +48,7 @@ if TYPE_CHECKING: from PIL import Image as PilImage -__all__ = ["ViewConfig", "view"] +__all__ = ["ViewConfig", "view", "view_orbital"] ViewDirections = Literal[ "along_x", @@ -123,6 +125,10 @@ class ViewConfig: :param unit_cell_edge_thickness: specify thickness of the displayed unit cell boundary, defaults to ``0.05`` :param show_unit_cell_faces: display unit cell for periodic systems using semi-transparent faces, defaults to ``False`` :param show_lattice_vectors: display the lattice vectors for periodic systems, defaults to ``False`` + :param render_type: use isosurface, isosurface (wireframe) or volume rendering, defaults to ``iso`` + :param iso_value: value to use for isosurface, defaults to ``0.03`` + :param opacity: opacity for volume rendering, defaults to ``40`` + :param grid: fineness of grid used for rendering, defaults to ``medium`` :param backend: program to use as a backend to generate images, defaults to ``auto`` i.e. any available program :param timeout: kill visualization process after given time in seconds, defaults to ``10`` if window is not opened, otherwise no limit :param open_window: open AMSview in a dedicated window if ``True``, otherwise render image offscreen, defaults to ``False`` @@ -155,6 +161,13 @@ class ViewConfig: show_unit_cell_faces: bool = False show_lattice_vectors: bool = False + # Iso/Volume rendering + orbital: Optional[Tuple[Literal["homo", "lumo"], int]] = None + render_type: Literal["iso", "iso_wireframe", "volume"] = "iso" + iso_value: float = 0.03 + opacity: float = 40 + grid: Literal["fine", "medium", "coarse"] = "medium" + # Program backend: Literal[Backends] = "auto" timeout: Optional[int] = None @@ -232,6 +245,27 @@ def validate(self) -> None: if not isinstance(self.show_lattice_vectors, bool): raise ValueError(f"show_lattice_vectors must be a boolean value, but was '{self.show_lattice_vectors}'") + if self.orbital: + if len(self.orbital) != 2: + raise ValueError( + f"orbital must be a tuple of length 2, comprising 'homo' or 'lumo' and then the orbital index, but was '{self.orbital}'" + ) + if not isinstance(self.orbital[0], str) or self.orbital[0] not in ["homo", "lumo"]: + raise ValueError(f"orbital first item must be one of: 'homo', 'lumo', but was '{self.orbital[0]}'") + if not isinstance(self.orbital[1], int): + raise ValueError(f"orbital second item must be an integer, but was '{self.orbital[1]}'") + + if not isinstance(self.render_type, str) or self.render_type not in ["iso", "iso_wireframe", "volume"]: + raise ValueError( + f"render_type must be one of: 'iso', 'iso_wireframe', 'volume', but was '{self.render_type}'" + ) + if not isinstance(self.iso_value, (int, float)) or self.iso_value < 0: + raise ValueError(f"iso_value must be a positive numeric value, but was '{self.iso_value}'") + if not isinstance(self.opacity, (int, float)) or self.opacity < 0 or self.opacity > 200: + raise ValueError(f"opacity must be a positive numeric value less than 200, but was '{self.opacity}'") + if not isinstance(self.grid, str) or self.grid not in ["fine", "medium", "coarse"]: + raise ValueError(f"grid must be one of: 'fine', 'medium', 'coarse' but was '{self.grid}'") + if not isinstance(self.backend, str) or self.backend not in Backends.__args__: # type: ignore[attr-defined] raise ValueError( f"backend must be one of: '{', '.join(Backends.__args__)}'; but was '{self.backend}'" # type: ignore[attr-defined] @@ -244,7 +278,7 @@ def validate(self) -> None: @requires_optional_package("PIL") def view( - system: Union[Molecule, "ChemicalSystem"], + system: Union[Molecule, "ChemicalSystem", AMSJob, str, os.PathLike], config: Optional[ViewConfig] = None, *, width: Optional[int] = None, @@ -263,7 +297,8 @@ def view( open_window: Optional[bool] = None, ) -> "PilImage.Image": """ - View a chemical system or molecule in a Jupyter notebook by generating an image using AMSview/ASE + View a chemical system or molecule in a Jupyter notebook by generating an image using AMSview/ASE. + A completed AMSJob or rkf file can also be supplied, in which case the main molecule will be displayed from the results. :param system: molecule or chemical system to visualize :param config: configuration for view @@ -342,7 +377,34 @@ def view( # Validation to help prevent crashing due to bad options config.validate() + if not isinstance(system, Molecule) and not (_has_scm_chemsys and isinstance(system, ChemicalSystem)): + if isinstance(system, AMSJob): + system = system.results.rkfpath() + + if isinstance(system, (str, os.PathLike)): + system = Path(system) + if not (system.is_file() and system.suffix.lower() == ".rkf"): + raise ValueError(f"Path must be to an existing .rkf file, got: {system}") + else: + raise ValueError( + "System must be one of: Molecule, ChemicalSystem, a completed AMSJob, or a path to an existing .rkf file" + ) + + # restrictions for viewing orbitals + if config.orbital is not None: + if not isinstance(system, Path): + raise ValueError( + "System must be a completed AMSJob, or a path to an existing .rkf file when viewing orbitals" + ) + if isinstance(selected_backend, _AsePlotBackend): + raise ValueError(f"Backend '{config.backend}' is not supported for viewing orbitals.") + if config.guess_bonds: + if isinstance(system, Path): + raise ValueError( + "Bond guessing is only supported for a Molecule or ChemicalSystem, not an AMSJob or rkf file." + ) + system = system.copy() system.guess_bonds() @@ -352,6 +414,91 @@ def view( return img +@requires_optional_package("PIL") +def view_orbital( + system: Union[AMSJob, str, os.PathLike], + kind: Literal["homo", "lumo"] = "homo", + selector: int = 0, + config: Optional[ViewConfig] = None, + *, + width: Optional[int] = None, + height: Optional[int] = None, + padding: Optional[float] = None, + direction: Optional[ViewDirections] = None, + fixed_atom_size: Optional[bool] = None, + show_atom_labels: Optional[bool] = None, + atom_label_type: Optional[Literal["Element", "AtomType", "Name"]] = None, + guess_bonds: Optional[bool] = None, + show_regions: Optional[bool] = None, + show_unit_cell_edges: Optional[bool] = None, + show_lattice_vectors: Optional[bool] = None, + render_type: Optional[Literal["iso", "iso_wireframe", "volume"]] = None, + iso_value: Optional[float] = None, + opacity: Optional[float] = None, + grid: Optional[Literal["fine", "medium", "coarse"]] = None, + picture_path: Optional[Union[str, os.PathLike]] = None, + backend: Optional[Backends] = None, + open_window: Optional[bool] = None, +) -> "PilImage.Image": + """ + View an orbital from a completed AMS calculation in a Jupyter notebook by generating an image using AMSview. + A completed AMSJob or rkf file must be supplied, in which case the main molecule and selected orbital will be displayed from the results. + + :param system: molecule or chemical system to visualize + :param kind: orbital type to view, one of ``homo`` or ``lumo`` + :param selector: orbital to view, index relative to the homo/lumo + :param config: configuration for view + :param width: override for width of the image in pixels + :param height: override for height of the image in pixels + :param padding: override for padding around system in Angstrom + :param direction: override for direction to view system along + :param fixed_atom_size: override to use the same radius for all elements (except Hydrogen) + :param show_atom_labels: override to display text label on each atom + :param atom_label_type: override for property used for atom labels + :param guess_bonds: override for guessing bonds before viewing + :param show_regions: override to display translucent spheres on atoms according to their regions + :param show_unit_cell_edges: override to display unit cell for periodic systems using semi-transparent edges + :param show_lattice_vectors: override to display the lattice vectors for periodic systems + :param render_type: override for render type for orbitals + :param iso_value: override for isosurface rendering + :param opacity: override for opacity for volume rendering + :param grid: override for grid fineness for rendering + :param picture_path: override for path for the location to save the generated image file + :param backend: override for program to use as a backend to generate images + :param open_window: override to open AMSview in a dedicated window + :return: image of the molecule generated using AMSView + """ + config = config or ViewConfig() + config.orbital = (kind, selector) + if render_type is not None: + config.render_type = render_type + if iso_value is not None: + config.iso_value = iso_value + if opacity is not None: + config.opacity = opacity + if grid is not None: + config.grid = grid + + return view( + system, + config=config, + width=width, + height=height, + padding=padding, + direction=direction, + fixed_atom_size=fixed_atom_size, + show_atom_labels=show_atom_labels, + atom_label_type=atom_label_type, + guess_bonds=guess_bonds, + show_regions=show_regions, + show_unit_cell_edges=show_unit_cell_edges, + show_lattice_vectors=show_lattice_vectors, + picture_path=picture_path, + backend=backend, + open_window=open_window, + ) + + class _ViewBackend(ABC): """ Abstract base class for a viewer for a molecule/chemical system @@ -370,7 +517,7 @@ def check_available(cls) -> None: @classmethod @abstractmethod - def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewConfig) -> "PilImage.Image": + def generate_image(cls, system: Union[Molecule, "ChemicalSystem", Path], config: ViewConfig) -> "PilImage.Image": """ Generate image file for the given system @@ -512,7 +659,7 @@ def check_available(cls) -> None: @classmethod def get_command( cls, - system: Union[Molecule, "ChemicalSystem"], + system: Union[Molecule, "ChemicalSystem", Path], config: ViewConfig, input_path: str, img_path: str, @@ -525,10 +672,20 @@ def get_command( :param input_path: path to .in file for system :param img_path: path to output image file """ + if isinstance(system, Path): + path = Path(system) + if not (path.is_file() and path.suffix.lower() == ".rkf"): + raise ValueError(f"Path must be to an existing .rkf file, got: {system}") + if _has_scm_chemsys: + system = ChemicalSystem.from_kf(str(path)) + else: + system = Molecule(str(path), inputformat="rkf") + command = [ os.path.expandvars("$AMSBIN/amsview"), input_path, "-transparent", + "-antialias", "-scmgeometry", f"{config.width}x{config.height}", "-dpi", @@ -559,6 +716,24 @@ def get_command( command += ["-showunitcell", f"{config.unit_cell_edge_thickness}"] else: command += ["-showunitcell", "hide"] + if config.orbital: + orbital_type, orbital_idx = config.orbital + if orbital_type == "homo": + command += ["-HOMO", f"{orbital_idx}"] + elif orbital_type == "lumo": + command += ["-LUMO", f"{orbital_idx}"] + if config.render_type.startswith("iso"): + command += ["-val", f"{config.iso_value}"] + if config.render_type == "iso_wireframe": + command += ["-wireframe"] + elif config.render_type == "volume": + command += ["-volume", "-opacity", f"{config.opacity}"] + if config.grid.lower() == "fine": + command += ["-grid", "Fine"] + elif config.grid.lower() == "medium": + command += ["-grid", "Medium"] + elif config.grid.lower() == "coarse": + command += ["-grid", "Coarse"] if not config.open_window: command += ["-save", img_path, "-batch"] @@ -573,10 +748,13 @@ def run_command(cls, command: List[str], config: ViewConfig) -> None: run_with_timeout(command, timeout=config.timeout) @classmethod - def write_system_input(cls, system: Union[Molecule, "ChemicalSystem"]) -> str: + def write_system_input(cls, system: Union[Molecule, "ChemicalSystem", Path]) -> Tuple[str, bool]: """ - Write the system to a temporary AMS input file and return the path. + Write the system to a temporary AMS input file and return the path, and whether the caller should delete it. """ + if isinstance(system, Path): + return str(system), False + with NamedTemporaryFile(mode="w", suffix=".in", delete=False) as input_file: input_path = input_file.name if isinstance(system, Molecule): @@ -585,9 +763,9 @@ def write_system_input(cls, system: Union[Molecule, "ChemicalSystem"]) -> str: input_file.write(str(system)) else: raise ValueError( - f"System must be a PLAMS Molecule or a ChemicalSystem, but was {type(system).__name__}" + f"System must be a PLAMS Molecule, ChemicalSystem or rkf path, but was {type(system).__name__}" ) - return input_path + return input_path, True @staticmethod def get_image_path(config: ViewConfig) -> Tuple[str, bool]: @@ -619,19 +797,20 @@ def load_and_resize_image(img_path: str, config: ViewConfig) -> "PilImage.Image" ) @classmethod - def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewConfig) -> "PilImage.Image": + def generate_image(cls, system: Union[Molecule, "ChemicalSystem", Path], config: ViewConfig) -> "PilImage.Image": if config.open_window: save_config = replace(config, open_window=False, timeout=10) img = _AMSViewManager()._generate_image(system, save_config) - input_path = cls.write_system_input(system) + input_path, cleanup_input = cls.write_system_input(system) command = cls.get_command(system, config, input_path, "") try: cls.run_command(command, config) except subprocess.CalledProcessError as ex: raise AMSExecutionError(" ".join(command), ex.stderr) finally: - os.remove(input_path) + if cleanup_input and os.path.exists(input_path): + os.remove(input_path) return img @@ -723,9 +902,9 @@ def close_instance(cls) -> None: if manager is not None: manager.close() - def _generate_image(self, system: Union[Molecule, "ChemicalSystem"], config: ViewConfig) -> "PilImage.Image": + def _generate_image(self, system: Union[Molecule, "ChemicalSystem", Path], config: ViewConfig) -> "PilImage.Image": with self._lock: - input_path = _AmsViewBackend.write_system_input(system) + input_path, cleanup_input = _AmsViewBackend.write_system_input(system) img_path, cleanup_image = _AmsViewBackend.get_image_path(config) final_img_path = img_path @@ -751,7 +930,8 @@ def _generate_image(self, system: Union[Molecule, "ChemicalSystem"], config: Vie self.close() raise AMSExecutionError("amsview -stdin -batch", str(ex)) finally: - os.remove(input_path) + if cleanup_input and os.path.exists(input_path): + os.remove(input_path) if cleanup_image and os.path.exists(img_path): os.remove(img_path) @@ -850,12 +1030,12 @@ def run_command(cls, command: List[str], config: ViewConfig) -> None: run_with_timeout(command, timeout=config.timeout, env=env) @classmethod - def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewConfig) -> "PilImage.Image": + def generate_image(cls, system: Union[Molecule, "ChemicalSystem", Path], config: ViewConfig) -> "PilImage.Image": # do not open the AMSview window with xvfb, otherwise it will hang if config.open_window: config = replace(config, open_window=False, timeout=10) - input_path = cls.write_system_input(system) + input_path, cleanup_input = cls.write_system_input(system) img_path, cleanup_image = cls.get_image_path(config) command = cls.get_command(system, config, input_path, img_path) @@ -866,7 +1046,8 @@ def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewC except subprocess.CalledProcessError as ex: raise AMSExecutionError(" ".join(command), ex.stderr) finally: - os.remove(input_path) + if cleanup_input and os.path.exists(input_path): + os.remove(input_path) if cleanup_image and os.path.exists(img_path): os.remove(img_path) @@ -1111,7 +1292,7 @@ def check_available(cls) -> None: super().check_available() @classmethod - def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewConfig) -> "PilImage.Image": + def generate_image(cls, system: Union[Molecule, "ChemicalSystem", Path], config: ViewConfig) -> "PilImage.Image": from PIL import Image as PilImage import matplotlib.pyplot as plt import matplotlib.patches as patches @@ -1120,12 +1301,22 @@ def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewC from scm.plams.interfaces.molecule.ase import toASE # Convert system to ASE atoms + if isinstance(system, Path): + path = Path(system) + if not (path.is_file() and path.suffix.lower() == ".rkf"): + raise ValueError(f"Path must be to an existing .rkf file, got: {system}") + if _has_scm_chemsys: + system = ChemicalSystem.from_kf(str(path)) + else: + system = Molecule(str(path), inputformat="rkf") if isinstance(system, Molecule): ase_atoms = toASE(system) elif _has_scm_chemsys and isinstance(system, ChemicalSystem): ase_atoms = system.to_ase_atoms() else: - raise ValueError(f"System must be a PLAMS Molecule or a ChemicalSystem, but was {type(system).__name__}") + raise ValueError( + f"System must be a PLAMS Molecule, ChemicalSystem or rkf path, but was {type(system).__name__}" + ) # Get image path for (temporary) file if config.picture_path: @@ -1296,6 +1487,8 @@ def generate_image(cls, system: Union[Molecule, "ChemicalSystem"], config: ViewC resample=PilImage.Resampling.LANCZOS, reducing_gap=3.0, ) + # clear non-string dpi metadata which can cause issues rendering in a notebook + resized_img.info.clear() finally: plt.close(fig) if not config.picture_path: diff --git a/unit_tests/test_tools_view.py b/unit_tests/test_tools_view.py index e0fb3d11b..8184149f5 100644 --- a/unit_tests/test_tools_view.py +++ b/unit_tests/test_tools_view.py @@ -12,6 +12,7 @@ from scm.plams.interfaces.molecule.rdkit import from_smiles from scm.plams.tools.view import ( view, + view_orbital, ViewConfig, _AMSViewManager, _AmsViewBackend, @@ -32,12 +33,18 @@ @pytest.fixture -def water(xyz_folder): +def water(): water = from_smiles("O") water.guess_bonds() return water +@pytest.fixture +def water_opt(rkf_folder): + water_opt = rkf_folder / "water_optimization" / "ams.rkf" + return water_opt + + class TestLazyViewBacked: @pytest.fixture @@ -66,7 +73,7 @@ def get_new_view_backends_cache(): "ase_plot": _LazyViewBackend(_AsePlotBackend()), } - def test_backends_cache2(self, water): + def test_backends_cache(self, water): # Given backend cache import scm.plams.tools.view as viewer @@ -132,6 +139,29 @@ def check_available_errors(): viewer._view_backends_cache = self.get_new_view_backends_cache() + def test_view_orbital_forwards_to_view(self, water_opt): + with patch("scm.plams.tools.view.view", return_value=MagicMock()) as mock_view: + result = view_orbital( + water_opt, + kind="lumo", + selector=1, + render_type="volume", + opacity=75, + grid="fine", + width=320, + backend="amsview", + ) + + assert result is mock_view.return_value + assert mock_view.call_args.args[0] == water_opt + config = mock_view.call_args.kwargs["config"] + assert config.orbital == ("lumo", 1) + assert config.render_type == "volume" + assert config.opacity == 75 + assert config.grid == "fine" + assert mock_view.call_args.kwargs["width"] == 320 + assert mock_view.call_args.kwargs["backend"] == "amsview" + class TestAmsViewBackend: @@ -148,11 +178,11 @@ def test_check_available(self, monkeypatch): [ ( ViewConfig(), - "foo.in -transparent -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.05 -save bar.png -batch", + "foo.in -transparent -antialias -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.05 -save bar.png -batch", ), ( ViewConfig(width=100, height=100, normal=(1.0, 0.0, 0.0)), - "foo.in -transparent -scmgeometry 100x100 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 1.000000 0.000000 0.000000 -fixedatomsize -hideregions -showunitcell 0.05 -save bar.png -batch", + "foo.in -transparent -antialias -scmgeometry 100x100 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 1.000000 0.000000 0.000000 -fixedatomsize -hideregions -showunitcell 0.05 -save bar.png -batch", ), ( ViewConfig( @@ -162,19 +192,31 @@ def test_check_available(self, monkeypatch): atom_label_size=2, show_regions=True, ), - "foo.in -transparent -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -atomlabel Element -labelcolor #FFFFFF -labelsize 2 -showunitcell 0.05 -save bar.png -batch", + "foo.in -transparent -antialias -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -atomlabel Element -labelcolor #FFFFFF -labelsize 2 -showunitcell 0.05 -save bar.png -batch", ), ( ViewConfig(show_unit_cell_edges=True, unit_cell_edge_thickness=0.2), - "foo.in -transparent -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.2 -save bar.png -batch", + "foo.in -transparent -antialias -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.2 -save bar.png -batch", ), ( ViewConfig(show_unit_cell_faces=True, show_lattice_vectors=True), - "foo.in -transparent -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 1 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell faces -save bar.png -batch", + "foo.in -transparent -antialias -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 1 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell faces -save bar.png -batch", ), ( ViewConfig(dpi=600), - "foo.in -transparent -scmgeometry 800x400 -dpi 600 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.05 -save bar.png -batch", + "foo.in -transparent -antialias -scmgeometry 800x400 -dpi 600 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.05 -save bar.png -batch", + ), + ( + ViewConfig(orbital=("homo", 0), render_type="iso"), + "foo.rkf -transparent -antialias -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.05 -HOMO 0 -val 0.03 -grid Medium -save bar.png -batch", + ), + ( + ViewConfig(orbital=("lumo", 1), render_type="iso_wireframe", iso_value=0.01), + "foo.rkf -transparent -antialias -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.05 -LUMO 1 -val 0.01 -wireframe -grid Medium -save bar.png -batch", + ), + ( + ViewConfig(orbital=("homo", -1), render_type="volume", opacity=100, grid="fine"), + "foo.rkf -transparent -antialias -scmgeometry 800x400 -dpi 300 -padding 0.000000 -showlatticevectors 0 -viewplane 0.000000 0.000000 1.000000 -fixedatomsize -hideregions -showunitcell 0.05 -HOMO -1 -volume -opacity 100 -grid Fine -save bar.png -batch", ), ], ids=[ @@ -184,10 +226,16 @@ def test_check_available(self, monkeypatch): "unit_cell_edges", "unit_cell_faces_lattice_vectors", "pic_dpi", + "orbital_homo_iso", + "orbital_lumo+1_wireframe", + "orbital_homo-1_volume", ], ) - def test_get_command(self, view_config, expected, water): - command = self.backend.get_command(water, view_config, input_path="foo.in", img_path="bar.png") + def test_get_command(self, view_config, expected, water, water_opt): + system = water if not view_config.orbital else water_opt + input_path = "foo.in" if not view_config.orbital else "foo.rkf" + + command = self.backend.get_command(system, view_config, input_path=input_path, img_path="bar.png") command = str.join(" ", command[1:]) assert command == expected @@ -201,7 +249,7 @@ def test_open_window_saves_with_manager_then_opens_window(self, water, tmp_path) image = MagicMock() with patch.object(_AMSViewManager, "_generate_image", return_value=image) as mock_generate, patch.object( - self.backend, "write_system_input", return_value=str(input_path) + self.backend, "write_system_input", return_value=(str(input_path), True) ), patch.object(self.backend, "run_command") as mock_run_command: actual = self.backend.generate_image(water, ViewConfig(open_window=True)) @@ -470,7 +518,7 @@ def send_command(command): active -= 1 with patch.object( - _AmsViewBackend, "write_system_input", side_effect=[str(path) for path in temp_paths] + _AmsViewBackend, "write_system_input", side_effect=[(str(path), True) for path in temp_paths] ), patch.object( _AmsViewBackend, "get_image_path", return_value=(str(tmp_path / "image.png"), False) ), patch.object(