././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4419942 nxmx-0.0.3/0000755000175100001730000000000014465466605012125 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4379942 nxmx-0.0.3/.github/0000755000175100001730000000000014465466605013465 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/.github/dependabot.yml0000644000175100001730000000076014465466571016322 0ustar00runnerdocker# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: pip # See documentation for possible values directory: / # Location of package manifests schedule: interval: weekly ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4379942 nxmx-0.0.3/.github/workflows/0000755000175100001730000000000014465466605015522 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/.github/workflows/ci.yml0000644000175100001730000000125314465466571016643 0ustar00runnerdockername: Python package on: [push] jobs: build: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ['3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: pip # caching pip dependencies - run: pip install -r requirements_dev.txt - name: Install nxmx run: pip install . - name: Run pytest run: pytest -vs post_build: name: "CI Successful" needs: [build] runs-on: ubuntu-latest steps: - name: Nothing run: "true" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/.github/workflows/publish-to-pypi.yml0000644000175100001730000000151714465466571021320 0ustar00runnerdockername: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI on: push jobs: build-n-publish: name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Set up Python 3.10 uses: actions/setup-python@v3 with: python-version: '3.10' - name: Install pypa/build run: >- python -m pip install build --user - name: Build a binary wheel and a source tarball run: >- python -m build --sdist --wheel --outdir dist/ . - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/.gitignore0000644000175100001730000000005314465466571014115 0ustar00runnerdocker/src/nxmx.egg-info/* /src/nxmx/_version.py ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/.pre-commit-config.yaml0000644000175100001730000000260414465466571016412 0ustar00runnerdocker# Run 'libtbx.precommit install' to enable repository pre-commits. repos: # Bring Python code up to date with pyupgrade - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: - id: pyupgrade # Automatically sort imports with isort - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort # Automatic source code formatting with Black - repo: https://github.com/psf/black rev: 22.12.0 hooks: - id: black args: [--safe, --quiet] # Enforce style with Flake8 - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: - id: flake8 args: [--max-line-length=88, '--select=E401,E711,E712,E713,E714,E721,E722,E901,F401,F402,F403,F405,F631,F632,F633,F811,F812,F821,F822,F841,F901,W191,W291,W292,W293,W602,W603,W604,W605,W606'] # Format YAML & TOML files prettily - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.6.0 hooks: - id: pretty-format-yaml args: [--autofix, --indent, '2'] - id: pretty-format-toml args: [--autofix] # Syntax check with pre-commit out-of-the-box hooks - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - id: check-ast - id: check-json - id: pretty-format-json args: [--autofix, --no-ensure-ascii, --no-sort-keys] - id: check-yaml - id: check-merge-conflict - id: check-added-large-files args: [--maxkb=200] - id: requirements-txt-fixer ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/LICENCE0000644000175100001730000000304214465466571013113 0ustar00runnerdockerCopyright (c) 2019 Diamond Light Source, Lawrence Berkeley National Laboratory and the Science and Technology Facilities Council. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4419942 nxmx-0.0.3/PKG-INFO0000644000175100001730000000562514465466605013232 0ustar00runnerdockerMetadata-Version: 2.1 Name: nxmx Version: 0.0.3 Summary: Read HDF5 data conforming to the NXmx application definition of the NeXus format License: BSD 3-Clause License Keywords: NeXus,NXmx Classifier: Programming Language :: Python :: 3 Requires-Python: >=3.9 Description-Content-Type: text/markdown License-File: LICENCE # Read NXmx-flavour NeXus HDF5 data in Python [![PyPI release](https://img.shields.io/pypi/v/nxmx.svg)](https://pypi.python.org/pypi/nxmx) [![Supported Python versions](https://img.shields.io/pypi/pyversions/nxmx.svg)](https://pypi.org/project/nxmx) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)]( https://github.com/ambv/black) This package provides a neat and tidy Python interface for reading data from [HDF5 files](https://www.hdfgroup.org/solutions/hdf5/) that are structured according to the [NXmx application definition](https://manual.nexusformat.org/classes/applications/NXmx.html) of the [NeXus standard](https://www.nexusformat.org/). ## Installation `python-nxmx` is available as `nxmx` on PyPI, so you just need Pip. ```Bash $ pip install nxmx ``` ## Getting started If you have an HDF5 file in NXmx format, inspecting it with `h5ls` will look something like this: ```Bash $ h5ls -r my-nxmx-file.h5 / Group /entry Group /entry/data Group /entry/definition Dataset {SCALAR} /entry/end_time Dataset {SCALAR} /entry/end_time_estimated Dataset {SCALAR} /entry/instrument Group /entry/instrument/beam Group /entry/instrument/beam/incident_beam_size Dataset {2} /entry/instrument/beam/incident_wavelength Dataset {SCALAR} /entry/instrument/beam/total_flux Dataset {SCALAR} /entry/instrument/detector Group ... etc. ... ``` With `nxmx`, you can access the NXmx data in Python like this: ```Python import h5py import nxmx with h5py.File("my-nxmx-file.h5") as f: nxmx_data = nxmx.NXmx(f) ``` ## A slightly more detailed example ```Python import h5py import nxmx with h5py.File("my-nxmx-file.h5") as f: nxmx_data = nxmx.NXmx(f) # Explore the NXmx data structure. entry, *_ = nxmx_data.entries print(entry.definition) # Prints "NXmx". instrument, *_ = entry.instruments detector, *_ = instrument.detectors # Get the h5py object underlying an instance of a NX class. entry_group = entry._handle # entry_group == f["entry"] # Find instances of a given NX class in a h5py.Group. beams = nxmx.find_class(instrument._handle, "NXbeam") # The equivalent for more than one NX class. beams, detectors = nxmx.find_classes(instrument._handle, "NXbeam", "NXdetector") # Query attributes of an object in the normal h5py way. # Suppose out detector has a transformation called "det_z". transformations, *_ = nxmx.find_class(detector._handle, "NXtransformations") attrs = transformations["det_z"].attrs # Get the attributes of the "det_z" dataset. ``` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/README.md0000644000175100001730000000512214465466571013406 0ustar00runnerdocker# Read NXmx-flavour NeXus HDF5 data in Python [![PyPI release](https://img.shields.io/pypi/v/nxmx.svg)](https://pypi.python.org/pypi/nxmx) [![Supported Python versions](https://img.shields.io/pypi/pyversions/nxmx.svg)](https://pypi.org/project/nxmx) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)]( https://github.com/ambv/black) This package provides a neat and tidy Python interface for reading data from [HDF5 files](https://www.hdfgroup.org/solutions/hdf5/) that are structured according to the [NXmx application definition](https://manual.nexusformat.org/classes/applications/NXmx.html) of the [NeXus standard](https://www.nexusformat.org/). ## Installation `python-nxmx` is available as `nxmx` on PyPI, so you just need Pip. ```Bash $ pip install nxmx ``` ## Getting started If you have an HDF5 file in NXmx format, inspecting it with `h5ls` will look something like this: ```Bash $ h5ls -r my-nxmx-file.h5 / Group /entry Group /entry/data Group /entry/definition Dataset {SCALAR} /entry/end_time Dataset {SCALAR} /entry/end_time_estimated Dataset {SCALAR} /entry/instrument Group /entry/instrument/beam Group /entry/instrument/beam/incident_beam_size Dataset {2} /entry/instrument/beam/incident_wavelength Dataset {SCALAR} /entry/instrument/beam/total_flux Dataset {SCALAR} /entry/instrument/detector Group ... etc. ... ``` With `nxmx`, you can access the NXmx data in Python like this: ```Python import h5py import nxmx with h5py.File("my-nxmx-file.h5") as f: nxmx_data = nxmx.NXmx(f) ``` ## A slightly more detailed example ```Python import h5py import nxmx with h5py.File("my-nxmx-file.h5") as f: nxmx_data = nxmx.NXmx(f) # Explore the NXmx data structure. entry, *_ = nxmx_data.entries print(entry.definition) # Prints "NXmx". instrument, *_ = entry.instruments detector, *_ = instrument.detectors # Get the h5py object underlying an instance of a NX class. entry_group = entry._handle # entry_group == f["entry"] # Find instances of a given NX class in a h5py.Group. beams = nxmx.find_class(instrument._handle, "NXbeam") # The equivalent for more than one NX class. beams, detectors = nxmx.find_classes(instrument._handle, "NXbeam", "NXdetector") # Query attributes of an object in the normal h5py way. # Suppose out detector has a transformation called "det_z". transformations, *_ = nxmx.find_class(detector._handle, "NXtransformations") attrs = transformations["det_z"].attrs # Get the attributes of the "det_z" dataset. ``` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/pyproject.toml0000644000175100001730000000117414465466571015046 0ustar00runnerdocker[build-system] build-backend = "setuptools.build_meta" requires = ["setuptools>=61", "setuptools-scm"] [project] classifiers = [ "Programming Language :: Python :: 3" ] dependencies = [ "h5py", "pint", "python-dateutil", "scipy" ] description = "Read HDF5 data conforming to the NXmx application definition of the NeXus format" dynamic = ["version"] keywords = ["NeXus", "NXmx"] license = {text = "BSD 3-Clause License"} name = "nxmx" readme = "README.md" requires-python = ">=3.9" [tool.setuptools.package-data] nxmx = ["py.typed"] [tool.setuptools_scm] local_scheme = "no-local-version" write_to = "src/nxmx/_version.py" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/requirements.txt0000644000175100001730000000011214465466571015405 0ustar00runnerdockerh5py==3.9.0 numpy==1.25.0 pint==0.22 python-dateutil==2.8.2 scipy==1.11.1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/requirements_dev.txt0000644000175100001730000000015214465466571016247 0ustar00runnerdockerh5py==3.9.0 numpy==1.25.0 pint==0.22 pytest==7.4.0 pytest-cov==4.1.0 python-dateutil==2.8.2 scipy==1.11.1 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4419942 nxmx-0.0.3/setup.cfg0000644000175100001730000000004614465466605013746 0ustar00runnerdocker[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4379942 nxmx-0.0.3/src/0000755000175100001730000000000014465466605012714 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4419942 nxmx-0.0.3/src/nxmx/0000755000175100001730000000000014465466605013706 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/src/nxmx/__init__.py0000644000175100001730000013161114465466571016024 0ustar00runnerdockerfrom __future__ import annotations import dataclasses import datetime import logging import operator from collections import abc, namedtuple from functools import cached_property, reduce from typing import Iterable, Iterator, Sequence, Union, overload import dateutil.parser import h5py import numpy as np import pint from scipy.spatial.transform import Rotation # NeXus field type for type annotations # https://manual.nexusformat.org/nxdl-types.html#nxdl-field-types-and-units NXBoolT = Union[bool, np.ndarray] NXFloatT = Union[float, np.ndarray] NXIntT = Union[int, np.ndarray] NXNumberT = Union[NXFloatT, NXIntT] ureg = pint.UnitRegistry() logger = logging.getLogger(__name__) NXNode = Union[h5py.File, h5py.Group] class NXNumber(abc.Sequence): def __init__(self, handle: h5py.Dataset, unit: pint.Unit | None): self._handle = handle self._unit = unit def __getitem__(self, key) -> NXNumberT: if self._unit: return self._handle[key] * self._unit return self._handle[key] def __len__(self): return len(self._handle) def h5str(h5_value: str | np.bytes_ | bytes | None) -> str | None: """ Convert a value returned from an h5py attribute to str. h5py can return either a bytes-like (numpy.string_) or str object for attribute values depending on whether the value was written as fixed or variable length. This function collapses the two to str. """ if isinstance(h5_value, (np.bytes_, bytes)): return h5_value.decode("utf-8") return h5_value def units(data: h5py.Dataset, default: str | None = None) -> pint.Unit: """Extract the units attribute, if any, from an h5py data set.""" return ureg.Unit(h5str(data.attrs.get("units", default))) def find_classes(node: NXNode, *nx_classes: str | None) -> tuple[list[h5py.Group], ...]: """ Find instances of multiple NXclass types within the children of the current node. Args: node: The input h5py node (h5py.File or h5py.Group). nx_classes: Names of NXclass types to search for. If None, search for children without an NXclass. Returns: A list of matching nodes for each of the specified NX_class types. """ results: dict[str | None, list[h5py.Group]] = { nx_class: [] for nx_class in nx_classes } values: Iterable[h5py.Group] = filter(None, node.values()) for v in values: class_name = h5str(v.attrs.get("NX_class")) if class_name in nx_classes: results[class_name].append(v) return tuple(results.values()) def find_class(node: NXNode, nx_class: str | None) -> list[h5py.Group]: """ Find instances of a single NXclass type within the children of the current node. This is a convenience function, equivalent to calling find_classes with a single NXclass type name argument and returning the list of matches. Args: node: The input h5py node (h5py.File or h5py.Group). nx_class: Names of NXclass type to search for. If None, search for children without an NXclass. Returns: The list of matching nodes for the specified NXclass type. """ return find_classes(node, nx_class)[0] class H5Mapping(abc.Mapping): def __init__(self, handle: h5py.File | h5py.Group): self._handle = handle def __getitem__(self, key: str) -> h5py.Group | h5py.Dataset: return self._handle[key] def __iter__(self) -> Iterator[str]: return iter(self._handle) def __len__(self) -> int: return len(self._handle) @cached_property def path(self) -> str | None: return h5str(self._handle.name) class NXmx(H5Mapping): def __init__(self, handle): super().__init__(handle) self._entries = [ entry for entry in find_class(handle, "NXentry") if "definition" in entry and h5str(entry["definition"][()]) == "NXmx" ] @cached_property def entries(self) -> list[NXentry]: return [NXentry(entry) for entry in self._entries] class NXentry(H5Mapping): """NXentry describes the measurement. The top-level NeXus group which contains all the data and associated information that comprise a single measurement. It is mandatory that there is at least one group of this type in the NeXus file. """ def __init__(self, handle): super().__init__(handle) self._data, self._instruments, self._samples, self._sources = find_classes( handle, "NXdata", "NXinstrument", "NXsample", "NXsource" ) @cached_property def instruments(self) -> list[NXinstrument]: return [NXinstrument(instrument) for instrument in self._instruments] @cached_property def samples(self) -> list[NXsample]: return [NXsample(sample) for sample in self._samples] @cached_property def data(self) -> list[NXdata]: return [NXdata(data) for data in self._data] @cached_property def source(self) -> NXsource: return NXsource(self._sources[0]) @cached_property def start_time(self) -> datetime.datetime: """Starting time of measurement. ISO 8601 time/date of the first data point collected in UTC, using the Z suffix to avoid confusion with local time. Note that the time zone of the beamline should be provided in NXentry/NXinstrument/time_zone. """ if "start_time" in self._handle: return dateutil.parser.isoparse(h5str(self._handle["start_time"][()])) @cached_property def end_time(self) -> datetime.datetime | None: """Ending time of measurement. ISO 8601 time/date of the last data point collected in UTC, using the Z suffix to avoid confusion with local time. Note that the time zone of the beamline should be provided in NXentry/NXinstrument/time_zone. This field should only be filled when the value is accurately observed. If the data collection aborts or otherwise prevents accurate recording of the end_time, this field should be omitted. """ if "end_time" in self._handle: return dateutil.parser.isoparse(h5str(self._handle["end_time"][()])) return None @cached_property def end_time_estimated(self) -> datetime.datetime: """Estimated ending time of the measurement. ISO 8601 time/date of the last data point collected in UTC, using the Z suffix to avoid confusion with local time. Note that the time zone of the beamline should be provided in NXentry/NXinstrument/time_zone. This field may be filled with a value estimated before an observed value is available. """ if "end_time_estimated" in self._handle: return dateutil.parser.isoparse( h5str(self._handle["end_time_estimated"][()]) ) @cached_property def definition(self) -> str: """NeXus NXDL schema to which this file conforms.""" return h5str(self._handle["definition"][()]) class NXdata(H5Mapping): """NXdata describes the plottable data and related dimension scales.""" @cached_property def signal(self) -> str | None: """Declares which dataset is the default. The value is the name of the dataset to be plotted. A field of this name must exist (either as dataset or as a link to a dataset). It is recommended (as of NIAC2014) to use this attribute rather than adding a signal attribute to the dataset. See https://www.nexusformat.org/2014_How_to_find_default_data.html for a summary of the discussion. """ return self._handle.attrs.get("signal") class NXtransformations(H5Mapping): """Collection of axis-based translations and rotations to describe a geometry. May also contain axes that do not move and therefore do not have a transformation type specified, but are useful in understanding coordinate frames within which transformations are done, or in documenting important directions, such as the direction of gravity. A nested sequence of transformations lists the translation and rotation steps needed to describe the position and orientation of any movable or fixed device. There will be one or more transformations (axes) defined by one or more fields for each transformation. The all-caps name AXISNAME designates the particular axis generating a transformation (e.g. a rotation axis or a translation axis or a general axis). The attribute units="NX_TRANSFORMATION" designates the units will be appropriate to the transformation_type attribute: - NX_LENGTH for translation - NX_ANGLE for rotation - NX_UNITLESS for axes for which no transformation type is specified This class will usually contain all axes of a sample stage or goniometer or a detector. The NeXus default McSTAS coordinate frame is assumed, but additional useful coordinate axes may be defined by using axes for which no transformation type has been specified. The entry point (depends_on) will be outside of this class and point to a field in here. Following the chain may also require following depends_on links to transformations outside, for example to a common base table. If a relative path is given, it is relative to the group enclosing the depends_on specification. For a chain of three transformations, where T1 depends on T2 and that in turn depends on T3, the final transformation Tf is Tf = T3 T2 T1 In explicit terms, the transformations are a subset of affine transformations expressed as 4x4 matrices that act on homogeneous coordinates, w = (x, y, z, 1)^T. For rotation and translation, Tr = (R o) (03 1) Tr = (I3 t + o) (03 1) where R is the usual 3x3 rotation matrix, o is an offset vector, 03 is a row of 3 zeros, I3 is the 3x3 identity matrix and t is the translation vector. o is given by the offset attribute, t is given by the vector attribute multiplied by the field value, and R is defined as a rotation about an axis in the direction of vector, of angle of the field value. NOTE: One possible use of NXtransformations is to define the motors and transformations for a diffractometer (goniometer). Such use is mentioned in the NXinstrument base class. Use one NXtransformations group for each diffractometer and name the group appropriate to the device. Collecting the motors of a sample table or xyz-stage in an NXtransformation group is equally possible. """ def __init__(self, handle): super().__init__(handle) self._axes = { k: NXtransformationsAxis(v) for k, v in handle.items() if isinstance(v, h5py.Dataset) and "vector" in v.attrs } @cached_property def default(self) -> str: """Declares which child group contains a path leading to a NXdata group. It is recommended (as of NIAC2014) to use this attribute to help define the path to the default dataset to be plotted. See https://www.nexusformat.org/2014_How_to_find_default_data.html for a summary of the discussion. """ return h5str(self._handle.attrs.get("default")) @cached_property def axes(self) -> dict[str, NXtransformationsAxis]: return self._axes class NXtransformationsAxis: """Axis-based translation and rotation to describe a given transformation. For a chain of three transformations, where T1 depends on T2 and that in turn depends on T3, the final transformation Tf is Tf = T3 T2 T1 In explicit terms, the transformations are a subset of affine transformations expressed as 4x4 matrices that act on homogeneous coordinates, w = (x, y, z, 1)^T. For rotation and translation, Tr = (R o) (03 1) Tr = (I3 t + o) (03 1) where R is the usual 3x3 rotation matrix, o is an offset vector, 03 is a row of 3 zeros, I3 is the 3x3 identity matrix and t is the translation vector. o is given by the offset attribute, t is given by the vector attribute multiplied by the field value, and R is defined as a rotation about an axis in the direction of vector, of angle of the field value. NOTE: One possible use of NXtransformations is to define the motors and transformations for a diffractometer (goniometer). Such use is mentioned in the NXinstrument base class. Use one NXtransformations group for each diffractometer and name the group appropriate to the device. Collecting the motors of a sample table or xyz-stage in an NXtransformation group is equally possible. """ def __init__(self, handle): self._handle = handle def __len__(self) -> int: return self._handle.size @cached_property def path(self) -> str | None: return h5str(self._handle.name) @cached_property def units(self) -> pint.Unit: """Units of the specified transformation. Could be any of these: NX_LENGTH, NX_ANGLE, or NX_UNITLESS There will be one or more transformations defined by one or more fields for each transformation. The units type NX_TRANSFORMATION designates the particular axis generating a transformation (e.g. a rotation axis or a translation axis or a general axis). NX_TRANSFORMATION designates the units will be appropriate to the type of transformation, indicated in the NXtransformations base class by the transformation_type value: - NX_LENGTH for translation - NX_ANGLE for rotation - NX_UNITLESS for axes for which no transformation type is specified. """ return units(self._handle) @cached_property def transformation_type(self) -> str: """The type of the transformation, either translation or rotation. The transformation_type may be translation, in which case the values are linear displacements along the axis, rotation, in which case the values are angular rotations around the axis. If this attribute is omitted, this is an axis for which there is no motion to be specifies, such as the direction of gravity, or the direction to the source, or a basis vector of a coordinate frame. Any of these values: translation | rotation. """ return h5str(self._handle.attrs.get("transformation_type")) @cached_property def equipment_component(self) -> str | None: """An arbitrary identifier of a component of the equipment to which the transformation belongs, such as ‘detector_arm’ or ‘detector_module’. NXtransformations with the same equipment_component label form a logical grouping which can be combined together into a single change-of-basis operation. """ return h5str(self._handle.attrs.get("equipment_component")) @cached_property def vector(self) -> NXNumberT: """Three values that define the axis for this transformation. The axis should be normalized to unit length, making it dimensionless. For rotation axes, the direction should be chosen for a right-handed rotation with increasing angle. For translation axes the direction should be chosen for increasing displacement. For general axes, an appropriate direction should be chosen. """ return self._handle.attrs.get("vector") @cached_property def offset(self) -> pint.Quantity | None: """A fixed offset applied before the transformation (three vector components). This is not intended to be a substitute for a fixed translation axis but, for example, as the mechanical offset from mounting the axis to its dependency. """ if "offset" in self._handle.attrs: return self._handle.attrs["offset"] * self.offset_units return None @cached_property def offset_units(self) -> pint.Unit: """Units of the offset. Values should be consistent with NX_LENGTH.""" if "offset_units" in self._handle.attrs: return ureg.Unit(h5str(self._handle.attrs["offset_units"])) # This shouldn't be the case, but DLS EIGER NeXus files include offset without # accompanying offset_units, so use units instead (which should strictly only # apply to vector, not offset). # See also https://jira.diamond.ac.uk/browse/MXGDA-3668 return self.units @cached_property def depends_on(self) -> NXtransformationsAxis | None: depends_on = h5str(self._handle.attrs.get("depends_on")) if depends_on and depends_on != ".": return NXtransformationsAxis(self._handle.parent[depends_on]) return None def __getitem__(self, key) -> pint.Quantity: return np.atleast_1d(self._handle)[key] * self.units @cached_property def end(self) -> NXNumber | None: end_name = self._handle.name + "_end" if end_name in self._handle.parent: return NXNumber(self._handle.parent[end_name], self.units) return None @cached_property def increment_set(self) -> pint.Quantity | None: increment_set_name = self._handle.name + "_increment_set" if increment_set_name in self._handle.parent: return self._handle.parent[increment_set_name][()] * self.units return None @cached_property def matrix(self) -> np.ndarray: values = self[()] if np.any(values): values = ( values.to("mm").magnitude if self.transformation_type == "translation" else values.to("rad").magnitude ) else: values = values.magnitude if self.transformation_type == "rotation": R = Rotation.from_rotvec(values[:, np.newaxis] * self.vector).as_matrix() T = np.zeros((values.size, 3)) else: R = np.identity(3) T = values[:, np.newaxis] * self.vector if self.offset is not None and np.any(self.offset): T += self.offset.to("mm").magnitude A = np.repeat(np.identity(4).reshape((1, 4, 4)), values.size, axis=0) A[:, :3, :3] = R A[:, :3, 3] = T return A class NXsample(H5Mapping): """Any information on the sample. This could include scanned variables that are associated with one of the data dimensions, e.g. the magnetic field, or logged data, e.g. monitored temperature vs elapsed time. """ def __init__(self, handle): super().__init__(handle) self._transformations = find_class(handle, "NXtransformations") @cached_property def name(self) -> str: """Descriptive name of sample""" return h5str(self._handle["name"][()]) @cached_property def depends_on(self) -> NXtransformationsAxis | None: """The axis on which the sample position depends""" if "depends_on" in self._handle: depends_on = h5str(self._handle["depends_on"][()]) if depends_on and depends_on != ".": return NXtransformationsAxis(self._handle[depends_on]) return None @cached_property def temperature(self) -> pint.Quantity | None: """The temperature of the sample.""" if temperature := self._handle.get("temperature"): return temperature[()] * units(temperature) return None @cached_property def transformations(self) -> list[NXtransformations]: """This is the recommended location for sample goniometer and other related axes. This is a requirement to describe for any scan experiment. The reason it is optional is mainly to accommodate XFEL single shot exposures. Use of the depends_on field and the NXtransformations group is strongly recommended. As noted above this should be an absolute requirement to have for any scan experiment. The reason it is optional is mainly to accommodate XFEL single shot exposures. """ return [ NXtransformations(transformations) for transformations in self._transformations ] class NXinstrument(H5Mapping): """Collection of the components of the instrument or beamline. Template of instrument descriptions comprising various beamline components. Each component will also be a NeXus group defined by its distance from the sample. Negative distances represent beamline components that are before the sample while positive distances represent components that are after the sample. This device allows the unique identification of beamline components in a way that is valid for both reactor and pulsed instrumentation. """ def __init__(self, handle): super().__init__(handle) ( self._attenuators, self._detector_groups, self._detectors, self._beams, self._transformations, ) = find_classes( handle, "NXattenuator", "NXdetector_group", "NXdetector", "NXbeam", "NXtransformations", ) @cached_property def transformations(self) -> NXtransformations: """ Transformations relating to the diffractometer but not to the sample. These might include a rotation to represent a 2θ arm on which a detector is mounted, or a translation of the detector. """ return [ NXtransformations(transformations) for transformations in self._transformations ] @cached_property def name(self) -> str: """Name of instrument. Consistency with the controlled vocabulary beamline naming in https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_diffrn_source.pdbx_synchrotron_beamline.html and https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_diffrn_source.type.html is highly recommended. """ return h5str(self._handle["name"][()]) @cached_property def short_name(self) -> str: """Short name for instrument, perhaps the acronym.""" return h5str(self._handle["name"].attrs.get("short_name")) @cached_property def time_zone(self) -> str | None: """ISO 8601 time_zone offset from UTC.""" return self._handle.get("time_zone") @cached_property def attenuators(self): return self._attenuators @cached_property def detector_groups(self) -> list[NXdetector_group]: """Optional logical grouping of detectors.""" return [NXdetector_group(group) for group in self._detector_groups] @cached_property def detectors(self) -> list[NXdetector]: """A detector, detector bank, or multidetector. Normally the detector group will have the name detector. However, in the case of multiple detectors, each detector needs a uniquely named NXdetector. """ return [NXdetector(detector) for detector in self._detectors] @cached_property def beams(self) -> list[NXbeam]: """Properties of the neutron or X-ray beam at a given location.""" return [NXbeam(beam) for beam in self._beams] class NXdetector_group(H5Mapping): """Optional logical grouping of detectors. Each detector is represented as an NXdetector with its own detector data array. Each detector data array may be further decomposed into array sections by use of NXdetector_module groups. Detectors can be grouped logically together using NXdetector_group. Groups can be further grouped hierarchically in a single NXdetector_group (for example, if there are multiple detectors at an endstation or multiple endstations at a facility). Alternatively, multiple NXdetector_groups can be provided. The groups are defined hierarchically, with names given in the group_names field, unique identifying indices given in the field group_index, and the level in the hierarchy given in the group_parent field. For example if an x-ray detector group, DET, consists of four detectors in a rectangular array: DTL DTR DLL DLR We could have: group_names: ["DET", "DTL", "DTR", "DLL", "DLR"] group_index: [1, 2, 3, 4, 5] group_parent: [-1, 1, 1, 1, 1] """ @cached_property def group_names(self) -> np.ndarray: """ An array of the names of the detectors or the names of hierarchical groupings of detectors. """ return self._handle["group_names"].asstr()[()] @cached_property def group_index(self) -> NXIntT: """An array of unique identifiers for detectors or groupings of detectors. Each ID is a unique ID for the corresponding detector or group named in the field group_names. The IDs are positive integers starting with 1. """ return self._handle["group_index"][()] @cached_property def group_parent(self) -> NXIntT: """ An array of the hierarchical levels of the parents of detectors or groupings of detectors. A top-level grouping has parent level -1. """ return self._handle["group_parent"][()] class NXdetector(H5Mapping): """ A detector, detector bank, or multidetector. Normally the detector group will have the name detector. However, in the case of multiple detectors, each detector needs a uniquely named NXdetector. """ def __init__(self, handle): super().__init__(handle) (self._modules,) = find_classes(handle, "NXdetector_module") @cached_property def depends_on(self) -> NXtransformationsAxis | None: """The axis on which the detector position depends. NeXus path to the detector positioner axis that most directly supports the detector. In the case of a single-module detector, the detector axis chain may start here. """ if "depends_on" in self._handle: return NXtransformationsAxis(self._handle[self._handle["depends_on"][()]]) return None @cached_property def data(self) -> NXNumber | None: """The raw data array for this detector. For a dimension-2 detector, the rank of the data array will be 3. For a dimension-3 detector, the rank of the data array will be 4. This allows for the introduction of the frame number as the first index. """ if "data" in self._handle: return self._handle["data"][()] return None @cached_property def description(self) -> str | None: """name/manufacturer/model/etc. information.""" if "description" in self._handle: return h5str(np.squeeze(self._handle["description"])[()]) return None @cached_property def distance(self) -> pint.Quantity | None: """Distance from the sample to the beam center. Normally this value is for guidance only, the proper geometry can be found following the depends_on axis chain, But in appropriate cases where the dectector distance to the sample is observable independent of the axis chain, that may take precedence over the axis chain calculation. """ if distance := self._handle.get("distance"): return np.squeeze(distance[()] * units(distance)) return None @cached_property def distance_derived(self) -> bool | None: """Boolean to indicate if the distance is a derived, rather than a primary observation. If distance_derived true or is not specified, the distance is assumed to be derived from detector axis specifications. """ if "distance_derived" in self._handle: return bool(self._handle["distance_derived"][()]) return None @cached_property def count_time(self) -> pint.Quantity | None: """Elapsed actual counting time.""" if count_time := self._handle.get("count_time"): return np.squeeze(count_time[()] * units(count_time, default="seconds")) return None @cached_property def beam_center_x(self) -> pint.Quantity | None: """This is the x position where the direct beam would hit the detector. This is a length and can be outside of the actual detector. The length can be in physical units or pixels as documented by the units attribute. Normally, this should be derived from the axis chain, but the direct specification may take precedence if it is not a derived quantity. """ if beam_centre_x := self._handle.get("beam_center_x"): return np.squeeze(beam_centre_x[()] * units(beam_centre_x, "pixels")) return None @cached_property def beam_center_y(self) -> pint.Quantity | None: """This is the y position where the direct beam would hit the detector. This is a length and can be outside of the actual detector. The length can be in physical units or pixels as documented by the units attribute. Normally, this should be derived from the axis chain, but the direct specification may take precedence if it is not a derived quantity. """ if beam_centre_y := self._handle.get("beam_center_y"): return np.squeeze(beam_centre_y[()] * units(beam_centre_y, "pixels")) return None @cached_property def pixel_mask_applied(self) -> bool | None: """ True when the pixel mask correction has been applied in the electronics, false otherwise (optional). """ if "pixel_mask_applied" in self._handle: return bool(self._handle["pixel_mask_applied"][()]) return None @cached_property def pixel_mask(self) -> h5py.Dataset | None: """The 32-bit pixel mask for the detector. Can be either one mask for the whole dataset (i.e. an array with indices i, j) or each frame can have its own mask (in which case it would be an array with indices nP, i, j). Contains a bit field for each pixel to signal dead, blind, high or otherwise unwanted or undesirable pixels. They have the following meaning: - bit 0: gap (pixel with no sensor) - bit 1: dead - bit 2: under-responding - bit 3: over-responding - bit 4: noisy - bit 5: -undefined- - bit 6: pixel is part of a cluster of problematic pixels (bit set in addition to others) - bit 7: -undefined- - bit 8: user defined mask (e.g. around beamstop) - bits 9-30: -undefined- - bit 31: virtual pixel (corner pixel with interpolated value) Normal data analysis software would not take pixels into account when a bit in (mask & 0x0000FFFF) is set. Tag bit in the upper two bytes would indicate special pixel properties that normally would not be a sole reason to reject the intensity value (unless lower bits are set. If the full bit depths is not required, providing a mask with fewer bits is permissible. If needed, additional pixel masks can be specified by including additional entries named pixel_mask_N, where N is an integer. For example, a general bad pixel mask could be specified in pixel_mask that indicates noisy and dead pixels, and an additional pixel mask from experiment-specific shadowing could be specified in pixel_mask_2. The cumulative mask is the bitwise OR of pixel_mask and any pixel_mask_N entries. If provided, it is recommended that it be compressed. """ return self._handle.get("pixel_mask") @cached_property def bit_depth_readout(self) -> int | None: """How many bits the electronics record per pixel (recommended).""" if "bit_depth_readout" in self._handle: return int(self._handle["bit_depth_readout"][()]) return None @cached_property def bit_depth_image(self) -> int | None: """The number of bits per pixel saved to the image data.""" if "bit_depth_image" in self._handle: return int(self._handle["bit_depth_image"][()]) return None @cached_property def sensor_material(self) -> str: """The name of the material a detector sensor is constructed from. At times, radiation is not directly sensed by the detector. Rather, the detector might sense the output from some converter like a scintillator. This is the name of this converter material.""" return h5str(np.squeeze(self._handle["sensor_material"])[()]) @cached_property def sensor_thickness(self) -> pint.Quantity: thickness = self._handle["sensor_thickness"] return np.squeeze(thickness)[()] * units(thickness) @cached_property def underload_value(self) -> int | None: """The lowest value at which pixels for this detector would be reasonably be measured. For example, given a saturation_value and an underload_value, the valid pixels are those less than or equal to the saturation_value and greater than or equal to the underload_value. """ if "underload_value" in self._handle: return int(self._handle["underload_value"][()]) return None @cached_property def saturation_value(self) -> int | None: """The value at which the detector goes into saturation. Data above this value is known to be invalid. For example, given a saturation_value and an underload_value, the valid pixels are those less than or equal to the saturation_value and greater than or equal to the underload_value. """ if "saturation_value" in self._handle: try: return int(self._handle["saturation_value"][()]) except TypeError as e: logger.warning(f"Error extracting {self.path}/saturation_value: {e}") return None @cached_property def modules(self) -> list[NXdetector_module]: """The list of NXdetector_modules comprising this NXdetector.""" return [NXdetector_module(module) for module in self._modules] @cached_property def type(self) -> str | None: """Description of type such as scintillator, ccd, pixel, image plate, CMOS, …""" if "type" in self._handle: return h5str(np.squeeze(self._handle["type"])[()]) return None @cached_property def frame_time(self) -> pint.Quantity | None: """This is time for each frame. This is exposure_time + readout time.""" if frame_time := self._handle.get("frame_time"): return np.squeeze(frame_time[()] * units(frame_time)) return None @cached_property def serial_number(self) -> str | None: """Serial number for the detector.""" if "serial_number" in self._handle: return h5str(np.squeeze(self._handle["serial_number"])[()]) return None class NXdetector_module(H5Mapping): """Representation of the NXdetector_module class. Many detectors consist of multiple smaller modules that are operated in sync and store their data in a common dataset. To allow consistent parsing of the experimental geometry, this application definiton requires all detectors to define a detector module, even if there is only one. This group specifies the hyperslab of data in the data array associated with the detector that contains the data for this module. If the module is associated with a full data array, rather than with a hyperslab within a larger array, then a single module should be defined, spanning the entire array. """ @cached_property def data_origin(self) -> np.ndarray: """The offset of this module into the raw data array. A dimension-2 or dimension-3 field which gives the indices of the origin of the hyperslab of data for this module in the main area detector image in the parent NXdetector module. The data_origin is 0-based. The frame number dimension (nP) is omitted. Thus the data_origin field for a dimension-2 dataset with indices (nP, i, j) will be an array with indices (i, j), and for a dimension-3 dataset with indices (nP, i, j, k) will be an array with indices (i, j, k). The order of indices (i, j or i, j, k) is slow to fast. """ origin = self._handle["data_origin"][()] assert not isinstance(origin, int) return origin @cached_property def data_size(self) -> np.ndarray: """Two or three values for the size of the module in pixels in each direction. Dimensionality and order of indices is the same as for data_origin. """ size = self._handle["data_size"][()] # Validate that we aren't the int part of NXIntT assert not isinstance(size, int) return size @cached_property def data_stride(self) -> NXIntT | None: """Two or three values for the stride of the module in pixels in each direction. By default the stride is [1,1] or [1,1,1], and this is the most likely case. This optional field is included for completeness. """ if "data_stride" in self._handle: return self._handle["data_stride"][()] return None @cached_property def module_offset(self) -> NXtransformationsAxis | None: """Offset of the module in regards to the origin of the detector in an arbitrary direction. """ if "module_offset" in self._handle: return NXtransformationsAxis(self._handle["module_offset"]) return None @cached_property def fast_pixel_direction(self) -> NXtransformationsAxis: """Values along the direction of fastest varying pixel direction. The direction itself is given through the vector attribute. """ return NXtransformationsAxis(self._handle["fast_pixel_direction"]) @cached_property def slow_pixel_direction(self) -> NXtransformationsAxis: """Values along the direction of slowest varying pixel direction. The direction itself is given through the vector attribute. """ return NXtransformationsAxis(self._handle["slow_pixel_direction"]) class NXsource(H5Mapping): """The neutron or x-ray storage ring/facility.""" @cached_property def name(self) -> str: """Name of source. Consistency with the naming in https://mmcif.wwpdb.org/dictionaries/mmcif_pdbx_v50.dic/Items/_diffrn_source.pdbx_synchrotron_site.html controlled vocabulary is highly recommended. """ return h5str(self._handle["name"][()]) @cached_property def short_name(self) -> str | None: """Short name for source, perhaps the acronym""" return h5str(self._handle["name"].attrs.get("short_name")) class NXbeam(H5Mapping): """Properties of the neutron or X-ray beam at a given location. It will be referenced by beamline component groups within the NXinstrument group or by the NXsample group. Note that variables such as the incident energy could be scalar values or arrays. This group is especially valuable in storing the results of instrument simulations in which it is useful to specify the beam profile, time distribution etc. at each beamline component. Otherwise, its most likely use is in the NXsample group in which it defines the results of the neutron scattering by the sample, e.g., energy transfer, polarizations. """ @cached_property def incident_wavelength(self) -> pint.Quantity: """Wavelength on entering beamline component. In the case of a monchromatic beam this is the scalar wavelength. Several other use cases are permitted, depending on the presence or absence of other incident_wavelength_X fields. In the case of a polychromatic beam this is an array of length m of wavelengths, with the relative weights in incident_wavelength_weights. In the case of a monochromatic beam that varies shot- to-shot, this is an array of wavelengths, one for each recorded shot. Here, incident_wavelength_weights and incident_wavelength_spread are not set. In the case of a polychromatic beam that varies shot-to- shot, this is an array of length m with the relative weights in incident_wavelength_weights as a 2D array. In the case of a polychromatic beam that varies shot-to- shot and where the channels also vary, this is a 2D array of dimensions nP by m (slow to fast) with the relative weights in incident_wavelength_weights as a 2D array. Note, variants are a good way to represent several of these use cases in a single dataset, e.g. if a calibrated, single-value wavelength value is available along with the original spectrum from which it was calibrated. """ wavelength = self._handle["incident_wavelength"] return wavelength[()] * units(wavelength) @cached_property def flux(self) -> pint.Quantity | None: """Flux density incident on beam plane area in photons per second per unit area. In the case of a beam that varies in flux shot-to-shot, this is an array of values, one for each recorded shot. """ if flux := self._handle.get("flux"): return flux[()] * units(flux) return None @cached_property def total_flux(self) -> pint.Quantity | None: """Flux incident on beam plane in photons per second. In the case of a beam that varies in total flux shot-to-shot, this is an array of values, one for each recorded shot. """ if total_flux := self._handle.get("total_flux"): return total_flux[()] * units(total_flux) return None @cached_property def incident_beam_size(self) -> pint.Quantity | None: """Two-element array of FWHM (if Gaussian or Airy function) or diameters (if top hat) or widths (if rectangular) of the beam in the order x, y. """ if beam_size := self._handle.get("incident_beam_size"): return beam_size[()] * units(beam_size) return None @cached_property def profile(self) -> str | None: """The beam profile, Gaussian, Airy function, top-hat or rectangular. The profile is given in the plane of incidence of the beam on the sample. Any of these values: Gaussian | Airy | top-hat | rectangular """ if "profile" in self._handle: return h5str(self._handle["profile"][()]) return None @dataclasses.dataclass(frozen=True) class DependencyChain(Sequence[NXtransformationsAxis]): transformations: list[NXtransformationsAxis] def __iter__(self) -> Iterator[NXtransformationsAxis]: return iter(self.transformations) @overload def __getitem__(self, idx: int) -> NXtransformationsAxis: ... @overload def __getitem__(self, idx: slice) -> Sequence[NXtransformationsAxis]: ... def __getitem__(self, idx): return self.transformations[idx] def __len__(self) -> int: return len(self.transformations) def __str__(self): string = [] for t in self.transformations: depends_on = t.depends_on.path if t.depends_on else "." string.extend( [ f"{t.path} = {t[()]:g}", f" @transformation_type = {t.transformation_type}", f" @vector = {t.vector}", f" @offset = {t.offset}", f" @depends_on = {depends_on}", ] ) return "\n".join(string) def get_dependency_chain( transformation: NXtransformationsAxis, ) -> DependencyChain: """Return the dependency chain for a given NXtransformationsAxis. Follow the `depends_on` tree for a given NXtransformationsAxis and construct the resulting list of NXtransformationsAxis. """ transformations = [] transform: NXtransformationsAxis | None = transformation while transform is not None: transformations.append(transform) transform = transform.depends_on return DependencyChain(transformations) def get_cumulative_transformation( dependency_chain: DependencyChain | Sequence[NXtransformationsAxis], ) -> np.ndarray: """Compute the cumulative transformation for a given dependency chain""" return reduce(operator.__matmul__, reversed([t.matrix for t in dependency_chain])) Axes = namedtuple("Axes", ["axes", "angles", "names", "is_scan_axis"]) def get_rotation_axes(dependency_chain: DependencyChain) -> Axes: axes = [] angles = [] axis_names = [] is_scan_axis = [] for transformation in dependency_chain: if transformation.transformation_type != "rotation": continue values = transformation[()].to("degrees").magnitude is_scan = len(values) > 1 and not np.all(values == values[0]) axes.append(transformation.vector) angles.append(values[0]) assert transformation.path axis_names.append(transformation.path.split("/")[-1]) is_scan_axis.append(is_scan) return Axes( np.array(axes), np.array(angles), np.array(axis_names), np.array(is_scan_axis) ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774341.0 nxmx-0.0.3/src/nxmx/_version.py0000644000175100001730000000024014465466605016100 0ustar00runnerdocker# file generated by setuptools_scm # don't change, don't track in version control __version__ = version = '0.0.3' __version_tuple__ = version_tuple = (0, 0, 3) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/src/nxmx/py.typed0000644000175100001730000000000014465466571015375 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4419942 nxmx-0.0.3/src/nxmx.egg-info/0000755000175100001730000000000014465466605015400 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774341.0 nxmx-0.0.3/src/nxmx.egg-info/PKG-INFO0000644000175100001730000000562514465466605016505 0ustar00runnerdockerMetadata-Version: 2.1 Name: nxmx Version: 0.0.3 Summary: Read HDF5 data conforming to the NXmx application definition of the NeXus format License: BSD 3-Clause License Keywords: NeXus,NXmx Classifier: Programming Language :: Python :: 3 Requires-Python: >=3.9 Description-Content-Type: text/markdown License-File: LICENCE # Read NXmx-flavour NeXus HDF5 data in Python [![PyPI release](https://img.shields.io/pypi/v/nxmx.svg)](https://pypi.python.org/pypi/nxmx) [![Supported Python versions](https://img.shields.io/pypi/pyversions/nxmx.svg)](https://pypi.org/project/nxmx) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)]( https://github.com/ambv/black) This package provides a neat and tidy Python interface for reading data from [HDF5 files](https://www.hdfgroup.org/solutions/hdf5/) that are structured according to the [NXmx application definition](https://manual.nexusformat.org/classes/applications/NXmx.html) of the [NeXus standard](https://www.nexusformat.org/). ## Installation `python-nxmx` is available as `nxmx` on PyPI, so you just need Pip. ```Bash $ pip install nxmx ``` ## Getting started If you have an HDF5 file in NXmx format, inspecting it with `h5ls` will look something like this: ```Bash $ h5ls -r my-nxmx-file.h5 / Group /entry Group /entry/data Group /entry/definition Dataset {SCALAR} /entry/end_time Dataset {SCALAR} /entry/end_time_estimated Dataset {SCALAR} /entry/instrument Group /entry/instrument/beam Group /entry/instrument/beam/incident_beam_size Dataset {2} /entry/instrument/beam/incident_wavelength Dataset {SCALAR} /entry/instrument/beam/total_flux Dataset {SCALAR} /entry/instrument/detector Group ... etc. ... ``` With `nxmx`, you can access the NXmx data in Python like this: ```Python import h5py import nxmx with h5py.File("my-nxmx-file.h5") as f: nxmx_data = nxmx.NXmx(f) ``` ## A slightly more detailed example ```Python import h5py import nxmx with h5py.File("my-nxmx-file.h5") as f: nxmx_data = nxmx.NXmx(f) # Explore the NXmx data structure. entry, *_ = nxmx_data.entries print(entry.definition) # Prints "NXmx". instrument, *_ = entry.instruments detector, *_ = instrument.detectors # Get the h5py object underlying an instance of a NX class. entry_group = entry._handle # entry_group == f["entry"] # Find instances of a given NX class in a h5py.Group. beams = nxmx.find_class(instrument._handle, "NXbeam") # The equivalent for more than one NX class. beams, detectors = nxmx.find_classes(instrument._handle, "NXbeam", "NXdetector") # Query attributes of an object in the normal h5py way. # Suppose out detector has a transformation called "det_z". transformations, *_ = nxmx.find_class(detector._handle, "NXtransformations") attrs = transformations["det_z"].attrs # Get the attributes of the "det_z" dataset. ``` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774341.0 nxmx-0.0.3/src/nxmx.egg-info/SOURCES.txt0000644000175100001730000000067714465466605017276 0ustar00runnerdocker.gitignore .pre-commit-config.yaml LICENCE README.md pyproject.toml requirements.txt requirements_dev.txt .github/dependabot.yml .github/workflows/ci.yml .github/workflows/publish-to-pypi.yml src/nxmx/__init__.py src/nxmx/_version.py src/nxmx/py.typed src/nxmx.egg-info/PKG-INFO src/nxmx.egg-info/SOURCES.txt src/nxmx.egg-info/dependency_links.txt src/nxmx.egg-info/requires.txt src/nxmx.egg-info/top_level.txt tests/conftest.py tests/test_nxmx.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774341.0 nxmx-0.0.3/src/nxmx.egg-info/dependency_links.txt0000644000175100001730000000000114465466605021446 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774341.0 nxmx-0.0.3/src/nxmx.egg-info/requires.txt0000644000175100001730000000004014465466605017772 0ustar00runnerdockerh5py pint python-dateutil scipy ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774341.0 nxmx-0.0.3/src/nxmx.egg-info/top_level.txt0000644000175100001730000000000514465466605020125 0ustar00runnerdockernxmx ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1691774341.4419942 nxmx-0.0.3/tests/0000755000175100001730000000000014465466605013267 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/tests/conftest.py0000644000175100001730000001440214465466571015471 0ustar00runnerdockerfrom __future__ import annotations import h5py import numpy as np import pytest def pytest_configure(): """Incantations to create an in-memory file in h5py.""" pytest.h5_in_memory = {"driver": "core", "backing_store": False} @pytest.fixture def nxmx_example(): with h5py.File(" ", mode="w", **pytest.h5_in_memory) as f: entry = f.create_group("/entry") entry.attrs["NX_class"] = "NXentry" entry["definition"] = "NXmx" entry["start_time"] = "2021-09-10T06:54:37Z" entry["end_time"] = "2021-09-10T06:55:09Z" entry["end_time_estimated"] = "2021-09-10T06:55:09Z" source = entry.create_group("source") source.attrs["NX_class"] = "NXsource" source_name = source.create_dataset("name", data="Diamond") source_name.attrs["short_name"] = "DLS" instrument = entry.create_group("instrument") instrument.attrs["NX_class"] = "NXinstrument" name = instrument.create_dataset( "name", data=np.string_("DIAMOND BEAMLINE I03") ) name.attrs["short_name"] = "I03" beam = instrument.create_group("beam") beam.attrs["NX_class"] = "NXbeam" beam.create_dataset("incident_beam_size", data=np.array([3e-5, 3e-5])) beam["incident_beam_size"].attrs["units"] = b"m" beam["incident_wavelength"] = 0.976223 beam["incident_wavelength"].attrs["units"] = b"angstrom" beam["total_flux"] = 1e12 beam["total_flux"].attrs["units"] = b"Hz" detector = instrument.create_group("detector") detector.attrs["NX_class"] = "NXdetector" detector["beam_center_x"] = 2079.79727597266 detector["beam_center_y"] = 2225.38773853771 detector["count_time"] = 0.00285260857097799 detector["depends_on"] = "/entry/instrument/detector/transformations/det_z" detector["description"] = "Eiger 16M" detector["distance"] = 0.237015940260233 detector.create_dataset("data", data=np.zeros((100, 100))) detector["sensor_material"] = "Silicon" detector["sensor_thickness"] = 0.00045 detector["sensor_thickness"].attrs["units"] = b"m" detector["x_pixel_size"] = 7.5e-05 detector["y_pixel_size"] = 7.5e-05 detector["underload_value"] = 0 detector["saturation_value"] = 9266 detector["frame_time"] = 0.1 detector["frame_time"].attrs["units"] = "s" detector["bit_depth_readout"] = np.array(32) detector["bit_depth_image"] = np.array(32) detector_transformations = detector.create_group("transformations") detector_transformations.attrs["NX_class"] = "NXtransformations" det_z = detector_transformations.create_dataset("det_z", data=np.array([289.3])) det_z.attrs["depends_on"] = b"." det_z.attrs["transformation_type"] = b"translation" det_z.attrs["units"] = b"mm" det_z.attrs["vector"] = np.array([0.0, 0.0, 1.0]) module = detector.create_group("module") module.attrs["NX_class"] = "NXdetector_module" module.create_dataset("data_origin", data=np.array([0.0, 0.0])) module.create_dataset("data_size", data=np.array([4362, 4148])) fast_pixel_direction = module.create_dataset( "fast_pixel_direction", data=7.5e-5 ) fast_pixel_direction.attrs["transformation_type"] = "translation" fast_pixel_direction.attrs[ "depends_on" ] = "/entry/instrument/detector/module/module_offset" fast_pixel_direction.attrs["vector"] = np.array([-1.0, 0.0, 0.0]) fast_pixel_direction.attrs["offset"] = np.array([0.0, 0.0, 0.0]) fast_pixel_direction.attrs["offset_units"] = b"m" fast_pixel_direction.attrs["units"] = b"m" slow_pixel_direction = module.create_dataset( "slow_pixel_direction", data=7.5e-5 ) slow_pixel_direction.attrs["transformation_type"] = "translation" slow_pixel_direction.attrs[ "depends_on" ] = "/entry/instrument/detector/module/module_offset" slow_pixel_direction.attrs["vector"] = np.array([0.0, -1.0, 0.0]) slow_pixel_direction.attrs["offset"] = np.array([0.0, 0.0, 0.0]) slow_pixel_direction.attrs["offset_units"] = b"m" slow_pixel_direction.attrs["units"] = b"m" module_offset = module.create_dataset("module_offset", data=0) module_offset.attrs["transformation_type"] = "translation" module_offset.attrs["depends_on"] = detector["depends_on"] module_offset.attrs["vector"] = np.array([1.0, 0.0, 0.0]) module_offset.attrs["offset"] = np.array([0.155985, 0.166904, -0]) module_offset.attrs["offset_units"] = b"m" module_offset.attrs["units"] = b"m" sample = entry.create_group("sample") sample.attrs["NX_class"] = "NXsample" sample["name"] = "mysample" sample["depends_on"] = b"/entry/sample/transformations/phi" sample["temperature"] = 273 sample["temperature"].attrs["units"] = b"K" transformations = sample.create_group("transformations") transformations.attrs["NX_class"] = "NXtransformations" omega = transformations.create_dataset("omega", data=np.arange(0, 1, 0.1)) omega.attrs["depends_on"] = b"." omega.attrs["transformation_type"] = b"rotation" omega.attrs["units"] = b"deg" omega.attrs["vector"] = np.array([-1.0, 0.0, 0.0]) omega.attrs["omega_offset"] = np.array([0.0, 0.0, 0.0]) transformations.create_dataset("omega_end", data=np.arange(0.1, 1.1, 0.1)) transformations.create_dataset("omega_increment_set", data=0.1) phi = transformations.create_dataset("phi", data=np.array([0.0])) phi.attrs["depends_on"] = b"/entry/sample/transformations/chi" phi.attrs["transformation_type"] = b"rotation" phi.attrs["units"] = b"deg" phi.attrs["vector"] = np.array([-1.0, 0, 0]) chi = transformations.create_dataset("chi", data=0.0) # scalar dataset chi.attrs["depends_on"] = b"/entry/sample/transformations/omega" chi.attrs["transformation_type"] = b"rotation" chi.attrs["units"] = b"deg" chi.attrs["vector"] = np.array([0, 0, 1]) data = entry.create_group("data") data.attrs["NX_class"] = "NXdata" yield f ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1691774329.0 nxmx-0.0.3/tests/test_nxmx.py0000644000175100001730000003475514465466571015712 0ustar00runnerdockerfrom __future__ import annotations import datetime import dateutil import h5py import numpy as np import pint import pytest import nxmx def test_nxentry(nxmx_example): nxentry = nxmx.NXentry(nxmx_example["/entry"]) assert nxentry.definition == "NXmx" assert len(nxentry.samples) == 1 assert isinstance(nxentry.samples[0], nxmx.NXsample) assert len(nxentry.instruments) == 1 assert isinstance(nxentry.instruments[0], nxmx.NXinstrument) assert isinstance(nxentry.source, nxmx.NXsource) assert len(nxentry.data) == 1 assert isinstance(nxentry.data[0], nxmx.NXdata) def test_axis_end_increment_set(nxmx_example): omega = nxmx.NXtransformationsAxis( nxmx_example["/entry/sample/transformations/omega"] ) assert len(omega.end) == len(omega) assert omega.end[0] - omega[0] == omega.increment_set phi = nxmx.NXtransformationsAxis(nxmx_example["/entry/sample/transformations/phi"]) assert phi.end is None assert phi.increment_set is None def test_nxmx(nxmx_example): nx = nxmx.NXmx(nxmx_example) assert len(nx) == 1 assert nx.keys() == nxmx_example.keys() entries = nx.entries assert len(entries) == 1 nxentry = entries[0] assert nxentry.definition == "NXmx" assert nxentry.path == "/entry" assert nxentry.start_time == datetime.datetime( 2021, 9, 10, 6, 54, 37, tzinfo=dateutil.tz.tzutc() ) assert nxentry.end_time == datetime.datetime( 2021, 9, 10, 6, 55, 9, tzinfo=dateutil.tz.tzutc() ) assert nxentry.end_time_estimated == datetime.datetime( 2021, 9, 10, 6, 55, 9, tzinfo=dateutil.tz.tzutc() ) samples = nxentry.samples assert len(samples) == 1 sample = samples[0] assert sample.name == "mysample" assert sample.depends_on.path == "/entry/sample/transformations/phi" assert sample.temperature == pint.Quantity(273, "K") assert sample.path == "/entry/sample" transformations = sample.transformations assert len(transformations) == 1 axes = transformations[0].axes assert len(axes) == 3 assert set(axes.keys()) == {"chi", "omega", "phi"} phi_depends_on = axes["phi"].depends_on assert phi_depends_on.path == "/entry/sample/transformations/chi" assert len(nxentry.instruments) == 1 instrument = nxentry.instruments[0] assert instrument.name == "DIAMOND BEAMLINE I03" assert instrument.short_name == "I03" assert len(instrument.beams) == 1 beam = instrument.beams[0] assert np.all(beam.incident_beam_size == pint.Quantity([3e-5, 3e-5], "m")) assert beam.incident_wavelength.to("angstrom").magnitude == 0.976223 assert beam.flux is None assert beam.total_flux == pint.Quantity(1e12, "Hz") assert len(instrument.detectors) == 1 detector = instrument.detectors[0] assert detector.description == "Eiger 16M" assert detector.sensor_material == "Silicon" assert detector.sensor_thickness.to("mm").magnitude == 0.45 assert ( detector.depends_on.path == "/entry/instrument/detector/transformations/det_z" ) assert detector.bit_depth_readout == 32 assert detector.bit_depth_image == 32 assert detector.beam_center_x == pint.Quantity(2079.79727597266, "pixel") assert detector.beam_center_y == pint.Quantity(2225.38773853771, "pixel") assert len(detector.modules) == 1 module = detector.modules[0] assert np.all(module.data_origin == [0, 0]) assert np.all(module.data_size == [4362, 4148]) assert module.fast_pixel_direction.matrix.shape == (1, 4, 4) assert list(module.fast_pixel_direction.matrix.flatten()) == [ 1, 0, 0, -0.075, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ] assert nxentry.source.name == "Diamond" assert nxentry.source.short_name == "DLS" @pytest.fixture(params=[(), (1,)], ids=["scalar", "length-1 array"]) def nx_detector(request): """A dummy NXdetector with some data sets that may be scalar or length-1 arrays.""" shape = request.param with h5py.File("_", "w", **pytest.h5_in_memory) as f: entry = f.create_group("entry") entry.attrs["NX_class"] = "NXentry" entry["definition"] = "NXmx" instrument = entry.create_group("instrument") instrument.attrs["NX_class"] = "NXinstrument" detector = instrument.create_group("detector") detector.attrs["NX_class"] = "NXdetector" time = detector.create_dataset("count_time", data=0, shape=shape) time.attrs["units"] = "s" distance = detector.create_dataset("distance", data=0.00314159, shape=shape) distance.attrs["units"] = "m" detector.create_dataset("pixel_mask_applied", data=False, shape=shape) detector.create_dataset("pixel_mask", data=np.zeros((2, 100, 200))) detector.create_dataset("saturation_value", data=12345, shape=shape) detector.create_dataset("serial_number", data="ABCDE", shape=shape) yield f def test_nxmx_single_value_properties(nx_detector): """ Check we correctly interpret scalar data stored as single-valued arrays. Some data sources, notably Dectris Eiger detectors at Diamond Light Source, record some scalar data as length-1 arrays. Check here that we correctly interpret such data as scalars, whether they are recorded as scalars or as length-1 arrays. """ with nx_detector as f: nx_detector = nxmx.NXmx(f).entries[0].instruments[0].detectors[0] # These scalar parameters are populated with data from single-valued arrays. assert nx_detector.count_time == pint.Quantity(0, "s") assert nx_detector.distance == pint.Quantity(3.14159, "mm") assert nx_detector.pixel_mask_applied is False assert nx_detector.saturation_value == 12345 assert nx_detector.serial_number == "ABCDE" def test_nxdetector_pixel_mask(nx_detector): with nx_detector as f: nx_detector = nxmx.NXmx(f).entries[0].instruments[0].detectors[0] assert isinstance(nx_detector.pixel_mask, h5py.Dataset) assert nx_detector.pixel_mask.shape == (2, 100, 200) assert nx_detector.pixel_mask[0].shape == (100, 200) assert nx_detector.pixel_mask[1].shape == (100, 200) def test_get_rotation_axes(nxmx_example): sample = nxmx.NXmx(nxmx_example).entries[0].samples[0] dependency_chain = nxmx.get_dependency_chain(sample.depends_on) axes = nxmx.get_rotation_axes(dependency_chain) assert np.all(axes.is_scan_axis == [False, False, True]) assert np.all(axes.names == ["phi", "chi", "omega"]) assert np.all(axes.angles == [0.0, 0.0, 0.0]) assert np.all( axes.axes == np.array([[-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [-1.0, 0.0, 0.0]]) ) @pytest.mark.parametrize( "scan_data", [np.array(0), np.array([0])], ids=["scalar", "vector"] ) def test_get_rotation_axis_scalar_or_vector(scan_data): """ Test that single-valued rotation axis positions can be scalar or vector. A rotation axis with a single angular position may be recorded in a HDF5 NeXus file either as an array data set with a single entry, or as a scalar data set. Both are equally valid. Check that they are handled correctly in get_rotation_axis. """ # Create a basic h5py data set. A non-empty string file name is required, # even though there is no corresponding file. with h5py.File(" ", "w", **pytest.h5_in_memory) as f: # Create a single data set representing the goniometer axis. scan_axis = f.create_dataset("dummy_axis", data=scan_data) # Add the attributes of a rotation scan axis aligned with the x axis. scan_axis.attrs["transformation_type"] = "rotation" scan_axis.attrs["vector"] = (1, 0, 0) scan_axis.attrs["units"] = "degrees" # Test that we can interpret the rotation axis datum. scan_axes = [nxmx.NXtransformationsAxis(scan_axis)] nxmx.get_rotation_axes(scan_axes) def test_get_dependency_chain(nxmx_example): sample = nxmx.NXmx(nxmx_example).entries[0].samples[0] dependency_chain = nxmx.get_dependency_chain(sample.depends_on) assert [d.path for d in dependency_chain] == [ "/entry/sample/transformations/phi", "/entry/sample/transformations/chi", "/entry/sample/transformations/omega", ] assert ( str(dependency_chain) == """\ /entry/sample/transformations/phi = [0] degree @transformation_type = rotation @vector = [-1. 0. 0.] @offset = None @depends_on = /entry/sample/transformations/chi /entry/sample/transformations/chi = [0] degree @transformation_type = rotation @vector = [0 0 1] @offset = None @depends_on = /entry/sample/transformations/omega /entry/sample/transformations/omega = [0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9] degree @transformation_type = rotation @vector = [-1. 0. 0.] @offset = None @depends_on = .""" ) @pytest.fixture( params=[True, False], ids=["equipment_component", "no equipment_component"] ) def detector_depends_on_example(request): with h5py.File(" ", "w", **pytest.h5_in_memory) as f: module = f.create_group("/entry/instrument/detector/module") module.attrs["NX_class"] = "NXdetector_module" fast_pixel_direction = module.create_dataset( "fast_pixel_direction", data=7.5e-5 ) fast_pixel_direction.attrs["transformation_type"] = "translation" fast_pixel_direction.attrs[ "depends_on" ] = "/entry/instrument/detector/module/module_offset" fast_pixel_direction.attrs["vector"] = np.array([-1.0, 0.0, 0.0]) fast_pixel_direction.attrs["offset"] = np.array([0.0, 0.0, 0.0]) fast_pixel_direction.attrs["offset_units"] = "m" fast_pixel_direction.attrs["units"] = "m" module_offset = module.create_dataset("module_offset", data=0) module_offset.attrs["transformation_type"] = "translation" if request.param: module_offset.attrs[ "depends_on" ] = "/entry/instrument/detector/transformations/det_z_tune" else: module_offset.attrs[ "depends_on" ] = "/entry/instrument/detector/transformations/det_z" module_offset.attrs["vector"] = np.array([1.0, 0.0, 0.0]) module_offset.attrs["offset"] = np.array([0.155985, 0.166904, -0]) module_offset.attrs["offset_units"] = "m" module_offset.attrs["units"] = "m" transformations = f.create_group("/entry/instrument/detector/transformations") if request.param: det_z_tune = transformations.create_dataset( "det_z_tune", data=np.array([-0.5]) ) det_z_tune.attrs[ "depends_on" ] = b"/entry/instrument/detector/transformations/det_z" det_z_tune.attrs["transformation_type"] = b"translation" det_z_tune.attrs["units"] = b"mm" det_z_tune.attrs["vector"] = np.array([0.0, 0.0, 1.0]) det_z_tune.attrs["equipment_component"] = "detector_arm" det_z = transformations.create_dataset("det_z", data=np.array([289.3])) det_z.attrs["depends_on"] = b"." det_z.attrs["transformation_type"] = b"translation" det_z.attrs["units"] = b"mm" det_z.attrs["vector"] = np.array([0.0, 0.0, 1.0]) if request.param: det_z.attrs["equipment_component"] = "detector_arm" yield f def test_get_dependency_chain_detector(detector_depends_on_example): equipment_component = ( "equipment_component" in detector_depends_on_example[ "/entry/instrument/detector/transformations/det_z" ].attrs ) fast_pixel_direction = detector_depends_on_example[ "/entry/instrument/detector/module/fast_pixel_direction" ] fast_axis = nxmx.NXtransformationsAxis(fast_pixel_direction) dependency_chain = nxmx.get_dependency_chain(fast_axis) if equipment_component: assert len(dependency_chain) == 4 assert [d.path for d in dependency_chain] == [ "/entry/instrument/detector/module/fast_pixel_direction", "/entry/instrument/detector/module/module_offset", "/entry/instrument/detector/transformations/det_z_tune", "/entry/instrument/detector/transformations/det_z", ] z = 288.8 else: assert len(dependency_chain) == 3 assert [d.path for d in dependency_chain] == [ "/entry/instrument/detector/module/fast_pixel_direction", "/entry/instrument/detector/module/module_offset", "/entry/instrument/detector/transformations/det_z", ] z = 289.3 A = nxmx.get_cumulative_transformation(dependency_chain) assert A.shape == (1, 4, 4) assert np.allclose( A[0], np.array( [ [1.0, 0.0, 0.0, 155.91], [0.0, 1.0, 0.0, 166.904], [0.0, 0.0, 1.0, z], [0.0, 0.0, 0.0, 1.0], ] ), ) def test_get_cumulative_transformation(nxmx_example): sample = nxmx.NXmx(nxmx_example).entries[0].samples[0] dependency_chain = nxmx.get_dependency_chain(sample.depends_on) A = nxmx.get_cumulative_transformation(dependency_chain) assert A.shape == (10, 4, 4) assert np.all( A[0] == np.array( [ [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] ) ) @pytest.fixture def detector_group(): with h5py.File(" ", "w", **pytest.h5_in_memory) as f: entry = f.create_group("entry") entry.attrs["NX_class"] = "NXentry" entry["definition"] = "NXmx" instrument = entry.create_group("instrument") instrument.attrs["NX_class"] = "NXinstrument" group = instrument.create_group("detector_group") group.attrs["NX_class"] = "NXdetector_group" group.create_dataset( "group_names", data=[np.string_(n) for n in ("DET", "DTL", "DTR", "DLL", "DLR")], dtype="S12", ) group.create_dataset("group_index", data=np.array([1, 2, 3, 4, 5])) group.create_dataset("group_parent", data=np.array([-1, 1, 1, 1])) yield f def test_nxdetector_group(detector_group): group = nxmx.NXmx(detector_group).entries[0].instruments[0].detector_groups[0] assert list(group.group_names) == ["DET", "DTL", "DTR", "DLL", "DLR"] assert list(group.group_index) == [1, 2, 3, 4, 5] assert list(group.group_parent) == [-1, 1, 1, 1]