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)