Repository: ISISNeutronMuon/MDANSE
Pull request #756: Molecule preview using rdkit
Author: login: MBartkowiakSTFC
Created: 2025-04-24T11:04:41Z | Merged: 2025-05-01T15:37:45Z

**Description of work**
An alternative version of PR #620, using rdkit for drawing molecules instead of Qt3D.

PR #620 works on most platforms, but has been found to cause crashes on modern MacOS combined with older Python interpreters. This seems to be the problem with the external libraries or Python interpreter itself, since for Python 3.13 the problem disappears.

In the future we should be able to use Qt3D, but for now this PR is meant to offer a working solution.

**Fixes**
1. Adds a molecule preview dialog,
2. Enables fast plotting of results based on in-file tags ("main", "partial").

**To test**
1. Load two trajectories, with and without molecules. Pick AngularCorrelation analysis and try to visualise different molecules. Check if the molecule view button becomes inactive if no molecules are present.
3. Load any recent analysis results, preferably one with partial results in 1D (e.g. Density of States). In the plot creator tab, double-click the file name. A plot should be created instantly with the breakdown of the results.



Diff:
diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/CoordinationNumber.py b/MDANSE/Src/MDANSE/Framework/Jobs/CoordinationNumber.py
index a4a89c9b73..bcd10440b3 100644
--- a/MDANSE/Src/MDANSE/Framework/Jobs/CoordinationNumber.py
+++ b/MDANSE/Src/MDANSE/Framework/Jobs/CoordinationNumber.py
@@ -112,6 +112,7 @@ def finalize(self):
                 (npoints,),
                 axis="r",
                 units="au",
+                main_result=True,
             )
             self._outputData.add(
                 f"cn_intra_{inv_pair_str}",
@@ -133,6 +134,7 @@ def finalize(self):
                 (npoints,),
                 axis="r",
                 units="au",
+                main_result=True,
             )
 
         nFrames = self.configuration["frames"]["number"]
diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/PairDistributionFunction.py b/MDANSE/Src/MDANSE/Framework/Jobs/PairDistributionFunction.py
index 0e51cd7ded..7f27133b92 100644
--- a/MDANSE/Src/MDANSE/Framework/Jobs/PairDistributionFunction.py
+++ b/MDANSE/Src/MDANSE/Framework/Jobs/PairDistributionFunction.py
@@ -82,6 +82,8 @@ def finalize(self):
                     (npoints,),
                     axis="r",
                     units="au",
+                    main_result=i == "pdf",
+                    partial_result=i == "pdf",
                 )
 
         for i in ["pdf", "rdf", "tcf"]:
@@ -100,7 +102,12 @@ def finalize(self):
                 units="au",
             )
             self._outputData.add(
-                f"{i}_total", "LineOutputVariable", (npoints,), axis="r", units="au"
+                f"{i}_total",
+                "LineOutputVariable",
+                (npoints,),
+                axis="r",
+                units="au",
+                main_result=i == "pdf",
             )
 
         nFrames = self.configuration["frames"]["number"]
diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/RootMeanSquareDeviation.py b/MDANSE/Src/MDANSE/Framework/Jobs/RootMeanSquareDeviation.py
index 40dad188a5..a4a6b5f141 100644
--- a/MDANSE/Src/MDANSE/Framework/Jobs/RootMeanSquareDeviation.py
+++ b/MDANSE/Src/MDANSE/Framework/Jobs/RootMeanSquareDeviation.py
@@ -156,17 +156,17 @@ def finalize(self):
 
         nAtomsPerElement = self.configuration["atom_selection"].get_natoms()
         for element, number in nAtomsPerElement.items():
-            self._outputData[f"rmsd_{element}"] /= number
+            self._outputData[f"rmsd_{element}"][:] /= number
 
         for element, number in nAtomsPerElement.items():
-            self._outputData[f"rmsd_{element}"] = np.sqrt(
+            self._outputData[f"rmsd_{element}"][:] = np.sqrt(
                 self._outputData[f"rmsd_{element}"]
             )
 
-        self._outputData["rmsd_all"] /= self.configuration[
+        self._outputData["rmsd_all"][:] /= self.configuration[
             "atom_selection"
         ].get_total_natoms()
-        self._outputData["rmsd_all"] = np.sqrt(self._outputData["rmsd_all"])
+        self._outputData["rmsd_all"][:] = np.sqrt(self._outputData["rmsd_all"])
 
         self._outputData.write(
             self.configuration["output_files"]["root"],
diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/StaticStructureFactor.py b/MDANSE/Src/MDANSE/Framework/Jobs/StaticStructureFactor.py
index adf0c9d64a..9fb94054b9 100644
--- a/MDANSE/Src/MDANSE/Framework/Jobs/StaticStructureFactor.py
+++ b/MDANSE/Src/MDANSE/Framework/Jobs/StaticStructureFactor.py
@@ -163,6 +163,8 @@ def finalize(self):
                 (nq,),
                 axis="q",
                 units="au",
+                main_result=True,
+                partial_result=True,
             )
 
             ni = nAtomsPerElement[pair[0]]
@@ -205,7 +207,12 @@ def finalize(self):
             units="au",
         )
         self._outputData.add(
-            "ssf_total", "LineOutputVariable", (nq,), axis="q", units="au"
+            "ssf_total",
+            "LineOutputVariable",
+            (nq,),
+            axis="q",
+            units="au",
+            main_result=True,
         )
 
         weights = self.configuration["weights"].get_weights()
diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionDistinct.py b/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionDistinct.py
index dca91731ad..f2334db83d 100644
--- a/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionDistinct.py
+++ b/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionDistinct.py
@@ -312,6 +312,7 @@ def initialize(self):
             (self.n_mid_points, self.numberOfSteps),
             axis="r|time",
             units="au",
+            main_result=True,
         )
         for x, y in self._elementsPairs:
             self._outputData.add(
diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionSelf.py b/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionSelf.py
index 2713a1d407..5840e76e9f 100644
--- a/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionSelf.py
+++ b/MDANSE/Src/MDANSE/Framework/Jobs/VanHoveFunctionSelf.py
@@ -172,6 +172,7 @@ def initialize(self):
             (self.n_mid_points, self.n_frames),
             axis="r|time",
             units="au",
+            main_result=True,
         )
         self._outputData.add(
             "4_pi_r2_g(r,t)_total",
diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/XRayStaticStructureFactor.py b/MDANSE/Src/MDANSE/Framework/Jobs/XRayStaticStructureFactor.py
index 4252c04285..9fc945c61b 100644
--- a/MDANSE/Src/MDANSE/Framework/Jobs/XRayStaticStructureFactor.py
+++ b/MDANSE/Src/MDANSE/Framework/Jobs/XRayStaticStructureFactor.py
@@ -156,6 +156,8 @@ def finalize(self):
                 (nq,),
                 axis="q",
                 units="au",
+                main_result=True,
+                partial_result=True,
             )
 
             ni = nAtomsPerElement[pair[0]]
@@ -188,13 +190,26 @@ def finalize(self):
             )
 
         self._outputData.add(
-            "xssf_intra", "LineOutputVariable", (nq,), axis="q", units="au"
+            "xssf_intra",
+            "LineOutputVariable",
+            (nq,),
+            axis="q",
+            units="au",
         )
         self._outputData.add(
-            "xssf_inter", "LineOutputVariable", (nq,), axis="q", units="au"
+            "xssf_inter",
+            "LineOutputVariable",
+            (nq,),
+            axis="q",
+            units="au",
         )
         self._outputData.add(
-            "xssf_total", "LineOutputVariable", (nq,), axis="q", units="au"
+            "xssf_total",
+            "LineOutputVariable",
+            (nq,),
+            axis="q",
+            units="au",
+            main_result=True,
         )
 
         asf = dict(
diff --git a/MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculePreviewWidget.py b/MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculePreviewWidget.py
new file mode 100644
index 0000000000..2675d78d54
--- /dev/null
+++ b/MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculePreviewWidget.py
@@ -0,0 +1,141 @@
+#    This file is part of MDANSE_GUI.
+#
+#    MDANSE_GUI is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+#
+
+from typing import Any, Union
+
+import rdkit.Chem as chem
+import rdkit.Chem.AllChem as allchem
+import rdkit.Chem.Draw as draw
+from MDANSE.MolecularDynamics.Trajectory import Trajectory
+from PIL.ImageQt import ImageQt
+from qtpy.QtCore import QObject
+from qtpy.QtGui import QFont, QImage, QPixmap
+from qtpy.QtWidgets import QDialog, QLabel, QVBoxLayout
+
+
+class MoleculePreviewWidget(QDialog):
+    """Shows the structure of a single molecule."""
+
+    def __init__(
+        self,
+        parent: Union[QObject, None],
+        molecule_information: dict[str, Any],
+        molecule_name: str,
+        atom_database: Trajectory,
+        max_atom_limit: int = 300,
+    ):
+        """Create the dialog and set up the widgets.
+
+        Parameters
+        ----------
+        parent : Union[QObject, None]
+            parent in the Qt object hierarchy, optional
+        molecule_information : dict[str, Any]
+            dictionary of collected molecule properies
+        molecule_name : str
+            molecule name as used in MDANSE
+        atom_database : Trajectory
+            trajectory instance, for looking up atom properties
+        max_atom_limit : int,
+            molecules with more atoms than this will not be drawn
+
+        """
+        super().__init__(parent)
+        self.setWindowTitle("Molecule Preview")
+        self.resize(800, 600)
+        self.max_atom_limit = max_atom_limit
+        self.axes = []
+        self.bond_mapping = {}
+        layout = QVBoxLayout(self)
+        self.setLayout(layout)
+        self.mol_name = molecule_name
+        self.mol_info = molecule_information
+        self.atom_database = atom_database
+        self.image_label = QLabel(self)
+        self.text_label = QLabel(self)
+        font = QFont("Arial", 12)
+        self.text_label.setFont(font)
+        self.text_label.setWordWrap(True)
+        layout.addWidget(self.image_label)
+        layout.addWidget(self.text_label)
+        self.update_text_label()
+        self.show_formula()
+
+    def update_text_label(self):
+        """Show the molecule information as text."""
+        info_text = f"Molecule name: {self.mol_name}\n"
+        for key, value in self.mol_info["atom_number"].items():
+            info_text += f"Number of {key} atoms: {value}\n"
+        info_text += f"Number of such molecules in trajectory: {self.mol_info['no_of_molecules']}\n"
+        self.text_label.setText(info_text)
+
+    def prepare_rdkit_molecule(self) -> Union[chem.RWMol, None]:
+        """Create an rdkit molecule from the selected atoms."""
+        if len(self.mol_info["atom_indices"]) > self.max_atom_limit:
+            self.image_label.clear()
+            self.image_label.setText("Molecule is too large for preview")
+            return None
+        self.image_label.setText("")
+        large_molecule = self.atom_database.chemical_system.rdkit_mol
+        submolecule = chem.RWMol()
+        mapping = {}
+        self.bond_mapping = {}
+        for index in self.mol_info["atom_indices"]:
+            new_index = submolecule.AddAtom(large_molecule.GetAtomWithIdx(index))
+            mapping[index] = new_index
+        for bond in self.mol_info["bond_list"]:
+            new_pair = mapping[bond[0]], mapping[bond[1]]
+            bond_index = submolecule.AddBond(new_pair[0], new_pair[1])
+            self.bond_mapping[bond_index] = new_pair
+        allchem.Compute2DCoords(submolecule)
+        return submolecule
+
+    def show_formula(self):
+        """Draw the molecule."""
+        submolecule = self.prepare_rdkit_molecule()
+        draw_options = draw.MolDrawOptions()
+        draw_options.addAtomIndices = True
+        pil_image = draw.MolToImage(submolecule, size=(600, 600), options=draw_options)
+        qt_image = ImageQt(pil_image).copy()
+        pixmap = QPixmap.fromImage(qt_image)
+        self.image_label.setPixmap(pixmap)
+
+    def filter_bonds(self, selected_atoms: list[int]) -> list[int]:
+        """Find and return only the bonds that appear in the molecule."""
+        return [
+            key
+            for key, pair in self.bond_mapping.items()
+            if pair[0] in selected_atoms and pair[1] in selected_atoms
+        ]
+
+    def show_formula_with_selection(self, selected_atoms: list[int]):
+        """Draw the formula and highlight the selected atoms and bonds."""
+        submolecule = self.prepare_rdkit_molecule()
+        optional_bonds = self.filter_bonds(selected_atoms)
+        driver = draw.rdMolDraw2D.MolDraw2DCairo(600, 600)
+        draw_options = draw.MolDrawOptions()
+        draw_options.addAtomIndices = True
+        driver.SetDrawOptions(draw_options)
+        draw.rdMolDraw2D.PrepareAndDrawMolecule(
+            driver,
+            submolecule,
+            highlightAtoms=selected_atoms,
+            highlightBonds=optional_bonds,
+        )
+        png_data = driver.GetDrawingText()
+        image = QImage.fromData(png_data).copy()
+        pixmap = QPixmap.fromImage(image)
+        self.image_label.setPixmap(pixmap)
diff --git a/MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculeWidget.py b/MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculeWidget.py
index 8d5a691eb1..e9ded1e54c 100644
--- a/MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculeWidget.py
+++ b/MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculeWidget.py
@@ -13,16 +13,25 @@
 #    You should have received a copy of the GNU General Public License
 #    along with this program.  If not, see <https://www.gnu.org/licenses/>.
 #
-from qtpy.QtWidgets import QComboBox
 
+from typing import Union
+
+import numpy as np
+from qtpy.QtCore import Slot
+from qtpy.QtWidgets import QComboBox, QPushButton
+
+from MDANSE_GUI.InputWidgets.MoleculePreviewWidget import MoleculePreviewWidget
 from MDANSE_GUI.InputWidgets.WidgetBase import WidgetBase
 
 
 class MoleculeWidget(WidgetBase):
+    """MDANSE input widget for selecting a molecule type in a trajectory."""
+
     def __init__(self, *args, **kwargs):
+        """Populate the combo box with valid molecule names."""
         super().__init__(*args, **kwargs)
-        configurator = kwargs.get("configurator", None)
-        trajectory_configurator = kwargs.get("trajectory_configurator", None)
+        configurator = kwargs.get("configurator")
+        trajectory_configurator = kwargs.get("trajectory_configurator")
         default_option = ""
         if trajectory_configurator is not None:
             option_list = trajectory_configurator[
@@ -30,42 +39,124 @@ def __init__(self, *args, **kwargs):
             ].chemical_system.unique_molecules()
             if len(option_list) > 0:
                 default_option = option_list[0]
+        elif configurator is None:
+            option_list = kwargs.get("choices", [])
         else:
-            if configurator is None:
-                option_list = kwargs.get("choices", [])
-            else:
-                option_list = configurator.choices
-                default_option = configurator.default
-        field = QComboBox(self._base)
-        field.addItems(option_list)
-        field.setCurrentText(default_option)
-        field.currentTextChanged.connect(self.updateValue)
+            option_list = configurator.choices
+            default_option = configurator.default
+        traj_config = self._configurator._configurable[
+            self._configurator._dependencies["trajectory"]
+        ]
+        hdf_traj = traj_config["hdf_trajectory"]
+        unique_molecules = hdf_traj.chemical_system.unique_molecules()
+        traj_bond_list = hdf_traj.chemical_system._bonds
+        self.atom_database = hdf_traj.trajectory
+        self.mol_dict = {}
+        for mol_name in unique_molecules:
+            no_of_molecules = len(hdf_traj.chemical_system._clusters[mol_name])
+            atom_indices = hdf_traj.chemical_system._clusters[mol_name][0]
+            atom_symbols = [
+                hdf_traj.chemical_system.atom_list[index] for index in atom_indices
+            ]
+            unique_atoms, atom_counts = np.unique(atom_symbols, return_counts=True)
+            atom_counts = {
+                unique_atoms[n]: atom_counts[n] for n in range(len(unique_atoms))
+            }
+            bonds = [
+                (bond[0], bond[1])
+                for bond in traj_bond_list
+                if bond[0] in atom_indices and bond[1] in atom_indices
+            ]
+            self.mol_dict[mol_name] = {
+                "no_of_molecules": no_of_molecules,
+                "atom_number": atom_counts,
+                "atom_indices": atom_indices,
+                "atom_symbols": atom_symbols,
+                "bond_list": bonds,
+            }
+
+        self.field = QComboBox(self._base)
+        self.field.addItems(option_list)
+        self.field.setCurrentText(default_option)
+        self.selected_name = self.field.currentText()
+        self.selected_mol = self.mol_dict.get(self.selected_name, None)
+        self.field.currentTextChanged.connect(self.updateValue)
+        self.field.currentTextChanged.connect(self.molecule_changed)
+        button = QPushButton(self._base)
+        button.setText("Molecule Preview")
+        button.clicked.connect(self.button_clicked)
         if self._tooltip:
             tooltip_text = self._tooltip
         else:
             tooltip_text = (
                 "A single option can be picked out of all the options listed."
             )
-        field.setToolTip(tooltip_text)
-        self._field = field
-        self._layout.addWidget(field)
+        self.view_button = button
+        self.field.setToolTip(tooltip_text)
+        self._field = self.field
+        self._layout.addWidget(self.field)
+        self._layout.addWidget(button)
         self._configurator = configurator
+        self.valid_changed.connect(self.toggle_button)
         self.default_labels()
         self.update_labels()
         self.updateValue()
 
+    @Slot()
+    def molecule_changed(self):
+        """Change molecule preview and molecule information."""
+        self.selected_name = self.field.currentText()
+        try:
+            self.selected_mol = self.mol_dict[self.selected_name]
+        except KeyError:
+            self.selected_mol = None
+        else:
+            self.window = MoleculePreviewWidget(
+                self._base,
+                self.selected_mol,
+                self.selected_name,
+                self.atom_database,
+            )
+
+    @Slot()
+    def button_clicked(self):
+        """Open a window that shows a preview of selected molecule."""
+        if self.selected_mol is None:
+            return
+        self.window = MoleculePreviewWidget(
+            self._base,
+            self.selected_mol,
+            self.selected_name,
+            self.atom_database,
+        )
+        if self.window.isVisible():
+            self.window.close()
+        else:
+            self.window.show()
+
     def configure_using_default(self):
-        """This is too complex to have a default value"""
+        """Do nothing in the case of this widget."""
 
     def default_labels(self):
-        """Each Widget should have a default tooltip and label,
+        """Set labels to default values.
+
+        Each Widget should have a default tooltip and label,
         which will be set in this method, unless specific
         values are provided in the settings of the job that
-        is being configured."""
+        is being configured.
+        """
         if self._label_text == "":
             self._label_text = "ComboWidget"
         if self._tooltip == "":
             self._tooltip = "You only have one option. Choose wisely."
 
-    def get_widget_value(self):
-        return self._field.currentText()
+    def toggle_button(self):
+        """Enable the button only if molecules are present."""
+        self.view_button.setEnabled(self.field.count())
+
+    def get_widget_value(self) -> Union[str, None]:
+        """Return the currently selected molecule name."""
+        mol_key = self._field.currentText()
+        if mol_key in self.mol_dict:
+            return mol_key
+        return None
diff --git a/MDANSE_GUI/Src/MDANSE_GUI/TabbedWindow.py b/MDANSE_GUI/Src/MDANSE_GUI/TabbedWindow.py
index b13ee00b88..425882bc40 100644
--- a/MDANSE_GUI/Src/MDANSE_GUI/TabbedWindow.py
+++ b/MDANSE_GUI/Src/MDANSE_GUI/TabbedWindow.py
@@ -47,6 +47,7 @@
 from MDANSE_GUI.Tabs.PlotSelectionTab import PlotSelectionTab
 from MDANSE_GUI.Tabs.PlotTab import PlotTab
 from MDANSE_GUI.Tabs.InstrumentTab import InstrumentTab
+from MDANSE_GUI.Tabs.Views.PlotDataView import PlotDataView
 from MDANSE_GUI.Widgets.StyleDialog import StyleDialog, StyleDatabase
 from MDANSE_GUI.Widgets.NotificationTabWidget import NotificationTabWidget
 
@@ -109,10 +110,10 @@ def __init__(
         self._tabs["Plot Creator"]._visualiser.create_new_text.connect(
             self._tabs["Plot Holder"]._visualiser.new_text
         )
-
         self._tabs["Instruments"]._visualiser.instrument_details_changed.connect(
             self._tabs["Actions"].update_action_after_instrument_change
         )
+
         self.tabs.currentChanged.connect(self.tabs.reset_current_color)
 
     def createCommonModels(self):
@@ -326,6 +327,11 @@ def createPlotSelection(self):
         self._tabs[name] = plot_tab
         self._job_holder.results_for_loading.connect(plot_tab.load_results)
         self._job_holder.results_for_loading.connect(plot_tab.tab_notification)
+        plot_tab._view.fast_plotting_data.connect(self.accept_external_data)
+
+    def accept_external_data(self, model):
+        self._tabs["Plot Creator"]._visualiser.new_plot()
+        self._tabs["Plot Holder"].accept_external_data(model)
 
     def createPlotHolder(self):
         name = "Plot Holder"
diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py
index 937165f34d..4ccea7aee0 100644
--- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py
+++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py
@@ -28,6 +28,7 @@
 from MDANSE_GUI.Tabs.Visualisers.TextInfo import TextInfo
 from MDANSE_GUI.Tabs.Models.JobTree import JobTree
 from MDANSE_GUI.Tabs.Views.ActionsTree import ActionsTree
+from MDANSE_GUI.InputWidgets.MoleculeWidget import MoleculeWidget
 
 
 job_tab_label = """This is the list of jobs
diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/PlottingContext.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/PlottingContext.py
index 60efe0ba65..249d8fa2a4 100644
--- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/PlottingContext.py
+++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/PlottingContext.py
@@ -57,12 +57,13 @@ def get_mpl_colours():
 class SingleDataset:
     """Manages a plottable data set from an .mda file."""
 
-    def __init__(self, name: str, source: "h5py.File"):
+    def __init__(self, name: str, source: "h5py.File", linestyle: str = "-"):
         self._name = name
         self._filename = source.filename
         self._use_scaling = True
         self._curves = {}
         self._curve_labels = {}
+        self._linestyle = linestyle
         self._planes = {}
         self._plane_labels = {}
         self._data_limits = None
@@ -88,7 +89,10 @@ def __init__(self, name: str, source: "h5py.File"):
         self._axes_tag = source[name].attrs["axis"]
         self._scaling_factor = 1.0
         with contextlib.suppress(KeyError):
-            self._scaling_factor = float(source[name].attrs["scaling_factor"])
+            try:
+                self._scaling_factor = float(source[name].attrs["scaling_factor"])
+            except TypeError:
+                self._scaling_factor = np.array(source[name].attrs["scaling_factor"])
         self._axes = {}
         self._axes_units = {}
         self._current_units = {}
@@ -605,7 +609,7 @@ def add_dataset(self, new_dataset: SingleDataset):
                 new_dataset.longest_axis()[-1],
                 "",
                 self.next_colour(),
-                "-",
+                new_dataset._linestyle,
                 "",
                 "",
             ]
diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/PlotSelectionTab.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/PlotSelectionTab.py
index ab0d60d6ad..a4e40bc3af 100644
--- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/PlotSelectionTab.py
+++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/PlotSelectionTab.py
@@ -33,6 +33,7 @@
 data sets. Load the files and assign the data sets
 to a plot. The plotting interface will appear
 in a new tab of the interface.
+DOUBLE CLICK FILE FOR FAST PLOTTING
 """
 
 
diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/PlotDataView.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/PlotDataView.py
index d4db9f2428..1dd09592b3 100644
--- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/PlotDataView.py
+++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/PlotDataView.py
@@ -14,12 +14,13 @@
 #    along with this program.  If not, see <https://www.gnu.org/licenses/>.
 #
 from typing import Union
-
+import time
 from qtpy.QtWidgets import QTreeView, QAbstractItemView, QApplication, QMenu
 from qtpy.QtCore import Signal, Slot, QModelIndex, Qt, QMimeData
 from qtpy.QtGui import QMouseEvent, QDrag, QContextMenuEvent, QStandardItem
 
 from MDANSE_GUI.Tabs.Visualisers.DataPlotter import DataPlotter
+from MDANSE_GUI.Tabs.Models.PlottingContext import PlottingContext, SingleDataset
 from MDANSE_GUI.Tabs.Visualisers.PlotDataInfo import PlotDataInfo
 from MDANSE_GUI.Widgets.DataDialog import DataDialog
 
@@ -29,7 +30,9 @@ class PlotDataView(QTreeView):
     execute_action = Signal(object)
     item_details = Signal(object)
     error = Signal(str)
+    fast_plotting_data = Signal(object)
     free_name = Signal(str)
+    fast_plotting_data = Signal(object)
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -40,6 +43,29 @@ def __init__(self, *args, **kwargs):
         # self.data_dialog = DataDialog(self)
         self._data_packet = None
 
+    def mouseDoubleClickEvent(self, e: QMouseEvent) -> None:
+        self.click_position = e.position()
+        if self.model() is None:
+            return None
+        index = self.indexAt(e.pos())
+        model = self.model()
+        mda_data_structure = model.inner_object(index)
+        model = PlottingContext()
+        for key in mda_data_structure._file.keys():
+            try:
+                if "main" in mda_data_structure._file[key].attrs["tags"]:
+                    if "partial" in mda_data_structure._file[key].attrs["tags"]:
+                        dataset = SingleDataset(
+                            key, mda_data_structure._file, linestyle="--"
+                        )
+                    else:
+                        dataset = SingleDataset(key, mda_data_structure._file)
+
+                    model.add_dataset(dataset)
+            except KeyError:
+                print(f"No attribute called Tag found in {key}, skipping")
+        self.fast_plotting_data.emit(model)
+
     def mousePressEvent(self, e: QMouseEvent) -> None:
         self.click_position = e.position()
         if self.model() is None:


Reviews:
user: login: ChiCheng45
body: I've tried running this and after I load the DFTB water trajectory and click the molecule preview on the angular correlation job, MDANSE hangs and then crashes.

@MBartkowiakSTFC Do you get this issue on Mac?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-30T12:54:36Z
user: login: ChiCheng45
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T09:26:12Z
user: login: ChiCheng45
body: Just one more thing. I've found that for the static structure factor, pair distribution function and both van Hove function results, double-clicking the file makes an empty plot. 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T10:18:08Z
user: login: ChiCheng45
body: Looks good to me.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-01T15:30:46Z

Review comments:
diff_hunk: @@ -0,0 +1,141 @@
+#    This file is part of MDANSE_GUI.
+#
+#    MDANSE_GUI is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+#
+
+from typing import Any, Union
+
+import rdkit.Chem as chem
+import rdkit.Chem.AllChem as allchem
+import rdkit.Chem.Draw as draw
+from MDANSE.MolecularDynamics.Trajectory import Trajectory
+from PIL.ImageQt import ImageQt
+from qtpy.QtCore import QObject
+from qtpy.QtGui import QFont, QImage, QPixmap
+from qtpy.QtWidgets import QDialog, QLabel, QVBoxLayout
+
+
+class MoleculePreviewWidget(QDialog):
+    """Shows the structure of a single molecule."""
+
+    def __init__(
+        self,
+        parent: Union[QObject, None],
+        molecule_information: dict[str, Any],
+        molecule_name: str,
+        atom_database: Trajectory,
+        max_atom_limit: int = 300,
+    ):
+        """Create the dialog and set up the widgets.
+
+        Parameters
+        ----------
+        parent : Union[QObject, None]
+            parent in the Qt object hierarchy, optional
+        molecule_information : dict[str, Any]
+            dictionary of collected molecule properies
+        molecule_name : str
+            molecule name as used in MDANSE
+        atom_database : Trajectory
+            trajectory instance, for looking up atom properties
+        max_atom_limit : int,
+            molecules with more atoms than this will not be drawn
+
+        """
+        super().__init__(parent)
+        self.setWindowTitle("Molecule Preview")
+        self.resize(800, 600)
+        self.max_atom_limit = max_atom_limit
+        self.axes = []
+        self.bond_mapping = {}
+        layout = QVBoxLayout(self)
+        self.setLayout(layout)
+        self.mol_name = molecule_name
+        self.mol_info = molecule_information
+        self.atom_database = atom_database
+        self.image_label = QLabel(self)
+        self.text_label = QLabel(self)
+        font = QFont("Arial", 12)
+        self.text_label.setFont(font)
+        self.text_label.setWordWrap(True)
+        layout.addWidget(self.image_label)
+        layout.addWidget(self.text_label)
+        self.update_text_label()
+        self.show_formula()
+
+    def update_text_label(self):
+        """Show the molecule information as text."""
+        info_text = f"Molecule name: {self.mol_name}\n"
+        for key, value in self.mol_info["atom_number"].items():
+            info_text += f"Number of {key} atoms: {value}\n"
+        info_text += f"Number of such molecules in trajectory: {self.mol_info['no_of_molecules']}\n"
+        self.text_label.setText(info_text)
+
+    def prepare_rdkit_molecule(self) -> Union[chem.RWMol, None]:
+        """Create an rdkit molecule from the selected atoms."""
+        if len(self.mol_info["atom_indices"]) > self.max_atom_limit:
+            self.image_label.clear()
+            self.image_label.setText("Molecule is too large for preview")
+            return None
+        self.image_label.setText("")
+        large_molecule = self.atom_database.chemical_system.rdkit_mol
+        submolecule = chem.RWMol()
+        mapping = {}
+        self.bond_mapping = {}
+        for index in self.mol_info["atom_indices"]:
+            new_index = submolecule.AddAtom(large_molecule.GetAtomWithIdx(index))
+            mapping[index] = new_index
+        for bond in self.mol_info["bond_list"]:
+            new_pair = mapping[bond[0]], mapping[bond[1]]
+            bond_index = submolecule.AddBond(new_pair[0], new_pair[1])
+            self.bond_mapping[bond_index] = new_pair
+        allchem.Compute2DCoords(submolecule)
+        return submolecule
+
+    def show_formula(self):
+        """Draw the molecule."""
+        submolecule = self.prepare_rdkit_molecule()
+        draw_options = draw.MolDrawOptions()
+        draw_options.addAtomIndices = True
+        pil_image = draw.MolToImage(submolecule, size=(600, 600), options=draw_options)
+        qt_image = ImageQt(pil_image)
path: MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculePreviewWidget.py
user: login: ChiCheng45
body: ```suggestion
        qt_image = ImageQt(pil_image).copy()
```
This fixes the crash on Windows.
created_at: 2025-05-01T09:25:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,141 @@
+#    This file is part of MDANSE_GUI.
+#
+#    MDANSE_GUI is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU General Public License as published by
+#    the Free Software Foundation, either version 3 of the License, or
+#    (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+#
+
+from typing import Any, Union
+
+import rdkit.Chem as chem
+import rdkit.Chem.AllChem as allchem
+import rdkit.Chem.Draw as draw
+from MDANSE.MolecularDynamics.Trajectory import Trajectory
+from PIL.ImageQt import ImageQt
+from qtpy.QtCore import QObject
+from qtpy.QtGui import QFont, QImage, QPixmap
+from qtpy.QtWidgets import QDialog, QLabel, QVBoxLayout
+
+
+class MoleculePreviewWidget(QDialog):
+    """Shows the structure of a single molecule."""
+
+    def __init__(
+        self,
+        parent: Union[QObject, None],
+        molecule_information: dict[str, Any],
+        molecule_name: str,
+        atom_database: Trajectory,
+        max_atom_limit: int = 300,
+    ):
+        """Create the dialog and set up the widgets.
+
+        Parameters
+        ----------
+        parent : Union[QObject, None]
+            parent in the Qt object hierarchy, optional
+        molecule_information : dict[str, Any]
+            dictionary of collected molecule properies
+        molecule_name : str
+            molecule name as used in MDANSE
+        atom_database : Trajectory
+            trajectory instance, for looking up atom properties
+        max_atom_limit : int,
+            molecules with more atoms than this will not be drawn
+
+        """
+        super().__init__(parent)
+        self.setWindowTitle("Molecule Preview")
+        self.resize(800, 600)
+        self.max_atom_limit = max_atom_limit
+        self.axes = []
+        self.bond_mapping = {}
+        layout = QVBoxLayout(self)
+        self.setLayout(layout)
+        self.mol_name = molecule_name
+        self.mol_info = molecule_information
+        self.atom_database = atom_database
+        self.image_label = QLabel(self)
+        self.text_label = QLabel(self)
+        font = QFont("Arial", 12)
+        self.text_label.setFont(font)
+        self.text_label.setWordWrap(True)
+        layout.addWidget(self.image_label)
+        layout.addWidget(self.text_label)
+        self.update_text_label()
+        self.show_formula()
+
+    def update_text_label(self):
+        """Show the molecule information as text."""
+        info_text = f"Molecule name: {self.mol_name}\n"
+        for key, value in self.mol_info["atom_number"].items():
+            info_text += f"Number of {key} atoms: {value}\n"
+        info_text += f"Number of such molecules in trajectory: {self.mol_info['no_of_molecules']}\n"
+        self.text_label.setText(info_text)
+
+    def prepare_rdkit_molecule(self) -> Union[chem.RWMol, None]:
+        """Create an rdkit molecule from the selected atoms."""
+        if len(self.mol_info["atom_indices"]) > self.max_atom_limit:
+            self.image_label.clear()
+            self.image_label.setText("Molecule is too large for preview")
+            return None
+        self.image_label.setText("")
+        large_molecule = self.atom_database.chemical_system.rdkit_mol
+        submolecule = chem.RWMol()
+        mapping = {}
+        self.bond_mapping = {}
+        for index in self.mol_info["atom_indices"]:
+            new_index = submolecule.AddAtom(large_molecule.GetAtomWithIdx(index))
+            mapping[index] = new_index
+        for bond in self.mol_info["bond_list"]:
+            new_pair = mapping[bond[0]], mapping[bond[1]]
+            bond_index = submolecule.AddBond(new_pair[0], new_pair[1])
+            self.bond_mapping[bond_index] = new_pair
+        allchem.Compute2DCoords(submolecule)
+        return submolecule
+
+    def show_formula(self):
+        """Draw the molecule."""
+        submolecule = self.prepare_rdkit_molecule()
+        draw_options = draw.MolDrawOptions()
+        draw_options.addAtomIndices = True
+        pil_image = draw.MolToImage(submolecule, size=(600, 600), options=draw_options)
+        qt_image = ImageQt(pil_image)
+        pixmap = QPixmap.fromImage(qt_image)
+        self.image_label.setPixmap(pixmap)
+
+    def filter_bonds(self, selected_atoms: list[int]) -> list[int]:
+        """Find and return only the bonds that appear in the molecule."""
+        return [
+            key
+            for key, pair in self.bond_mapping.items()
+            if pair[0] in selected_atoms and pair[1] in selected_atoms
+        ]
+
+    def show_formula_with_selection(self, selected_atoms: list[int]):
+        """Draw the formula and highlight the selected atoms and bonds."""
+        submolecule = self.prepare_rdkit_molecule()
+        optional_bonds = self.filter_bonds(selected_atoms)
+        driver = draw.rdMolDraw2D.MolDraw2DCairo(600, 600)
+        draw_options = draw.MolDrawOptions()
+        draw_options.addAtomIndices = True
+        driver.SetDrawOptions(draw_options)
+        draw.rdMolDraw2D.PrepareAndDrawMolecule(
+            driver,
+            submolecule,
+            highlightAtoms=selected_atoms,
+            highlightBonds=optional_bonds,
+        )
+        png_data = driver.GetDrawingText()
+        image = QImage.fromData(png_data)
path: MDANSE_GUI/Src/MDANSE_GUI/InputWidgets/MoleculePreviewWidget.py
user: login: ChiCheng45
body: ```suggestion
        image = QImage.fromData(png_data).copy()
```
Suggested change since the copy was required to fix the crash above.
created_at: 2025-05-01T09:25:58Z
author_association: COLLABORATOR

PR comments:
user: login: MBartkowiakSTFC
created_at: 2025-04-30T13:03:55Z
body: ![Screenshot 2025-04-30 at 14 03 18](https://github.com/user-attachments/assets/8e4af946-6ed1-4995-9131-fdd5b5101762)
I get this window, which looks correct to me.
author_association: COLLABORATOR
user: login: MBartkowiakSTFC
created_at: 2025-04-30T13:08:09Z
body: I was getting the crash with the other branch, from #620.
author_association: COLLABORATOR
user: login: ChiCheng45
created_at: 2025-04-30T14:20:20Z
body: > ![Screenshot 2025-04-30 at 14 03 18](https://private-user-images.githubusercontent.com/108934199/439240330-8e4af946-6ed1-4995-9131-fdd5b5101762.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDYwMjE4NzgsIm5iZiI6MTc0NjAyMTU3OCwicGF0aCI6Ii8xMDg5MzQxOTkvNDM5MjQwMzMwLThlNGFmOTQ2LTZlZDEtNDk5NS05MTMxLWZkZDViNTEwMTc2Mi5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNDMwJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDQzMFQxMzU5MzhaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1kMjlkNGI5MWQ1MmY4Njg3NzI5YzYzZjJjZDhiMjg3MGIzMjFhODkzMjY5MjIyYmVlYjg3YWNmNGRkMDAzOGFkJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.NuLIIdS3LNwghPYVXKLlvIgSE8H8sY-SzUeu7tH76-A) I get this window, which looks correct to me.

Yeah not sure what's going on, works fine on my Ubuntu VM but crashes on Windows. Maybe an issue with rdkit?

![Animation72](https://github.com/user-attachments/assets/dd58fdb6-c697-4b81-83af-ae10a2c52ef4)

author_association: COLLABORATOR
user: login: MBartkowiakSTFC
created_at: 2025-04-30T14:54:00Z
body: Maybe try upgrading rdkit on your Windows machine? Also, what Python version are you using?
author_association: COLLABORATOR
user: login: ChiCheng45
created_at: 2025-04-30T15:10:35Z
body: > Maybe try upgrading rdkit on your Windows machine? Also, what Python version are you using?

Yea I'm going to try some different stuff out to figure out what it might be. 

The version from above is Python 3.12.4 and rdkit 2025.03.2.
I also find a crash with Python 3.11.11 and rdkit 2024.09.6.





author_association: COLLABORATOR
user: login: MBartkowiakSTFC
created_at: 2025-05-01T09:37:39Z
body: Thanks for the corrections. On my machine it still works the same after the changes.
author_association: COLLABORATOR
user: login: ChiCheng45
created_at: 2025-05-01T09:49:53Z
body: Great, everything seems to work fine. I've tested this on Windows and Ubuntu. 

I had a suggestion in PR #620 (https://github.com/ISISNeutronMuon/MDANSE/pull/620#pullrequestreview-2508546857) that maybe the fast plotting should be done by right-clicking the file for plotting the main results. I think you could right-click the individual results to plot them as well. The double click is a bit clunky since it also opens the drop-down. I'm happy to approve this PR and put my suggestions as an enhancement request if you prefer. 
author_association: COLLABORATOR
user: login: MBartkowiakSTFC
created_at: 2025-05-01T09:59:37Z
body: I would prefer to merge it now and add improvements later. (I saw some of the users work with the GUI and they used double click a lot, expecting something to happen.)
author_association: COLLABORATOR
user: login: MBartkowiakSTFC
created_at: 2025-05-01T10:36:30Z
body: I added a few changes:
1. PDF, SSF and both van Hove calculations now have the fast plotting tags included in the output data. The files produced with the current version will produce output with fast plotting.
2. Double click on the file name in tree view will not show the child nodes. It is still possible to unfold the tree structure by clicking on the bullet/triangle symbol left of the name.

author_association: COLLABORATOR
user: login: ChiCheng45
created_at: 2025-05-01T12:41:20Z
body: I've gone through all the jobs, and this should be the rest of the results that don't have a working double click.

RootMeanSquareDeviation
CoordinationNumber
XrayStructureFactor
author_association: COLLABORATOR
user: login: MBartkowiakSTFC
created_at: 2025-05-01T14:49:14Z
body: Thanks for pointing these out. They were all failing for different reasons.

They should be fine now.
author_association: COLLABORATOR