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..51a311939 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,93 @@ +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 + self.auto_render = 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 3ddd7f55e..ae3e704bf 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,85 +8,126 @@ 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 not hasattr(self, "widget"): - structure = self._model.get_structure() - self.widget = StructureDataViewer(structure=structure) - # Select the Cell tab by default - self.widget.configuration_box.selected_index = 2 - self.table_description = ipw.HTML(""" + 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. + ngl = self.widget._viewer + ngl._set_size("100%", "300px") + ngl.control.zoom(0.0) + return + + 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") + ipw.dlink( + (self._model, "table_data"), + (self.atom_coordinates_table, "data"), + ) + + ipw.link( + (self.widget, "displayed_selection"), + (self.atom_coordinates_table, "selected_rows"), + ) + + self.results_container.children = [ + self.header_box, + self.widget, + ipw.HTML("""

- Structure table information: Atom coordinates in Å + Structure information: Atom coordinates in Å

- You can click on a row to select an atom. Multiple atoms - can be selected by clicking on additional rows. To unselect - an atom, click on the selected row again. + You can click on a row to select an atom. Multiple atoms can be + selected by clicking on additional rows. To unselect an atom, click + on the selected row again.

- """) - self.atom_coordinates_table = TableWidget() - self._generate_table(structure.get_ase()) - - structure_info = self._get_structure_info(structure) - - self.results_container.children = [ - structure_info, - self.widget, - self.table_description, - self.atom_coordinates_table, - ] - - self.atom_coordinates_table.observe(self._change_selection, "selected_rows") - # Listen for changes in self.widget.displayed_selection and update the table - self.widget.observe(self._update_table_selection, "displayed_selection") - - # 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. - ngl = self.widget._viewer - ngl._set_size("100%", "300px") - ngl.control.zoom(0.0) - - def _get_structure_info(self, structure): - return ipw.HTML( - f""" -
- PK: {structure.pk}
- Label: {structure.label}
- Description: {structure.description}
- Number of atoms: {len(structure.sites)}
- Creation time: {format_time(structure.ctime)} ({relative_time(structure.ctime)})
-
- """ - ) - - def _generate_table(self, structure): - data = [ - [ - "Atom index", - "Chemical symbol", - "Tag", - "x (Å)", - "y (Å)", - "z (Å)", - ] + """), + self.atom_coordinates_table, ] - 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 - ): - # Format position values to two decimal places - formatted_position = [f"{coord:.2f}" for coord in position] - data.append([index, symbol, tag, *formatted_position]) - self.atom_coordinates_table.data = data - - def _change_selection(self, _): - selected_indices = self.atom_coordinates_table.selected_rows - self.widget.displayed_selection = selected_indices - - def _update_table_selection(self, change): - selected_indices = change.new - self.atom_coordinates_table.selected_rows = selected_indices + + def _on_process_change(self, _): + super()._on_process_change(_) + if self.rendered: + self.view_toggle_button.layout.display = ( + "block" if self._model.is_relaxed else "none" + ) + + def _on_selected_view_change(self, _): + self._model.update() + + def _toggle_view(self, _): + self._model.toggle_selected_view() diff --git a/src/aiidalab_qe/app/static/styles/results.css b/src/aiidalab_qe/app/static/styles/results.css index be790bce8..8ec3d0b32 100644 --- a/src/aiidalab_qe/app/static/styles/results.css +++ b/src/aiidalab_qe/app/static/styles/results.css @@ -2,10 +2,12 @@ display: flex; margin: 10px auto; } + .results-step-toggles div { display: flex; column-gap: 8px; } + .results-step-toggles i { margin-left: 4px; } diff --git a/src/aiidalab_qe/app/static/styles/structure.css b/src/aiidalab_qe/app/static/styles/structure.css new file mode 100644 index 000000000..11ce63a7d --- /dev/null +++ b/src/aiidalab_qe/app/static/styles/structure.css @@ -0,0 +1,35 @@ +/* Structure manager */ + +.structure-viewer .view-box { + justify-content: center !important; +} + +/* Structure coordinates table */ + +.atom-coordinates-table > div { + width: fit-content; + max-height: 300px; + overflow: auto; +} + +.atom-coordinates-table table { + width: 100% !important; +} + +.atom-coordinates-table table tr:first-child { + background-color: #ccc !important; +} + +.atom-coordinates-table table tr:not(:first-child):hover { + background-color: #ddd !important; +} + +.atom-coordinates-table table tr:not(:first-child):active { + background-color: #ccc !important; +} + +.atom-coordinates-table table th, +.atom-coordinates-table table td { + text-align: center !important; + padding: 0.5em 1em !important; +} 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(""" diff --git a/tests/test_result.py b/tests/test_result.py index fabd07d13..dcd926f51 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -44,7 +44,7 @@ def test_workchainview(generate_qeapp_workchain): model.process_uuid = workchain.node.uuid viewer.render() assert len(viewer.tabs.children) == 2 - assert viewer.tabs._titles["0"] == "Relaxed structure" # type: ignore + assert viewer.tabs._titles["0"] == "Structure" # type: ignore def test_summary_report(data_regression, generate_qeapp_workchain): @@ -129,15 +129,41 @@ def test_summary_view(generate_qeapp_workchain): def test_structure_results_panel(generate_qeapp_workchain): """Test the structure results panel can be properly generated.""" + model = StructureResultsModel() - _ = StructureResultsPanel(model=model) + panel = StructureResultsPanel(model=model) + + def test_table_data(model): + rows = model.table_data[1:] # skip table header + for i, row in enumerate(rows): + position = model.structure.sites[i].position + x, y, z = (f"{coordinate:.2f}" for coordinate in position) + assert row == [i + 1, "Si", 0, x, y, z] # type: ignore + + assert model.title == "Structure" wc = generate_qeapp_workchain(relax_type="none") model.process_uuid = wc.node.uuid - assert model.title == "Initial structure" - assert "properties" in model.source # source should be inputs + node = model.fetch_process_node() + assert "Initial" in model.header + assert "properties" in model.source # inputs + assert model.structure.pk == node.inputs.structure.pk + assert str(node.inputs.structure.pk) in model.info + test_table_data(model) + + panel.render() + assert panel.view_toggle_button.layout.display == "none" wc = generate_qeapp_workchain(relax_type="positions_cell") model.process_uuid = wc.node.uuid - assert model.title == "Relaxed structure" - assert "properties" not in model.source # source should be outputs + node = model.fetch_process_node() + assert "Initial" in model.header + assert panel.view_toggle_button.layout.display == "block" + assert panel.view_toggle_button.description == "View relaxed" + panel.view_toggle_button.click() + assert panel.view_toggle_button.description == "View initial" + assert "Relaxed" in model.header + assert "properties" not in model.source # outputs + assert model.structure.pk == node.outputs.structure.pk + assert str(node.outputs.structure.pk) in model.info + test_table_data(model)