diff --git a/aiidalab_widgets_base/structures.py b/aiidalab_widgets_base/structures.py index a151fca1f..238d8b10c 100644 --- a/aiidalab_widgets_base/structures.py +++ b/aiidalab_widgets_base/structures.py @@ -170,13 +170,19 @@ def _structure_editors(self, editors): """Preparing structure editors.""" if editors and len(editors) == 1: link((editors[0], "structure"), (self, "structure")) + + if editors[0].has_trait("input_selection"): + dlink((editors[0], "input_selection"), (self.viewer, "input_selection")) + if editors[0].has_trait("selection"): - link((editors[0], "selection"), (self.viewer, "selection")) + dlink((self.viewer, "selection"), (editors[0], "selection")) + if editors[0].has_trait("camera_orientation"): dlink( (self.viewer._viewer, "_camera_orientation"), (editors[0], "camera_orientation"), ) # pylint: disable=protected-access + return editors[0] # If more than one editor was defined. @@ -942,6 +948,7 @@ class BasicStructureEditor(ipw.VBox): # pylint: disable=too-many-instance-attri position of periodic structure in cell) editing.""" structure = Instance(Atoms, allow_none=True) + input_selection = List(Int, allow_none=True) selection = List(Int) camera_orientation = List() @@ -1206,13 +1213,15 @@ def def_point(self, _=None): """Define the action point.""" self.point.value = self.vec2str(self.sel2com()) if self.autoclear_selection.value: - self.selection = [] + self.input_selection = None + self.input_selection = [] def def_axis_p1(self, _=None): """Define the first point of axis.""" self.axis_p1.value = self.vec2str(self.sel2com()) if self.autoclear_selection.value: - self.selection = [] + self.input_selection = None + self.input_selection = [] def def_axis_p2(self, _=None): """Define the second point of axis.""" @@ -1230,7 +1239,8 @@ def def_axis_p2(self, _=None): ) self.axis_p2.value = self.vec2str(com) if self.autoclear_selection.value: - self.selection = [] + self.input_selection = None + self.input_selection = [] def def_perpendicular_to_screen(self, _=None): """Define a normalized vector perpendicular to the screen.""" @@ -1251,7 +1261,7 @@ def translate_dr(self, _=None, atoms=None, selection=None): self.action_vector * self.displacement.value ) - self.structure, self.selection = atoms, selection + self.structure, self.input_selection = atoms, selection @_register_structure @_register_selection @@ -1261,7 +1271,7 @@ def translate_dxdydz(self, _=None, atoms=None, selection=None): # The action. atoms.positions[self.selection] += np.array(self.str2vec(self.dxyz.value)) - self.structure, self.selection = atoms, selection + self.structure, self.input_selection = atoms, selection @_register_structure @_register_selection @@ -1271,7 +1281,7 @@ def translate_to_xyz(self, _=None, atoms=None, selection=None): geo_center = np.average(self.structure[self.selection].get_positions(), axis=0) atoms.positions[self.selection] += self.str2vec(self.dxyz.value) - geo_center - self.structure, self.selection = atoms, selection + self.structure, self.input_selection = atoms, selection @_register_structure @_register_selection @@ -1283,9 +1293,9 @@ def rotate(self, _=None, atoms=None, selection=None): vec = self.str2vec(self.vec2str(self.action_vector)) center = self.str2vec(self.point.value) rotated_subset.rotate(self.phi.value, v=vec, center=center, rotate_cell=False) - atoms.positions[list(self.selection)] = rotated_subset.positions + atoms.positions[self.selection] = rotated_subset.positions - self.structure, self.selection = atoms, selection + self.structure, self.input_selection = atoms, selection @_register_structure @_register_selection @@ -1318,7 +1328,7 @@ def mirror(self, _=None, norm=None, point=None, atoms=None, selection=None): # Mirror atoms. atoms.positions[selection] -= 2 * projections - self.structure, self.selection = atoms, selection + self.structure, self.input_selection = atoms, selection def mirror_3p(self, _=None): """Mirror atoms on the plane containing action vector and action point.""" @@ -1342,7 +1352,7 @@ def align(self, _=None, atoms=None, selection=None): subset.rotate(self.action_vector, self.str2vec(self.dxyz.value), center=center) atoms.positions[selection] = subset.positions - self.structure, self.selection = atoms, selection + self.structure, self.input_selection = atoms, selection @_register_structure @_register_selection @@ -1373,7 +1383,7 @@ def mod_element(self, _=None, atoms=None, selection=None): range(last_atom, last_atom + len(selection) * len(lgnd)) ) - self.structure, self.selection = atoms, new_selection + self.structure, self.input_selection = atoms, new_selection @_register_structure @_register_selection @@ -1387,8 +1397,7 @@ def copy_sel(self, _=None, atoms=None, selection=None): atoms += add_atoms new_selection = list(range(last_atom, last_atom + len(selection))) - - self.structure, self.selection = atoms, new_selection + self.structure, self.input_selection = atoms, new_selection @_register_structure @_register_selection @@ -1421,7 +1430,7 @@ def add(self, _=None, atoms=None, selection=None): new_selection = list(range(last_atom, last_atom + len(selection) * len(lgnd))) - self.structure, self.selection = atoms, new_selection + self.structure, self.input_selection = atoms, new_selection @_register_structure @_register_selection @@ -1429,4 +1438,6 @@ def remove(self, _, atoms=None, selection=None): """Remove selected atoms.""" del [atoms[selection]] - self.structure, self.selection = atoms, [] + self.structure = atoms + self.input_selection = None + self.input_selection = [] diff --git a/aiidalab_widgets_base/viewers.py b/aiidalab_widgets_base/viewers.py index 5052983da..597f85050 100644 --- a/aiidalab_widgets_base/viewers.py +++ b/aiidalab_widgets_base/viewers.py @@ -169,8 +169,9 @@ class _StructureDataBaseViewer(ipw.VBox): """ + input_selection = List(Int, allow_none=True) selection = List(Int) - selection_adv = Unicode() + displayed_selection = List(Int) supercell = List(Int) cell = Instance(Cell, allow_none=True) DEFAULT_SELECTION_OPACITY = 0.2 @@ -213,12 +214,6 @@ def __init__( if configuration_tabs is None: configuration_tabs = ["Selection", "Appearance", "Cell", "Download"] - self.selection_tab_idx = None - if len(configuration_tabs) != 0: - try: - self.selection_tab_idx = configuration_tabs.index("Selection") - except ValueError: - pass self.configuration_box = ipw.Tab( layout=ipw.Layout(flex="1 1 auto", width="auto") ) @@ -243,7 +238,7 @@ def _selection_tab(self): # 1. Selected atoms. self._selected_atoms = ipw.Text( - description="Selected atoms:", + description="Select atoms:", value="", style={"description_width": "initial"}, ) @@ -263,16 +258,14 @@ def _selection_tab(self): # clear_selection.on_click(lambda _: self.set_trait('selection', list())) # lambda cannot contain assignments clear_selection.on_click( lambda _: ( + self.set_trait("displayed_selection", []), self.set_trait("selection", []), - self.set_trait("selection_adv", ""), - # self.wrong_syntax.layout.visibility = 'hidden' ) ) - # CLEAR self.wrong_syntax.layout.visibility = 'visible' # 5. Button to apply selection - apply_selection = ipw.Button(description="Apply selection") - apply_selection.on_click(self.apply_selection) + apply_displayed_selection = ipw.Button(description="Apply selection") + apply_displayed_selection.on_click(self.apply_displayed_selection) self.selection_info = ipw.HTML() @@ -288,7 +281,9 @@ def _selection_tab(self): (x>1 and name not [N,O]) or d_from [1,1,1]>2 or id>=10
""" ), - ipw.HBox([copy_to_clipboard, clear_selection, apply_selection]), + ipw.HBox( + [copy_to_clipboard, clear_selection, apply_displayed_selection] + ), self.selection_info, ] ) @@ -305,9 +300,9 @@ def change_supercell(_=None): ] _supercell = [ - ipw.BoundedIntText(value=1, min=1, layout={"width": "30px"}), - ipw.BoundedIntText(value=1, min=1, layout={"width": "30px"}), - ipw.BoundedIntText(value=1, min=1, layout={"width": "30px"}), + ipw.BoundedIntText(value=1, min=1, layout={"width": "40px"}), + ipw.BoundedIntText(value=1, min=1, layout={"width": "40px"}), + ipw.BoundedIntText(value=1, min=1, layout={"width": "40px"}), ] for elem in _supercell: elem.observe(change_supercell, names="value") @@ -496,7 +491,7 @@ def _download_tab(self): def _render_structure(self, change=None): """Render the structure with POVRAY.""" - if not isinstance(self.structure, Atoms): + if not isinstance(self.displayed_structure, Atoms): return self.render_btn.disabled = True @@ -505,7 +500,7 @@ def _render_structure(self, change=None): zfactor = norm(omat[0, 0:3]) omat[0:3, 0:3] = omat[0:3, 0:3] / zfactor - bb = deepcopy(self.structure.repeat(self.supercell)) + bb = deepcopy(self.displayed_structure) bb.pbc = (False, False, False) for i in bb: @@ -633,17 +628,16 @@ def _on_atom_click(self, _=None): if "atom1" not in self._viewer.picked.keys(): return # did not click on atom index = self._viewer.picked["atom1"]["index"] - selection = self.selection.copy() + displayed_selection = self.displayed_selection.copy() - if selection: - if index not in selection: - selection.append(index) + if displayed_selection: + if index not in displayed_selection: + displayed_selection.append(index) else: - selection.remove(index) + displayed_selection.remove(index) else: - selection = [index] - - self.selection = selection + displayed_selection = [index] + self.displayed_selection = displayed_selection def highlight_atoms( self, @@ -670,38 +664,50 @@ def highlight_atoms( def _default_supercell(self): return [1, 1, 1] - @default("selection") - def _default_selection(self): - return [] + @observe("input_selection") + def _observe_input_selection(self, value): + if value["new"] is None: + return + + # Exclude everything that is beyond the total number of atoms. + selection_list = [x for x in value["new"] if x < self.natom] - @validate("selection") - def _validate_selection(self, provided): - return list(provided["value"]) + # In the case of a super cell, we need to multiply the selection as well + multiplier = sum(self.supercell) - 2 + selection_list = [ + x + self.natom * i for x in selection_list for i in range(multiplier) + ] - @observe("selection") - def _observe_selection(self, _=None): - self.highlight_atoms(self.selection) - self._selected_atoms.value = list_to_string_range(self.selection, shift=1) + self.displayed_selection = selection_list - # if atom is selected from nglview, shift to selection tab - if self._selected_atoms.value and self.selection_tab_idx is not None: - self.configuration_box.selected_index = self.selection_tab_idx + @observe("displayed_selection") + def _observe_displayed_selection(self, _=None): + seen = set() + seq = [x % self.natom for x in self.displayed_selection] + self.selection = [x for x in seq if not (x in seen or seen.add(x))] + self.highlight_atoms(self.displayed_selection) - def apply_selection(self, _=None): + def apply_displayed_selection(self, _=None): """Apply selection specified in the text field.""" - selection_string = self._selected_atoms.value expanded_selection, syntax_ok = string_range_to_list( self._selected_atoms.value, shift=-1 ) - # self.wrong_syntax.layout.visibility = 'hidden' if syntax_ok else 'visible' + if not syntax_ok: + try: + sel = self._parse_advanced_selection( + condition=self._selected_atoms.value + ) + sel = list_to_string_range(sel, shift=1) + expanded_selection, syntax_ok = string_range_to_list(sel, shift=-1) + except (IndexError, TypeError, AttributeError): + syntax_ok = False + self.wrong_syntax.layout.visibility = "visible" + if syntax_ok: self.wrong_syntax.layout.visibility = "hidden" - self.selection = expanded_selection - self._selected_atoms.value = ( - selection_string # Keep the old string for further editing. - ) + self.displayed_selection = expanded_selection else: - self.selection_adv = selection_string + self.wrong_syntax.layout.visibility = "visible" def download(self, change=None): # pylint: disable=unused-argument """Prepare a structure for downloading.""" @@ -767,7 +773,7 @@ class StructureDataViewer(_StructureDataBaseViewer): def __init__(self, structure=None, **kwargs): super().__init__(**kwargs) self.structure = structure - # self.supercell.observe(self.repeat, names='value') + self.natom = len(self.structure) if self.structure is not None else 0 @observe("supercell") def repeat(self, _=None): @@ -796,6 +802,7 @@ def _valid_structure(self, change): # pylint: disable=no-self-use @observe("structure") def _observe_structure(self, change): """Update displayed_structure trait after the structure trait has been modified.""" + self.natom = len(self.structure) if self.structure is not None else 0 # Remove the current structure(s) from the viewer. if change["new"] is not None: self.set_trait("displayed_structure", change["new"].repeat(self.supercell)) @@ -812,7 +819,7 @@ def _update_structure_viewer(self, change): comp_id ) in self._viewer._ngl_component_ids: # pylint: disable=protected-access self._viewer.remove_component(comp_id) - self.selection = [] + self.displayed_selection = [] if change["new"] is not None: self._viewer.add_component(nglview.ASEStructure(change["new"])) self._viewer.clear() @@ -823,7 +830,7 @@ def _update_structure_viewer(self, change): def d_from(self, operand): point = np.array([float(i) for i in operand[1:-1].split(",")]) - return np.linalg.norm(self.structure.positions - point, axis=1) + return np.linalg.norm(self.displayed_structure.positions - point, axis=1) def name_operator(self, operand): """Defining the name operator which will handle atom kind names.""" @@ -831,7 +838,7 @@ def name_operator(self, operand): names = operand[1:-1].split(",") elif not operand.endswith("[") and not operand.startswith("]"): names = [operand] - symbols = self.structure.get_chemical_symbols() + symbols = self.displayed_structure.get_chemical_symbols() return np.array([i for i, val in enumerate(symbols) if val in names]) def not_operator(self, operand): @@ -842,11 +849,13 @@ def not_operator(self, operand): names = [operand] return ( "[" - + ",".join(list(set(self.structure.get_chemical_symbols()) - set(names))) + + ",".join( + list(set(self.displayed_structure.get_chemical_symbols()) - set(names)) + ) + "]" ) - def parse_advanced_sel(self, condition=None): + def _parse_advanced_selection(self, condition=None): """Apply advanced selection specified in the text field.""" def addition(opa, opb): @@ -894,10 +903,10 @@ def union(opa, opb): return np.union1d(opa, opb) operandsdict = { - "x": self.structure.positions[:, 0], - "y": self.structure.positions[:, 1], - "z": self.structure.positions[:, 2], - "id": np.array([atom.index + 1 for atom in self.structure]), + "x": self.displayed_structure.positions[:, 0], + "y": self.displayed_structure.positions[:, 1], + "z": self.displayed_structure.positions[:, 2], + "id": np.array([atom.index + 1 for atom in self.displayed_structure]), } operatorsdict = { @@ -991,76 +1000,92 @@ def union(opa, opb): def create_selection_info(self): """Create information to be displayed with selected atoms""" - if not self.selection: + if not self.displayed_selection: return "" def print_pos(pos): return " ".join([str(i) for i in pos.round(2)]) def add_info(indx, atom): - return f"Id: {indx + 1}; Symbol: {atom.symbol}; Coordinates: ({print_pos(atom.position)})Id: {indx + 1}; Symbol: {atom.symbol}; Coordinates: ({print_pos(atom.position)})
" + # Unit and displayed cell atoms' selection. + info_selected_atoms = ( + f"Selected atoms: {list_to_string_range(self.displayed_selection, shift=1)}
" + + f"Selected unit cell atoms: {list_to_string_range(self.selection, shift=1)}
" + ) # Find geometric center. geom_center = print_pos( - np.average(self.structure[self.selection].get_positions(), axis=0) + np.average( + self.displayed_structure[self.displayed_selection].get_positions(), + axis=0, + ) ) # Report coordinates. - if len(self.selection) == 1: - return add_info(self.selection[0], self.structure[self.selection[0]]) + if len(self.displayed_selection) == 1: + return info_selected_atoms + add_info( + self.displayed_selection[0], + self.displayed_structure[self.displayed_selection[0]], + ) # Report coordinates, distance and center. - if len(self.selection) == 2: + if len(self.displayed_selection) == 2: info = "" - info += add_info(self.selection[0], self.structure[self.selection[0]]) - info += add_info(self.selection[1], self.structure[self.selection[1]]) - dist = self.structure.get_distance(*self.selection) - distv = self.structure.get_distance(*self.selection, vector=True) - info += f"Distance: {dist:.2f} ({print_pos(distv)})Distance: {dist:.2f} ({print_pos(distv)})
Geometric center: ({geom_center})
" + return info_selected_atoms + info + + info_natoms_geo_center = f"{len(self.displayed_selection)} atoms selected
Geometric center: ({geom_center})
" # Report angle geometric center and normal. - if len(self.selection) == 3: - angle = self.structure.get_angle(*self.selection).round(2) + if len(self.displayed_selection) == 3: + angle = self.displayed_structure.get_angle(*self.displayed_selection).round( + 2 + ) normal = np.cross( - *self.structure.get_distances( - self.selection[1], - [self.selection[0], self.selection[2]], + *self.displayed_structure.get_distances( + self.displayed_selection[1], + [self.displayed_selection[0], self.displayed_selection[2]], mic=False, vector=True, ) ) normal = normal / np.linalg.norm(normal) - return f"{info_natoms_geo_center}{info_natoms_geo_center}
Angle: {angle}
Normal: ({print_pos(normal)})
" + ) # Report dihedral angle and geometric center. - if len(self.selection) == 4: + if len(self.displayed_selection) == 4: try: - dihedral = self.structure.get_dihedral(self.selection) * 180 / np.pi + dihedral = self.displayed_structure.get_dihedral( + *self.displayed_selection + ) dihedral_str = f"{dihedral:.2f}" except ZeroDivisionError: dihedral_str = "nan" - return f"{info_natoms_geo_center}{info_natoms_geo_center}
Dihedral angle: {dihedral_str}
" + ) - @observe("selection_adv") - def _observe_selection_adv(self, _=None): - """Apply the advanced boolean atom selection""" - try: - sel = self.parse_advanced_sel(condition=self.selection_adv) - self._selected_atoms.value = list_to_string_range(sel, shift=1) - self.wrong_syntax.layout.visibility = "hidden" - self.apply_selection() - except (IndexError, TypeError, AttributeError): - self.wrong_syntax.layout.visibility = "visible" + return info_selected_atoms + info_natoms_geo_center - @observe("selection") - def _observe_selection_2(self, _=None): + @observe("displayed_selection") + def _observe_displayed_selection_2(self, _=None): self.selection_info.value = self.create_selection_info() diff --git a/notebooks/aiida_datatypes_viewers.ipynb b/notebooks/aiida_datatypes_viewers.ipynb index 583baeaaf..c77ca0e26 100644 --- a/notebooks/aiida_datatypes_viewers.ipynb +++ b/notebooks/aiida_datatypes_viewers.ipynb @@ -50,9 +50,12 @@ "outputs": [], "source": [ "# create molecule\n", - "from ase.build import molecule\n", + "from ase.build import molecule, bulk\n", "m = molecule('H2O')\n", - "m.center(vacuum=2.0)" + "m.center(vacuum=2.0)\n", + "#\n", + "# create bulk Pt\n", + "pt = bulk('Pt', cubic = True)" ] }, { @@ -69,7 +72,7 @@ "outputs": [], "source": [ "CifData = DataFactory('core.cif')\n", - "s = CifData(ase=m)\n", + "s = CifData(ase=pt)\n", "vwr = viewer(s.store(), configuration_tabs=['Selection', 'Appearance', 'Cell', 'Download'])\n", "display(vwr)" ] diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index 039683077..e0abd3e9d 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -2,6 +2,7 @@ import requests from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys def test_notebook_service_available(notebook_service): @@ -58,10 +59,48 @@ def test_structures_generate_from_smiles(selenium_driver, final_screenshot): # Select the first atom driver.find_element(By.XPATH, "//*[text()='Selection']").click() driver.find_element( - By.XPATH, "//label[text()='Selected atoms:']/following-sibling::input" + By.XPATH, "//label[text()='Select atoms:']/following-sibling::input" ).send_keys("1") driver.find_element(By.XPATH, '//button[text()="Apply selection"]').click() - driver.find_element(By.XPATH, "//div[starts-with(text(),'Id: 1; Symbol: C;')]") + driver.find_element(By.XPATH, "//p[contains(text(),'Id: 1; Symbol: C;')]") + + +def test_structure_from_examples_and_supercell_selection( + selenium_driver, final_screenshot +): + + driver = selenium_driver("notebooks/structures.ipynb") + driver.set_window_size(1000, 900) + # Switch to "From Examples tab in StructureManagerWidget + driver.find_element(By.XPATH, "//*[text()='From Examples']").click() + + # Select SiO2 example + driver.find_element(By.XPATH, '//option[@value="Silicon oxide"]').click() + + # Expand cell view in z direction + driver.find_element(By.XPATH, "//*[text()='Appearance']").click() + driver.find_element(By.XPATH, "(//input[@type='number'])[3]").send_keys( + Keys.BACKSPACE + ) + driver.find_element(By.XPATH, "(//input[@type='number'])[3]").send_keys("2") + driver.find_element(By.XPATH, "(//input[@type='number'])[3]").send_keys(Keys.ENTER) + + # Select the 12th atom + driver.find_element(By.XPATH, "//*[text()='Selection']").click() + driver.find_element( + By.XPATH, "//label[text()='Select atoms:']/following-sibling::input" + ).send_keys("12") + time.sleep( + 1 + ) # This is needed, otherwise selenium often presses "Apply selection" button too fast. + driver.find_element(By.XPATH, '//button[text()="Apply selection"]').click() + + # Make sure the selection is what we expect + driver.find_element(By.XPATH, "//p[contains(text(), 'Selected atoms: 12')]") + driver.find_element( + By.XPATH, "//p[contains(text(), 'Selected unit cell atoms: 6')]" + ) + driver.find_element(By.XPATH, "//p[contains(text(),'Id: 12; Symbol: O;')]") def test_eln_import(selenium_driver, final_screenshot):