diff --git a/src/aiidalab_qe/app/result/components/viewer/structure/model.py b/src/aiidalab_qe/app/result/components/viewer/structure/model.py index eb9526357..2e5e83fe0 100644 --- a/src/aiidalab_qe/app/result/components/viewer/structure/model.py +++ b/src/aiidalab_qe/app/result/components/viewer/structure/model.py @@ -1,27 +1,92 @@ +from __future__ import annotations + +import traitlets as tl + +from aiida import orm from aiidalab_qe.common.panel import ResultsModel +from aiidalab_qe.common.time import format_time, relative_time class StructureResultsModel(ResultsModel): title = "Structure" identifier = "structure" + structure = tl.Instance(orm.StructureData, allow_none=True) + selected_view = tl.Unicode("initial") + header = tl.Unicode() + source = tl.Instance(orm.utils.managers.NodeLinksManager, allow_none=True) + info = tl.Unicode() + table_data = tl.List(tl.List()) + _this_process_label = "PwRelaxWorkChain" - source = None + header_template = "

{title}

" @property def include(self): return True + @property + def is_relaxed(self): + return "relax" in self.properties + def update(self): - is_relaxed = "relax" in self.properties - self.title = "Relaxed structure" if is_relaxed else "Initial structure" - self.source = self.outputs if is_relaxed else self.inputs - self.auto_render = not is_relaxed or self.has_results + with self.hold_trait_notifications(): + if not self.is_relaxed or self.selected_view == "initial": + self.header = self.header_template.format(title="Initial") + self.source = self.inputs + else: + self.header = self.header_template.format(title="Relaxed") + self.source = self.outputs + self.structure = self._get_structure() + if self.structure: + self.info = self._get_structure_info() + self.table_data = self._get_atom_table_data() - def get_structure(self): + def toggle_selected_view(self): + self.selected_view = "relaxed" if self.selected_view == "initial" else "initial" + + def _get_structure(self) -> orm.StructureData | None: try: return self.source.structure if self.source else None except AttributeError: # If source is outputs but job failed, there may not be a structure return None + + def _get_structure_info(self): + structure = self.structure + formatted = format_time(structure.ctime) + relative = relative_time(structure.ctime) + return f""" +
+ PK: {structure.pk}
+ Label: {structure.label}
+ Description: {structure.description}
+ Number of atoms: {len(structure.sites)}
+ Creation time: {formatted} ({relative})
+
+ """ + + def _get_atom_table_data(self): + structure = self.structure.get_ase() + data = [ + [ + "Atom index", + "Chemical symbol", + "Tag", + "x (Å)", + "y (Å)", + "z (Å)", + ] + ] + positions = structure.positions + chemical_symbols = structure.get_chemical_symbols() + tags = structure.get_tags() + + for index, (symbol, tag, position) in enumerate( + zip(chemical_symbols, tags, positions), start=1 + ): + formatted_position = [f"{coord:.2f}" for coord in position] + data.append([index, symbol, tag, *formatted_position]) + + return data diff --git a/src/aiidalab_qe/app/result/components/viewer/structure/structure.py b/src/aiidalab_qe/app/result/components/viewer/structure/structure.py index cf6fe9278..c8ba112b3 100644 --- a/src/aiidalab_qe/app/result/components/viewer/structure/structure.py +++ b/src/aiidalab_qe/app/result/components/viewer/structure/structure.py @@ -1,7 +1,6 @@ import ipywidgets as ipw from aiidalab_qe.common.panel import ResultsPanel -from aiidalab_qe.common.time import format_time, relative_time from aiidalab_qe.common.widgets import TableWidget from aiidalab_widgets_base.viewers import StructureDataViewer @@ -9,27 +8,95 @@ class StructureResultsPanel(ResultsPanel[StructureResultsModel]): + def __init__(self, model: StructureResultsModel, **kwargs): + super().__init__(model, **kwargs) + self._model.observe( + self._on_selected_view_change, + "selected_view", + ) + def _render(self): if hasattr(self, "widget"): # HACK to resize the NGL viewer in cases where it auto-rendered when its - # container was not displayed, which leads to a null width. This hack restores - # the original dimensions. + # container was not displayed, which leads to a null width. This hack + # restores the original dimensions. ngl = self.widget._viewer ngl._set_size("100%", "300px") ngl.control.zoom(0.0) return - structure = self._model.get_structure() - self.widget = StructureDataViewer(structure=structure) + self.header = ipw.HTML() + ipw.dlink( + (self._model, "header"), + (self.header, "value"), + ) + + self.view_toggle_button = ipw.Button( + icon="eye", + layout=ipw.Layout( + display="block" if self._model.is_relaxed else "none", + width="125px", + ), + ) + ipw.dlink( + (self._model, "selected_view"), + (self.view_toggle_button, "description"), + lambda view: f"View {'initial' if view == 'relaxed' else 'relaxed'}", + ) + ipw.dlink( + (self._model, "monitor_counter"), + (self.view_toggle_button, "disabled"), + lambda _: not self._model.has_results, + ) + ipw.dlink( + (self.view_toggle_button, "disabled"), + (self.view_toggle_button, "tooltip"), + lambda disabled: "Waiting for results" + if disabled + else "Toggle between the initial and relaxed structures", + ) + + self.view_toggle_button.on_click(self._toggle_view) + + self.structure_info = ipw.HTML(layout=ipw.Layout(margin="0")) + ipw.dlink( + (self._model, "info"), + (self.structure_info, "value"), + ) + + self.header_box = ipw.HBox( + children=[ + ipw.VBox( + children=[ + self.header, + self.view_toggle_button, + ], + layout=ipw.Layout(justify_content="space-between"), + ), + ipw.VBox( + children=[ + self.structure_info, + ], + layout=ipw.Layout(justify_content="flex-end"), + ), + ], + layout=ipw.Layout(grid_gap="1em"), + ) + + self.widget = StructureDataViewer() + ipw.dlink( + (self._model, "structure"), + (self.widget, "structure"), + ) self.widget.configuration_box.selected_index = 2 # select the Cell tab self.atom_coordinates_table = TableWidget() self.atom_coordinates_table.add_class("atom-coordinates-table") - - self._generate_table(structure.get_ase()) - - structure_info = self._get_structure_info(structure) + ipw.dlink( + (self._model, "table_data"), + (self.atom_coordinates_table, "data"), + ) ipw.link( (self.widget, "displayed_selection"), @@ -37,7 +104,7 @@ def _render(self): ) self.results_container.children = [ - structure_info, + self.header_box, self.widget, ipw.HTML("""

@@ -52,40 +119,8 @@ def _render(self): self.atom_coordinates_table, ] - def _get_structure_info(self, structure): - formatted = format_time(structure.ctime) - relative = relative_time(structure.ctime) - return ipw.HTML( - f""" -
- PK: {structure.pk}
- Label: {structure.label}
- Description: {structure.description}
- Number of atoms: {len(structure.sites)}
- Creation time: {formatted} ({relative})
-
- """ - ) - - def _generate_table(self, structure): - data = [ - [ - "Atom index", - "Chemical symbol", - "Tag", - "x (Å)", - "y (Å)", - "z (Å)", - ] - ] - positions = structure.positions - chemical_symbols = structure.get_chemical_symbols() - tags = structure.get_tags() - - for index, (symbol, tag, position) in enumerate( - zip(chemical_symbols, tags, positions), start=1 - ): - formatted_position = [f"{coord:.2f}" for coord in position] - data.append([index, symbol, tag, *formatted_position]) + def _on_selected_view_change(self, _): + self._model.update() - self.atom_coordinates_table.data = data + def _toggle_view(self, _): + self._model.toggle_selected_view() diff --git a/src/aiidalab_qe/common/panel.py b/src/aiidalab_qe/common/panel.py index 561e2b2de..effde0a78 100644 --- a/src/aiidalab_qe/common/panel.py +++ b/src/aiidalab_qe/common/panel.py @@ -627,6 +627,8 @@ def render(self): else: self._render_controls() self.children += (self.results_container,) + if self._model.identifier == "structure": + self._load_results() def _on_process_change(self, _): self._model.update() @@ -666,7 +668,7 @@ def _render_controls(self): self.load_controls = ipw.HBox( children=[] - if self._model.auto_render + if self._model.auto_render or self._model.identifier == "structure" else [ self.load_results_button, ipw.HTML("""