Repository: scipp/scippnexus

Issue:
Support label-based indexing
We have always had in mind to implement [label-based indexing like Scipp](https://scipp.github.io/user-guide/slicing.html#Label-based-indexing). This is required in particular when loading multiple groups at once, where positional indexing is often not meaningful.

In fact, we may consider the current behavior a bug, or at least a discrepancy to how `scipp.DataGroup` behaves: When loading a tree of groups, the dimension length may be ambiguous (indicated by `None`). ScippNexus nevertheless accepts positional indices. Therefore, we get inconsistent behavior:

```python
with snx.File(name) as f:
    dg1 = f['time', 10:20]  # may return some zero-length subgroups, if indices out of range
    dg2 = f[()]['time', 10:20]  # raises if sizes is {'time': None, ...}
```

See scipp/scipp#3371.

Discussion:
SimonHeybrock: Can you add the equivalent of https://github.com/scipp/scippnexus/blob/58ffbbe67e90bf3341a7589e89e00e36859ba712/tests/nxdetector_test.py#L279-L310, but with label-based indexing instead of positional indexing? Note that this is a special case because the `event_time_zero` coord is typically in a subgroup that is handled as a special "event field".
jokasimr: I'm unsure about the `NXlog` case. Perhaps we should talk about that in person.
jokasimr: Okay there are a couple of issues with this implementation.

* I've assumed that the labels are `dimensions` that have coords with the same name. That needs to be fixed.
* It's a bit unclear how the label based slicing should work for hierarchical data, the label based slicing for `DataGroup` in scipp seems to be a bit broken, see example below, so it is unclear how it is supposed to work in scippnexus.

### Something seems wrong with label based slicing for `DataGroup`

Example from the docs:
```python
da = sc.DataArray(
    data=sc.array(dims=['time', 'x'], values=np.random.random((3, 7))),
    coords={
        'x': sc.array(dims=['x'], values=np.linspace(0.1, 0.9, num=7), unit='m'),
        'year': sc.array(dims=['time'], values=[2020, 2023, 2027]),
    },
)
da['year', sc.scalar(2023)].dims # (x,)
```
everything works as expected for `DataArray`.

But for `DataGroup`
```python
dg = sc.DataGroup(
    a=sc.DataArray(
        data=sc.array(dims=['time', 'x'], values=np.random.random((3, 7))),
        coords={
            'x': sc.array(dims=['x'], values=np.linspace(0.1, 0.9, num=7), unit='m'),
            'year': sc.array(dims=['time'], values=[2020, 2023, 2027]),
        },
    ),
)
dg['year', sc.scalar(2023)].dims   # ('time', 'x')
```
the data is not sliced. I'd expect the above to produce a data group with `a` mapped to the sliced `DataArray`.
SimonHeybrock: Initially `scipp.DataArray` was only support label-based indexing with dimension-coords (i.e., coord name matching dim names). It seems `scipp.DataGroup` still does this... and it might actually be hard/unrealistic to change since we would otherwise need to recursively get a listing of all coords of all children. `scipp.DataGroup` does not "know" about coords, this is not a change I would like to make.

Another approach might be to simply forward all slicing to children, and let them decide if it applies. But that would require not raising on label-based indexing if the coord does not exist, which seems even worse.
jokasimr: Initially my feeling was that your last suggestion was the most sensible approach, letting each leaf in the data group determine if the slicing is applicable and behave accordingly. This seems to best match most realistic use cases, for example, slicing multiple different detectors / measurements to a particular time interval.

But this has some drawbacks

* no error message if you spell the coord wrong, in scippnexus this behavior would silently load a much larger dataset than intended
* just because cords are named the same does not mean that they refer to the same quantity, they might not even have the same dimension, slicing different leafs in a data group in different dimensions just because their coords are named the same does not seem  convenient 
SimonHeybrock: In conclusion, I think we should not change the `DataGroup` behavior right now (but maybe add a note in the docstring about the difference to `DataArray`).
jokasimr: Then I guess we should also make the scippnexus label based slicing behave the same, that is, when slicing a group only accept slicing dim-coords, and when slicing a data array accept any coord
SimonHeybrock: I think that sounds ok for now!
jokasimr:  The tests are failing because I included the test that compares to label slicing a data group in scipp, just to illustrate the problem.
 
 I think this is ready for another review.
jokasimr: The error messages should probably be changed a bit, they are not very clear.

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 8945f9d..ca5a241 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,7 +32,7 @@ requires-python = ">=3.9"
 # Make sure to list one dependency per line.
 dependencies = [
     "python-dateutil",
-    "scipp>=23.07.0",
+    "scipp>=24.02.0",
     "scipy",
     "h5py",
 ]
diff --git a/requirements/base.txt b/requirements/base.txt
index 61bce98..39249cf 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -21,7 +21,7 @@ jedi==0.19.1
     # via ipython
 matplotlib-inline==0.1.6
     # via ipython
-numpy==1.26.2
+numpy==1.26.4
     # via
     #   h5py
     #   scipp
@@ -46,7 +46,7 @@ python-dateutil==2.8.2
     # via -r base.in
 scipp==24.2.0
     # via -r base.in
-scipy==1.11.4
+scipy==1.12.0
     # via -r base.in
 six==1.16.0
     # via
@@ -54,9 +54,9 @@ six==1.16.0
     #   python-dateutil
 stack-data==0.6.3
     # via ipython
-traitlets==5.14.0
+traitlets==5.14.1
     # via
     #   ipython
     #   matplotlib-inline
-wcwidth==0.2.12
+wcwidth==0.2.13
     # via prompt-toolkit
diff --git a/requirements/ci.txt b/requirements/ci.txt
index 279a173..1993875 100644
--- a/requirements/ci.txt
+++ b/requirements/ci.txt
@@ -7,7 +7,7 @@
 #
 cachetools==5.3.2
     # via tox
-certifi==2023.11.17
+certifi==2024.2.2
     # via requests
 chardet==5.2.0
     # via tox
@@ -15,7 +15,7 @@ charset-normalizer==3.3.2
     # via requests
 colorama==0.4.6
     # via tox
-distlib==0.3.7
+distlib==0.3.8
     # via virtualenv
 filelock==3.13.1
     # via
@@ -23,7 +23,7 @@ filelock==3.13.1
     #   virtualenv
 gitdb==4.0.11
     # via gitpython
-gitpython==3.1.40
+gitpython==3.1.42
     # via -r ci.in
 idna==3.6
     # via requests
@@ -32,11 +32,11 @@ packaging==23.2
     #   -r ci.in
     #   pyproject-api
     #   tox
-platformdirs==4.1.0
+platformdirs==4.2.0
     # via
     #   tox
     #   virtualenv
-pluggy==1.3.0
+pluggy==1.4.0
     # via tox
 pyproject-api==1.6.1
     # via tox
@@ -48,9 +48,9 @@ tomli==2.0.1
     # via
     #   pyproject-api
     #   tox
-tox==4.11.4
+tox==4.13.0
     # via -r ci.in
-urllib3==2.1.0
+urllib3==2.2.1
     # via requests
 virtualenv==20.25.0
     # via tox
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 4b3e502..8352d76 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -14,8 +14,10 @@
 -r wheels.txt
 annotated-types==0.6.0
     # via pydantic
-anyio==4.1.0
-    # via jupyter-server
+anyio==4.3.0
+    # via
+    #   httpx
+    #   jupyter-server
 argon2-cffi==23.1.0
     # via jupyter-server
 argon2-cffi-bindings==21.2.0
@@ -30,14 +32,20 @@ click==8.1.7
     # via
     #   pip-compile-multi
     #   pip-tools
-copier==9.1.0
+copier==9.1.1
     # via -r dev.in
-dunamai==1.19.0
+dunamai==1.19.2
     # via copier
 fqdn==1.5.1
     # via jsonschema
 funcy==2.0
     # via copier
+h11==0.14.0
+    # via httpcore
+httpcore==1.0.3
+    # via httpx
+httpx==0.26.0
+    # via jupyterlab
 isoduration==20.11.0
     # via jsonschema
 jinja2-ansible-filters==1.3.2
@@ -46,50 +54,50 @@ json5==0.9.14
     # via jupyterlab-server
 jsonpointer==2.4
     # via jsonschema
-jsonschema[format-nongpl]==4.20.0
+jsonschema[format-nongpl]==4.21.1
     # via
     #   jupyter-events
     #   jupyterlab-server
     #   nbformat
 jupyter-events==0.9.0
     # via jupyter-server
-jupyter-lsp==2.2.1
+jupyter-lsp==2.2.2
     # via jupyterlab
-jupyter-server==2.12.1
+jupyter-server==2.12.5
     # via
     #   jupyter-lsp
     #   jupyterlab
     #   jupyterlab-server
     #   notebook-shim
-jupyter-server-terminals==0.4.4
+jupyter-server-terminals==0.5.2
     # via jupyter-server
-jupyterlab==4.0.9
+jupyterlab==4.1.1
     # via -r dev.in
-jupyterlab-server==2.25.2
+jupyterlab-server==2.25.3
     # via jupyterlab
-notebook-shim==0.2.3
+notebook-shim==0.2.4
     # via jupyterlab
-overrides==7.4.0
+overrides==7.7.0
     # via jupyter-server
-pathspec==0.11.2
+pathspec==0.12.1
     # via copier
 pip-compile-multi==2.6.3
     # via -r dev.in
-pip-tools==7.3.0
+pip-tools==7.4.0
     # via pip-compile-multi
 plumbum==1.8.2
     # via copier
-prometheus-client==0.19.0
+prometheus-client==0.20.0
     # via jupyter-server
 pycparser==2.21
     # via cffi
-pydantic==2.5.2
+pydantic==2.6.1
     # via copier
-pydantic-core==2.14.5
+pydantic-core==2.16.2
     # via pydantic
 python-json-logger==2.0.7
     # via jupyter-events
-pyyaml-include==1.3.1
+pyyaml-include==1.3.2
     # via copier
 questionary==2.0.1
     # via copier
@@ -104,14 +112,16 @@ rfc3986-validator==0.1.1
 send2trash==1.8.2
     # via jupyter-server
 sniffio==1.3.0
-    # via anyio
+    # via
+    #   anyio
+    #   httpx
 terminado==0.18.0
     # via
     #   jupyter-server
     #   jupyter-server-terminals
 toposort==1.10
     # via pip-compile-multi
-types-python-dateutil==2.8.19.14
+types-python-dateutil==2.8.19.20240106
     # via arrow
 uri-template==1.3.0
     # via jsonschema
diff --git a/requirements/docs.txt b/requirements/docs.txt
index f497722..f78d5ce 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -8,33 +8,33 @@
 -r base.txt
 accessible-pygments==0.0.4
     # via pydata-sphinx-theme
-alabaster==0.7.13
+alabaster==0.7.16
     # via sphinx
-attrs==23.1.0
+attrs==23.2.0
     # via
     #   jsonschema
     #   referencing
-babel==2.13.1
+babel==2.14.0
     # via
     #   pydata-sphinx-theme
     #   sphinx
-beautifulsoup4==4.12.2
+beautifulsoup4==4.12.3
     # via
     #   nbconvert
     #   pydata-sphinx-theme
 bleach==6.1.0
     # via nbconvert
-certifi==2023.11.17
+certifi==2024.2.2
     # via requests
 charset-normalizer==3.3.2
     # via requests
-comm==0.2.0
+comm==0.2.1
     # via ipykernel
 contourpy==1.2.0
     # via matplotlib
 cycler==0.12.1
     # via matplotlib
-debugpy==1.8.0
+debugpy==1.8.1
     # via ipykernel
 defusedxml==0.7.1
     # via nbconvert
@@ -44,38 +44,31 @@ docutils==0.20.1
     #   nbsphinx
     #   pydata-sphinx-theme
     #   sphinx
-fastjsonschema==2.19.0
+fastjsonschema==2.19.1
     # via nbformat
-fonttools==4.46.0
+fonttools==4.49.0
     # via matplotlib
 idna==3.6
     # via requests
 imagesize==1.4.1
     # via sphinx
-importlib-metadata==7.0.0
-    # via
-    #   jupyter-client
-    #   nbconvert
-    #   sphinx
-importlib-resources==6.1.1
-    # via matplotlib
-ipykernel==6.27.1
+ipykernel==6.29.2
     # via -r docs.in
-jinja2==3.1.2
+jinja2==3.1.3
     # via
     #   myst-parser
     #   nbconvert
     #   nbsphinx
     #   sphinx
-jsonschema==4.20.0
+jsonschema==4.21.1
     # via nbformat
-jsonschema-specifications==2023.11.2
+jsonschema-specifications==2023.12.1
     # via jsonschema
 jupyter-client==8.6.0
     # via
     #   ipykernel
     #   nbclient
-jupyter-core==5.5.0
+jupyter-core==5.7.1
     # via
     #   ipykernel
     #   jupyter-client
@@ -90,11 +83,11 @@ markdown-it-py==3.0.0
     # via
     #   mdit-py-plugins
     #   myst-parser
-markupsafe==2.1.3
+markupsafe==2.1.5
     # via
     #   jinja2
     #   nbconvert
-matplotlib==3.8.2
+matplotlib==3.8.3
     # via
     #   -r docs.in
     #   plopp
@@ -108,7 +101,7 @@ myst-parser==2.0.0
     # via -r docs.in
 nbclient==0.9.0
     # via nbconvert
-nbconvert==7.12.0
+nbconvert==7.16.0
     # via nbsphinx
 nbformat==5.9.2
     # via
@@ -117,7 +110,7 @@ nbformat==5.9.2
     #   nbsphinx
 nbsphinx==0.9.3
     # via -r docs.in
-nest-asyncio==1.5.8
+nest-asyncio==1.6.0
     # via ipykernel
 packaging==23.2
     # via
@@ -127,21 +120,21 @@ packaging==23.2
     #   pooch
     #   pydata-sphinx-theme
     #   sphinx
-pandocfilters==1.5.0
+pandocfilters==1.5.1
     # via nbconvert
-pillow==10.1.0
+pillow==10.2.0
     # via matplotlib
-platformdirs==4.1.0
+platformdirs==4.2.0
     # via
     #   jupyter-core
     #   pooch
-plopp==23.11.0
+plopp==24.1.1
     # via -r docs.in
 pooch==1.8.0
     # via -r docs.in
-psutil==5.9.6
+psutil==5.9.8
     # via ipykernel
-pydata-sphinx-theme==0.14.4
+pydata-sphinx-theme==0.15.2
     # via -r docs.in
 pyparsing==3.1.1
     # via matplotlib
@@ -151,7 +144,7 @@ pyzmq==25.1.2
     # via
     #   ipykernel
     #   jupyter-client
-referencing==0.31.1
+referencing==0.33.0
     # via
     #   jsonschema
     #   jsonschema-specifications
@@ -159,7 +152,7 @@ requests==2.31.0
     # via
     #   pooch
     #   sphinx
-rpds-py==0.13.2
+rpds-py==0.18.0
     # via
     #   jsonschema
     #   referencing
@@ -176,28 +169,23 @@ sphinx==7.2.6
     #   sphinx-autodoc-typehints
     #   sphinx-copybutton
     #   sphinx-design
-    #   sphinxcontrib-applehelp
-    #   sphinxcontrib-devhelp
-    #   sphinxcontrib-htmlhelp
-    #   sphinxcontrib-qthelp
-    #   sphinxcontrib-serializinghtml
-sphinx-autodoc-typehints==1.25.2
+sphinx-autodoc-typehints==2.0.0
     # via -r docs.in
 sphinx-copybutton==0.5.2
     # via -r docs.in
 sphinx-design==0.5.0
     # via -r docs.in
-sphinxcontrib-applehelp==1.0.7
+sphinxcontrib-applehelp==1.0.8
     # via sphinx
-sphinxcontrib-devhelp==1.0.5
+sphinxcontrib-devhelp==1.0.6
     # via sphinx
-sphinxcontrib-htmlhelp==2.0.4
+sphinxcontrib-htmlhelp==2.0.5
     # via sphinx
 sphinxcontrib-jsmath==1.0.1
     # via sphinx
-sphinxcontrib-qthelp==1.0.6
+sphinxcontrib-qthelp==1.0.7
     # via sphinx
-sphinxcontrib-serializinghtml==1.1.9
+sphinxcontrib-serializinghtml==1.1.10
     # via sphinx
 tinycss2==1.2.1
     # via nbconvert
@@ -205,15 +193,11 @@ tornado==6.4
     # via
     #   ipykernel
     #   jupyter-client
-typing-extensions==4.8.0
+typing-extensions==4.9.0
     # via pydata-sphinx-theme
-urllib3==2.1.0
+urllib3==2.2.1
     # via requests
 webencodings==0.5.1
     # via
     #   bleach
     #   tinycss2
-zipp==3.17.0
-    # via
-    #   importlib-metadata
-    #   importlib-resources
diff --git a/requirements/mypy.txt b/requirements/mypy.txt
index f98e1a5..ac28568 100644
--- a/requirements/mypy.txt
+++ b/requirements/mypy.txt
@@ -6,9 +6,9 @@
 #    pip-compile-multi
 #
 -r test.txt
-mypy==1.7.1
+mypy==1.8.0
     # via -r mypy.in
 mypy-extensions==1.0.0
     # via mypy
-typing-extensions==4.8.0
+typing-extensions==4.9.0
     # via mypy
diff --git a/requirements/nightly.in b/requirements/nightly.in
index 84c4b08..5171585 100644
--- a/requirements/nightly.in
+++ b/requirements/nightly.in
@@ -4,4 +4,4 @@
 python-dateutil
 scipy
 h5py
-https://github.com/scipp/scipp/releases/download/nightly/scipp-nightly-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+https://github.com/scipp/scipp/releases/download/nightly/scipp-nightly-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
diff --git a/requirements/nightly.txt b/requirements/nightly.txt
index de99562..d53eae8 100644
--- a/requirements/nightly.txt
+++ b/requirements/nightly.txt
@@ -1,4 +1,4 @@
-# SHA1:799a1fd7b9563a4277cadc6e4fc5fad5039cad2c
+# SHA1:6cffc86dc2938853053dd653f44046481a5e0580
 #
 # This file is autogenerated by pip-compile-multi
 # To update, run:
@@ -6,20 +6,18 @@
 #    pip-compile-multi
 #
 -r basetest.txt
-graphlib-backport==1.0.3
-    # via scipp
 h5py==3.10.0
     # via -r nightly.in
-numpy==1.26.2
+numpy==1.26.4
     # via
     #   h5py
     #   scipp
     #   scipy
 python-dateutil==2.8.2
     # via -r nightly.in
-scipp @ https://github.com/scipp/scipp/releases/download/nightly/scipp-nightly-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+scipp @ https://github.com/scipp/scipp/releases/download/nightly/scipp-nightly-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
     # via -r nightly.in
-scipy==1.11.4
+scipy==1.12.0
     # via -r nightly.in
 six==1.16.0
     # via python-dateutil
diff --git a/requirements/static.txt b/requirements/static.txt
index 38c7aa8..68bf60c 100644
--- a/requirements/static.txt
+++ b/requirements/static.txt
@@ -7,17 +7,17 @@
 #
 cfgv==3.4.0
     # via pre-commit
-distlib==0.3.7
+distlib==0.3.8
     # via virtualenv
 filelock==3.13.1
     # via virtualenv
-identify==2.5.33
+identify==2.5.35
     # via pre-commit
 nodeenv==1.8.0
     # via pre-commit
-platformdirs==4.1.0
+platformdirs==4.2.0
     # via virtualenv
-pre-commit==3.5.0
+pre-commit==3.6.2
     # via -r static.in
 pyyaml==6.0.1
     # via pre-commit
diff --git a/requirements/wheels.txt b/requirements/wheels.txt
index d5378cf..2e33cfa 100644
--- a/requirements/wheels.txt
+++ b/requirements/wheels.txt
@@ -7,8 +7,6 @@
 #
 build==1.0.3
     # via -r wheels.in
-importlib-metadata==7.0.0
-    # via build
 packaging==23.2
     # via build
 pyproject-hooks==1.0.0
@@ -17,5 +15,3 @@ tomli==2.0.1
     # via
     #   build
     #   pyproject-hooks
-zipp==3.17.0
-    # via importlib-metadata
diff --git a/src/scippnexus/_common.py b/src/scippnexus/_common.py
index 6b2bf4d..8b75835 100644
--- a/src/scippnexus/_common.py
+++ b/src/scippnexus/_common.py
@@ -81,7 +81,7 @@ def _to_canonical_select(
                 "but multiple indices {select} were specified."
             )
         return {dims[0]: select[0]}
-    elif isinstance(select, int) or isinstance(select, slice):
+    elif isinstance(select, (int, sc.Variable)) or isinstance(select, slice):
         check_1d()
         return {dims[0]: select}
     if not isinstance(select, dict):
diff --git a/src/scippnexus/base.py b/src/scippnexus/base.py
index c0d119d..c6e8f04 100644
--- a/src/scippnexus/base.py
+++ b/src/scippnexus/base.py
@@ -12,6 +12,7 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, overload
 
 import numpy as np
 import scipp as sc
+from scipp.core import label_based_index_to_positional_index
 
 from ._cache import cached_property
 from ._common import to_child_select
@@ -140,6 +141,46 @@ class NXobject:
         """
         return dg
 
+    def convert_label_index_to_positional(self, sel):
+        if isinstance(sel, dict):
+            return dict(
+                (self.convert_label_index_to_positional(s) for s in sel.items())
+            )
+        if (
+            isinstance(sel, tuple)
+            and len(sel) > 1
+            and (
+                # Scalar label index
+                isinstance((index := sel[1]), sc.Variable)
+                or (
+                    # Slice label index
+                    isinstance(index, slice)
+                    and (
+                        isinstance(index.start, sc.Variable)
+                        or isinstance(index.stop, sc.Variable)
+                    )
+                )
+            )
+        ):
+            if (dim := sel[0]) in self.sizes:
+                if (coord := self._children.get(dim)) is not None:
+                    return label_based_index_to_positional_index(
+                        self.sizes, coord[()], index
+                    )
+                if isinstance(self._signal, Field):
+                    # If it is *not* a field, the translation can happen in subgroup
+                    raise sc.DimensionError(
+                        (
+                            f'Invalid slice dimension: \'{dim}\': '
+                            f'no coordinate for that dimension. '
+                            f'Coordinates are {tuple(self._children.keys())}'
+                        )
+                    )
+
+        # It is not a label index, or the index will be translated in subgroup.
+        # Either way, pass it on.
+        return sel
+
 
 class NXroot(NXobject):
     pass
diff --git a/src/scippnexus/nxdata.py b/src/scippnexus/nxdata.py
index 2d6839c..55c1e5c 100644
--- a/src/scippnexus/nxdata.py
+++ b/src/scippnexus/nxdata.py
@@ -329,6 +329,10 @@ class NXdata(NXobject):
         )
         return child[child_sel]
 
+    def read_children(self, sel: ScippIndex) -> sc.DataGroup:
+        sel = self.convert_label_index_to_positional(sel)
+        return super().read_children(sel)
+
     def assemble(
         self, dg: sc.DataGroup
     ) -> Union[sc.DataGroup, sc.DataArray, sc.Dataset]:
diff --git a/src/scippnexus/nxevent_data.py b/src/scippnexus/nxevent_data.py
index 27e0b22..3485fd8 100644
--- a/src/scippnexus/nxevent_data.py
+++ b/src/scippnexus/nxevent_data.py
@@ -66,6 +66,7 @@ class NXevent_data(NXobject):
         if not children:  # TODO Check that select is trivial?
             return sc.DataGroup()
 
+        select = self.convert_label_index_to_positional(select)
         index = to_plain_index([_pulse_dimension], select)
         event_time_zero = children['event_time_zero'][index]
         last_loaded, event_index = self._get_event_index(children, index)


Test patch:
diff --git a/requirements/basetest.txt b/requirements/basetest.txt
index 6bf43fa..bc93620 100644
--- a/requirements/basetest.txt
+++ b/requirements/basetest.txt
@@ -11,9 +11,9 @@ iniconfig==2.0.0
     # via pytest
 packaging==23.2
     # via pytest
-pluggy==1.3.0
+pluggy==1.4.0
     # via pytest
-pytest==7.4.3
+pytest==8.0.1
     # via -r basetest.in
 tomli==2.0.1
     # via pytest
diff --git a/tests/nexus_test.py b/tests/nexus_test.py
index e4aa228..cfcb944 100644
--- a/tests/nexus_test.py
+++ b/tests/nexus_test.py
@@ -517,6 +517,34 @@ def test_nxdata_positional_indexing_returns_correct_slice(h5root):
     assert sc.identical(da, ref['time', 0:2])
 
 
+def test_nxdata_label_indexing_returns_correct_slice(h5root):
+    entry = h5root.create_group('entry')
+    data = entry.create_group('data')
+    data.attrs['NX_class'] = 'NXdata'
+    data.attrs['signal'] = 'signal'
+    data.attrs['axes'] = ['time', 'temperature']
+    data.attrs['time_indices'] = [0]
+    data.attrs['temperature_indices'] = [1]
+    ref = sc.DataArray(
+        data=sc.ones(dims=['time', 'temperature'], shape=[3, 4], unit='m')
+    )
+    ref.coords['time'] = sc.array(dims=['time'], values=np.arange(3), unit='s')
+    ref.coords['temperature'] = sc.array(
+        dims=['temperature'], values=np.arange(4), unit='K'
+    )
+    data['signal'] = ref.values
+    data['signal'].attrs['units'] = str(ref.unit)
+    data['time'] = ref.coords['time'].values
+    data['time'].attrs['units'] = str(ref.coords['time'].unit)
+    data['temperature'] = ref.coords['temperature'].values
+    data['temperature'].attrs['units'] = str(ref.coords['temperature'].unit)
+    obj = snx.Group(data, definitions=snx.base_definitions())
+    da = obj['time', sc.scalar(0, unit='s') : sc.scalar(2, unit='s')]
+    assert sc.identical(
+        da, ref['time', sc.scalar(0, unit='s') : sc.scalar(2, unit='s')]
+    )
+
+
 def test_nxdata_with_bin_edges_positional_indexing_returns_correct_slice(h5root):
     entry = h5root.create_group('entry')
     data = entry.create_group('data')
@@ -543,6 +571,90 @@ def test_nxdata_with_bin_edges_positional_indexing_returns_correct_slice(h5root)
     assert sc.identical(da, ref['temperature', 0:2])
 
 
+def test_nxdata_with_bin_edges_label_indexing_returns_correct_slice(h5root):
+    entry = h5root.create_group('entry')
+    data = entry.create_group('data')
+    data.attrs['NX_class'] = 'NXdata'
+    data.attrs['signal'] = 'signal'
+    data.attrs['axes'] = ['time', 'temperature']
+    data.attrs['time_indices'] = [0]
+    data.attrs['temperature_indices'] = [1]
+    ref = sc.DataArray(
+        data=sc.ones(dims=['time', 'temperature'], shape=[3, 4], unit='m')
+    )
+    ref.coords['time'] = sc.array(dims=['time'], values=np.arange(3), unit='s')
+    ref.coords['temperature'] = sc.array(
+        dims=['temperature'], values=np.arange(5), unit='K'
+    )
+    data['signal'] = ref.values
+    data['signal'].attrs['units'] = str(ref.unit)
+    data['time'] = ref.coords['time'].values
+    data['time'].attrs['units'] = str(ref.coords['time'].unit)
+    data['temperature'] = ref.coords['temperature'].values
+    data['temperature'].attrs['units'] = str(ref.coords['temperature'].unit)
+    obj = snx.Group(data, definitions=snx.base_definitions())
+    da = obj['temperature', sc.scalar(0, unit='K') : sc.scalar(2, unit='K')]
+    assert sc.identical(
+        da, ref['temperature', sc.scalar(0, unit='K') : sc.scalar(2, unit='K')]
+    )
+
+
+def create_nexus_group_with_data_arrays(h5root, dims, coords):
+    entry = h5root.create_group('entry')
+    for i, (dim, coord) in enumerate(zip(dims, coords)):
+        data = entry.create_group(f'data_{i}')
+        data.attrs['NX_class'] = 'NXdata'
+        data['signal'] = np.arange(5)
+        data['signal'].attrs['units'] = 'm'
+        data[coord] = np.arange(5)
+        data[coord].attrs['units'] = 's'
+        data.attrs['signal'] = 'signal'
+        data.attrs['axes'] = [dim]
+        data.attrs[f'{dim}_indices'] = [0]
+    return snx.Group(h5root, definitions=snx.base_definitions())
+
+
+@pytest.mark.parametrize(
+    '_slice',
+    (
+        ('time', sc.scalar(1, unit='s')),
+        ('time', slice(sc.scalar(1, unit='s'), None)),
+        ('time', slice(None, sc.scalar(1, unit='s'))),
+        {'time': sc.scalar(1, unit='s'), 'x': sc.scalar(1, unit='s')},
+    ),
+)
+@pytest.mark.parametrize(
+    'dims, coords',
+    (
+        (('time',), ('time2',)),
+        (('time', 'time'), ('time2', 'time')),
+    ),
+)
+def test_label_indexing_group_behaves_same_as_indexing_scipp_datagroup(
+    h5root, _slice, dims, coords
+):
+    nx = create_nexus_group_with_data_arrays(h5root, dims, coords)
+    dg = nx[()]
+
+    exception = None
+    try:
+        # Scipp does not support dict slicing,
+        # manually slice datagroup in multiple coords
+        if isinstance(_slice, dict):
+            for s in _slice.items():
+                dg = dg[s]
+        else:
+            dg = dg[_slice]
+    except Exception as e:
+        exception = type(e)
+
+    if exception:
+        with pytest.raises(exception):
+            nx[_slice]
+    else:
+        assert_identical(nx[_slice], dg)
+
+
 def test_create_field_saves_errors(nxroot):
     entry = nxroot['entry']
     data = sc.array(
diff --git a/tests/nxdata_test.py b/tests/nxdata_test.py
index 74ed75c..36bbd97 100644
--- a/tests/nxdata_test.py
+++ b/tests/nxdata_test.py
@@ -97,6 +97,24 @@ def test_slice_of_1d(h5root):
     assert sc.identical(data[:2], da['xx', :2])
 
 
+def test_label_slice_of_1d(h5root):
+    da = sc.DataArray(sc.array(dims=['xx'], unit='m', values=[1, 2, 3]))
+    da.coords['xx'] = da.data
+    da.coords['xx2'] = da.data
+    da.coords['scalar'] = sc.scalar(1.2)
+    data = snx.create_class(h5root, 'data1', NXdata)
+    data.attrs['axes'] = da.dims
+    data.attrs['signal'] = 'signal'
+    snx.create_field(data, 'signal', da.data)
+    snx.create_field(data, 'xx', da.coords['xx'])
+    snx.create_field(data, 'xx2', da.coords['xx2'])
+    snx.create_field(data, 'scalar', da.coords['scalar'])
+    data = snx.Group(data, definitions=snx.base_definitions())
+    assert sc.identical(
+        data['xx', : sc.scalar(3, unit='m')], da['xx', : sc.scalar(3, unit='m')]
+    )
+
+
 def test_slice_of_multiple_coords(h5root):
     da = sc.DataArray(
         sc.array(dims=['xx', 'yy'], unit='m', values=[[1, 2, 3], [4, 5, 6]])
@@ -112,7 +130,9 @@ def test_slice_of_multiple_coords(h5root):
     snx.create_field(data, 'xx2', da.coords['xx2'])
     snx.create_field(data, 'yy', da.coords['yy'])
     data = snx.Group(data, definitions=snx.base_definitions())
-    assert sc.identical(data['xx', :2], da['xx', :2])
+    assert sc.identical(
+        data['xx', : sc.scalar(3, unit='m')], da['xx', : sc.scalar(3, unit='m')]
+    )
 
 
 def test_guessed_dim_for_2d_coord_not_matching_axis_name(h5root):
@@ -696,6 +716,96 @@ def test_slicing_raises_given_invalid_index(h5root):
         data['zz', 0]
 
 
+def test_label_based_slicing(h5root):
+    da = sc.DataArray(
+        sc.array(dims=['xx', 'yy'], unit='m', values=[[1, 2], [4, 5]]),
+        coords=dict(
+            xx=sc.array(dims=['xx'], unit='m', values=[1.0, 2.0]),
+            yy=sc.array(dims=['yy'], unit='m', values=[0.1, 0.0]),
+        ),
+    )
+    data = snx.create_class(h5root, 'data1', NXdata)
+    snx.create_field(data, 'signal', da.data)
+    snx.create_field(data, 'xx', da.coords['xx'])
+    snx.create_field(data, 'yy', da.coords['yy'])
+    data.attrs['axes'] = da.dims
+    data.attrs['signal'] = 'signal'
+    data = snx.Group(data, definitions=snx.base_definitions())
+    sc.testing.assert_identical(
+        data['xx', sc.scalar(1.0, unit='m') : sc.scalar(3.0, unit='m')],
+        da['xx', sc.scalar(1.0, unit='m') : sc.scalar(3.0, unit='m')],
+    )
+    sc.testing.assert_identical(
+        data['yy', sc.scalar(0.2, unit='m') : sc.scalar(0.01, unit='m')],
+        da['yy', sc.scalar(0.2, unit='m') : sc.scalar(0.01, unit='m')],
+    )
+
+
+def create_nxdata(h5root, dims, coords):
+    da = sc.DataArray(
+        sc.array(dims=dims, unit='m', values=np.random.randn(*(5 for _ in dims))),
+        coords={
+            coord: sc.linspace(dim, 0, 1, 5, unit='s')
+            for dim, coord in zip(dims, coords)
+        },
+    )
+    data = snx.create_class(h5root, 'data1', NXdata)
+    snx.create_field(data, 'signal', da.data)
+    for dim, coord in zip(dims, coords):
+        snx.create_field(data, dim, da.coords[coord])
+
+    data.attrs['axes'] = da.dims
+    data.attrs['signal'] = 'signal'
+    data = snx.Group(data, definitions=snx.base_definitions())
+    return data
+
+
+@pytest.mark.parametrize(
+    '_slice',
+    (
+        ('time', sc.scalar(1, unit='s')),
+        ('time', slice(sc.scalar(1, unit='s'), None)),
+        ('time', slice(None, sc.scalar(1, unit='s'))),
+        ('time', slice(sc.scalar(1, unit='s'), sc.scalar(3, unit='s'))),
+        {'time': sc.scalar(1, unit='s'), 'x': sc.scalar(1, unit='s')},
+        {'x': sc.scalar(1, unit='s')},
+    ),
+)
+@pytest.mark.parametrize(
+    'dims, coords',
+    (
+        (('time',), ('time',)),
+        (('time',), ('time2',)),
+        (('time', 'x'), ('time2', 'x')),
+        (('x', 'y'), ('time2', 'time')),
+        (('x', 'y'), ('x', 'y')),
+    ),
+)
+def test_label_indexing_dataset_behaves_same_as_indexing_scipp_dataarray(
+    h5root, _slice, dims, coords
+):
+    nx = create_nxdata(h5root, dims, coords)
+    da = nx[()]
+
+    exception = None
+    try:
+        # Scipp does not support dict slicing,
+        # manually slice datagroup in multiple coords
+        if isinstance(_slice, dict):
+            for s in _slice.items():
+                da = da[s]
+        else:
+            da = da[_slice]
+    except Exception as e:
+        exception = type(e)
+
+    if exception:
+        with pytest.raises(exception):
+            nx[_slice]
+    else:
+        assert_identical(nx[_slice], da)
+
+
 def test_scalar_signal_without_unit_works(h5root):
     da = sc.DataArray(
         sc.scalar(1.1, unit=None), coords={'xx': sc.scalar(2.0, unit='m')}
diff --git a/tests/nxdetector_test.py b/tests/nxdetector_test.py
index 31ae3a7..1ae2838 100644
--- a/tests/nxdetector_test.py
+++ b/tests/nxdetector_test.py
@@ -310,6 +310,40 @@ def test_select_events_slices_underlying_event_data(nxroot):
     )
 
 
+def test_label_based_select_events_slices_underlying_event_data(nxroot):
+    detector = nxroot.create_class('detector0', NXdetector)
+    detector.create_field('detector_number', detector_numbers_xx_yy_1234())
+    create_event_data_ids_1234(detector.create_class('events', snx.NXevent_data))
+    da = detector['event_time_zero', : sc.scalar(3, unit='s')]['events']
+    assert sc.identical(
+        da.bins.size().data,
+        sc.array(
+            dims=['dim_0', 'dim_1'], unit=None, dtype='int64', values=[[1, 1], [0, 1]]
+        ),
+    )
+    da = detector['event_time_zero', : sc.scalar(4, unit='s')]['events']
+    assert sc.identical(
+        da.bins.size().data,
+        sc.array(
+            dims=['dim_0', 'dim_1'], unit=None, dtype='int64', values=[[2, 2], [0, 1]]
+        ),
+    )
+    da = detector['event_time_zero', sc.scalar(4, unit='s')]['events']
+    assert sc.identical(
+        da.bins.size().data,
+        sc.array(
+            dims=['dim_0', 'dim_1'], unit=None, dtype='int64', values=[[0, 1], [0, 0]]
+        ),
+    )
+    da = detector[()]['events']
+    assert sc.identical(
+        da.bins.size().data,
+        sc.array(
+            dims=['dim_0', 'dim_1'], unit=None, dtype='int64', values=[[2, 3], [0, 1]]
+        ),
+    )
+
+
 def test_loading_event_data_without_detector_numbers_does_not_group_events(nxroot):
     detector = nxroot.create_class('detector0', NXdetector)
     create_event_data_ids_1234(detector.create_class('events', snx.NXevent_data))
diff --git a/tests/nxlog_test.py b/tests/nxlog_test.py
index 112a11e..9e06b0b 100644
--- a/tests/nxlog_test.py
+++ b/tests/nxlog_test.py
@@ -158,7 +158,7 @@ def test_nxlog_with_shape_0(nxroot):
     assert_identical(log[...], da.rename(ignored='dim_1'))
 
 
-def test_log_with_connection_status_raises_with_positional_indexing(h5root):
+def test_log_with_connection_status_raises_with_positional_and_label_indexing(h5root):
     da = sc.DataArray(
         sc.array(dims=['time'], values=[1.1, 2.2, 3.3]),
         coords={
@@ -176,6 +176,8 @@ def test_log_with_connection_status_raises_with_positional_indexing(h5root):
     log = snx.Group(log, definitions=snx.base_definitions())
     with pytest.raises(sc.DimensionError):
         log['time', :2]
+    with pytest.raises(sc.DimensionError):
+        log['time', : sc.scalar(60, unit='s')]
     with pytest.raises(sc.DimensionError):
         log[:2]
     with pytest.raises(sc.DimensionError):
diff --git a/tests/nxtransformations_test.py b/tests/nxtransformations_test.py
index 0f4406d..5360245 100644
--- a/tests/nxtransformations_test.py
+++ b/tests/nxtransformations_test.py
@@ -434,6 +434,43 @@ def test_slice_transformations(h5root):
     )
 
 
+def test_label_slice_transformations(h5root):
+    transformations = snx.create_class(h5root, 'transformations', NXtransformations)
+    log = sc.DataArray(
+        sc.array(dims=['time'], values=[1.1, 2.2, 3.3], unit='m'),
+        coords={'time': sc.array(dims=['time'], values=[11, 22, 33], unit='s')},
+    )
+    log.coords['time'] = sc.epoch(unit='ns') + log.coords['time'].to(unit='ns')
+    offset = sc.spatial.translation(value=[1, 2, 3], unit='m')
+    vector = sc.vector(value=[0, 0, 1])
+    t = log * vector
+    t.data = sc.spatial.translations(dims=t.dims, values=t.values, unit=t.unit)
+    value1 = snx.create_class(transformations, 't1', snx.NXlog)
+    snx.create_field(value1, 'time', log.coords['time'] - sc.epoch(unit='ns'))
+    snx.create_field(value1, 'value', log.data)
+    value1.attrs['transformation_type'] = 'translation'
+    value1.attrs['offset'] = offset.values
+    value1.attrs['offset_units'] = str(offset.unit)
+    value1.attrs['vector'] = vector.value
+
+    expected = t * offset
+
+    assert sc.identical(
+        make_group(h5root)['transformations'][
+            'time',
+            sc.scalar(22, unit='s')
+            .to(unit='ns') : sc.scalar(44, unit='s')
+            .to(unit='ns'),
+        ]['t1'],
+        expected[
+            'time',
+            sc.datetime('1970-01-01T00:00:22', unit='ns') : sc.datetime(
+                '1970-01-01T00:00:44', unit='ns'
+            ),
+        ],
+    )
+
+
 def test_TransformationChainResolver_path_handling():
     tree = TransformationChainResolver.from_root({'a': {'b': {'c': 1}}})
     assert tree['a']['b']['c'].value == 1