Repository: scipp/scippnexus

Issue:
Slicing with a single index raises error
Example:

```Py
from scippnexus import data
filename = data.get_path('PG3_4844_event.nxs')
import scippnexus as snx
f = snx.File(filename)

data = f['entry/bank103']

data['x_pixel_offset', 0]
```
raises
```
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [3], in <cell line: 1>()
----> 1 data['x_pixel_offset', 0]

File ~/code/nxus/jupyter/scippnexus/nxobject.py:315, in NXobject.__getitem__(self, name)
    312 def __getitem__(
    313         self,
    314         name: NXobjectIndex) -> Union['NXobject', Field, sc.DataArray, sc.Dataset]:
--> 315     return self._get_child(name, use_field_dims=True)

File ~/code/nxus/jupyter/scippnexus/nxobject.py:307, in NXobject._get_child(self, name, use_field_dims)
    305     else:
    306         return _make(item)
--> 307 da = self._getitem(name)
    308 if (t := self.depends_on) is not None:
    309     da.coords['depends_on'] = t if isinstance(t, sc.Variable) else sc.scalar(t)

File ~/code/nxus/jupyter/scippnexus/nxdata.py:149, in NXdata._getitem(self, select)
    148 def _getitem(self, select: ScippIndex) -> sc.DataArray:
--> 149     signal = self._signal[select]
    150     if self._errors_name in self:
    151         stddevs = self[self._errors_name][select]

File ~/code/nxus/jupyter/scippnexus/nxobject.py:185, in Field.__getitem__(self, select)
    183 shape = list(self.shape)
    184 for i, ind in enumerate(index):
--> 185     shape[i] = len(range(*ind.indices(shape[i])))
    187 variable = sc.empty(dims=self.dims,
    188                     shape=shape,
    189                     dtype=self.dtype,
    190                     unit=self.unit)
    192 # If the variable is empty, return early

AttributeError: 'int' object has no attribute 'indices'
```

Discussion:

Patch:
diff --git a/docs/_templates/topbar/launchbuttons.html b/docs/_templates/topbar/launchbuttons.html
index 1eea0ec..b9b12ee 100644
--- a/docs/_templates/topbar/launchbuttons.html
+++ b/docs/_templates/topbar/launchbuttons.html
@@ -12,7 +12,7 @@
             href="https://scipp.github.io/scippnexus"><button type="button"
                 class="btn btn-secondary topbarbtn">latest</button></a>
         <a class="dropdown-buttons"
-            href="https://scipp.github.io/scippnexus/release/0.1.0"><button type="button"
+            href="https://scipp.github.io/scippnexus/release/0.1.3"><button type="button"
                 class="btn btn-secondary topbarbtn">v0.1</button></a>
     </div>
 </div>
diff --git a/docs/about/release-notes.rst b/docs/about/release-notes.rst
index 24f5e56..9395d4d 100644
--- a/docs/about/release-notes.rst
+++ b/docs/about/release-notes.rst
@@ -3,6 +3,20 @@
 Release Notes
 =============
 
+v0.1.3 (June 2022)
+------------------
+
+Bugfixes
+~~~~~~~~
+
+* Fixed exception when slicing with single integer (instead of a range) `#36 <https://github.com/scipp/scipp/pull/36>`_.
+* Fixed slicing with bin-edge coords, which previously dropped the upper bound `#36 <https://github.com/scipp/scipp/pull/36>`_.
+
+Contributors
+~~~~~~~~~~~~
+
+Simon Heybrock :sup:`a`
+
 v0.1.0 (May 2022)
 -----------------
 
diff --git a/docs/index.rst b/docs/index.rst
index 5bf883a..d530d44 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -27,7 +27,7 @@ This is especially powerful since a number of concepts of scipp map well to conc
 News
 ----
 
-- [May 2022] Scippnexus-0.1.0 has been released.
+- [June 2022] Scippnexus-0.1.3 has been released.
 
 Get in touch
 ------------
diff --git a/src/scippnexus/_common.py b/src/scippnexus/_common.py
index ac3bd1d..6dd4d73 100644
--- a/src/scippnexus/_common.py
+++ b/src/scippnexus/_common.py
@@ -1,7 +1,7 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 # @author Simon Heybrock
-from typing import Dict, Union, List
+from typing import Dict, Union, List, Optional
 import scipp as sc
 import numpy as np
 from .typing import ScippIndex
@@ -94,18 +94,25 @@ def to_plain_index(dims: List[str], select: ScippIndex) -> Union[int, slice, tup
     return tuple(index)
 
 
-def to_child_select(dims: List[str], child_dims: List[str],
-                    select: ScippIndex) -> ScippIndex:
+def to_child_select(dims: List[str],
+                    child_dims: List[str],
+                    select: ScippIndex,
+                    bin_edge_dim: Optional[str] = None) -> ScippIndex:
     """
     Given a valid "scipp" index 'select' for a Nexus class, return a selection for a
     child field of the class, which may have fewer dimensions.
 
     This removes any selections that apply to the parent but not the child.
     """
-    if set(dims) == set(child_dims):
-        return select
     select = _to_canonical_select(dims, select)
     for d in dims:
         if d not in child_dims and d in select:
             del select[d]
+    for dim in select:
+        if dim == bin_edge_dim:
+            index = select[dim]
+            if isinstance(index, int):
+                select[dim] = slice(index, index + 2)
+            elif index.stop > index.start:
+                select[dim] = slice(index.start, index.stop + 1)
     return select
diff --git a/src/scippnexus/nxdata.py b/src/scippnexus/nxdata.py
index 1914245..899a714 100644
--- a/src/scippnexus/nxdata.py
+++ b/src/scippnexus/nxdata.py
@@ -145,6 +145,28 @@ class NXdata(NXobject):
                     return True
         return False
 
+    def _bin_edge_dim(self, coord: Field) -> Union[None, str]:
+        sizes = dict(zip(self.dims, self.shape))
+        for dim, size in zip(coord.dims, coord.shape):
+            if dim in sizes and sizes[dim] + 1 == size:
+                return dim
+        return None
+
+    def _dim_of_coord(self, name: str, coord: Field) -> Union[None, str]:
+        if len(coord.dims) == 1:
+            return coord.dims[0]
+        if name in coord.dims and name in self.dims:
+            return name
+        return self._bin_edge_dim(coord)
+
+    def _coord_to_attr(self, da: sc.DataArray, name: str, coord: Field) -> bool:
+        dim_of_coord = self._dim_of_coord(name, coord)
+        if dim_of_coord is None:
+            return False
+        if dim_of_coord not in da.dims:
+            return True
+        return False
+
     def _getitem(self, select: ScippIndex) -> sc.DataArray:
         signal = self._signal[select]
         if self._errors_name in self:
@@ -162,7 +184,10 @@ class NXdata(NXobject):
                                                   in skip) or self._is_errors(name):
                 continue
             try:
-                sel = to_child_select(self.dims, field.dims, select)
+                sel = to_child_select(self.dims,
+                                      field.dims,
+                                      select,
+                                      bin_edge_dim=self._bin_edge_dim(field))
                 coord: sc.Variable = self[name][sel]
                 for suffix in self._error_suffixes:
                     if f'{name}{suffix}' in self:
@@ -171,7 +196,12 @@ class NXdata(NXobject):
                                  f"{name}_error. The latter will be ignored.")
                         stddevs = self[f'{name}{suffix}'][sel]
                         coord.variances = sc.pow(stddevs, sc.scalar(2)).values
-                da.coords[name] = coord
+                if self._coord_to_attr(da, name, field):
+                    # Like scipp, slicing turns coord into attr if slicing removes the
+                    # dim corresponding to the coord.
+                    da.attrs[name] = coord
+                else:
+                    da.coords[name] = coord
             except sc.DimensionError as e:
                 warn(f"Skipped load of axis {field.name} due to:\n{e}")
 
diff --git a/src/scippnexus/nxobject.py b/src/scippnexus/nxobject.py
index 4b86c51..08086df 100644
--- a/src/scippnexus/nxobject.py
+++ b/src/scippnexus/nxobject.py
@@ -174,17 +174,19 @@ class Field:
 
     def __getitem__(self, select) -> sc.Variable:
         index = to_plain_index(self.dims, select)
-        if isinstance(index, slice):
+        if isinstance(index, (int, slice)):
             index = (index, )
 
-        shape = list(self.shape)
+        base_dims = self.dims
+        base_shape = self.shape
+        dims = []
+        shape = []
         for i, ind in enumerate(index):
-            shape[i] = len(range(*ind.indices(shape[i])))
+            if not isinstance(ind, int):
+                dims.append(base_dims[i])
+                shape.append(len(range(*ind.indices(base_shape[i]))))
 
-        variable = sc.empty(dims=self.dims,
-                            shape=shape,
-                            dtype=self.dtype,
-                            unit=self.unit)
+        variable = sc.empty(dims=dims, shape=shape, dtype=self.dtype, unit=self.unit)
 
         # If the variable is empty, return early
         if np.prod(shape) == 0:


Test patch:
diff --git a/tests/nxdata_test.py b/tests/nxdata_test.py
index 64b634e..130fa66 100644
--- a/tests/nxdata_test.py
+++ b/tests/nxdata_test.py
@@ -378,3 +378,23 @@ def test_fields_with_datetime_attribute_are_loaded_as_datetime(nxroot):
     data.create_field('xx2', da.coords['xx2'])
     data.create_field('yy', da.coords['yy'])
     assert sc.identical(data[...], da)
+
+
+def test_slicing_with_bin_edge_coord_returns_bin_edges(nxroot):
+    da = sc.DataArray(sc.array(dims=['xx'], unit='K', values=[1.1, 2.2, 3.3]))
+    da.coords['xx'] = sc.array(dims=['xx'], unit='m', values=[0.1, 0.2, 0.3, 0.4])
+    da.coords['xx2'] = sc.array(dims=['xx'], unit='m', values=[0.3, 0.4, 0.5, 0.6])
+    data = nxroot.create_class('data', NX_class.NXdata)
+    data.create_field('xx', da.coords['xx'])
+    data.create_field('xx2', da.coords['xx2'])
+    data.create_field('data', da.data)
+    data.attrs['signal'] = 'data'
+    data.attrs['axes'] = ['xx']
+    data.attrs['xx_indices'] = [0]
+    data.attrs['xx2_indices'] = [0]
+    assert sc.identical(data[...], da)
+    assert sc.identical(data['xx', 0], da['xx', 0])
+    assert sc.identical(data['xx', 1], da['xx', 1])
+    assert sc.identical(data['xx', 0:1], da['xx', 0:1])
+    assert sc.identical(data['xx', 1:3], da['xx', 1:3])
+    assert sc.identical(data['xx', 1:1], da['xx', 1:1])  # empty slice
diff --git a/tests/nxdetector_test.py b/tests/nxdetector_test.py
index a420096..b34a117 100644
--- a/tests/nxdetector_test.py
+++ b/tests/nxdetector_test.py
@@ -23,10 +23,10 @@ def test_raises_if_no_data_found(nxroot):
 
 
 def test_loads_events_when_data_and_events_found(nxroot):
-    detector_numbers = sc.array(dims=[''], unit=None, values=np.array([1, 2]))
+    detector_number = sc.array(dims=[''], unit=None, values=np.array([1, 2]))
     data = sc.ones(dims=['xx'], shape=[2])
     detector = nxroot.create_class('detector0', NX_class.NXdetector)
-    detector.create_field('detector_numbers', detector_numbers)
+    detector.create_field('detector_number', detector_number)
     detector.create_field('data', data)
     assert detector[...].bins is None
     detector.create_field('event_id', sc.array(dims=[''], unit=None, values=[1]))
@@ -84,6 +84,38 @@ def test_loads_data_with_coords(nxroot):
     assert sc.identical(detector[...], da.rename_dims({'yy': 'dim_1'}))
 
 
+def test_slicing_works_as_in_scipp(nxroot):
+    da = sc.DataArray(
+        sc.array(dims=['xx', 'yy'], unit='K', values=[[1.1, 2.2, 3.3], [3.3, 4.4,
+                                                                        5.5]]))
+    da.coords['detector_numbers'] = sc.array(dims=['xx', 'yy'],
+                                             unit=None,
+                                             values=np.array([[1, 2, 3], [4, 5, 6]]))
+    da.coords['xx'] = sc.array(dims=['xx'], unit='m', values=[0.1, 0.2])
+    da.coords['xx2'] = sc.array(dims=['xx'], unit='m', values=[0.3, 0.4])
+    da.coords['yy'] = sc.array(dims=['yy'], unit='m', values=[0.1, 0.2, 0.3])
+    da.coords['2d_edges'] = sc.array(dims=['yy', 'xx'],
+                                     unit='m',
+                                     values=[[1, 2, 3], [4, 5, 6], [7, 8, 9]])
+    detector = nxroot.create_class('detector0', NX_class.NXdetector)
+    detector.create_field('detector_numbers', da.coords['detector_numbers'])
+    detector.create_field('xx', da.coords['xx'])
+    detector.create_field('xx2', da.coords['xx2'])
+    detector.create_field('yy', da.coords['yy'])
+    detector.create_field('2d_edges', da.coords['2d_edges'])
+    detector.create_field('data', da.data)
+    detector.attrs['axes'] = ['xx', 'yy']
+    detector.attrs['2d_edges_indices'] = [1, 0]
+    assert sc.identical(detector[...], da)
+    assert sc.identical(detector['xx', 0], da['xx', 0])
+    assert sc.identical(detector['xx', 1], da['xx', 1])
+    assert sc.identical(detector['xx', 0:1], da['xx', 0:1])
+    assert sc.identical(detector['yy', 0], da['yy', 0])
+    assert sc.identical(detector['yy', 1], da['yy', 1])
+    assert sc.identical(detector['yy', 0:1], da['yy', 0:1])
+    assert sc.identical(detector['yy', 1:1], da['yy', 1:1])  # empty slice
+
+
 def create_event_data_ids_1234(group):
     group.create_field('event_id',
                        sc.array(dims=[''], unit=None, values=[1, 2, 4, 1, 2, 2]))