Repository: silx-kit/silx (python)

Issue:
[plot] Display units in addition to the label of axes
When the units are provided as attribute of an axis in a NXdata, I guess it would be quite easy to display them in addition to the label of the axis.

My use-case is quite simple: in SAXS, half of the software expect the scattering vector to be in 1/nm, while the other half expects them to be in 1/Å. Those units differ by a factor 10 so that the user is easily confused between the two.

The initial idea was to provide 2 hoizontal axis, one in 1/nm, the other in 1/Å ...
Then the same story occurred on the vertical axis between absolute scattering intensities and kDa equivalent. This makes then 4 options and as a consequence 3 users out of 4 will be upset by the default display (and are probably not interested in learning how to switch the display).

This is why I believe this issue can easily be addressed by _properly_ labeling the various axis not only with the name of the axe but also the units (this is regardless to the log-scale)


Pull request description:
silx.io.nxdata: add units to dataset name (signal/axes) when present
Checklist:

- [x] The PR title is formatted as: `<Module or Topic>: <Action> <Summary>` (see [contributing guidelines](https://github.com/silx-kit/silx/blob/main/CONTRIBUTING.rst#pull-request-title-format))

Fix #2927 #4199 

The dataset name (signal or axes) is by order of priority:
- `@long_name`
- `"<dataset_name> (@units)"`
- `"<dataset_name>"`

![image](https://github.com/user-attachments/assets/6315e5b4-6f7a-4f68-be58-16301cb6812d)


Patch:
diff --git a/src/silx/io/nxdata/_utils.py b/src/silx/io/nxdata/_utils.py
index 58a9a6a95..2346be466 100644
--- a/src/silx/io/nxdata/_utils.py
+++ b/src/silx/io/nxdata/_utils.py
@@ -94,9 +94,7 @@ def get_attr_as_unicode(
         return copy.deepcopy(attr)
 
 
-def get_uncertainties_names(
-    group: h5py.Group, signal_name: str
-) -> list[str] | None:
+def get_uncertainties_names(group: h5py.Group, signal_name: str) -> list[str] | None:
     # Test consistency of @uncertainties
     uncertainties_names = get_attr_as_unicode(group, "uncertainties")
     if uncertainties_names is None:
@@ -201,3 +199,17 @@ def validate_number_of_axes(
                 + "must define %d or %d axes." % (ndims, INTERPDIM[interpretation])
             )
     return issues
+
+
+def get_dataset_name(nxdata_group: h5py.Group, dataset_name: str | None) -> str | None:
+    if dataset_name is None:
+        return None
+
+    dataset = nxdata_group[dataset_name]
+    if "long_name" in dataset.attrs:
+        return f"{get_attr_as_unicode(dataset, 'long_name')}"
+
+    if "units" in dataset.attrs:
+        return f"{dataset_name} ({get_attr_as_unicode(dataset, 'units')})"
+
+    return dataset_name
diff --git a/src/silx/io/nxdata/parse.py b/src/silx/io/nxdata/parse.py
index 79321b1e9..2e6a6c777 100644
--- a/src/silx/io/nxdata/parse.py
+++ b/src/silx/io/nxdata/parse.py
@@ -52,6 +52,7 @@ from ._utils import (
     Interpretation,
     get_attr_as_unicode,
     INTERPDIM,
+    get_dataset_name,
     nxdata_logger,
     get_uncertainties_names,
     get_signal_name,
@@ -209,10 +210,7 @@ class NXdata:
             nxdata_logger.debug("%s", self.issues)
         else:
             self.signal = self.group[self.signal_dataset_name]
-            self.signal_name = get_attr_as_unicode(self.signal, "long_name")
-
-            if self.signal_name is None:
-                self.signal_name = self.signal_dataset_name
+            self.signal_name = get_dataset_name(self.group, self.signal_dataset_name)
 
             # ndim will be available in very recent h5py versions only
             self.signal_ndim = getattr(self.signal, "ndim", len(self.signal.shape))
@@ -222,15 +220,10 @@ class NXdata:
             self.signal_is_2d = self.signal_ndim == 2
             self.signal_is_3d = self.signal_ndim == 3
 
-            self.axes_names = []
-            # check if axis dataset defines @long_name
-            for _, dsname in enumerate(self.axes_dataset_names):
-                if dsname is not None and "long_name" in self.group[dsname].attrs:
-                    self.axes_names.append(
-                        get_attr_as_unicode(self.group[dsname], "long_name")
-                    )
-                else:
-                    self.axes_names.append(dsname)
+            self.axes_names = [
+                get_dataset_name(self.group, dsname)
+                for dsname in self.axes_dataset_names
+            ]
 
             # excludes scatters
             self.signal_is_1d = (


Test patch:
diff --git a/src/silx/io/test/test_nxdata.py b/src/silx/io/test/test_nxdata.py
index 1d3462c7f..528b075a3 100644
--- a/src/silx/io/test/test_nxdata.py
+++ b/src/silx/io/test/test_nxdata.py
@@ -725,3 +725,25 @@ class TestGetDefault:
         )
         default = nxdata.get_default(hdf5_file)
         assert default is None
+
+
+def test_units(tmp_path):
+    with h5py.File(tmp_path / "nx.h5", "w") as h5file:
+        nxdata_grp = h5file.create_group("NXdata")
+        nxdata_grp.attrs["NX_class"] = "NXdata"
+        signal = nxdata_grp.create_dataset(
+            "Temperature", data=numpy.random.random((10, 20))
+        )
+        x = nxdata_grp.create_dataset("Latitude", data=numpy.linspace(0, 1, 10))
+        y = nxdata_grp.create_dataset("Longitude", data=numpy.linspace(0, 40, 20))
+        nxdata_grp.attrs["signal"] = "Temperature"
+        nxdata_grp.attrs["axes"] = ["Latitude", "Longitude"]
+        signal.attrs["units"] = "K"
+        x.attrs["units"] = "deg"
+        y.attrs["units"] = "sec"
+
+        nxd = nxdata.NXdata(nxdata_grp)
+        assert nxd.signal_name == "Temperature (K)"
+        assert len(nxd.axes_names) == 2
+        assert nxd.axes_names[0] == "Latitude (deg)"
+        assert nxd.axes_names[1] == "Longitude (sec)"