Repository: datalab-org/datalab
Pull request #1119: NMR and XRD block improvements
Author: login: ml-evs
Labels: name: refactoring
description: For issues/PRs that refactor existing code/features
name: datablock
description: An issue pertaining to a specific datablock
Created: 2025-04-12T13:32:07Z | Merged: 2025-04-18T08:48:19Z

Main one being using a temp dir when extracting data, to avoid clogging up the server.

Also improve the NMR block plots and nest metadata under their own key (which the app will now try to use by default in the UI, falling back to the original).


Diff:
diff --git a/pydatalab/src/pydatalab/apps/nmr/blocks.py b/pydatalab/src/pydatalab/apps/nmr/blocks.py
index 5f34e5acd..cfe3ee5d1 100644
--- a/pydatalab/src/pydatalab/apps/nmr/blocks.py
+++ b/pydatalab/src/pydatalab/apps/nmr/blocks.py
@@ -1,15 +1,16 @@
 import os
+import tempfile
+import warnings
 import zipfile
+from pathlib import Path
 
 import bokeh.embed
 import pandas as pd
 
+from pydatalab.apps.nmr.utils import read_bruker_1d
 from pydatalab.blocks.base import DataBlock
 from pydatalab.bokeh_plots import DATALAB_BOKEH_THEME, selectable_axes_plot
 from pydatalab.file_utils import get_file_info_by_id
-from pydatalab.logger import LOGGER
-
-from .utils import read_bruker_1d
 
 
 class NMRBlock(DataBlock):
@@ -25,82 +26,125 @@ class NMRBlock(DataBlock):
     def plot_functions(self):
         return (self.generate_nmr_plot,)
 
-    def read_bruker_nmr_data(self):
-        if "file_id" not in self.data:
-            LOGGER.warning("NMRPlot.read_bruker_nmr_data(): No file set in the DataBlock")
-            return
-
-        zip_file_info = get_file_info_by_id(self.data["file_id"], update_if_live=True)
-        filename = zip_file_info["name"]
+    def read_bruker_nmr_data(
+        self, filename: str | Path | None = None
+    ) -> tuple[dict | None, dict] | None:
+        """Loads a Bruker project from the passed or attached zip file
+        and parses it into a serialized dataframe and metadata dictionary.
+
+        Parameters:
+            filename: Optional local file to use instead of the database lookup.
+
+        Returns:
+            A tuple of the dataframe (serialized as dictionary) and the metadata
+                dictionary, or None if no compatible data is available.
+
+        """
+        if not filename:
+            if "file_id" not in self.data:
+                return None
+
+            zip_file_info = get_file_info_by_id(self.data["file_id"], update_if_live=True)
+            _filename = zip_file_info["name"]
+            location = zip_file_info["location"]
+            name, ext = os.path.splitext(_filename)
+        else:
+            location = Path(filename)
+            name = Path(filename).stem
+            ext = Path(filename).suffix
 
-        name, ext = os.path.splitext(filename)
         if ext.lower() not in self.accepted_file_extensions:
-            LOGGER.warning(
-                "NMRBlock.read_bruker_nmr_data(): Unsupported file extension (must be .zip)"
+            raise RuntimeError(
+                f"Unsupported file extension {ext.lower()} (must be one of {self.accepted_file_extensions})"
             )
-            return
 
-        # unzip:
-        directory_location = zip_file_info["location"] + ".extracted"
-        LOGGER.debug(f"Directory location is: {directory_location}")
-        with zipfile.ZipFile(zip_file_info["location"], "r") as zip_ref:
-            zip_ref.extractall(directory_location)
+        # unzip to tmp directory
+        with tempfile.TemporaryDirectory() as tmpdirname:
+            # Create a Path object for the temporary directory
+            tmpdir_path = Path(tmpdirname)
 
-        extracted_directory_name = os.path.join(directory_location, name)
-        available_processes = os.listdir(os.path.join(extracted_directory_name, "pdata"))
+            # Unzip the file to the temporary directory
+            with zipfile.ZipFile(location, "r") as zip_ref:
+                zip_ref.extractall(tmpdir_path)
 
-        if self.data.get("selected_process") not in available_processes:
-            self.data["selected_process"] = available_processes[0]
-
-        try:
-            df, a_dic, topspin_title, processed_data_shape = read_bruker_1d(
-                os.path.join(directory_location, name),
-                process_number=self.data["selected_process"],
-                verbose=False,
+            extracted_directory_name = tmpdir_path / name
+            available_processes = sorted(
+                os.listdir(os.path.join(extracted_directory_name, "pdata"))
             )
-        except Exception as error:
-            LOGGER.critical(f"Unable to parse {name} as Bruker project. {error}")
-            return
+
+            if self.data.get("selected_process") not in available_processes:
+                self.data["selected_process"] = available_processes[0]
+
+            try:
+                df, a_dic, topspin_title, processed_data_shape = read_bruker_1d(
+                    tmpdir_path / name,
+                    process_number=self.data["selected_process"],
+                    verbose=False,
+                )
+            except Exception as error:
+                raise RuntimeError(f"Unable to parse {name!r} as Bruker project. Error: {error!r}")
 
         serialized_df = df.to_dict() if (df is not None) else None
 
-        # all data sorted in a fairly raw way
+        metadata = {}
+        metadata["acquisition_parameters"] = a_dic["acqus"]
+        metadata["processing_parameters"] = a_dic["procs"]
+        metadata["pulse_program"] = a_dic["pprog"]
+        metadata["available_processes"] = available_processes
+        metadata["nucleus"] = a_dic["acqus"]["NUC1"]
+        metadata["carrier_frequency_MHz"] = a_dic["acqus"]["SFO1"]
+        metadata["carrier_offset_Hz"] = a_dic["acqus"]["O1"]
+        metadata["recycle_delay"] = a_dic["acqus"]["D"][1]
+        metadata["nscans"] = a_dic["acqus"]["NS"]
+        metadata["CNST31"] = a_dic["acqus"]["CNST"][31]
+        metadata["processed_data_shape"] = processed_data_shape
+        metadata["probe_name"] = a_dic["acqus"]["PROBHD"]
+        metadata["pulse_program_name"] = a_dic["acqus"]["PULPROG"]
+        metadata["topspin_title"] = topspin_title
+
+        self.data["metadata"] = metadata
         self.data["processed_data"] = serialized_df
-        self.data["acquisition_parameters"] = a_dic["acqus"]
-        self.data["processing_parameters"] = a_dic["procs"]
-        self.data["pulse_program"] = a_dic["pprog"]
-
-        # specific things that we might want to pull out for the UI:
-        self.data["available_processes"] = available_processes
-        self.data["nucleus"] = a_dic["acqus"]["NUC1"]
-        self.data["carrier_frequency_MHz"] = a_dic["acqus"]["SFO1"]
-        self.data["carrier_offset_Hz"] = a_dic["acqus"]["O1"]
-        self.data["recycle_delay"] = a_dic["acqus"]["D"][1]
-        self.data["nscans"] = a_dic["acqus"]["NS"]
-        self.data["CNST31"] = a_dic["acqus"]["CNST"][31]
-        self.data["processed_data_shape"] = processed_data_shape
-
-        self.data["probe_name"] = a_dic["acqus"]["PROBHD"]
-        self.data["pulse_program_name"] = a_dic["acqus"]["PULPROG"]
-        self.data["topspin_title"] = topspin_title
-
-    def generate_nmr_plot(self):
+
+        return serialized_df, metadata
+
+    def generate_nmr_plot(self, parse: bool = True):
         # currently calls every time plotting happens, but it should only happen if the file was updated
-        self.read_bruker_nmr_data()
+        if parse:
+            self.read_bruker_nmr_data()
+
         if "processed_data" not in self.data or not self.data["processed_data"]:
             self.data["bokeh_plot_data"] = None
+            warnings.warn("No compatible processed data available for plotting.")
             return
 
         df = pd.DataFrame(self.data["processed_data"])
         df["normalized intensity"] = df.intensity / df.intensity.max()
 
+        nucleus_label = self.data["metadata"].get("nucleus") or ""
+        # replace numbers with superscripts
+        nucleus_label = nucleus_label.translate(str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹"))
+        df.rename(
+            {
+                "ppm": f"{nucleus_label} chemical shift (ppm)",
+                "hz": f"{nucleus_label} chemical shift (Hz)",
+                "intensity": "Intensity",
+                "intensity_per_scan": "Intensity per scan",
+                "normalized intensity": "Normalized intensity",
+            },
+            axis=1,
+            inplace=True,
+        )
+
         bokeh_layout = selectable_axes_plot(
             df,
-            x_options=["ppm", "hz"],
+            x_options=[
+                f"{nucleus_label} chemical shift (ppm)",
+                f"{nucleus_label} chemical shift (Hz)",
+            ],
             y_options=[
-                "intensity",
-                "intensity_per_scan",
-                "normalized intensity",
+                "Intensity",
+                "Intensity per scan",
+                "Normalized intensity",
             ],
             plot_line=True,
             point_size=3,
diff --git a/pydatalab/src/pydatalab/apps/nmr/utils.py b/pydatalab/src/pydatalab/apps/nmr/utils.py
index 2ac03f22b..e582eeef7 100644
--- a/pydatalab/src/pydatalab/apps/nmr/utils.py
+++ b/pydatalab/src/pydatalab/apps/nmr/utils.py
@@ -1,5 +1,4 @@
 import itertools
-import os
 import re
 from pathlib import Path
 
@@ -52,11 +51,10 @@ def read_bruker_1d(
     a_dic, a_data = ng.fileio.bruker.read(str(data_dir))  # aquisition_data
     p_dic, p_data = ng.fileio.bruker.read_pdata(str(processed_data_dir))  # processing data
 
-    try:
-        with open(os.path.join(processed_data_dir, "title")) as f:
-            topspin_title = f.read()
-    except FileNotFoundError:
-        topspin_title = None
+    topspin_title = None
+    title_file = processed_data_dir / "title"
+    if title_file.exists():
+        topspin_title = title_file.read_text()
 
     if len(p_data.shape) > 1:
         return None, a_dic, topspin_title, p_data.shape
diff --git a/pydatalab/src/pydatalab/apps/xrd/utils.py b/pydatalab/src/pydatalab/apps/xrd/utils.py
index 3f59a38db..c982dfa1f 100644
--- a/pydatalab/src/pydatalab/apps/xrd/utils.py
+++ b/pydatalab/src/pydatalab/apps/xrd/utils.py
@@ -1,9 +1,9 @@
 import os
 import re
+import tempfile
 import warnings
 import zipfile
 from pathlib import Path
-from typing import List, Tuple
 
 import numpy as np
 import pandas as pd
@@ -101,7 +101,7 @@ def convertSinglePattern(
     return outfn
 
 
-def getStartEnd(s: str) -> Tuple[float, float]:
+def getStartEnd(s: str) -> tuple[float, float]:
     """Parse a given string representation of an xrdml file to find the start and end 2Theta points of the scan.
     Note: this could match either Omega or 2Theta depending on their order in the XRDML file.
 
@@ -122,7 +122,7 @@ def getStartEnd(s: str) -> Tuple[float, float]:
     return start, end
 
 
-def getIntensities(s: str) -> List[float]:
+def getIntensities(s: str) -> list[float]:
     """Parse a given string representation of an xrdml file to find the peak intensities.
 
     Raises:
@@ -140,7 +140,7 @@ def getIntensities(s: str) -> List[float]:
     return out
 
 
-def toXY(intensities: List[float], start: float, end: float) -> str:
+def toXY(intensities: list[float], start: float, end: float) -> str:
     """Converts a given list of intensities, along with a start and end angle,
     to a string in XY format.
 
@@ -158,28 +158,34 @@ def parse_rasx_zip(filename: str) -> pd.DataFrame:
         filename: The file to parse.
 
     """
-    # Unzip the file
+    # Unzip the file to a tmp dir
     zip_path = Path(filename)
-    destination = zip_path.parent / "extracted"
-    with zipfile.ZipFile(zip_path, "r") as zip_ref:
-        zip_ref.extractall(destination)
-
-    # Find the .txt data file inside the unzipped .rasx archive
-    # Seems to normally unzip to a folder called "Data0" with one .txt file inside
-    data_file = None
-    for file in destination.glob("Data*/*.txt"):
-        if data_file is not None:
-            warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
-            break
-        data_file = file
-
-    if data_file is None:
-        raise FileNotFoundError("No .txt file found in the .rasx archive.")
-
-    xrd_data = pd.read_csv(data_file, sep="\t", header=None)
-    xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
-    # Extract the data
-    # twotheta, intensities = getTwoThetaIntensities(s)
+
+    # Create a temporary directory
+    with tempfile.TemporaryDirectory() as tmpdirname:
+        # Create a Path object for the temporary directory
+        tmpdir_path = Path(tmpdirname)
+
+        # Unzip the file to the temporary directory
+        with zipfile.ZipFile(zip_path, "r") as zip_ref:
+            zip_ref.extractall(tmpdir_path)
+
+        # Find the .txt data file inside the unzipped .rasx archive
+        # Seems to normally unzip to a folder called "Data0" with one .txt file inside
+        data_file = None
+        for file in tmpdir_path.glob("Data*/*.txt"):
+            if data_file is not None:
+                warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
+                break
+            data_file = file
+
+        if data_file is None:
+            raise FileNotFoundError("No .txt file found in the .rasx archive.")
+
+        # Extract the data
+        xrd_data = pd.read_csv(data_file, sep="\t", header=None)
+        xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
+
     return pd.DataFrame(
         {
             "twotheta": xrd_data["twotheta"],
diff --git a/pydatalab/tests/apps/test_nmr.py b/pydatalab/tests/apps/test_nmr.py
index f9be2b30f..8f5060c82 100644
--- a/pydatalab/tests/apps/test_nmr.py
+++ b/pydatalab/tests/apps/test_nmr.py
@@ -3,6 +3,7 @@
 
 import pytest
 
+from pydatalab.apps.nmr.blocks import NMRBlock
 from pydatalab.apps.nmr.utils import read_bruker_1d
 
 
@@ -13,21 +14,33 @@ def _extract_example(filename, dir):
 
 
 @pytest.fixture(scope="function")
-def nmr_1d_solution_example(tmpdir):
-    zip_path = Path(__file__).parent.parent.parent / "example_data" / "NMR" / "1.zip"
-    return _extract_example(zip_path, tmpdir)
+def nmr_1d_solution_path():
+    yield Path(__file__).parent.parent.parent / "example_data" / "NMR" / "1.zip"
 
 
 @pytest.fixture(scope="function")
-def nmr_1d_solid_example(tmpdir):
-    zip_path = Path(__file__).parent.parent.parent / "example_data" / "NMR" / "71.zip"
-    return _extract_example(zip_path, tmpdir)
+def nmr_1d_solution_example(tmpdir, nmr_1d_solution_path):
+    return _extract_example(nmr_1d_solution_path, tmpdir)
 
 
 @pytest.fixture(scope="function")
-def nmr_2d_matpass_example(tmpdir):
-    zip_path = Path(__file__).parent.parent.parent / "example_data" / "NMR" / "72.zip"
-    return _extract_example(zip_path, tmpdir)
+def nmr_1d_solid_path():
+    yield Path(__file__).parent.parent.parent / "example_data" / "NMR" / "71.zip"
+
+
+@pytest.fixture(scope="function")
+def nmr_1d_solid_example(tmpdir, nmr_1d_solid_path):
+    return _extract_example(nmr_1d_solid_path, tmpdir)
+
+
+@pytest.fixture(scope="function")
+def nmr_2d_matpass_path():
+    yield Path(__file__).parent.parent.parent / "example_data" / "NMR" / "72.zip"
+
+
+@pytest.fixture(scope="function")
+def nmr_2d_matpass_example(tmpdir, nmr_2d_matpass_path):
+    return _extract_example(nmr_2d_matpass_path, tmpdir)
 
 
 def test_bruker_reader_solution(nmr_1d_solution_example):
@@ -52,3 +65,28 @@ def test_bruker_reader_2D(nmr_2d_matpass_example):
     assert a_dic
     assert topspin_title
     assert shape == (8, 4096)
+
+
+def test_nmr_block(nmr_1d_solution_path, nmr_1d_solid_path, nmr_2d_matpass_path):
+    block = NMRBlock(item_id="nmr-block")
+    block.read_bruker_nmr_data(nmr_1d_solid_path)
+    assert block.data["metadata"]["topspin_title"].split("\n")[0] == "7Li 40 kHz 40 C hahn-echo"
+    block.generate_nmr_plot(parse=False)
+    plot = block.data["bokeh_plot_data"]
+    assert plot is not None
+
+    block = NMRBlock(item_id="nmr-block")
+    block.read_bruker_nmr_data(nmr_1d_solution_path)
+    assert block.data["metadata"]["topspin_title"].split("\n")[0] == "31P reference, 85% H3PO4"
+    block.generate_nmr_plot(parse=False)
+    plot = block.data["bokeh_plot_data"]
+    assert plot is not None
+
+    block = NMRBlock(item_id="nmr-block")
+    block.read_bruker_nmr_data(nmr_2d_matpass_path)
+    assert block.data["metadata"]["topspin_title"].split("\n")[0] == "7Li 40kHz 40 C MATPASS"
+    # catch warning about processed data
+    with pytest.warns(UserWarning, match="processed data"):
+        block.generate_nmr_plot(parse=False)
+        plot = block.data["bokeh_plot_data"]
+        assert plot is None  # cannot plot MATPASS yet
diff --git a/webapp/src/components/datablocks/NMRBlock.vue b/webapp/src/components/datablocks/NMRBlock.vue
index 8e18e9df1..43df73279 100644
--- a/webapp/src/components/datablocks/NMRBlock.vue
+++ b/webapp/src/components/datablocks/NMRBlock.vue
@@ -23,7 +23,7 @@ DataBlockBase as a prop, and save from within DataBlockBase  -->
 
       <div class="mt-4">
         <span class="mr-2">
-          <Isotope :isotope-string="block.nucleus" /> {{ block.pulse_program_name }}
+          <Isotope :isotope-string="metadata.nucleus" /> {{ metadata.pulse_program_name }}
         </span>
         <a type="button" class="btn btn-default btn-sm mb-2" @click="titleShown = !titleShown">{{
           titleShown ? "hide title" : "show title"
@@ -37,7 +37,7 @@ DataBlockBase as a prop, and save from within DataBlockBase  -->
       </div>
       <div v-if="titleShown" class="card mb-2">
         <div class="card-body" style="white-space: pre">
-          {{ block.topspin_title }}
+          {{ metadata.topspin_title }}
         </div>
       </div>
       <div class="row">
@@ -52,47 +52,47 @@ DataBlockBase as a prop, and save from within DataBlockBase  -->
             <tbody>
               <tr>
                 <th scope="row">nucleus</th>
-                <td><Isotope :isotope-string="block.nucleus" /></td>
+                <td><Isotope :isotope-string="metadata.nucleus" /></td>
               </tr>
               <tr>
                 <th scope="row">pulse program</th>
-                <td>{{ block.pulse_program_name }}</td>
+                <td>{{ metadata.pulse_program_name }}</td>
               </tr>
               <tr>
                 <th scope="row">Data shape</th>
                 <td>
-                  {{ block.processed_data_shape }} (<i>d</i> =
-                  {{ block.processed_data_shape.length }})
+                  {{ metadata.processed_data_shape }} (<i>d</i> =
+                  {{ metadata.processed_data_shape.length }})
                 </td>
               </tr>
               <tr>
                 <th scope="row">probe</th>
-                <td>{{ block.probe_name }} s</td>
+                <td>{{ metadata.probe_name }} s</td>
               </tr>
 
               <tr>
                 <th scope="row"># of scans</th>
-                <td>{{ block.nscans }}</td>
+                <td>{{ metadata.nscans }}</td>
               </tr>
 
               <tr>
                 <th scope="row">recycle delay</th>
-                <td>{{ block.recycle_delay }} s</td>
+                <td>{{ metadata.recycle_delay }} s</td>
               </tr>
               <tr>
                 <th scope="row">carrier frequency</th>
-                <td>{{ block.carrier_frequency_MHz }} MHz</td>
+                <td>{{ metadata.carrier_frequency_MHz }} MHz</td>
               </tr>
 
               <tr>
                 <th scope="row">carrier offset</th>
                 <td>
-                  {{ (block.carrier_offset_Hz / block.carrier_frequency_MHz).toFixed(1) }} ppm
+                  {{ (metadata.carrier_offset_Hz / metadata.carrier_frequency_MHz).toFixed(1) }} ppm
                 </td>
               </tr>
               <tr>
                 <th scope="row">cnst31</th>
-                <td>{{ block.CNST31 }}</td>
+                <td>{{ metadata.CNST31 }}</td>
               </tr>
             </tbody>
           </table>
@@ -139,6 +139,13 @@ export default {
     block() {
       return this.$store.state.all_item_data[this.item_id]["blocks_obj"][this.block_id];
     },
+    metadata() {
+      // For backwards compatibility, if there is no metadata key, then try to treat the whole block as metadata
+      return (
+        this.$store.state.all_item_data[this.item_id]["blocks_obj"][this.block_id]?.metadata ||
+        this.$store.state.all_item_data[this.item_id]["blocks_obj"][this.block_id]
+      );
+    },
     bokehPlotData() {
       return this.$store.state.all_item_data[this.item_id]["blocks_obj"][this.block_id]
         .bokeh_plot_data;


Reviews:
user: login: be-smith
body: XRD works fine! Had a look at NMR and seems good but am less familiar with the code. Both plotted example data succesfully
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-15T11:00:35Z
user: login: BenjaminCharmes
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T11:48:42Z
user: login: BenjaminCharmes
body: Everything still works well!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:04:02Z
user: login: jdbocarsly
body: looks good, see comment on third column of the rasx format
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T20:01:25Z
user: login: ml-evs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T08:28:38Z
user: login: ml-evs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T08:29:11Z
user: login: be-smith
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T15:18:22Z

Review comments:
diff_hunk: @@ -158,28 +158,34 @@ def parse_rasx_zip(filename: str) -> pd.DataFrame:
         filename: The file to parse.
 
     """
-    # Unzip the file
+    # Unzip the file to a tmp dir
     zip_path = Path(filename)
-    destination = zip_path.parent / "extracted"
-    with zipfile.ZipFile(zip_path, "r") as zip_ref:
-        zip_ref.extractall(destination)
-
-    # Find the .txt data file inside the unzipped .rasx archive
-    # Seems to normally unzip to a folder called "Data0" with one .txt file inside
-    data_file = None
-    for file in destination.glob("Data*/*.txt"):
-        if data_file is not None:
-            warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
-            break
-        data_file = file
-
-    if data_file is None:
-        raise FileNotFoundError("No .txt file found in the .rasx archive.")
-
-    xrd_data = pd.read_csv(data_file, sep="\t", header=None)
-    xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
-    # Extract the data
-    # twotheta, intensities = getTwoThetaIntensities(s)
+
+    # Create a temporary directory
+    with tempfile.TemporaryDirectory() as tmpdirname:
+        # Create a Path object for the temporary directory
+        tmpdir_path = Path(tmpdirname)
+
+        # Unzip the file to the temporary directory
+        with zipfile.ZipFile(zip_path, "r") as zip_ref:
+            zip_ref.extractall(tmpdir_path)
+
+        # Find the .txt data file inside the unzipped .rasx archive
+        # Seems to normally unzip to a folder called "Data0" with one .txt file inside
+        data_file = None
+        for file in tmpdir_path.glob("Data*/*.txt"):
+            if data_file is not None:
+                warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
+                break
+            data_file = file
+
+        if data_file is None:
+            raise FileNotFoundError("No .txt file found in the .rasx archive.")
+
+        # Extract the data
+        xrd_data = pd.read_csv(data_file, sep="\t", header=None)
+        xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
path: pydatalab/src/pydatalab/apps/xrd/utils.py
user: login: jdbocarsly
body: lol. the third column might be error?
created_at: 2025-04-17T19:55:39Z
author_association: MEMBER
diff_hunk: @@ -25,82 +26,125 @@ class NMRBlock(DataBlock):
     def plot_functions(self):
         return (self.generate_nmr_plot,)
 
-    def read_bruker_nmr_data(self):
-        if "file_id" not in self.data:
-            LOGGER.warning("NMRPlot.read_bruker_nmr_data(): No file set in the DataBlock")
-            return
-
-        zip_file_info = get_file_info_by_id(self.data["file_id"], update_if_live=True)
-        filename = zip_file_info["name"]
+    def read_bruker_nmr_data(
+        self, filename: str | Path | None = None
+    ) -> tuple[dict | None, dict] | None:
+        """Loads a Bruker project from the passed or attached zip file
+        and parses it into a serialized dataframe and metadata dictionary.
+
+        Parameters:
+            filename: Optional local file to use instead of the database lookup.
+
+        Returns:
+            A tuple of the dataframe (serialized as dictionary) and the metadata
+                dictionary, or None if no compatible data is available.
+
+        """
+        if not filename:
+            if "file_id" not in self.data:
+                return None
+
+            zip_file_info = get_file_info_by_id(self.data["file_id"], update_if_live=True)
+            _filename = zip_file_info["name"]
+            location = zip_file_info["location"]
+            name, ext = os.path.splitext(_filename)
+        else:
+            location = Path(filename)
+            name = Path(filename).stem
+            ext = Path(filename).suffix
 
-        name, ext = os.path.splitext(filename)
         if ext.lower() not in self.accepted_file_extensions:
-            LOGGER.warning(
-                "NMRBlock.read_bruker_nmr_data(): Unsupported file extension (must be .zip)"
+            raise RuntimeError(
+                f"Unsupported file extension {ext.lower()} (must be one of {self.accepted_file_extensions})"
             )
-            return
 
-        # unzip:
-        directory_location = zip_file_info["location"] + ".extracted"
-        LOGGER.debug(f"Directory location is: {directory_location}")
-        with zipfile.ZipFile(zip_file_info["location"], "r") as zip_ref:
-            zip_ref.extractall(directory_location)
+        # unzip to tmp directory
+        with tempfile.TemporaryDirectory() as tmpdirname:
+            # Create a Path object for the temporary directory
+            tmpdir_path = Path(tmpdirname)
 
-        extracted_directory_name = os.path.join(directory_location, name)
-        available_processes = os.listdir(os.path.join(extracted_directory_name, "pdata"))
+            # Unzip the file to the temporary directory
+            with zipfile.ZipFile(location, "r") as zip_ref:
+                zip_ref.extractall(tmpdir_path)
 
-        if self.data.get("selected_process") not in available_processes:
-            self.data["selected_process"] = available_processes[0]
-
-        try:
-            df, a_dic, topspin_title, processed_data_shape = read_bruker_1d(
-                os.path.join(directory_location, name),
-                process_number=self.data["selected_process"],
-                verbose=False,
+            extracted_directory_name = tmpdir_path / name
+            available_processes = sorted(
+                os.listdir(os.path.join(extracted_directory_name, "pdata"))
             )
-        except Exception as error:
-            LOGGER.critical(f"Unable to parse {name} as Bruker project. {error}")
-            return
+
+            if self.data.get("selected_process") not in available_processes:
+                self.data["selected_process"] = available_processes[0]
+
+            try:
+                df, a_dic, topspin_title, processed_data_shape = read_bruker_1d(
+                    tmpdir_path / name,
+                    process_number=self.data["selected_process"],
+                    verbose=False,
+                )
+            except Exception as error:
+                raise RuntimeError(f"Unable to parse {name!r} as Bruker project. Error: {error!r}")
 
         serialized_df = df.to_dict() if (df is not None) else None
 
-        # all data sorted in a fairly raw way
+        metadata = {}
path: pydatalab/src/pydatalab/apps/nmr/blocks.py
user: login: jdbocarsly
body: 👍  . Now we will have to put a metadata model in...
created_at: 2025-04-17T20:00:59Z
author_association: MEMBER
diff_hunk: @@ -25,82 +26,125 @@ class NMRBlock(DataBlock):
     def plot_functions(self):
         return (self.generate_nmr_plot,)
 
-    def read_bruker_nmr_data(self):
-        if "file_id" not in self.data:
-            LOGGER.warning("NMRPlot.read_bruker_nmr_data(): No file set in the DataBlock")
-            return
-
-        zip_file_info = get_file_info_by_id(self.data["file_id"], update_if_live=True)
-        filename = zip_file_info["name"]
+    def read_bruker_nmr_data(
+        self, filename: str | Path | None = None
+    ) -> tuple[dict | None, dict] | None:
+        """Loads a Bruker project from the passed or attached zip file
+        and parses it into a serialized dataframe and metadata dictionary.
+
+        Parameters:
+            filename: Optional local file to use instead of the database lookup.
+
+        Returns:
+            A tuple of the dataframe (serialized as dictionary) and the metadata
+                dictionary, or None if no compatible data is available.
+
+        """
+        if not filename:
+            if "file_id" not in self.data:
+                return None
+
+            zip_file_info = get_file_info_by_id(self.data["file_id"], update_if_live=True)
+            _filename = zip_file_info["name"]
+            location = zip_file_info["location"]
+            name, ext = os.path.splitext(_filename)
+        else:
+            location = Path(filename)
+            name = Path(filename).stem
+            ext = Path(filename).suffix
 
-        name, ext = os.path.splitext(filename)
         if ext.lower() not in self.accepted_file_extensions:
-            LOGGER.warning(
-                "NMRBlock.read_bruker_nmr_data(): Unsupported file extension (must be .zip)"
+            raise RuntimeError(
+                f"Unsupported file extension {ext.lower()} (must be one of {self.accepted_file_extensions})"
             )
-            return
 
-        # unzip:
-        directory_location = zip_file_info["location"] + ".extracted"
-        LOGGER.debug(f"Directory location is: {directory_location}")
-        with zipfile.ZipFile(zip_file_info["location"], "r") as zip_ref:
-            zip_ref.extractall(directory_location)
+        # unzip to tmp directory
+        with tempfile.TemporaryDirectory() as tmpdirname:
+            # Create a Path object for the temporary directory
+            tmpdir_path = Path(tmpdirname)
 
-        extracted_directory_name = os.path.join(directory_location, name)
-        available_processes = os.listdir(os.path.join(extracted_directory_name, "pdata"))
+            # Unzip the file to the temporary directory
+            with zipfile.ZipFile(location, "r") as zip_ref:
+                zip_ref.extractall(tmpdir_path)
 
-        if self.data.get("selected_process") not in available_processes:
-            self.data["selected_process"] = available_processes[0]
-
-        try:
-            df, a_dic, topspin_title, processed_data_shape = read_bruker_1d(
-                os.path.join(directory_location, name),
-                process_number=self.data["selected_process"],
-                verbose=False,
+            extracted_directory_name = tmpdir_path / name
+            available_processes = sorted(
+                os.listdir(os.path.join(extracted_directory_name, "pdata"))
             )
-        except Exception as error:
-            LOGGER.critical(f"Unable to parse {name} as Bruker project. {error}")
-            return
+
+            if self.data.get("selected_process") not in available_processes:
+                self.data["selected_process"] = available_processes[0]
+
+            try:
+                df, a_dic, topspin_title, processed_data_shape = read_bruker_1d(
+                    tmpdir_path / name,
+                    process_number=self.data["selected_process"],
+                    verbose=False,
+                )
+            except Exception as error:
+                raise RuntimeError(f"Unable to parse {name!r} as Bruker project. Error: {error!r}")
 
         serialized_df = df.to_dict() if (df is not None) else None
 
-        # all data sorted in a fairly raw way
+        metadata = {}
path: pydatalab/src/pydatalab/apps/nmr/blocks.py
user: login: ml-evs
body: Yeah, going to merge #1128 then try to re-refactor this next and see how generic we can be.
created_at: 2025-04-18T08:28:38Z
author_association: MEMBER
diff_hunk: @@ -158,28 +158,34 @@ def parse_rasx_zip(filename: str) -> pd.DataFrame:
         filename: The file to parse.
 
     """
-    # Unzip the file
+    # Unzip the file to a tmp dir
     zip_path = Path(filename)
-    destination = zip_path.parent / "extracted"
-    with zipfile.ZipFile(zip_path, "r") as zip_ref:
-        zip_ref.extractall(destination)
-
-    # Find the .txt data file inside the unzipped .rasx archive
-    # Seems to normally unzip to a folder called "Data0" with one .txt file inside
-    data_file = None
-    for file in destination.glob("Data*/*.txt"):
-        if data_file is not None:
-            warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
-            break
-        data_file = file
-
-    if data_file is None:
-        raise FileNotFoundError("No .txt file found in the .rasx archive.")
-
-    xrd_data = pd.read_csv(data_file, sep="\t", header=None)
-    xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
-    # Extract the data
-    # twotheta, intensities = getTwoThetaIntensities(s)
+
+    # Create a temporary directory
+    with tempfile.TemporaryDirectory() as tmpdirname:
+        # Create a Path object for the temporary directory
+        tmpdir_path = Path(tmpdirname)
+
+        # Unzip the file to the temporary directory
+        with zipfile.ZipFile(zip_path, "r") as zip_ref:
+            zip_ref.extractall(tmpdir_path)
+
+        # Find the .txt data file inside the unzipped .rasx archive
+        # Seems to normally unzip to a folder called "Data0" with one .txt file inside
+        data_file = None
+        for file in tmpdir_path.glob("Data*/*.txt"):
+            if data_file is not None:
+                warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
+                break
+            data_file = file
+
+        if data_file is None:
+            raise FileNotFoundError("No .txt file found in the .rasx archive.")
+
+        # Extract the data
+        xrd_data = pd.read_csv(data_file, sep="\t", header=None)
+        xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
path: pydatalab/src/pydatalab/apps/xrd/utils.py
user: login: ml-evs
body: This is how we know that @be-smith is not an LLM -- tbf we're still really not sure what this column is lol
created_at: 2025-04-18T08:29:11Z
author_association: MEMBER
diff_hunk: @@ -158,28 +158,34 @@ def parse_rasx_zip(filename: str) -> pd.DataFrame:
         filename: The file to parse.
 
     """
-    # Unzip the file
+    # Unzip the file to a tmp dir
     zip_path = Path(filename)
-    destination = zip_path.parent / "extracted"
-    with zipfile.ZipFile(zip_path, "r") as zip_ref:
-        zip_ref.extractall(destination)
-
-    # Find the .txt data file inside the unzipped .rasx archive
-    # Seems to normally unzip to a folder called "Data0" with one .txt file inside
-    data_file = None
-    for file in destination.glob("Data*/*.txt"):
-        if data_file is not None:
-            warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
-            break
-        data_file = file
-
-    if data_file is None:
-        raise FileNotFoundError("No .txt file found in the .rasx archive.")
-
-    xrd_data = pd.read_csv(data_file, sep="\t", header=None)
-    xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
-    # Extract the data
-    # twotheta, intensities = getTwoThetaIntensities(s)
+
+    # Create a temporary directory
+    with tempfile.TemporaryDirectory() as tmpdirname:
+        # Create a Path object for the temporary directory
+        tmpdir_path = Path(tmpdirname)
+
+        # Unzip the file to the temporary directory
+        with zipfile.ZipFile(zip_path, "r") as zip_ref:
+            zip_ref.extractall(tmpdir_path)
+
+        # Find the .txt data file inside the unzipped .rasx archive
+        # Seems to normally unzip to a folder called "Data0" with one .txt file inside
+        data_file = None
+        for file in tmpdir_path.glob("Data*/*.txt"):
+            if data_file is not None:
+                warnings.warn(f"Found other data files in .rasx archive, only using {data_file}")
+                break
+            data_file = file
+
+        if data_file is None:
+            raise FileNotFoundError("No .txt file found in the .rasx archive.")
+
+        # Extract the data
+        xrd_data = pd.read_csv(data_file, sep="\t", header=None)
+        xrd_data.columns = ["twotheta", "intensity", "imnotsure"]
path: pydatalab/src/pydatalab/apps/xrd/utils.py
user: login: be-smith
body: It was copilot that named that column, so not convinced it prooves anything!
created_at: 2025-04-18T15:18:22Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-12T13:33:23Z
body: ## [Codecov](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org) Report
Attention: Patch coverage is `82.43243%` with `13 lines` in your changes missing coverage. Please review.
> Project coverage is 71.30%. Comparing base [(`39a8a70`)](https://app.codecov.io/gh/datalab-org/datalab/commit/39a8a70f14165423d24fe4f48e02d894c3ed72ab?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org) to head [(`0568f3f`)](https://app.codecov.io/gh/datalab-org/datalab/commit/0568f3f0f534a1c5b862767aeab1e9ae193b580f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org).
> Report is 110 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org) | Patch % | Lines |
|---|---|---|
| [pydatalab/src/pydatalab/apps/nmr/blocks.py](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?src=pr&el=tree&filepath=pydatalab%2Fsrc%2Fpydatalab%2Fapps%2Fnmr%2Fblocks.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org#diff-cHlkYXRhbGFiL3NyYy9weWRhdGFsYWIvYXBwcy9ubXIvYmxvY2tzLnB5) | 80.76% | [10 Missing :warning: ](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org) |
| [pydatalab/src/pydatalab/apps/xrd/utils.py](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?src=pr&el=tree&filepath=pydatalab%2Fsrc%2Fpydatalab%2Fapps%2Fxrd%2Futils.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org#diff-cHlkYXRhbGFiL3NyYy9weWRhdGFsYWIvYXBwcy94cmQvdXRpbHMucHk=) | 83.33% | [3 Missing :warning: ](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1119      +/-   ##
==========================================
+ Coverage   70.28%   71.30%   +1.01%     
==========================================
  Files          63       63              
  Lines        4133     4144      +11     
==========================================
+ Hits         2905     2955      +50     
+ Misses       1228     1189      -39     
```

| [Files with missing lines](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org) | Coverage Δ | |
|---|---|---|
| [pydatalab/src/pydatalab/apps/nmr/utils.py](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?src=pr&el=tree&filepath=pydatalab%2Fsrc%2Fpydatalab%2Fapps%2Fnmr%2Futils.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org#diff-cHlkYXRhbGFiL3NyYy9weWRhdGFsYWIvYXBwcy9ubXIvdXRpbHMucHk=) | `37.80% <100.00%> (+0.90%)` | :arrow_up: |
| [pydatalab/src/pydatalab/apps/xrd/utils.py](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?src=pr&el=tree&filepath=pydatalab%2Fsrc%2Fpydatalab%2Fapps%2Fxrd%2Futils.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org#diff-cHlkYXRhbGFiL3NyYy9weWRhdGFsYWIvYXBwcy94cmQvdXRpbHMucHk=) | `56.79% <83.33%> (+0.54%)` | :arrow_up: |
| [pydatalab/src/pydatalab/apps/nmr/blocks.py](https://app.codecov.io/gh/datalab-org/datalab/pull/1119?src=pr&el=tree&filepath=pydatalab%2Fsrc%2Fpydatalab%2Fapps%2Fnmr%2Fblocks.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalab-org#diff-cHlkYXRhbGFiL3NyYy9weWRhdGFsYWIvYXBwcy9ubXIvYmxvY2tzLnB5) | `86.25% <80.76%> (+56.83%)` | :arrow_up: |

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: cypress[bot]
created_at: 2025-04-12T13:44:09Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/4kqx5i?utm_source=github&utm_medium=PASSED&utm_campaign=project">datalab</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #3128</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#3128</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/datalab-org/datalab/commit/2bedbdb2a2b98333d88c30cc7cb263e9751fceba">2bedbdb2a2 <span style="cursor:help;" title="Your CI provider generated this commit by merging this branch into the target branch.">ℹ️</span></a>: Merge 0568f3f0f534a1c5b862767aeab1e9ae193b580f into 39a8a70f14165423d24fe4f48e02...</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/4kqx5i?utm_source=github&utm_medium=PASSED&utm_campaign=project">datalab</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/4kqx5i/branches/ml-evs%2Fuse-tmp-for-rasx/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1119">ml-evs/use-tmp-for-rasx</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#3128</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>07m 19s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/datalab-org/datalab/commit/2bedbdb2a2b98333d88c30cc7cb263e9751fceba">2bedbdb2a2 <span style="cursor:help;" title="Your CI provider generated this commit by merging this branch into the target branch.">ℹ️</span></a>: Merge 0568f3f0f534a1c5b862767aeab1e9ae193b580f into 39a8a70f14165423d24fe4f48e02...</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Matthew Evans</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4kqx5i/runs/3128/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>477</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/4kqx5i/branches/ml-evs%2Fuse-tmp-for-rasx/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1119">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE
user: login: ml-evs
created_at: 2025-04-12T14:14:54Z
body: Probably one for @jdbocarsly and @be-smith to review when you get a moment
author_association: MEMBER
user: login: ml-evs
created_at: 2025-04-16T15:38:22Z
body: Made a couple of tweaks to testing since your reviews @be-smith and @BenjaminCharmes, will also wait for @jdbocarsly sign off anyway
author_association: MEMBER