Repository: scipp/scippnexus

Issue:
Support loading NXdetector with NXevent_data that lacks `event_time_zero`
I *think* this should be possible. It might just be the mapping to pixels that assumes it exists. From what I can tell, this requires relatively simple changes in:

1. [NXevent_data](https://github.com/scipp/scippnexus/blob/main/src/scippnexus/nxevent_data.py) (many places, but all simple, I think).
2. The grouping function that NXdetector calls if it contains an NXevent_data at https://github.com/scipp/scippnexus/blob/6e4e566c56f6375312fa2d3aa03c4c63ee30687c/src/scippnexus/nxdata.py#L768-L770 (simply add a branch, checking if `event_time_zero` coord exists).

Make sure to add tests for both cases:
- NXevent_data loaded directly.
- NXdetector containing event data.

Discussion:

Patch:
diff --git a/src/scippnexus/nxdata.py b/src/scippnexus/nxdata.py
index af23708..c334b16 100644
--- a/src/scippnexus/nxdata.py
+++ b/src/scippnexus/nxdata.py
@@ -765,9 +765,10 @@ def _group_events(
         # copy since sc.bin cannot deal with a non-contiguous view
         grouping = asvariable(grouping)
         event_id = grouping.flatten(to='event_id').copy()
-    event_data.bins.coords['event_time_zero'] = sc.bins_like(
-        event_data, fill_value=event_data.coords['event_time_zero']
-    )
+    if 'event_time_zero' in event_data.coords:
+        event_data.bins.coords['event_time_zero'] = sc.bins_like(
+            event_data, fill_value=event_data.coords['event_time_zero']
+        )
     # After loading raw NXevent_data it is guaranteed that the event table
     # is contiguous and that there is no masking. We can therefore use the
     # more efficient approach of binning from scratch instead of erasing the
diff --git a/src/scippnexus/nxevent_data.py b/src/scippnexus/nxevent_data.py
index 3485fd8..3abedae 100644
--- a/src/scippnexus/nxevent_data.py
+++ b/src/scippnexus/nxevent_data.py
@@ -29,8 +29,11 @@ def _check_for_missing_fields(fields):
 
 
 class NXevent_data(NXobject):
-    mandatory_fields = ("event_time_zero", "event_index", "event_time_offset")
-    handled_fields = mandatory_fields + ("event_id",)
+    mandatory_fields = ("event_index", "event_time_offset")
+    handled_fields = mandatory_fields + (
+        "event_time_zero",
+        "event_id",
+    )
 
     def __init__(self, attrs: Dict[str, Any], children: Dict[str, Union[Field, Group]]):
         super().__init__(attrs=attrs, children=children)
@@ -68,9 +71,12 @@ class NXevent_data(NXobject):
 
         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)
 
+        coords = {}
+        if 'event_time_zero' in children:
+            coords['event_time_zero'] = children['event_time_zero'][index]
+
+        last_loaded, event_index = self._get_event_index(children, index)
         num_event = children["event_time_offset"].shape[0]
         # Some files contain uint64 "max" indices, which turn into negatives during
         # conversion to int64. This is a hack to get around this.
@@ -95,9 +101,9 @@ class NXevent_data(NXobject):
         event_index -= event_index.min()
 
         dg = sc.DataGroup(
-            event_time_zero=event_time_zero,
             event_index=event_index,
             event_time_offset=event_time_offset,
+            **coords,
         )
         if (event_id := children.get('event_id')) is not None:
             dg['event_id'] = event_id[event_select]
@@ -129,7 +135,10 @@ class NXevent_data(NXobject):
     def assemble(self, children: sc.DataGroup) -> sc.DataArray:
         _check_for_missing_fields(children)
         event_time_offset = children['event_time_offset']
-        event_time_zero = children['event_time_zero']
+
+        coords = {}
+        if 'event_time_zero' in children:
+            coords['event_time_zero'] = children['event_time_zero']
         event_index = children['event_index']
 
         # Weights are not stored in NeXus, so use 1s
@@ -150,7 +159,7 @@ class NXevent_data(NXobject):
         # different institutions. We try to make sure here that it is what would be the
         # first index of the next pulse. In other words, ensure that event_index
         # includes the bin edge for the last pulse.
-        if event_time_zero.ndim == 0:
+        if 'event_time_zero' in coords and coords['event_time_zero'].ndim == 0:
             begins = event_index[_pulse_dimension, 0]
         else:
             begins = event_index
@@ -161,7 +170,7 @@ class NXevent_data(NXobject):
             path = self._children['event_index'].name
             raise NexusStructureError(f"Invalid index in NXevent_data at {path}:\n{e}")
 
-        return sc.DataArray(data=binned, coords={'event_time_zero': event_time_zero})
+        return sc.DataArray(data=binned, coords=coords)
 
 
 base_definitions_dict['NXevent_data'] = NXevent_data


Test patch:
diff --git a/tests/nxdetector_test.py b/tests/nxdetector_test.py
index 1ae2838..c8cb4ce 100644
--- a/tests/nxdetector_test.py
+++ b/tests/nxdetector_test.py
@@ -929,3 +929,38 @@ def test_detector_with_event_data_and_unordered_event_time_zero_can_be_loaded(nx
         },
     )
     assert_identical(pixel2, ref2)
+
+
+def test_detector_with_event_data_and_no_event_time_zero_can_be_loaded(nxroot):
+    detector_numbers = sc.array(dims=[''], unit=None, values=np.array([1, 2]))
+    detector0 = nxroot.create_class('detector0', NXdetector)
+    detector0.create_field('detector_number', detector_numbers)
+    event_id = sc.array(dims=[''], unit=None, values=[1, 2, 1, 1, 2, 2])
+    event_time_offset = sc.array(dims=[''], unit='s', values=[11, 22, 33, 44, 55, 66])
+    event_data0 = detector0.create_class('events', snx.NXevent_data)
+    event_data0.create_field('event_id', event_id)
+    event_data0.create_field('event_time_offset', event_time_offset)
+    event_data0.create_field(
+        'event_index', sc.array(dims=[''], unit='None', values=[0, 3, 4, 5])
+    )
+    da = detector0[...]['events']
+    pixel1 = da.values[0]
+    ref1 = sc.DataArray(
+        data=sc.array(dims=['event'], values=[1, 1, 1], unit='counts', dtype='float32'),
+        coords={
+            'event_time_offset': sc.array(
+                dims=['event'], values=[11, 33, 44], unit='s'
+            ),
+        },
+    )
+    assert_identical(pixel1, ref1)
+    pixel2 = da.values[1]
+    ref2 = sc.DataArray(
+        data=sc.array(dims=['event'], values=[1, 1, 1], unit='counts', dtype='float32'),
+        coords={
+            'event_time_offset': sc.array(
+                dims=['event'], values=[22, 55, 66], unit='s'
+            ),
+        },
+    )
+    assert_identical(pixel2, ref2)
diff --git a/tests/nxevent_data_test.py b/tests/nxevent_data_test.py
index 411ea06..679553d 100644
--- a/tests/nxevent_data_test.py
+++ b/tests/nxevent_data_test.py
@@ -68,6 +68,22 @@ def test_event_data_without_event_id_can_be_loaded(nxroot):
     assert 'event_time_offset' in da.bins.coords
 
 
+def create_event_data_without_event_time_zero(group):
+    group['event_id'] = sc.array(dims=[''], unit=None, values=[1, 2, 4, 1, 2, 2])
+    group['event_time_offset'] = sc.array(
+        dims=[''], unit='s', values=[456, 7, 3, 345, 632, 23]
+    )
+    group['event_index'] = sc.array(dims=[''], unit=None, values=[0, 3, 3, 5])
+
+
+def test_event_data_without_event_time_zero_can_be_loaded(nxroot):
+    event_data = nxroot['entry'].create_class('events_0', snx.NXevent_data)
+    create_event_data_without_event_time_zero(event_data)
+    da = event_data[...]
+    assert len(da.bins.coords) == 2
+    assert 'event_time_offset' in da.bins.coords
+
+
 def test_event_mode_monitor_without_event_id_can_be_loaded(nxroot):
     monitor = nxroot['entry'].create_class('monitor', snx.NXmonitor)
     create_event_data_without_event_id(monitor)