Skip to content

Commit 921e4cd

Browse files
committed
draft of GUI
1 parent 22e0b0e commit 921e4cd

File tree

3 files changed

+255
-1
lines changed

3 files changed

+255
-1
lines changed

src/aiidalab_qe/app/structure/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from aiidalab_qe.app.structure.model import StructureStepModel
1111
from aiidalab_qe.app.utils import get_entry_items
1212
from aiidalab_qe.common import (
13+
AddingFixedAtomsEditor,
1314
AddingTagsEditor,
1415
LazyLoadedOptimade,
1516
LazyLoadedStructureBrowser,
@@ -105,6 +106,7 @@ def _render(self):
105106
BasicCellEditor(title="Edit cell"),
106107
BasicStructureEditor(title="Edit structure"),
107108
AddingTagsEditor(title="Edit atom tags"),
109+
AddingFixedAtomsEditor(title="Edit fixed atoms"),
108110
PeriodicityEditor(title="Edit periodicity"),
109111
ShakeNBreakEditor(title="ShakeNBreak"),
110112
]

src/aiidalab_qe/common/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .node_view import CalcJobNodeViewerWidget # noqa: F401
33
from .process import QeAppWorkChainSelector, WorkChainSelector
44
from .widgets import (
5+
AddingFixedAtomsEditor,
56
AddingTagsEditor,
67
LazyLoadedOptimade,
78
LazyLoadedStructureBrowser,
@@ -10,6 +11,7 @@
1011
)
1112

1213
__all__ = [
14+
"AddingFixedAtomsEditor",
1315
"AddingTagsEditor",
1416
"LazyLoadedOptimade",
1517
"LazyLoadedStructureBrowser",

src/aiidalab_qe/common/widgets.py

Lines changed: 251 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import base64
77
import hashlib
88
import warnings
9+
import html
10+
import re
911
from copy import deepcopy
1012
from queue import Queue
1113
from tempfile import NamedTemporaryFile
@@ -626,6 +628,254 @@ def _reset_all_tags(self, _=None):
626628
self.input_selection = deepcopy(self.selection)
627629

628630

631+
class AddingFixedAtomsEditor(ipw.VBox):
632+
"""Editor for adding tags to atoms."""
633+
634+
structure = traitlets.Instance(ase.Atoms, allow_none=True)
635+
selection = traitlets.List(traitlets.Int(), allow_none=True)
636+
input_selection = traitlets.List(traitlets.Int(), allow_none=True)
637+
structure_node = traitlets.Instance(orm_Data, allow_none=True, read_only=True)
638+
639+
def __init__(self, title="", **kwargs):
640+
self.title = title
641+
642+
self._status_message = StatusHTML()
643+
self.atom_selection = ipw.Text(
644+
placeholder="e.g. 1..5 8 10",
645+
description="Index of atoms",
646+
value="",
647+
style={"description_width": "100px"},
648+
layout={"width": "initial"},
649+
)
650+
self.from_selection = ipw.Button(description="From selection")
651+
self.from_selection.on_click(self._from_selection)
652+
self.fixed = ipw.Text(
653+
description="Free axes",
654+
placeholder="e.g. 0 1 0 or (1,0,0)",
655+
value="1 1 1",
656+
layout={"width": "initial"},
657+
style={"description_width": "100px"},
658+
)
659+
660+
self.add_fixed = ipw.Button(
661+
description="Update fixed atoms",
662+
button_style="primary",
663+
layout={"width": "initial"},
664+
)
665+
666+
self.reset_fixed = ipw.Button(
667+
description="Reset fixed atoms",
668+
button_style="primary",
669+
layout={"width": "initial"},
670+
)
671+
self.reset_all_fixed = ipw.Button(
672+
description="Reset all fixed atoms",
673+
button_style="warning",
674+
layout={"width": "initial"},
675+
)
676+
self.scroll_note = ipw.HTML(
677+
value="<p style='font-style: italic;'>Note: The table is scrollable.</p>",
678+
layout={"visibility": "hidden"},
679+
)
680+
self.fixed_display = ipw.Output()
681+
self.add_fixed.on_click(self._add_fixed)
682+
self.reset_fixed.on_click(self._reset_fixed)
683+
self.reset_all_fixed.on_click(self._reset_all_fixed)
684+
self.atom_selection.observe(self._display_table, "value")
685+
self.add_fixed.on_click(self._display_table)
686+
self.reset_fixed.on_click(self._display_table)
687+
self.reset_all_fixed.on_click(self._display_table)
688+
689+
super().__init__(
690+
children=[
691+
ipw.HTML(
692+
"""
693+
<p>
694+
Fix x,y,z for selected atoms. <br>
695+
For example, 0 1 0 for a given atom means the atom can move only in y.
696+
</p>
697+
<p style="font-weight: bold; color: #1f77b4;">NOTE:</p>
698+
<ul style="padding-left: 2em; list-style-type: disc;">
699+
<li>Atom indices start from 1, not 0. This means that the first atom in the list is numbered 1, the second atom is numbered 2, and so on.</li>
700+
</ul>
701+
</p>
702+
"""
703+
),
704+
ipw.HBox(
705+
[
706+
self.atom_selection,
707+
self.from_selection,
708+
self.fixed,
709+
]
710+
),
711+
self.fixed_display,
712+
self.scroll_note,
713+
ipw.HBox([self.add_fixed, self.reset_fixed, self.reset_all_fixed]),
714+
self._status_message,
715+
],
716+
**kwargs,
717+
)
718+
def _parse_mask_text(self, text):
719+
"""Parse text like '0 1 0' or '(0,1,0)' into a validated (3,) int array in {0,1}."""
720+
nums = re.findall(r"-?\d+", text)
721+
if len(nums) != 3:
722+
raise ValueError("Provide exactly three integers (e.g. 0 1 0).")
723+
vals = np.array([int(n) for n in nums], dtype=int)
724+
if not np.all(np.isin(vals, [0, 1])):
725+
raise ValueError("Values must be 0 or 1 only.")
726+
return vals
727+
728+
def _ensure_mask_array(self, atoms):
729+
"""Ensure atoms has an Nx3 int mask array named 'fixed_atoms' (default ones)."""
730+
if atoms is None:
731+
return
732+
if 'fixed_atoms' not in atoms.arrays:
733+
atoms.set_array('fixed_atoms', np.ones((len(atoms), 3), dtype=int))
734+
735+
def _parse_mask_text(self, text):
736+
"""Parse text like '0 1 0' or '(0,1,0)' into a validated (3,) int array in {0,1}."""
737+
nums = re.findall(r"-?\d+", text)
738+
if len(nums) != 3:
739+
raise ValueError("Provide exactly three integers (e.g. 0 1 0).")
740+
vals = np.array([int(n) for n in nums], dtype=int)
741+
if not np.all(np.isin(vals, [0, 1])):
742+
raise ValueError("Values must be 0 or 1 only.")
743+
return vals
744+
745+
def _display_table(self, _=None):
746+
"""Show table with Index, Element, and current (x y z) free-mask for selected atoms."""
747+
if self.structure is None:
748+
return
749+
self._ensure_mask_array(self.structure)
750+
751+
selection = string_range_to_list(self.atom_selection.value)[0]
752+
selection = [s for s in selection if 0 <= s < len(self.structure)]
753+
chemichal_symbols = self.structure.get_chemical_symbols()
754+
current_mask = self.structure.get_array('fixed_atoms')
755+
756+
if selection and (min(selection) >= 0):
757+
table_data = []
758+
for index in selection:
759+
symbol = chemichal_symbols[index]
760+
mask = current_mask[index]
761+
mask_str = f"{int(mask[0])} {int(mask[1])} {int(mask[2])}"
762+
table_data.append([f"{index + 1}", f"{symbol}", mask_str])
763+
764+
table_html = "<table>"
765+
table_html += "<tr><th>Index</th><th>Element</th><th>Free (x y z)</th></tr>"
766+
for row in table_data:
767+
table_html += "<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>"
768+
table_html += "</table>"
769+
770+
self.fixed_display.layout = {"overflow": "auto", "height": "120px", "width": "240px"}
771+
with self.fixed_display:
772+
clear_output()
773+
display(HTML(table_html))
774+
self.scroll_note.layout = {"visibility": "visible"}
775+
else:
776+
self.fixed_display.layout = {}
777+
with self.fixed_display:
778+
clear_output()
779+
self.scroll_note.layout = {"visibility": "hidden"}
780+
781+
782+
def _from_selection(self, _=None):
783+
"""Set the atom selection from the current selection."""
784+
self.atom_selection.value = list_to_string_range(self.selection)
785+
786+
def _add_fixed(self, _=None):
787+
"""Apply parsed free-axes mask to the selected atoms."""
788+
if not self.atom_selection.value:
789+
self._status_message.message = """
790+
<div class="alert alert-info"><strong>Please select atoms first.</strong></div>
791+
"""
792+
return
793+
794+
try:
795+
new_mask_row = self._parse_mask_text(self.fixed.value) # (3,)
796+
except Exception as exc:
797+
self._status_message.message = f"""
798+
<div class="alert alert-danger"><strong>Invalid mask:</strong> {html.escape(str(exc))}</div>
799+
"""
800+
return
801+
802+
selection = string_range_to_list(self.atom_selection.value)[0]
803+
selection = [s for s in selection if 0 <= s < len(self.structure)]
804+
805+
new_structure = deepcopy(self.structure)
806+
self._ensure_mask_array(new_structure)
807+
808+
mask = new_structure.get_array('fixed_atoms').copy() # (N,3)
809+
if len(selection) == 0:
810+
self._status_message.message = """
811+
<div class="alert alert-warning"><strong>No valid atom indices selected.</strong></div>
812+
"""
813+
return
814+
815+
mask[selection, :] = new_mask_row # broadcast
816+
new_structure.set_array('fixed_atoms', mask)
817+
818+
# trigger traitlet updates
819+
self.structure = None
820+
self.structure = deepcopy(new_structure)
821+
self.input_selection = None
822+
self.input_selection = deepcopy(self.selection)
823+
824+
self._status_message.message = """
825+
<div class="alert alert-success"><strong>Updated movement mask for selected atoms.</strong></div>
826+
"""
827+
828+
def _reset_fixed(self, _=None):
829+
"""Reset selected atoms back to (1,1,1) free movement."""
830+
if not self.atom_selection.value:
831+
self._status_message.message = """
832+
<div class="alert alert-info"><strong>Please select atoms first.</strong></div>
833+
"""
834+
return
835+
836+
selection = string_range_to_list(self.atom_selection.value)[0]
837+
selection = [s for s in selection if 0 <= s < len(self.structure)]
838+
839+
new_structure = deepcopy(self.structure)
840+
self._ensure_mask_array(new_structure)
841+
842+
mask = new_structure.get_array('fixed_atoms').copy()
843+
if len(selection) == 0:
844+
self._status_message.message = """
845+
<div class="alert alert-warning"><strong>No valid atom indices selected.</strong></div>
846+
"""
847+
return
848+
849+
mask[selection, :] = np.array([1, 1, 1], dtype=int)
850+
new_structure.set_array('fixed_atoms', mask)
851+
852+
self.structure = None
853+
self.structure = deepcopy(new_structure)
854+
self.input_selection = None
855+
self.input_selection = deepcopy(self.selection)
856+
857+
self._status_message.message = """
858+
<div class="alert alert-success"><strong>Selected atoms reset to (1,1,1).</strong></div>
859+
"""
860+
861+
def _reset_all_fixed(self, _=None):
862+
"""Reset all atoms to (1,1,1) free movement."""
863+
new_structure = deepcopy(self.structure)
864+
self._ensure_mask_array(new_structure)
865+
866+
mask = np.ones((len(new_structure), 3), dtype=int)
867+
new_structure.set_array('fixed_atoms', mask)
868+
869+
self.structure = None
870+
self.structure = deepcopy(new_structure)
871+
self.input_selection = None
872+
self.input_selection = deepcopy(self.selection)
873+
874+
self._status_message.message = """
875+
<div class="alert alert-success"><strong>All atoms reset to (1,1,1).</strong></div>
876+
"""
877+
878+
629879
class PeriodicityEditor(ipw.VBox):
630880
"""Editor for changing periodicity of structures."""
631881

@@ -696,7 +946,7 @@ def __init__(self, **kwargs):
696946
description=kwargs.pop("description", None),
697947
default_calc_job_plugin=kwargs.pop("default_calc_job_plugin", None),
698948
include_setup_widget=False,
699-
fetch_codes=True, # TODO resolve testing issues when set to `False`
949+
fetch_codes=True,
700950
**kwargs,
701951
)
702952
self.code_selection.layout.width = "80%"

0 commit comments

Comments
 (0)