diff --git a/pyproject.toml b/pyproject.toml index 92c51469..5e4e2b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dependencies = [ dynamic = ["version"] +[project.scripts] +grow-nexus = "ess.reduce.scripts.grow_nexus:main" + [project.urls] "Bug Tracker" = "https://github.com/scipp/essreduce/issues" "Documentation" = "https://scipp.github.io/essreduce" diff --git a/requirements/basetest.in b/requirements/basetest.in index e4a48b29..dd8cd9a6 100644 --- a/requirements/basetest.in +++ b/requirements/basetest.in @@ -2,3 +2,4 @@ # Do not make an environment from this file, use test.txt instead! pytest +numpy diff --git a/requirements/basetest.txt b/requirements/basetest.txt index a1fc27b6..af039930 100644 --- a/requirements/basetest.txt +++ b/requirements/basetest.txt @@ -1,4 +1,4 @@ -# SHA1:0eaa389e1fdb3a1917c0f987514bd561be5718ee +# SHA1:43a90d54540ba9fdbb2c21a785a9d5d7483af53b # # This file is autogenerated by pip-compile-multi # To update, run: @@ -9,11 +9,13 @@ exceptiongroup==1.2.0 # via pytest iniconfig==2.0.0 # via pytest -packaging==23.2 +numpy==1.26.4 + # via -r basetest.in +packaging==24.0 # via pytest pluggy==1.4.0 # via pytest -pytest==8.0.2 +pytest==8.1.1 # via -r basetest.in tomli==2.0.1 # via pytest diff --git a/requirements/ci.txt b/requirements/ci.txt index 87aaf8e4..2f27a0eb 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -27,7 +27,7 @@ gitpython==3.1.42 # via -r ci.in idna==3.6 # via requests -packaging==23.2 +packaging==24.0 # via # -r ci.in # pyproject-api @@ -48,7 +48,7 @@ tomli==2.0.1 # via # pyproject-api # tox -tox==4.13.0 +tox==4.14.1 # via -r ci.in urllib3==2.2.1 # via requests diff --git a/requirements/dev.txt b/requirements/dev.txt index 42476c74..c3c5526d 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -50,7 +50,7 @@ isoduration==20.11.0 # via jsonschema jinja2-ansible-filters==1.3.2 # via copier -json5==0.9.20 +json5==0.9.22 # via jupyterlab-server jsonpointer==2.4 # via jsonschema @@ -61,7 +61,7 @@ jsonschema[format-nongpl]==4.21.1 # nbformat jupyter-events==0.9.0 # via jupyter-server -jupyter-lsp==2.2.3 +jupyter-lsp==2.2.4 # via jupyterlab jupyter-server==2.13.0 # via @@ -71,7 +71,7 @@ jupyter-server==2.13.0 # notebook-shim jupyter-server-terminals==0.5.2 # via jupyter-server -jupyterlab==4.1.3 +jupyterlab==4.1.4 # via -r dev.in jupyterlab-server==2.25.3 # via jupyterlab @@ -83,7 +83,7 @@ pathspec==0.12.1 # via copier pip-compile-multi==2.6.3 # via -r dev.in -pip-tools==7.4.0 +pip-tools==7.4.1 # via pip-compile-multi plumbum==1.8.2 # via copier @@ -121,7 +121,7 @@ terminado==0.18.0 # jupyter-server-terminals toposort==1.10 # via pip-compile-multi -types-python-dateutil==2.8.19.20240106 +types-python-dateutil==2.8.19.20240311 # via arrow uri-template==1.3.0 # via jsonschema diff --git a/requirements/docs.txt b/requirements/docs.txt index 4381e0a2..a6caed92 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -118,7 +118,7 @@ nbsphinx==0.9.3 # via -r docs.in nest-asyncio==1.6.0 # via ipykernel -packaging==23.2 +packaging==24.0 # via # ipykernel # nbconvert diff --git a/requirements/mypy.txt b/requirements/mypy.txt index 49722576..066d33ac 100644 --- a/requirements/mypy.txt +++ b/requirements/mypy.txt @@ -6,7 +6,7 @@ # pip-compile-multi # -r test.txt -mypy==1.8.0 +mypy==1.9.0 # via -r mypy.in mypy-extensions==1.0.0 # via mypy diff --git a/requirements/nightly.txt b/requirements/nightly.txt index 2126652e..02ab46cf 100644 --- a/requirements/nightly.txt +++ b/requirements/nightly.txt @@ -8,11 +8,6 @@ -r basetest.txt h5py==3.10.0 # via scippnexus -numpy==1.26.4 - # via - # h5py - # scipp - # scipy python-dateutil==2.9.0.post0 # via scippnexus scipp==24.2.0 diff --git a/requirements/wheels.txt b/requirements/wheels.txt index 12ac3232..23d6d310 100644 --- a/requirements/wheels.txt +++ b/requirements/wheels.txt @@ -7,7 +7,7 @@ # build==1.1.1 # via -r wheels.in -packaging==23.2 +packaging==24.0 # via build pyproject-hooks==1.0.0 # via build diff --git a/src/ess/reduce/scripts/grow_nexus.py b/src/ess/reduce/scripts/grow_nexus.py new file mode 100644 index 00000000..918af5d2 --- /dev/null +++ b/src/ess/reduce/scripts/grow_nexus.py @@ -0,0 +1,115 @@ +import argparse +import shutil +from typing import Optional + +import h5py + + +def _scale_group(event_data: h5py.Group, scale: int): + if not all( + required_field in event_data + for required_field in ('event_index', 'event_time_offset', 'event_id') + ): + return + event_index = (event_data['event_index'][:] * scale).astype('uint') + event_data['event_index'][:] = event_index + + size = event_data['event_id'].size + event_data['event_id'].resize(event_index[-1], axis=0) + event_data['event_time_offset'].resize(event_index[-1], axis=0) + + for s in range(1, scale): + event_data['event_id'][s * size : (s + 1) * size] = event_data['event_id'][ + :size + ] + event_data['event_time_offset'][s * size : (s + 1) * size] = event_data[ + 'event_time_offset' + ][:size] + + +def _grow_nexus_file_impl(file: h5py.File, detector_scale: int, monitor_scale: int): + for group in file.values(): + if group.attrs.get('NX_class', '') == 'NXentry': + entry = group + break + for group in entry.values(): + if group.attrs.get('NX_class', '') == 'NXinstrument': + instrument = group + break + for group in instrument.values(): + if (nx_class := group.attrs.get('NX_class', '')) in ( + 'NXdetector', + 'NXmonitor', + ): + for subgroup in group.values(): + if subgroup.attrs.get('NX_class', '') == 'NXevent_data': + _scale_group( + subgroup, + scale=detector_scale + if nx_class == 'NXdetector' + else monitor_scale, + ) + + +def grow_nexus_file( + *, filename: str, detector_scale: int, monitor_scale: Optional[int] +): + with h5py.File(filename, 'a') as f: + _grow_nexus_file_impl( + f, + detector_scale, + monitor_scale if monitor_scale is not None else detector_scale, + ) + + +def integer_greater_than_one(x): + x = int(x) + if x < 1: + raise argparse.ArgumentTypeError('Must be larger than or equal to 1') + return x + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "-f", + "--file", + type=str, + help=( + 'Input file name. The events in the input file will be ' + 'repeated `scale` times and stored in the output file.' + ), + required=True, + ) + parser.add_argument( + "-o", + "--output", + type=str, + help='Output file name where the resulting nexus file will be written.', + required=True, + ) + parser.add_argument( + "-s", + "--detector-scale", + type=integer_greater_than_one, + help=('Scale factor to multiply the number of detector events by.'), + required=True, + ) + parser.add_argument( + "-m", + "--monitor-scale", + type=integer_greater_than_one, + default=None, + help=( + 'Scale factor to multiply the number of monitor events by. ' + 'If not given, the detector scale will be used' + ), + ) + args = parser.parse_args() + if args.file != args.output: + shutil.copy2(args.file, args.output) + grow_nexus_file( + filename=args.output, + detector_scale=args.detector_scale, + monitor_scale=args.monitor_scale, + ) diff --git a/tests/scripts/test_grow_nexus.py b/tests/scripts/test_grow_nexus.py new file mode 100644 index 00000000..548d594a --- /dev/null +++ b/tests/scripts/test_grow_nexus.py @@ -0,0 +1,84 @@ +import os +import tempfile + +import h5py +import numpy as np +import pytest + +from ess.reduce.scripts.grow_nexus import grow_nexus_file + + +@pytest.fixture +def nexus_file(): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, 'test.nxs') + with h5py.File(path, 'a') as hf: + entry = hf.create_group('entry') + entry.attrs['NX_class'] = 'NXentry' + + instrument = entry.create_group('instrument') + instrument.attrs['NX_class'] = 'NXinstrument' + + for group, nxclass in ( + ('detector', 'NXdetector'), + ('monitor', 'NXmonitor'), + ): + detector = instrument.create_group(group) + detector.attrs['NX_class'] = nxclass + + event_data = detector.create_group('event_data') + event_data.attrs['NX_class'] = 'NXevent_data' + + event_data.create_dataset( + 'event_index', + data=np.array([2, 4, 6]), + maxshape=(None,), + chunks=True, + ) + event_data.create_dataset( + 'event_time_zero', + data=np.array([0, 1, 2]), + maxshape=(None,), + chunks=True, + ) + event_data.create_dataset( + 'event_id', + data=np.array([0, 1, 2, 0, 1, 2]), + maxshape=(None,), + chunks=True, + ) + event_data.create_dataset( + 'event_time_offset', + data=np.array([1, 2, 1, 2, 1, 2]), + maxshape=(None,), + chunks=True, + ) + + yield path + + +@pytest.mark.parametrize('monitor_scale', (1, 2, None)) +@pytest.mark.parametrize('detector_scale', (1, 2)) +def test_grow_nexus(nexus_file, detector_scale, monitor_scale): + grow_nexus_file( + filename=nexus_file, detector_scale=detector_scale, monitor_scale=monitor_scale + ) + + monitor_scale = monitor_scale if monitor_scale is not None else detector_scale + + with h5py.File(nexus_file, 'r') as f: + for detector, scale in zip( + ('detector', 'monitor'), (detector_scale, monitor_scale) + ): + np.testing.assert_equal( + [scale * i for i in [2, 4, 6]], + f[f'entry/instrument/{detector}/event_data/event_index'][()], + ) + np.testing.assert_equal( + scale * [0, 1, 2, 0, 1, 2], + f[f'entry/instrument/{detector}/event_data/event_id'][()], + ) + np.testing.assert_equal( + scale * [1, 2, 1, 2, 1, 2], + f[f'entry/instrument/{detector}/event_data/event_time_offset'][()], + )