From 4ad2777a8da40f962fa306a043998080e0c2def9 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 2 Jan 2024 11:26:03 +0000 Subject: [PATCH 001/174] Fix documentation link to hyperspy 2.0 documentation --- CHANGES.rst | 4 ++-- doc/conf.py | 1 + doc/user_guide/supported_formats/hspy.rst | 4 ++-- doc/user_guide/supported_formats/mrc.rst | 2 +- doc/user_guide/supported_formats/nexus.rst | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ec58e0206..e000255dc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -160,10 +160,10 @@ Maintenance - Fix minimum install, add corresponding tests build and tidy up leftover code (`#13 `_) - Fixes and code consistency improvements based on analysis provided by lgtm.org (`#23 `_) - Added github action for code scanning using the codeQL engine. (`#26 `_) -- Following the deprecation cycle announced in `HyperSpy `_, +- Following the deprecation cycle announced in `HyperSpy `_, the following keywords and attributes have been removed: - - :ref:`Bruker composite file (BCF) `: The ``'spectrum'`` option for the + - :ref:`Bruker composite file (BCF) `: The ``'spectrum'`` option for the ``select_type`` parameter was removed. Use 'spectrum_image' instead. - :ref:`Electron Microscopy Dataset (EMD) NCEM `: Using the keyword ``'dataset_name'`` was removed, use ``'dataset_path'`` instead. diff --git a/doc/conf.py b/doc/conf.py index 149bbc026..b567ba3da 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -45,6 +45,7 @@ intersphinx_mapping = { "conda": ("https://conda.io/projects/conda/en/latest", None), "dask": ("https://docs.dask.org/en/latest", None), + "exspy": ("https://hyperspy.org/exspy", None), "hyperspy": ("https://hyperspy.org/hyperspy-doc/current/", None), "h5py": ("https://docs.h5py.org/en/stable/", None), "matplotlib": ("https://matplotlib.org/stable", None), diff --git a/doc/user_guide/supported_formats/hspy.rst b/doc/user_guide/supported_formats/hspy.rst index 2c47b50db..3d0d3ae5f 100644 --- a/doc/user_guide/supported_formats/hspy.rst +++ b/doc/user_guide/supported_formats/hspy.rst @@ -40,7 +40,7 @@ filename e.g.: When saving to ``.hspy``, all supported objects in the signal's -:external+hyperspy:attr:`hyperspy.signal.BaseSignal.metadata` are stored. This includes lists, tuples +:external+hyperspy:attr:`hyperspy.api.signals.BaseSignal.metadata` are stored. This includes lists, tuples and signals. Please note that in order to increase saving efficiency and speed, if possible, the inner-most structures are converted to numpy arrays when saved. This procedure homogenizes any types of the objects inside, most notably casting @@ -58,7 +58,7 @@ The change of type is done using numpy "safe" rules, so no information is lost, as numbers are represented to full machine precision. This feature is particularly useful when using -:external+hyperspy:meth:`hyperspy._signals.eds.EDSSpectrum.get_lines_intensity`: +:external+exspy:meth:`exspy.signals.EDSSpectrum.get_lines_intensity`: .. code-block:: python diff --git a/doc/user_guide/supported_formats/mrc.rst b/doc/user_guide/supported_formats/mrc.rst index 2ed1b2042..2bd9f475f 100644 --- a/doc/user_guide/supported_formats/mrc.rst +++ b/doc/user_guide/supported_formats/mrc.rst @@ -39,7 +39,7 @@ not be passed (Default is ``None``): mrcz.file_writer('test.mrc', s_dict) -Alternatively, use :py:meth:`hyperspy.signal.BaseSignal.save`, which will pick the +Alternatively, use :py:meth:`hyperspy.api.signals.BaseSignal.save`, which will pick the ``mrcz`` plugin automatically: .. code-block:: python diff --git a/doc/user_guide/supported_formats/nexus.rst b/doc/user_guide/supported_formats/nexus.rst index 9514f7644..b96430d76 100644 --- a/doc/user_guide/supported_formats/nexus.rst +++ b/doc/user_guide/supported_formats/nexus.rst @@ -58,7 +58,7 @@ flexible and can also be used to inspect any hdf5 based file. Differences with respect to HSpy ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The `HyperSpy metadata structure `_ +The :external+hyperspy:ref:`HyperSpy metadata structure ` stores arrays as hdf datasets without attributes and stores floats, ints and strings as attributes. The NeXus format uses hdf dataset attributes to store additional From 21fc577f9a5be2254db90fe193de4131a1cc5fca Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 2 Jan 2024 11:49:30 +0000 Subject: [PATCH 002/174] Add changelog entry --- upcoming_changes/210.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/210.maintenance.rst diff --git a/upcoming_changes/210.maintenance.rst b/upcoming_changes/210.maintenance.rst new file mode 100644 index 000000000..433ada278 --- /dev/null +++ b/upcoming_changes/210.maintenance.rst @@ -0,0 +1 @@ +Fix documentation links following release of hyperspy 2.0. \ No newline at end of file From 4301190ebdabfaf3f06e8fa9a153f79a936c60f2 Mon Sep 17 00:00:00 2001 From: cssfrancis Date: Wed, 17 Jan 2024 08:40:34 -0600 Subject: [PATCH 003/174] Bugfix: Set equal chunking for shapes and dataset --- rsciio/_hierarchical.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index a1f36c1fb..5095bb8d6 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -764,7 +764,7 @@ def overwrite_dataset( shapes[i] = np.array(data[i].shape) shape_dset = cls._get_object_dset( - group, shapes, f"_ragged_shapes_{key}", shapes.shape, **kwds + group, shapes, f"_ragged_shapes_{key}", chunks, **kwds ) cls._store_data( @@ -772,7 +772,7 @@ def overwrite_dataset( (dset, shape_dset), group, (key, f"_ragged_shapes_{key}"), - (chunks, shapes.shape), + (chunks, chunks), show_progressbar, ) else: From 995818cfc80ac58db05ee46fb7a2dcf694eb282b Mon Sep 17 00:00:00 2001 From: cssfrancis Date: Wed, 17 Jan 2024 08:47:13 -0600 Subject: [PATCH 004/174] Bugfix: from array with data chunks. --- rsciio/_hierarchical.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index 5095bb8d6..d58d38ba3 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -267,8 +267,9 @@ def _read_array(group, dataset_key): # cast to a numpy array to avoid multiple calls to # _decode_chunk in zarr (or h5py) data = da.from_array(data, chunks=data.chunks) - shape = da.from_array(ragged_shape, chunks=ragged_shape.chunks) - shape = shape.rechunk(data.chunks) + shape = da.from_array( + ragged_shape, chunks=data.chunks + ) # same chunks as data data = da.apply_gufunc(unflatten_data, "(),()->()", data, shape) return data From 68b72bfb4270bb78cce57ddf858173270cd48cee Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 23 Jan 2024 11:54:28 -0600 Subject: [PATCH 005/174] Add Changelog and document reason for changes. --- rsciio/_hierarchical.py | 11 +++++------ upcoming_changes/211.bugfix.rst | 1 + 2 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 upcoming_changes/211.bugfix.rst diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index d58d38ba3..47ac2b7c4 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -263,13 +263,12 @@ def _read_array(group, dataset_key): key = "ragged_shapes" if key in group: ragged_shape = group[key] - # if the data is chunked saved array we must first - # cast to a numpy array to avoid multiple calls to - # _decode_chunk in zarr (or h5py) + # Use same chunks as data so that apply_gufunc doesn't rechunk + # Reduces the transfer of data between workers which + # significantly improves performance for distributed loading data = da.from_array(data, chunks=data.chunks) - shape = da.from_array( - ragged_shape, chunks=data.chunks - ) # same chunks as data + shape = da.from_array(ragged_shape, chunks=data.chunks) + data = da.apply_gufunc(unflatten_data, "(),()->()", data, shape) return data diff --git a/upcoming_changes/211.bugfix.rst b/upcoming_changes/211.bugfix.rst new file mode 100644 index 000000000..fd8dffc84 --- /dev/null +++ b/upcoming_changes/211.bugfix.rst @@ -0,0 +1 @@ +Fix saving ragged arrays of vectors from/to a chunked ``hspy`` and ``zspy`` store. Greatly increases the speed of saving and loading ragged arrays from chunked datasets. \ No newline at end of file From 992f0a17d526fba29d81742898e6cc3dd98a2a87 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 26 Jan 2024 22:14:23 +0000 Subject: [PATCH 006/174] Remove GitHub lint workflow since it is redundant with pre-commit.com and can get out-of-sync with pre-commit configuration --- .github/workflows/black.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index cc2368f48..000000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - - uses: psf/black@stable From d2f60ef12ad9cbfed43c25b0dc57910f49ea4b99 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 26 Jan 2024 22:20:18 +0000 Subject: [PATCH 007/174] Update contributing guide --- CONTRIBUTING.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5402aae39..93a0e91aa 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -32,11 +32,16 @@ in order to get started and for detailed contributing guidelines. Lint ---- + +.. _pre-commit.ci: https://pre-commit.ci + To keep the code style consistent (and more readable), `black `_ is used to check the code formatting. When the code doesn't comply with the expected formatting, -the `lint `_ will fail. -In practise, the code formatting can be fixed by installing ``black`` and running it on the +the `pre-commit.ci build `_ +will fail. In practise, the code formatting can be fixed by installing ``black`` and running it on the source code or by using :ref:`pre-commit hooks `. +Alternatively, adding the message ``pre-commit.ci autofix`` in a pull request will push a commit with +the fixes using `pre-commit.ci`_. .. _adding-and-updating-test-data: @@ -88,7 +93,7 @@ Two pre-commit hooks are set up: These can be run locally by using `pre-commit `__. Alternatively, the comment ``pre-commit.ci autofix`` can be added to a PR to fix the formatting -using `pre-commit.ci `_. +using `pre-commit.ci`_. .. _defining-plugins: From 05e02babb6b7cec593da0484fb4d047417bf9a7b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 23 Jan 2024 18:25:39 +0000 Subject: [PATCH 008/174] Tidy up unnecessary code --- rsciio/_hierarchical.py | 13 ++++++------- rsciio/hspy/_api.py | 7 +------ rsciio/zspy/_api.py | 7 +------ 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index 47ac2b7c4..f622a26af 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -133,6 +133,8 @@ def get_signal_chunks(shape, dtype, signal_axes=None, target_size=1e6): class HierarchicalReader: """A generic Reader class for reading data from hierarchical file types.""" + _file_type = "" + def __init__(self, file): """ Initializes a general reader for hierarchical signals. @@ -147,8 +149,6 @@ def __init__(self, file): self.version = self.get_format_version() self.Dataset = None self.Group = None - self.unicode_kwds = None - self.ragged_kwds = None if self.version > Version(version): warnings.warn( @@ -638,6 +638,7 @@ class HierarchicalWriter: """ target_size = 1e6 + _unicode_kwds = None def __init__(self, file, signal, group, **kwds): """Initialize a generic file writer for hierachical data storage types. @@ -658,8 +659,6 @@ def __init__(self, file, signal, group, **kwds): self.group = group self.Dataset = None self.Group = None - self.unicode_kwds = None - self.ragged_kwds = None self.kwds = kwds @staticmethod @@ -895,17 +894,17 @@ def parse_structure(self, key, group, value, _type, **kwds): except ValueError: tmp = np.array([[0]]) - if tmp.dtype == np.dtype("O") or tmp.ndim != 1: + if np.issubdtype(tmp.dtype, object) or tmp.ndim != 1: self.dict2group( dict(zip([str(i) for i in range(len(value))], value)), group.require_group(_type + str(len(value)) + "_" + key), **kwds, ) - elif tmp.dtype.type is np.unicode_: + elif np.issubdtype(tmp.dtype, np.dtype("U")): if _type + key in group: del group[_type + key] group.create_dataset( - _type + key, shape=tmp.shape, **self.unicode_kwds, **kwds + _type + key, shape=tmp.shape, **self._unicode_kwds, **kwds ) group[_type + key][:] = tmp[:] else: diff --git a/rsciio/hspy/_api.py b/rsciio/hspy/_api.py index 87d9cd067..11762ec22 100644 --- a/rsciio/hspy/_api.py +++ b/rsciio/hspy/_api.py @@ -53,7 +53,6 @@ def __init__(self, file): super().__init__(file) self.Dataset = h5py.Dataset self.Group = h5py.Group - self.unicode_kwds = {"dtype": h5py.special_dtype(vlen=str)} class HyperspyWriter(HierarchicalWriter): @@ -63,16 +62,12 @@ class HyperspyWriter(HierarchicalWriter): """ target_size = 1e6 + _unicode_kwds = {"dtype": h5py.string_dtype()} def __init__(self, file, signal, expg, **kwds): super().__init__(file, signal, expg, **kwds) self.Dataset = h5py.Dataset self.Group = h5py.Group - self.unicode_kwds = {"dtype": h5py.special_dtype(vlen=str)} - if len(signal["data"]) > 0: - self.ragged_kwds = { - "dtype": h5py.special_dtype(vlen=signal["data"][0].dtype) - } @staticmethod def _store_data(data, dset, group, key, chunks, show_progressbar=True): diff --git a/rsciio/zspy/_api.py b/rsciio/zspy/_api.py index 39bade250..2fe1d781b 100644 --- a/rsciio/zspy/_api.py +++ b/rsciio/zspy/_api.py @@ -79,16 +79,11 @@ def __init__(self, file): class ZspyWriter(HierarchicalWriter): target_size = 1e8 _file_type = "zspy" + _unicode_kwds = {"dtype": object, "object_codec": numcodecs.JSON()} def __init__(self, file, signal, expg, **kwargs): super().__init__(file, signal, expg, **kwargs) self.Dataset = zarr.Array - self.unicode_kwds = {"dtype": object, "object_codec": numcodecs.JSON()} - self.ragged_kwds = { - "dtype": object, - "object_codec": numcodecs.VLenArray(signal["data"][0].dtype), - "exact": True, - } @staticmethod def _get_object_dset(group, data, key, chunks, **kwds): From 696be703d68129c47f657188ce250827121c35ca Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 26 Jan 2024 21:59:14 +0000 Subject: [PATCH 009/174] Save items of ragged arrays of str as list and use msgPack encoder when dtype is of python type, e.g. str, list, tuple, etc. --- pyproject.toml | 2 +- rsciio/_hierarchical.py | 25 ++++++++++++++----------- rsciio/tests/test_hspy.py | 19 +++++++++++++++++++ rsciio/zspy/_api.py | 34 +++++++++++++++++++++++++--------- 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c0e70ba1b..40267c465 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ tiff = ["tifffile>=2020.2.16", "imagecodecs>=2020.1.31"] # Add sidpy dependency and pinning as workaround to fix pyUSID import # Remove sidpy dependency once https://github.com/pycroscopy/pyUSID/issues/85 is fixed. usid = ["pyUSID", "sidpy<=0.12.0"] -zspy = ["zarr"] +zspy = ["zarr", "msgpack"] tests = [ "pooch", "pytest>=3.6", diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index f622a26af..bf63ce626 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -46,15 +46,16 @@ def flatten_data(x): new_data = np.empty(shape=x.shape, dtype=object) shapes = np.empty(shape=x.shape, dtype=object) for i in np.ndindex(x.shape): - new_data[i] = x[i].ravel() - shapes[i] = np.array(x[i].shape) + # Convert to list to save ragged array of array with string dtype + new_data[i] = x[i].ravel().tolist() + shapes[i] = x[i].shape return new_data, shapes def unflatten_data(data, shape): new_data = np.empty(shape=data.shape, dtype=object) for i in np.ndindex(new_data.shape): - new_data[i] = np.reshape(data[i], shape[i]) + new_data[i] = np.reshape(np.array(data[i]), shape[i]) return new_data @@ -267,9 +268,15 @@ def _read_array(group, dataset_key): # Reduces the transfer of data between workers which # significantly improves performance for distributed loading data = da.from_array(data, chunks=data.chunks) - shape = da.from_array(ragged_shape, chunks=data.chunks) - - data = da.apply_gufunc(unflatten_data, "(),()->()", data, shape) + shapes = da.from_array(ragged_shape, chunks=data.chunks) + + data = da.apply_gufunc( + unflatten_data, + "(),()->()", + data, + shapes, + output_dtypes=object, + ) return data def group2signaldict(self, group, lazy=False): @@ -756,11 +763,7 @@ def overwrite_dataset( allow_rechunk=False, ) else: - new_data = np.empty(shape=data.shape, dtype=object) - shapes = np.empty(shape=data.shape, dtype=object) - for i in np.ndindex(data.shape): - new_data[i] = data[i].ravel() - shapes[i] = np.array(data[i].shape) + new_data, shapes = flatten_data(data) shape_dset = cls._get_object_dset( group, shapes, f"_ragged_shapes_{key}", chunks, **kwds diff --git a/rsciio/tests/test_hspy.py b/rsciio/tests/test_hspy.py index 4130a2ca6..3f76d4f77 100644 --- a/rsciio/tests/test_hspy.py +++ b/rsciio/tests/test_hspy.py @@ -809,6 +809,25 @@ def test_save_variable_length_markers(self, tmp_path): s2.plot() +@zspy_marker +def test_saving_ragged_array_string(tmp_path, file): + # h5py doesn't support numpy unicode dtype and when saving ragged + # array, we need to change the array dtype + fname = tmp_path / file + + string_data = np.empty((5,), dtype=object) + for index in np.ndindex(string_data.shape): + i = index[0] + string_data[index] = np.array(["a" * (i + 1), "b", "c", "d", "e"][: i + 2]) + + s = hs.signals.BaseSignal(string_data, ragged=True) + s.save(fname) + + s2 = hs.load(fname) + for index in np.ndindex(s.data.shape): + np.testing.assert_equal(s.data[index], s2.data[index]) + + @zspy_marker @pytest.mark.parametrize("lazy", [True, False]) def test_save_load_model(tmp_path, file, lazy): diff --git a/rsciio/zspy/_api.py b/rsciio/zspy/_api.py index 2fe1d781b..07caae9f9 100644 --- a/rsciio/zspy/_api.py +++ b/rsciio/zspy/_api.py @@ -22,6 +22,7 @@ import dask.array as da from dask.diagnostics import ProgressBar import numcodecs +import numpy as np import zarr from rsciio._docstrings import ( @@ -79,14 +80,14 @@ def __init__(self, file): class ZspyWriter(HierarchicalWriter): target_size = 1e8 _file_type = "zspy" - _unicode_kwds = {"dtype": object, "object_codec": numcodecs.JSON()} + _unicode_kwds = dict(dtype=str) def __init__(self, file, signal, expg, **kwargs): super().__init__(file, signal, expg, **kwargs) self.Dataset = zarr.Array @staticmethod - def _get_object_dset(group, data, key, chunks, **kwds): + def _get_object_dset(group, data, key, chunks, dtype=None, **kwds): """Creates a Zarr Array object for saving ragged data Forces the number of chunks span the array if not a dask array as @@ -97,17 +98,32 @@ def _get_object_dset(group, data, key, chunks, **kwds): chunks = data.shape these_kwds = kwds.copy() these_kwds.update(dict(dtype=object, exact=True, chunks=chunks)) - test_ind = data.ndim * (0,) - # Need to know the underlying dtype for the codec - # Note this can't be an object array - if isinstance(data, da.Array): - dtype = data[test_ind].compute().dtype + + if dtype is None: + test_data = data[data.ndim * (0,)] + if isinstance(test_data, da.Array): + test_data = test_data.compute() + if hasattr(test_data, "dtype"): + # this is a numpy array + dtype = test_data.dtype + else: + dtype = type(test_data) + + # For python type, JSON / MsgPack codecs, otherwise + # use VLenArray with specific numpy dtype + if ( + np.issubdtype(dtype, str) + or np.issubdtype(dtype, list) + or np.issubdtype(dtype, tuple) + ): + object_codec = numcodecs.MsgPack() else: - dtype = data[test_ind].dtype + object_codec = numcodecs.VLenArray(dtype) + dset = group.require_dataset( key, data.shape, - object_codec=numcodecs.VLenArray(dtype), + object_codec=object_codec, **these_kwds, ) return dset From eb5052feb86e4b8a23bcff0140b5a085cdd1c7a9 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 26 Jan 2024 21:11:10 +0000 Subject: [PATCH 010/174] Fix saving ragged array string for hspy plugin --- rsciio/_hierarchical.py | 56 +++++++++++++++++++++++++++++---------- rsciio/hspy/_api.py | 23 +++++++++------- rsciio/tests/test_hspy.py | 56 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 23 deletions(-) diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index bf63ce626..539cc1fb7 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -42,20 +42,47 @@ # ragged arrays in hdf5 with dimensionality higher than 1 -def flatten_data(x): +def flatten_data(x, is_hdf5=False): new_data = np.empty(shape=x.shape, dtype=object) shapes = np.empty(shape=x.shape, dtype=object) for i in np.ndindex(x.shape): - # Convert to list to save ragged array of array with string dtype - new_data[i] = x[i].ravel().tolist() - shapes[i] = x[i].shape + data_ = x[i].ravel() + if np.issubdtype(x[i].dtype, np.dtype("U")): + if is_hdf5: + # h5py doesn't support numpy unicode dtype, convert to + # compatible dtype + new_data[i] = data_.astype(h5py.string_dtype()) + else: + # Convert to list to save ragged array of array with string dtype + new_data[i] = data_.tolist() + else: + new_data[i] = data_ + shapes[i] = np.array(x[i].shape) return new_data, shapes -def unflatten_data(data, shape): +def unflatten_data(data, shape, is_hdf5=False): new_data = np.empty(shape=data.shape, dtype=object) for i in np.ndindex(new_data.shape): - new_data[i] = np.reshape(np.array(data[i]), shape[i]) + try: + # For hspy file, ragged array of string are saving with + # "h5py.string_dtype()" type and we need to convert it back + # to numpy unicode type. The only to know when this needs to be + # done is look at the numpy metadata + # This numpy feature is "not well supported in numpy" + # https://numpy.org/doc/stable/reference/generated/numpy.dtype.metadata.html + convert_to_unicode = ( + is_hdf5 + and data.dtype is not None + and data.dtype.metadata.get("vlen") is not None + and data.dtype.metadata["vlen"].metadata.get("vlen") == str + ) + except (AttributeError, KeyError): + # AttributeError in case `dtype.metadata`` is None (most of the time) + # KeyError in case "vlen" is not a key + convert_to_unicode = False + data_ = data[i].astype("U") if convert_to_unicode else data[i] + new_data[i] = np.reshape(np.array(data_), shape[i]) return new_data @@ -135,6 +162,7 @@ class HierarchicalReader: """A generic Reader class for reading data from hierarchical file types.""" _file_type = "" + _is_hdf5 = False def __init__(self, file): """ @@ -250,8 +278,7 @@ def read(self, lazy): return exp_dict_list - @staticmethod - def _read_array(group, dataset_key): + def _read_array(self, group, dataset_key): # This is a workaround for the lack of support for n-d ragged array # in h5py and zarr. There is work in progress for implementation in zarr: # https://github.com/zarr-developers/zarr-specs/issues/62 which may be @@ -275,6 +302,7 @@ def _read_array(group, dataset_key): "(),()->()", data, shapes, + is_hdf5=self._is_hdf5, output_dtypes=object, ) return data @@ -646,6 +674,7 @@ class HierarchicalWriter: target_size = 1e6 _unicode_kwds = None + _is_hdf5 = False def __init__(self, file, signal, group, **kwds): """Initialize a generic file writer for hierachical data storage types. @@ -726,9 +755,7 @@ def overwrite_dataset( # Saving numpy unicode type is not supported in h5py data = data.astype(np.dtype("S")) - if data.dtype == np.dtype("O"): - dset = cls._get_object_dset(group, data, key, chunks, **kwds) - else: + if data.dtype != np.dtype("O"): got_data = False while not got_data: try: @@ -758,15 +785,16 @@ def overwrite_dataset( flatten_data, "()->(),()", data, - dtype=object, + is_hdf5=cls._is_hdf5, output_dtypes=[object, object], allow_rechunk=False, ) else: - new_data, shapes = flatten_data(data) + new_data, shapes = flatten_data(data, is_hdf5=cls._is_hdf5) + dset = cls._get_object_dset(group, new_data, key, chunks, **kwds) shape_dset = cls._get_object_dset( - group, shapes, f"_ragged_shapes_{key}", chunks, **kwds + group, shapes, f"_ragged_shapes_{key}", chunks, dtype=int, **kwds ) cls._store_data( diff --git a/rsciio/hspy/_api.py b/rsciio/hspy/_api.py index 11762ec22..d17c6e978 100644 --- a/rsciio/hspy/_api.py +++ b/rsciio/hspy/_api.py @@ -48,6 +48,7 @@ class HyperspyReader(HierarchicalReader): _file_type = "hspy" + _is_hdf5 = True def __init__(self, file): super().__init__(file) @@ -63,6 +64,7 @@ class HyperspyWriter(HierarchicalWriter): target_size = 1e6 _unicode_kwds = {"dtype": h5py.string_dtype()} + _is_hdf5 = True def __init__(self, file, signal, expg, **kwds): super().__init__(file, signal, expg, **kwds) @@ -85,11 +87,13 @@ def _store_data(data, dset, group, key, chunks, show_progressbar=True): dset = [ dset, ] + for i, (data_, dset_) in enumerate(zip(data, dset)): if isinstance(data_, da.Array): if data_.chunks != dset_.chunks: data[i] = data_.rechunk(dset_.chunks) if data_.ndim == 1 and data_.dtype == object: + # https://github.com/hyperspy/rosettasciio/issues/198 raise ValueError( "Saving a 1-D ragged dask array to hspy is not supported yet. " "Please use the .zspy extension." @@ -108,18 +112,19 @@ def _store_data(data, dset, group, key, chunks, show_progressbar=True): da.store(data, dset) @staticmethod - def _get_object_dset(group, data, key, chunks, **kwds): + def _get_object_dset(group, data, key, chunks, dtype=None, **kwds): """Creates a h5py dataset object for saving ragged data""" - # For saving ragged array - if chunks is None: + if chunks is None: # pragma: no cover chunks = 1 - test_ind = data.ndim * (0,) - if isinstance(data, da.Array): - dtype = data[test_ind].compute().dtype - else: - dtype = data[test_ind].dtype + + if dtype is None: + test_data = data[data.ndim * (0,)] + if isinstance(test_data, da.Array): + test_data = test_data.compute() + dtype = test_data.dtype + dset = group.require_dataset( - key, data.shape, dtype=h5py.special_dtype(vlen=dtype), chunks=chunks, **kwds + key, data.shape, dtype=h5py.vlen_dtype(dtype), chunks=chunks, **kwds ) return dset diff --git a/rsciio/tests/test_hspy.py b/rsciio/tests/test_hspy.py index 3f76d4f77..6d8489083 100644 --- a/rsciio/tests/test_hspy.py +++ b/rsciio/tests/test_hspy.py @@ -808,6 +808,45 @@ def test_save_variable_length_markers(self, tmp_path): s2 = hs.load(fname) s2.plot() + @zspy_marker + def test_texts_markers(self, tmp_path, file): + # h5py doesn't support numpy unicode dtype and when saving ragged + # array with + fname = tmp_path / file + + # Create a Signal2D with 1 navigation dimension + rng = np.random.default_rng(0) + data = np.ones((5, 100, 100)) + s = hs.signals.Signal2D(data) + + # Create an navigation dependent (ragged) Texts marker + offsets = np.empty(s.axes_manager.navigation_shape, dtype=object) + texts = np.empty(s.axes_manager.navigation_shape, dtype=object) + + for index in np.ndindex(offsets.shape): + i = index[0] + offsets[index] = rng.random((5, 2))[: i + 2] * 100 + texts[index] = np.array(["a" * (i + 1), "b", "c", "d", "e"][: i + 2]) + + m = hs.plot.markers.Texts( + offsets=offsets, + texts=texts, + sizes=3, + facecolor="black", + ) + + s.add_marker(m, permanent=True) + s.plot() + s.save(fname) + + s2 = hs.load(fname) + + m_texts = m.kwargs["texts"] + m2_texts = s2.metadata.Markers.Texts.kwargs["texts"] + + for index in np.ndindex(m_texts.shape): + np.testing.assert_equal(m_texts[index], m2_texts[index]) + @zspy_marker def test_saving_ragged_array_string(tmp_path, file): @@ -828,6 +867,23 @@ def test_saving_ragged_array_string(tmp_path, file): np.testing.assert_equal(s.data[index], s2.data[index]) +@zspy_marker +def test_saving_ragged_array_single_string(tmp_path, file): + fname = tmp_path / file + + string_data = np.empty((2, 5), dtype=object) + for i, index in enumerate(np.ndindex(string_data.shape)): + string_data[index] = "a" * (i + 1) + + s = hs.signals.BaseSignal(string_data, ragged=True) + + s.save(fname, overwrite=True) + + s2 = hs.load(fname) + for index in np.ndindex(s.data.shape): + np.testing.assert_equal(s.data[index], s2.data[index]) + + @zspy_marker @pytest.mark.parametrize("lazy", [True, False]) def test_save_load_model(tmp_path, file, lazy): From 85ee957e5af39f9bedb458effc2ae09424f4c783 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 27 Jan 2024 12:04:02 +0000 Subject: [PATCH 011/174] Add support for saving ragged array of list --- rsciio/_hierarchical.py | 6 +++--- rsciio/tests/test_hspy.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index 539cc1fb7..d22b067ed 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -46,8 +46,8 @@ def flatten_data(x, is_hdf5=False): new_data = np.empty(shape=x.shape, dtype=object) shapes = np.empty(shape=x.shape, dtype=object) for i in np.ndindex(x.shape): - data_ = x[i].ravel() - if np.issubdtype(x[i].dtype, np.dtype("U")): + data_ = np.array(x[i]).ravel() + if np.issubdtype(data_.dtype, np.dtype("U")): if is_hdf5: # h5py doesn't support numpy unicode dtype, convert to # compatible dtype @@ -57,7 +57,7 @@ def flatten_data(x, is_hdf5=False): new_data[i] = data_.tolist() else: new_data[i] = data_ - shapes[i] = np.array(x[i].shape) + shapes[i] = np.array(np.array(x[i]).shape) return new_data, shapes diff --git a/rsciio/tests/test_hspy.py b/rsciio/tests/test_hspy.py index 6d8489083..ea6f25633 100644 --- a/rsciio/tests/test_hspy.py +++ b/rsciio/tests/test_hspy.py @@ -849,7 +849,8 @@ def test_texts_markers(self, tmp_path, file): @zspy_marker -def test_saving_ragged_array_string(tmp_path, file): +@pytest.mark.parametrize("use_list", [True, False]) +def test_saving_ragged_array_string(tmp_path, file, use_list): # h5py doesn't support numpy unicode dtype and when saving ragged # array, we need to change the array dtype fname = tmp_path / file @@ -857,7 +858,10 @@ def test_saving_ragged_array_string(tmp_path, file): string_data = np.empty((5,), dtype=object) for index in np.ndindex(string_data.shape): i = index[0] - string_data[index] = np.array(["a" * (i + 1), "b", "c", "d", "e"][: i + 2]) + data = np.array(["a" * (i + 1), "b", "c", "d", "e"][: i + 2]) + if use_list: + data = data.tolist() + string_data[index] = data s = hs.signals.BaseSignal(string_data, ragged=True) s.save(fname) From 9bd865ef58c0afb27d7e7db5768af7399cf10fbd Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 27 Jan 2024 09:52:28 +0000 Subject: [PATCH 012/174] Add changelog entry --- upcoming_changes/217.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/217.bugfix.rst diff --git a/upcoming_changes/217.bugfix.rst b/upcoming_changes/217.bugfix.rst new file mode 100644 index 000000000..3f3de20ba --- /dev/null +++ b/upcoming_changes/217.bugfix.rst @@ -0,0 +1 @@ +Fix saving ragged array of strings in ``hspy`` and ``zspy`` format. \ No newline at end of file From 643c97ce54d0c32a8314c1bf6952afc911ff2e90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:14:50 +0000 Subject: [PATCH 013/174] Bump pypa/cibuildwheel from 2.16.2 to 2.16.4 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 2.16.2 to 2.16.4. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v2.16.2...v2.16.4) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 106c4c096..5dd6ed46e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels for CPython - uses: pypa/cibuildwheel@v2.16.2 + uses: pypa/cibuildwheel@v2.16.4 env: CIBW_ARCHS: ${{ matrix.CIBW_ARCHS }} From 7012307840631f5a14c03a1c85c40e8b4782e2f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:33:47 +0000 Subject: [PATCH 014/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.12.0 → 24.1.1](https://github.com/psf/black/compare/23.12.0...24.1.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 040c91515..8992c2fb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/psf/black # Version can be updated by running "pre-commit autoupdate" - rev: 23.12.0 + rev: 24.1.1 hooks: - id: black - repo: local From 31447434c9ff7aedc0ce1ddebd39a3fb149b2ad4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:34:07 +0000 Subject: [PATCH 015/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/bruker/_api.py | 1 - rsciio/digitalmicrograph/_api.py | 7 +++---- rsciio/edax/_api.py | 6 +++--- rsciio/emd/_emd_ncem.py | 1 - rsciio/jobinyvon/_api.py | 30 +++++++++++++++--------------- rsciio/mrc/_api.py | 24 +++++++++++++++--------- rsciio/nexus/_api.py | 10 ++++++---- rsciio/pantarhei/_api.py | 8 +++++--- rsciio/ripple/_api.py | 6 +++--- rsciio/semper/_api.py | 5 +---- rsciio/utils/exceptions.py | 1 - rsciio/utils/hdf5.py | 1 + rsciio/utils/skimage_exposure.py | 1 + setup.py | 1 - 14 files changed, 53 insertions(+), 49 deletions(-) diff --git a/rsciio/bruker/_api.py b/rsciio/bruker/_api.py index d63148202..d20bde1a9 100644 --- a/rsciio/bruker/_api.py +++ b/rsciio/bruker/_api.py @@ -869,7 +869,6 @@ def gen_hspy_item_dict_basic(self): class BCF_reader(SFS_reader): - """Class to read bcf (Bruker hypermapping) file. Inherits SFS_reader and all its attributes and methods. diff --git a/rsciio/digitalmicrograph/_api.py b/rsciio/digitalmicrograph/_api.py index e00f18fed..1d6777c9e 100644 --- a/rsciio/digitalmicrograph/_api.py +++ b/rsciio/digitalmicrograph/_api.py @@ -38,7 +38,6 @@ class DigitalMicrographReader(object): - """Class to read Gatan Digital Micrograph (TM) files. Currently it supports versions 3 and 4. @@ -1153,9 +1152,9 @@ def get_mapping(self): ): ("Acquisition_instrument.Detector.processing", None), "{}.Acquisition.Device.CCD.Pixel_Size_um".format(tags_path): ( "Acquisition_instrument.Detector.pixel_size", - lambda x: x[0] - if (isinstance(x, tuple) and x[0] == x[1]) - else x, + lambda x: ( + x[0] if (isinstance(x, tuple) and x[0] == x[1]) else x + ), ), # Serial Spectrum "{}.CL.Acquisition.Acquisition_begin".format(tags_path): ( diff --git a/rsciio/edax/_api.py b/rsciio/edax/_api.py index 365b14afa..0298e7ea9 100644 --- a/rsciio/edax/_api.py +++ b/rsciio/edax/_api.py @@ -888,9 +888,9 @@ def spd_reader( "size": data.shape[2], "index_in_array": 2, "name": "Energy", - "scale": original_metadata["spc_header"]["evPerChan"] / 1000.0 - if read_spc - else 1, + "scale": ( + original_metadata["spc_header"]["evPerChan"] / 1000.0 if read_spc else 1 + ), "offset": original_metadata["spc_header"]["startEnergy"] if read_spc else 1, "units": "keV" if read_spc else None, "navigate": False, diff --git a/rsciio/emd/_emd_ncem.py b/rsciio/emd/_emd_ncem.py index 1ebfd1f91..9664e2f9e 100644 --- a/rsciio/emd/_emd_ncem.py +++ b/rsciio/emd/_emd_ncem.py @@ -43,7 +43,6 @@ class EMD_NCEM: - """Class for reading and writing the Berkeley variant of the electron microscopy datasets (EMD) file format. It reads files EMD NCEM, including files generated by the prismatic software. diff --git a/rsciio/jobinyvon/_api.py b/rsciio/jobinyvon/_api.py index 89135967d..f1c4ec4bb 100644 --- a/rsciio/jobinyvon/_api.py +++ b/rsciio/jobinyvon/_api.py @@ -223,9 +223,9 @@ def _clean_up_metadata(self): ## use second extracted value for key in change_to_second_value: try: - self.original_metadata["experimental_setup"][ - key - ] = self.original_metadata["experimental_setup"][key]["2"] + self.original_metadata["experimental_setup"][key] = ( + self.original_metadata["experimental_setup"][key]["2"] + ) except KeyError: pass @@ -234,9 +234,9 @@ def _clean_up_metadata(self): if isinstance(value, dict): # only if there is an entry/value if bool(value): - self.original_metadata["experimental_setup"][ - key - ] = self.original_metadata["experimental_setup"][key]["1"] + self.original_metadata["experimental_setup"][key] = ( + self.original_metadata["experimental_setup"][key]["1"] + ) for key, value in self.original_metadata["date"].items(): if isinstance(value, dict): @@ -248,9 +248,9 @@ def _clean_up_metadata(self): for key, value in self.original_metadata["file_information"].items(): if isinstance(value, dict): if bool(value): - self.original_metadata["file_information"][ - key - ] = self.original_metadata["file_information"][key]["1"] + self.original_metadata["file_information"][key] = ( + self.original_metadata["file_information"][key]["1"] + ) ## convert strings to float for key in convert_to_numeric: @@ -263,17 +263,17 @@ def _clean_up_metadata(self): ## move the unit from grating to the key name try: - self.original_metadata["experimental_setup"][ - "Grating (gr/mm)" - ] = self.original_metadata["experimental_setup"].pop("Grating") + self.original_metadata["experimental_setup"]["Grating (gr/mm)"] = ( + self.original_metadata["experimental_setup"].pop("Grating") + ) except KeyError: # pragma: no cover pass # pragma: no cover ## add percentage for filter key name try: - self.original_metadata["experimental_setup"][ - "ND Filter (%)" - ] = self.original_metadata["experimental_setup"].pop("ND Filter") + self.original_metadata["experimental_setup"]["ND Filter (%)"] = ( + self.original_metadata["experimental_setup"].pop("ND Filter") + ) except KeyError: # pragma: no cover pass # pragma: no cover diff --git a/rsciio/mrc/_api.py b/rsciio/mrc/_api.py index 55cbf0340..56d096e4b 100644 --- a/rsciio/mrc/_api.py +++ b/rsciio/mrc/_api.py @@ -344,15 +344,21 @@ def file_reader( if fei_header is None: # The scale is in Angstroms, we convert it to nm scales = [ - float(std_header["Zlen"] / std_header["MZ"]) / 10 - if float(std_header["Zlen"]) != 0 and float(std_header["MZ"]) != 0 - else 1, - float(std_header["Ylen"] / std_header["MY"]) / 10 - if float(std_header["MY"]) != 0 - else 1, - float(std_header["Xlen"] / std_header["MX"]) / 10 - if float(std_header["MX"]) != 0 - else 1, + ( + float(std_header["Zlen"] / std_header["MZ"]) / 10 + if float(std_header["Zlen"]) != 0 and float(std_header["MZ"]) != 0 + else 1 + ), + ( + float(std_header["Ylen"] / std_header["MY"]) / 10 + if float(std_header["MY"]) != 0 + else 1 + ), + ( + float(std_header["Xlen"] / std_header["MX"]) / 10 + if float(std_header["MX"]) != 0 + else 1 + ), ] offsets = [ float(std_header["ZORIGIN"]) / 10, diff --git a/rsciio/nexus/_api.py b/rsciio/nexus/_api.py index efc015e9e..00cc8d44b 100644 --- a/rsciio/nexus/_api.py +++ b/rsciio/nexus/_api.py @@ -1,4 +1,5 @@ """NeXus file reading and writing.""" + # -*- coding: utf-8 -*- # Copyright 2007-2023 The HyperSpy developers # @@ -588,10 +589,11 @@ def file_reader( "original_metadata" ] else: - dictionary[ - "original_metadata" - ] = _find_search_keys_in_dict( - (oma["original_metadata"]), search_keys=metadata_key + dictionary["original_metadata"] = ( + _find_search_keys_in_dict( + (oma["original_metadata"]), + search_keys=metadata_key, + ) ) # reconstruct the axes_list for axes_manager for k, v in oma["original_metadata"].items(): diff --git a/rsciio/pantarhei/_api.py b/rsciio/pantarhei/_api.py index 0bab7f163..8c3da7237 100644 --- a/rsciio/pantarhei/_api.py +++ b/rsciio/pantarhei/_api.py @@ -181,9 +181,11 @@ def _navigation_first(i): default_labels = reversed(["X", "Y", "Z"][: content_type_np_order.count(None)]) data_labels = [ - content_type_np_order[i] - if content_type_np_order[i] is not None - else next(default_labels) + ( + content_type_np_order[i] + if content_type_np_order[i] is not None + else next(default_labels) + ) for i in new_order ] calibration_ordered = [calibrations_np_order[i] for i in new_order] diff --git a/rsciio/ripple/_api.py b/rsciio/ripple/_api.py index 094d9d514..9c7c0d9b1 100644 --- a/rsciio/ripple/_api.py +++ b/rsciio/ripple/_api.py @@ -584,9 +584,9 @@ def file_writer(filename, signal, encoding="latin-1"): if "Detector.EDS.live_time" in mp: keys_dictionary["live-time"] = mp.Detector.EDS.live_time if "Detector.EDS.energy_resolution_MnKa" in mp: - keys_dictionary[ - "detector-peak-width-ev" - ] = mp.Detector.EDS.energy_resolution_MnKa + keys_dictionary["detector-peak-width-ev"] = ( + mp.Detector.EDS.energy_resolution_MnKa + ) write_rpl(filename, keys_dictionary, encoding) write_raw(filename, signal, record_by, sig_axes, nav_axes) diff --git a/rsciio/semper/_api.py b/rsciio/semper/_api.py index 239eeda6a..7249b2b63 100644 --- a/rsciio/semper/_api.py +++ b/rsciio/semper/_api.py @@ -94,7 +94,6 @@ class SemperFormat(object): - """Class for importing and exporting SEMPER `.unf`-files. The :class:`~.SemperFormat` class represents a SEMPER binary file format @@ -220,9 +219,7 @@ def _read_label(cls, unf_file): assert label["SEMPER"] == "Semper" # Process dimensions: for key in ["NCOL", "NROW", "NLAY", "ICCOLN", "ICROWN", "ICLAYN"]: - value = ( - 256**2 * label.pop(key + "H") + 256 * label[key][0] + label[key][1] - ) + value = 256**2 * label.pop(key + "H") + 256 * label[key][0] + label[key][1] label[key] = value # Process date: date = "{}-{}-{} {}:{}:{}".format(label["DATE"][0] + 1900, *label["DATE"][1:]) diff --git a/rsciio/utils/exceptions.py b/rsciio/utils/exceptions.py index 37a3377c7..4c6d77178 100644 --- a/rsciio/utils/exceptions.py +++ b/rsciio/utils/exceptions.py @@ -74,7 +74,6 @@ def __str__(self): class VisibleDeprecationWarning(UserWarning): - """Visible deprecation warning. By default, python will not show deprecation warnings, so this class provides a visible one. diff --git a/rsciio/utils/hdf5.py b/rsciio/utils/hdf5.py index 72b2abaf9..c41ae6718 100644 --- a/rsciio/utils/hdf5.py +++ b/rsciio/utils/hdf5.py @@ -1,4 +1,5 @@ """HDF5 file inspection.""" + # -*- coding: utf-8 -*- # Copyright 2007-2023 The HyperSpy developers # diff --git a/rsciio/utils/skimage_exposure.py b/rsciio/utils/skimage_exposure.py index f3467edbe..b11955b08 100644 --- a/rsciio/utils/skimage_exposure.py +++ b/rsciio/utils/skimage_exposure.py @@ -1,5 +1,6 @@ """skimage's `rescale_intensity` that takes and returns dask arrays. """ + from packaging.version import Version import warnings diff --git a/setup.py b/setup.py index e0609b709..9e81720f3 100644 --- a/setup.py +++ b/setup.py @@ -93,7 +93,6 @@ def no_cythonize(extensions): class Recythonize(Command): - """cythonize all extensions""" description = "(re-)cythonize all changed cython extensions" From 73c3c79d2fc19104ad11313c171d59f18db55f92 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 2 Feb 2024 20:27:23 +0000 Subject: [PATCH 016/174] Add osx arm64 build to GitHub CI --- .github/workflows/tests.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index be3b941ec..575108063 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,8 +4,8 @@ on: [push, pull_request, workflow_dispatch] jobs: run_test_site: - name: ${{ matrix.os }}-py${{ matrix.PYTHON_VERSION }}${{ matrix.LABEL }} - runs-on: ${{ matrix.os }}-latest + name: ${{ matrix.os }}-${{ matrix.os_version }}-py${{ matrix.PYTHON_VERSION }}${{ matrix.LABEL }} + runs-on: ${{ matrix.os }}-${{ matrix.os_version }} timeout-minutes: 30 env: MPLBACKEND: agg @@ -13,11 +13,13 @@ jobs: fail-fast: false matrix: os: [ubuntu, windows, macos] + os_version: [latest] PYTHON_VERSION: ['3.9', '3.10'] LABEL: [''] include: # test oldest supported version of main dependencies on python 3.8 - os: ubuntu + os_version: latest PYTHON_VERSION: '3.8' # Set pillow and scikit-image version to be compatible with imageio and scipy # matplotlib needs 3.5 to support markers in hyperspy 2.0 (requires `collection.set_offset_transform`) @@ -25,17 +27,25 @@ jobs: LABEL: '-oldest' # test minimum requirement - os: ubuntu + os_version: latest PYTHON_VERSION: '3.9' LABEL: '-minimum' - os: ubuntu + os_version: latest PYTHON_VERSION: '3.12' LABEL: '-minimum-without-hyperspy' - os: ubuntu + os_version: latest PYTHON_VERSION: '3.9' LABEL: '-without-hyperspy' - os: ubuntu + os_version: latest PYTHON_VERSION: '3.8' - os: ubuntu + os_version: latest + PYTHON_VERSION: '3.11' + - os: macos + os_version: '14' PYTHON_VERSION: '3.11' steps: From 668d624612e2b54914751ad19a0a5affce00b465 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 2 Feb 2024 20:27:36 +0000 Subject: [PATCH 017/174] Get number of cpu and use it to speed up running test suite --- .github/workflows/tests.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 575108063..aec8e47cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,7 +69,19 @@ jobs: name: Install Python with: python-version: ${{ matrix.PYTHON_VERSION }} - + + - name: Get the number of CPUs + id: cpus + run: | + import os, platform + num_cpus = os.cpu_count() + print(f"Number of CPU: {num_cpus}") + print(f"Architecture: {platform.machine()}") + output_file = os.environ["GITHUB_OUTPUT"] + with open(output_file, "a", encoding="utf-8") as output_stream: + output_stream.write(f"count={num_cpus}\n") + shell: python + - name: Set Environment Variable shell: bash # Set PIP_SELECTOR environment variable according to matrix.LABEL @@ -113,7 +125,7 @@ jobs: - name: Run test suite run: | - pytest --pyargs rsciio --reruns 3 -n 2 --cov=. --cov-report=xml + pytest --pyargs rsciio --reruns 3 -n ${{ steps.cpus.outputs.count }} --cov=. --cov-report=xml - name: Upload coverage to Codecov if: ${{ always() }} From bab2e8c677e6957b2abd17da78cb90b6abef8ee6 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 2 Feb 2024 20:37:06 +0000 Subject: [PATCH 018/174] Add build to test against dev version of hyperspy and exspy and use release version for other builds --- .github/workflows/tests.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aec8e47cc..8cbfd04ab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,10 @@ jobs: os_version: latest PYTHON_VERSION: '3.12' LABEL: '-minimum-without-hyperspy' + - os: ubuntu + os_version: latest + PYTHON_VERSION: '3.11' + LABEL: '-hyperspy-dev' - os: ubuntu os_version: latest PYTHON_VERSION: '3.9' @@ -103,15 +107,15 @@ jobs: run: | pip install ${{ matrix.DEPENDENCIES }} - - name: Install (HyperSpy dev) + - name: Install hyperspy and exspy if: ${{ ! contains(matrix.LABEL, 'without-hyperspy') }} - # Need to install hyperspy dev until hyperspy 2.0 is released run: | - pip install git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_major + pip install hyperspy exspy - - name: Install (exspy) - if: ${{ ! contains(matrix.LABEL, '-minimum') && ! contains(matrix.LABEL, 'without-hyperspy') }} + - name: Install hyperspy and exspy (dev) + if: ${{ contains(matrix.LABEL, 'hyperspy-dev') }} run: | + pip install git+https://github.com/hyperspy/hyperspy.git pip install git+https://github.com/hyperspy/exspy.git - name: Install From 0131d9ec99e4517c4eb4bd6e7bc985cdf8b28621 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 2 Feb 2024 21:07:37 +0000 Subject: [PATCH 019/174] Remove dimension of a ragged array test to speed up test suite - nav dimension 3 is not needed --- rsciio/tests/test_hspy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsciio/tests/test_hspy.py b/rsciio/tests/test_hspy.py index ea6f25633..74862d404 100644 --- a/rsciio/tests/test_hspy.py +++ b/rsciio/tests/test_hspy.py @@ -935,7 +935,7 @@ def test_save_ragged_array(tmp_path, file): @zspy_marker -@pytest.mark.parametrize("nav_dim", [1, 2, 3]) +@pytest.mark.parametrize("nav_dim", [1, 2]) @pytest.mark.parametrize("lazy", [True, False]) def test_save_ragged_dim(tmp_path, file, nav_dim, lazy): file = f"nav{nav_dim}_" + file From e3930b7a3a423989dde93d0245ffa3166952663a Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 2 Feb 2024 21:17:31 +0000 Subject: [PATCH 020/174] Add changelog entry --- upcoming_changes/222.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/222.maintenance.rst diff --git a/upcoming_changes/222.maintenance.rst b/upcoming_changes/222.maintenance.rst new file mode 100644 index 000000000..5638f2a57 --- /dev/null +++ b/upcoming_changes/222.maintenance.rst @@ -0,0 +1 @@ +Run test suite on osx arm64 on GitHub CI and speed running test suite using all available CPUs (3 or 4) instead of only 2. \ No newline at end of file From febebb1b9d6ba13b3b64ddee1aaf090ddb1df387 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 13:07:35 +0000 Subject: [PATCH 021/174] Bump codecov/codecov-action from 3 to 4 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3 to 4. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3...v4) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8cbfd04ab..65e131627 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -133,4 +133,4 @@ jobs: - name: Upload coverage to Codecov if: ${{ always() }} - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 From 5c1e518878e9e16e933f5e8c562ddf7d046548ea Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 12 Feb 2024 21:38:53 +0000 Subject: [PATCH 022/174] Use codecov token to upload coverage results --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65e131627..7667b9e9f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -134,3 +134,5 @@ jobs: - name: Upload coverage to Codecov if: ${{ always() }} uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From 8761acde3fa8960dddb534d55dd1cc51ee65acec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 00:00:40 +0000 Subject: [PATCH 023/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 24.1.1 → 24.2.0](https://github.com/psf/black/compare/24.1.1...24.2.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8992c2fb4..154832478 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/psf/black # Version can be updated by running "pre-commit autoupdate" - rev: 24.1.1 + rev: 24.2.0 hooks: - id: black - repo: local From 05a0694d204db6242aaa7ca5bcac20f91ad66490 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Thu, 1 Feb 2024 08:57:43 +0000 Subject: [PATCH 024/174] Return image in the list of dictionary instead of adding it in the `original_metadata` --- rsciio/renishaw/_api.py | 41 ++++++++++++++++++++++++++++------- rsciio/tests/test_renishaw.py | 27 +++++++++++++++++------ 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index 18753ac7f..046209825 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -66,11 +66,14 @@ from enum import IntEnum, Enum, EnumMeta from io import BytesIO from pathlib import Path +import os import numpy as np from numpy.polynomial.polynomial import polyfit from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC +from rsciio.utils import rgb_tools + _logger = logging.getLogger(__name__) @@ -469,7 +472,6 @@ def read_file(self, filesize): self._parse_MAP("MAP_0") self._parse_MAP("MAP_1") self._parse_TEXT() - self._parse_WHTL() ## parse blocks with axes information signal_dict = self._parse_XLST() @@ -1144,7 +1146,7 @@ def _reshape_data(self): def _map_general_md(self): general = {} general["title"] = self.original_metadata.get("WDF1_1", {}).get("title") - general["original_filename"] = self._filename + general["original_filename"] = os.path.split(self._filename)[1] try: date, time = self.original_metadata["WDF1_1"]["time_start"].split("#") except KeyError: @@ -1245,7 +1247,7 @@ def _parse_TEXT(self): text = self.__read_utf8(block_size - 16) self.original_metadata.update({"TEXT_0": text}) - def _parse_WHTL(self): + def _get_WHTL(self): if not self._check_block_exists("WHTL_0"): return pos, size = self._block_info["WHTL_0"] @@ -1253,11 +1255,12 @@ def _parse_WHTL(self): self._file_obj.seek(pos) img_bytes = self._file_obj.read(size - jpeg_header) img = BytesIO(img_bytes) - whtl_metadata = {"image": img} + whtl_metadata = {} ## extract EXIF tags and store them in metadata if PIL_installed: pil_img = Image.open(img) + data = rgb_tools.regular_array2rgbx(np.array(pil_img)) ## missing header keys when Pillow >= 8.2.0 -> does not flatten IFD anymore ## see https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html#image-getexif-exif-and-gps-ifd ## Use fall-back _getexif method instead @@ -1281,7 +1284,25 @@ def _parse_WHTL(self): whtl_metadata["Unknown"] = exif_header.get(ExifTags.Unknown) whtl_metadata["FieldOfViewXY"] = exif_header.get(ExifTags.FieldOfViewXY) - self.original_metadata.update({"WHTL_0": whtl_metadata}) + return { + "axes": [ + { + "name": name, + "units": whtl_metadata["FocalPlaneResolutionUnit"], + "size": size, + "scale": whtl_metadata["FieldOfViewXY"][i] / size, + "offset": whtl_metadata["FocalPlaneXYOrigins"][i], + "index_in_array": i, + } + for i, name, size in zip([1, 0], ["y", "x"], data.shape) + ], + "data": data, + "metadata": { + "General": {"original_filename": os.path.split(self._filename)[1]}, + "Signal": {"signal_type": ""}, + }, + "original_metadata": whtl_metadata, + } def file_reader( @@ -1332,9 +1353,13 @@ def file_reader( dictionary["metadata"] = deepcopy(wdf.metadata) dictionary["original_metadata"] = deepcopy(wdf.original_metadata) - return [ - dictionary, - ] + image_dict = wdf._get_WHTL() + + dict_list = [dictionary] + if image_dict is not None: + dict_list.append(image_dict) + + return dict_list file_reader.__doc__ %= (FILENAME_DOC, LAZY_DOC, RETURNS_DOC) diff --git a/rsciio/tests/test_renishaw.py b/rsciio/tests/test_renishaw.py index e506fc9c4..133244225 100644 --- a/rsciio/tests/test_renishaw.py +++ b/rsciio/tests/test_renishaw.py @@ -891,7 +891,7 @@ def setup_class(cls): testfile_linescan, reader="Renishaw", use_uniform_signal_axis=True, - ) + )[0] @classmethod def teardown_class(cls): @@ -971,7 +971,7 @@ def setup_class(cls): testfile_map, reader="Renishaw", use_uniform_signal_axis=True, - ) + )[0] @classmethod def teardown_class(cls): @@ -1180,7 +1180,7 @@ def setup_class(cls): testfile_streamline, reader="Renishaw", use_uniform_signal_axis=True, - ) + )[0] @classmethod def teardown_class(cls): @@ -1196,7 +1196,12 @@ def test_data(self): self.s.inav[44, 48].isig[-3:].data, [587.48083, 570.73505, 583.5814] ) - def test_original_metadata_WHTL(self): + def test_WHTL(self): + s = hs.load( + testfile_streamline, + reader="Renishaw", + use_uniform_signal_axis=True, + )[1] expected_WTHL = { "FocalPlaneResolutionUnit": "µm", "FocalPlaneXResolution": 445.75, @@ -1208,8 +1213,16 @@ def test_original_metadata_WHTL(self): "FieldOfViewXY": (8915.0, 5417.0), } - metadata_WHTL = deepcopy(self.s.original_metadata.WHTL_0.as_dictionary()) - metadata_WHTL.pop("image", None) + for i, (axis, scale) in enumerate( + zip(s.axes_manager._axes, (22.570833, 23.710106)) + ): + assert axis.units == expected_WTHL["FocalPlaneResolutionUnit"] + np.testing.assert_allclose(axis.scale, scale) + np.testing.assert_allclose( + axis.offset, expected_WTHL["FocalPlaneXYOrigins"][::-1][i] + ) + + metadata_WHTL = s.original_metadata.as_dictionary() assert metadata_WHTL == expected_WTHL def test_original_metadata_WMAP(self): @@ -1263,7 +1276,7 @@ def setup_class(cls): testfile_map_block, reader="Renishaw", use_uniform_signal_axis=True, - ) + )[0] @classmethod def teardown_class(cls): From b50cb18b5d5cab90abdf53f0a64f3fd2e0e16b48 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Thu, 1 Feb 2024 08:58:50 +0000 Subject: [PATCH 025/174] Add markers for the location of the acquisition to bright field image --- rsciio/renishaw/_api.py | 68 ++++++++++++++++++++++++----------- rsciio/tests/test_renishaw.py | 9 ++++- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index 046209825..95863e129 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -1227,6 +1227,8 @@ def map_metadata(self): laser = self._map_laser_md() spectrometer = self._map_spectrometer_md() + # TODO: find laser power? + metadata = { "General": general, "Signal": signal, @@ -1284,25 +1286,50 @@ def _get_WHTL(self): whtl_metadata["Unknown"] = exif_header.get(ExifTags.Unknown) whtl_metadata["FieldOfViewXY"] = exif_header.get(ExifTags.FieldOfViewXY) - return { - "axes": [ - { - "name": name, - "units": whtl_metadata["FocalPlaneResolutionUnit"], - "size": size, - "scale": whtl_metadata["FieldOfViewXY"][i] / size, - "offset": whtl_metadata["FocalPlaneXYOrigins"][i], - "index_in_array": i, - } - for i, name, size in zip([1, 0], ["y", "x"], data.shape) - ], - "data": data, - "metadata": { - "General": {"original_filename": os.path.split(self._filename)[1]}, - "Signal": {"signal_type": ""}, - }, - "original_metadata": whtl_metadata, - } + metadata = { + "General": {"original_filename": os.path.split(self._filename)[1]}, + "Signal": {"signal_type": ""}, + } + + map_md = self.original_metadata.get("WMAP_0") + if map_md is not None: + width = map_md["scale_xyz"][0] * map_md["size_xyz"][0] + length = map_md["scale_xyz"][1] * map_md["size_xyz"][1] + offset = ( + np.array(map_md["offset_xyz"][:2]) + np.array([width, length]) / 2 + ) + + marker_dict = { + "class": "Rectangles", + "name": "Map", + "plot_on_signal": True, + "kwargs": { + "offsets": offset, + "widths": width, + "heights": length, + "color": ("red",), + "facecolor": "none", + }, + } + + metadata["Markers"] = {"Map": marker_dict} + + return { + "axes": [ + { + "name": name, + "units": whtl_metadata["FocalPlaneResolutionUnit"], + "size": size, + "scale": whtl_metadata["FieldOfViewXY"][i] / size, + "offset": whtl_metadata["FocalPlaneXYOrigins"][i], + "index_in_array": i, + } + for i, name, size in zip([1, 0], ["y", "x"], data.shape) + ], + "data": data, + "metadata": metadata, + "original_metadata": whtl_metadata, + } def file_reader( @@ -1312,7 +1339,8 @@ def file_reader( load_unmatched_metadata=False, ): """ - Read Renishaw's ``.wdf`` file. + Read Renishaw's ``.wdf`` file. In case of mapping data, the image area will + be returned with a marker showing the mapped area. Parameters ---------- diff --git a/rsciio/tests/test_renishaw.py b/rsciio/tests/test_renishaw.py index 133244225..29bf602e8 100644 --- a/rsciio/tests/test_renishaw.py +++ b/rsciio/tests/test_renishaw.py @@ -1200,7 +1200,6 @@ def test_WHTL(self): s = hs.load( testfile_streamline, reader="Renishaw", - use_uniform_signal_axis=True, )[1] expected_WTHL = { "FocalPlaneResolutionUnit": "µm", @@ -1225,6 +1224,14 @@ def test_WHTL(self): metadata_WHTL = s.original_metadata.as_dictionary() assert metadata_WHTL == expected_WTHL + md = s.metadata.Markers.as_dictionary() + np.testing.assert_allclose( + md["Map"]["kwargs"]["offsets"], + [-8041.7998, -1137.6001], + ) + np.testing.assert_allclose(md["Map"]["kwargs"]["widths"], 116.99999) + np.testing.assert_allclose(md["Map"]["kwargs"]["heights"], 127.39999) + def test_original_metadata_WMAP(self): expected_WMAP = { "linefocus_size": 0, From e057979f8402acaf19de7e9904958728abb96b47 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 2 Feb 2024 11:55:07 +0000 Subject: [PATCH 026/174] Workaround for non-ordered or invariant axis to use default axis, for example when using `FocusTrack_Z` or invariant series --- rsciio/renishaw/_api.py | 30 +++++++++++++++--- .../renishaw_test_focustrack_invariant.wdf | Bin 0 -> 121831 bytes rsciio/tests/test_renishaw.py | 17 ++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 rsciio/tests/data/renishaw/renishaw_test_focustrack_invariant.wdf diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index 95863e129..c18b81cde 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -1064,13 +1064,32 @@ def _set_nav_via_ORGN(self, orgn_data): ) for axis in orgn_data.keys(): del nav_dict[axis]["annotation"] + data = nav_dict[axis].pop("data") nav_dict[axis]["navigate"] = True - data = np.unique(nav_dict[axis].pop("data")) nav_dict[axis]["size"] = data.size - nav_dict[axis]["offset"] = data[0] - ## time axis in test data is not perfectly uniform, but X,Y,Z are - nav_dict[axis]["scale"] = np.mean(np.diff(data)) nav_dict[axis]["name"] = axis + scale_mean = np.mean(np.diff(data)) + if axis == "FocusTrack_Z" or scale_mean == 0: + # FocusTrack_Z is not uniform and not necessarily ordered + # Fix me when hyperspy supports non-ordered non-uniform axis + # For now, remove units and fall back on default axis + # nav_dict[axis]["axis"] = data + if scale_mean == 0: + # case "scale_mean == 0" is for series where the axis is invariant. + # In principle, this should happen but the WiRE software allows it + reason = f"Axis {axis} is invariant" + else: + reason = "Non-ordered axis is not supported" + _logger.warning( + f"{reason}, a default axis with scale 1 " + "and offset 0 will be used." + ) + del nav_dict[axis]["units"] + else: + # time axis in test data is not perfectly uniform, but X,Y,Z are + nav_dict[axis]["offset"] = data[0] + nav_dict[axis]["scale"] = scale_mean + return nav_dict def _compare_measurement_type_to_ORGN_WMAP(self, orgn_data, wmap_data): @@ -1330,6 +1349,9 @@ def _get_WHTL(self): "metadata": metadata, "original_metadata": whtl_metadata, } + else: # pragma: no cover + # Explicit return for readibility + return None def file_reader( diff --git a/rsciio/tests/data/renishaw/renishaw_test_focustrack_invariant.wdf b/rsciio/tests/data/renishaw/renishaw_test_focustrack_invariant.wdf new file mode 100644 index 0000000000000000000000000000000000000000..67b06e00794b3fae721c7b2dde9896dcc561274a GIT binary patch literal 121831 zcmdSBcU07jyS57m(xgif5Cl;H0Y!>5osc`AqM(8m8`7&%#D+-kO{Az`MMb1n3n+sH zJBkej6_wspkYYpqGU0uHd+&3;wb%Lg%vz9{^a+zmuIIWR)>D?c++6g7hx6;d9z}QU z3)VPQnuj_~Hk|*v_5OcN`LAbRCvkBJ{qyqQA!l8jAI>yhdbr-h*5aR8{>Oli*`UHK z&b*vW%P)02_t>_DTlUK40?u$UJud6vik?}RdEMt-!)1`S$!Zc&-h6y9eG85U&9T((ou4L@l@Cr^^mzKqvW(0KJ>`^ zA!n?agyrQw$cCsl%)eVxVjqRi>evss~c5?a} zew2y7Ay=#NVP)VTxn{V93?KbYYOWQ;!uQ|Fj6b|cJ>5;tWy#@gLN|G(ix=zs@Ja|_yNAgg>)Oayn|YD>u#aR}4U=rYE^?awFj=a|gW$R;kgT49 zCC~nn10DjXS9nQUw~Hh9=x4G)@*5e#|Bc+v!;8V7k$j6A+edrJ zvBEKuyHXPU4=2GVS{(Pbi6ZU?KRzy)f-6&mFg9xn`fmRrH}3yQihTP(evh3BlQS}q zb>_yyW=d0nmk2EY2 zweZ_U9nlXLU@l7?-xX)0#z_k?=9<|5(h#A#n%I-Ck6M965Uo%_-{@*Io?4Ct#|**E zUJ18uJ2)&^0@g4AR*NS>1nn_9awUTG0Rmf!(({aj&x%_X;Od!zsK}l)DHO z_*IlLJjqYJ?hvJ-oW!WNmLini0y%2c95qV!?+j{cn-X=velE59n-29(c@8BNFq2w0 zJfBiYnnOAH&Z5#Zm8s!aF-kgIj51s-NF9llp!y2`LNB)yidTEEmhTnxa~jaG{{ghW zUPIB#YB)93V59tP48>Pr{>}=dyH-G~UIRqgSZhQ=kOx zgN1mnSP4JP%V;aVfCC0cpjUPgQCSx-y5SVWB{JdIbqFh-CLa#PIY8q{~J?#w``==Eb4O zG8rO5QJA5f2!-E~SUoEVnupJT)IW^p3(~-T8wty%NK{QZ1bwa)BnlqJ$AKi+&&@{A zkHhF7a*+8Z1y#+5@V6@+GbB&JTKzo4=H($=FbDnLPC>se1Ne0on`06Y5}F943IgCfYkD&a=QS>w_e8$~wFawnEaO1Mm!Vz#?yFT;6Pt{-q?6C|fw0+2i3nTU4ppz(Rfn zPDyNq=wvJG`sIO@gH90Xbi%G?7o2_&h;;gIJBj|=?s%AjwtJDdo#KbGgF!GaOMp&c zEWFRhBJ4~gVu&C-S+oF9yQf*f!O#Sn>SpMeX#$&h3-Dy^4Ad{0jd`sWc)CCh zr%jb`olgg)od%dCr-~EL4e-867YR2EFe;=7r6vt5|2Pw>OICt=z!jx)SL2xLB8Zt= zAUA3rEb?Yz;Z_5fsQ{WQ44?)BJQFrYuaO?4)D0jpM-L&N7K2@Gh144q)^D=M*pkh7 z{4xNkCHwGlo;{wu^T71K+i<~b6*lnNgWq~7)&`kj;ivVWq6|?Zu>$9HD0qHQf-3(~ zBpI9G+go{D{!O1Ek?NS;2UIE>K_pBKhqV_#`-%kula}MV!A7_ZJK~*a1Zuy`1R%a1~_J1hbF_a|U)NC-mN0nn@S zgGg>D!VibT@{$jN!vnBhFBtZF!(o>0gRvQ#Y<3|%ArW#i zd%^WC6cX1%a4XCeGP8UUo$Y|%TV3(>{AL{ZvkGUnI3g@A0?s-~unj(qr^fNfS4l*+ zd?G$oWWvq$By!fC!Y%4FM8Yd!v4Y-@`8AL(zl(S3FLBzp8=IwGV%pjF^znTGvAIuR z3&@$VQd=!RZ?7koy>Fm3V( z{?6v1bW$f#tK>x~C;D2Ye{4Rbs4|n1Zq%THHHM2 zaTv8%dSS9|2(vQ#aK!El>T`a>=+k7Xb~+Db=_^hNeU_(20@WyIYei~7j0!ckM3VYy z%S$cg@52IxXF%IaOn0opE&dDG(U=dtuu`P-)gVW>0YUGcz;|T}>>jn_uRtqYUp<0S zL@nwbU&gw$Cy+D0fey`^*zu(vY8M_rmQ{;8VmENFwizy2Z*U~_GnzHqKpk$zxX(Qh z_n)J06Mc=`d>32SRpQgE%g`IWiswrza4?_*iKhxsmsx-#p@pd3QwSaU`ot?;j(M-n zBDT8%EdDZxysSX0)H9I8V@$c)1m&Z>AOgps_j&|Eo2F0-?NZcGjw}@uB~DR~q$oWL zVQPF`7Y=Uhg!4cP4r(+Z#k&q48t+4reHGWn>Jc$ekKJ=4(m0s5f9ml*tG?-KlmX+ zdOOapScSiQL6H3ufg|H5px#k{Lj5c(Jr;u*Z}!5YfWDvW*$jaUI|vtU#bB2kUeEA@ z;A?Bh-*m%b)_(ln8HF>GcjDU|2jqks;Gwt<8NqR3Uc+2me3U2%4G9pjDjL?zLHMbNR zAfgURnX^Yq3G|l|m9P5Qq7S`@3HssUl0DzgvMVnoZYOvV@*!TtPXB(^Eth^4XS&CV ze%7O%rOdorX7{s-rg;(LQ~Fs}Tocnfx;%;bEuKVBLmw+$yp#~;FC{iy=wp`cPU<7H z!}?eOyF8iY&2Ez5F>lRbzZVZE$2*^;`Yq9w%PO}(sJHodH2a}Q#sK`-m3?{)LL zarUH)$!RiQ!;VxrCSk7G(?i(3ac8!1=7u|Qr?Q9Df1!shn%%?FO?D@2!+Kc$yLwnf z*2P4reGjX_x`)~KNpp(pl;`xYjO2URt3)Qo9T@IrrFOXy=bF1&mmj$?>*S&zKG%wf z(WBkWav~w!EII#)=gQ$lgzj=T!f>%0VXE8BVyU^+nag&wNTL5)c5!31RcT02=ijh$@Hx-MSoBw{bR5H80%S>`F7tPGVx;)-k`(csp}K5Oqn$p0)LRuG+R zB|R77%dAdj9}Q6mrroEllZue zd7mdL9GTbWm%JqxeHbFO=8Ta-4~NLu=2s+p{ZBIX)Fc#%yd}rdCc)woAByLHA~T~q zNVP{jq|Y6Gl!#41Sk@@%to?`F(LF$NIR2*=H@@8+AcL1q0*B+d#sS5G#p zj*@RX+8K^-xmwR~yv3RUhU41<1R)waNUA*LLfP`)it5F&K@BjA8H_5ukqrAsxX|r`bjYp5g3K>K=FDT z=@rNW4#yjpPNu)xTS(Qr0%%{)1BIF{vfuOzDNuKh9Q@Ent~B9cIDTfpWPG;1O`5xP zkq>rWCU45{plAUv7F~Kr7LI=z+z|KsL^f=jiVq6n&?C5EXu^$BUq1LRnu<6%Nql@M zg1|CiT)roQq~-MZ=HFz@X(e2GD26+VGT@IC#L{KGhDD14cYqj8!L zl~G0JK22=Yl|bV(HS9MqWjOv)^#Z6%&xU%l3Ib;?LhB<#_!KU~$QgYIRT!dwvjvX7 zFu_sR1vr+t92U>b;Gu4h*gO;5IkFP`QO3pM zZ@BwPnDTwgMajhSQO_Pvp}q}^Q?)PTsSGa(YSEAqYEfl`L`US)&Xk|3Zj5H&v1<@8Y6r{!F5_aeaYELm$2s^}tcTLyR zrSF;6>0f3zKC`0)7uVO~tJyV-i(g?lo?EXL)5UA>^?4=s?aoJq={XEMy9`b1JjAAy zA$)fUgxXJF;MW-hM^$3o_*u+aQ;K@iVkn>#lC~8%6H|k;LWO9FypBHW%P1Zyz-`-V z^of*X(#%WP=5+%*&gO#aX9fBN)38o16K1PV!QS))&MMK@?@wt6xRs1Atu%`^9>!bG zNK|f#0MFJ)*jk0d&h7wWqLN_X6^~S%14xqIkIFcjzc(jBY+WL5O^?KQMF3vgrJzD5 z9)DEgU=*5#fc`N26p6#IWjcah(|p|?iO+@^kkU+NIR17=9JHRtKwl&sEBces^Z5{x zzoo%g?Ia$|J`HiTGjKJ?1Fw_*0kCP#YPKW&7gbm7IdEHIXRsh=;3h z5{^00e4!Bv{h6VV+mnG*`$PC-nFOAh(YU=fo#FVp$9vGeBootDWkFu`5cYA!p-vzP zuep=3aUd4&?uBB;r$k&HOo7wYFgzYOjGop*aBDq`fbJvU`fvo5Z;mn?-z??Zasr=;8`75tBW&Rw^j(O@ekmWwa=XD$Bp62%<1y)AIM(lvg8R-;@YefcN0}?C z(`_*MxH}%DdLlT~0W)^4L9OE|++4Q;gRAWkZn*)=4JkxvFU5c=1^MZYsFC!BB)>Oe zf4SiA;6^ldxWKV56bIX47>;+k>;l0;7y5fJ1XZ`fkb5~1H>x7=jQ$R&T^E9dIT0A$ zzLMd1Gqn&Tv+PhYXFI~DdO%v(4JG;uAd2UcK3Z^0BoH$G7I_z*kHb^UiDHT&_BdPpae3ti`Z0HNq}4 zL+EbN!Bc&G6ci}p5RVDGKbs5jtMAe5WBf-5T;NQ*{XVHo0E5+K{-fsU*Iym{xwaD3oN zAIzN=0yTqu@O6$r7FRGHUChQkxdeQ1&q2L#G31|C;t=aD4%*&Ajr0Rd+V>91YTcMp z^c;7btD$nL42uj-z<|m^>p%py-i={6-gl6$FP@EqbYToynuFn;9EbCM?wBF47Ch5@ z(HP>0Z+A98-2^sSL+2{hNYm^to(n7z^RosVLSshqq4);c&Sa zH`B@(jxVuzjtZGqXb@0jJtMb)*ABYe_#y#3LN*ppq5u4#k5DHmn2Mv%I!GllwaNrpNexq!l< z`IIQ{ENcH<4Ju==8s)e~figKGMO~rm)QcDKQoHsIqI<KF>hmX6N}_{@`s>)naQuh0k07C5jU!6cxZ__A zm5~}`Ih4crZ6mbeoA98o0d}wHdCfjE96zo3A&y2}hkJSr{C|{V`NJxxS+bG5zZz-F zuVb4*Efj}dVz*cqH5UcQc#dzFS3A>&OBwy{qKFi-&+9FS#(3(y)HcK!Zf94b~)$cco$n0m(ns*h= zF=uh+(MfcE%|;nd0wgv@!OxGbU#AA4Q7s0ZpW|RXEfdc_9>r$+)8Ml`jd#cBy7co{ z9NrR&tj=hJ@p)r(cK}x99>lRd30P2&iNhNb(aCZ~>(334KI?_3Gn)~zS|Y2QW|-gSQK^;VY1j>gJO$Ym0!VS}3aJ z=sI!GT0FnJ6+BNjV?mGuW?MPZ_h?oS*DEvS`aBx+d8|@(c8mOCCNWhemIabB*iBz?dUsE`rF2L{;*Y`Y(p#JBp;txpJk< zwdM~H2{XNkUKwxVcdU(h&hLJL!|@wCyokFWO9|13{j7$^6MRl{JbTB{e%9@TewKVl zKbynx98Qwm*iXeOKFauV0E!dPnzQ=p3@x9 zYWpz3=?@wvhKp_bSfS>941dua&n6Z6SYe_Q^N){sFg!+cJez0J#Qe%ty(|vLCue)m z9N)|4aJ+V)2VwS6f^o4l$204iY2ZOT%4jh^`)oVO^{|+{cXd0-J=#NaygPC5Trsi# zu{*)x_>7ohqW^+BF~RXYEZZ>mI-28|?b002tgDxp;C{|AFp_sCI2`ZFH8E{qpy>a_ z@tpZM9M6;JMu>#C5j4jWDlTqynj72*=e*I2?cP%)~ej$Mf&+VBPZWV4Yl2K>Wk;MDD^4<~@i`cP1Kz zC+Yzc9N*5`)8a%-aC|$-~Y?H(Bo8sG9G{>{Dceb;P94EM)GmYkW7Kh_?m7NGX z>j`$}jNjGkSjYXgjZJeri}U*52gk)nC4XW#{)*2}GAf*Qz%*NSTq<0Qw&FB$72XIt{Xl;DA*!!UU@x{EYB z&_$9d0yv?{gN0QJs6PLT+?CKr-r^r-IDX4LQE-_}M)6y56bW>Z!9*WfZ_r0>e!~q3 z$Iqm|i%xQd_%D*f@gYO6$pywjNR|}9ip(+c#GR>VIK>Uwz)mtg?kL!ugSF;lR&0Sg5jGXa^WIzhT|816~?g?KH8sh!_7nz8(s@D9Pji{7-wVovHLB3 zj0FCW(@Z8Y9G{b_2$^Ovj8uxi;tv-r+9Vi`_W?fy_XuOF)F>(WP73N;@>u#r3V|tN zP(Ck#z9mX%c_Izhi?i|I$23Ses=`1-5E1ue@$2n06hjqud`k3vk1Wb1X5i*s9h|Y7 zjT^h9adB7~O_Noj8L5R6-&9~+GaZ?N#@IMdo#FW4GYc>;WImQa3np<(7>*wpGsB5z zD-lv|gg=A@tRJt2Tk zw4-aw!UEq^H1M2)h~YVg<2gQFsG|yc(saFiVG|C9-9h^3QxuE5#NL)3$p7S~e$M5m zN&-Zwf^+=TXG)m5TO>#Y?319rnogmd0+gsnjS3XoMv>y{nM-N#&7>S}YEVx^7f@3( z^{9e5hSZcM9ZLR@3ghFw&84X`3X;_FTY?P7-#z*peldJh=btYO$1m@GflHAO;oegN zvzu4oYjqE$mPL@2yNI7+7g0vgUVI=IIp6Z|cxn+i93S3u5neWz7$3hssuo5Ys$sf; zz8BKJf~ObmK>2M6wDfDRxw9NkHLu~!*d;iPUB;BLD_Bxpj^f<~=u0V~Yw^eMlIJw` zY0>vQ$rUK*KgIa?82ti>?y5x4rg9veS;+YKYw|@{qFaXc>^dAiT!%TzczdNei@$8h&X6vjG}P_W@VX1q>9md`4R@5NdV+73FKVXyrrsW z163tXU=K(9hvR3y35EX~dRV>o_wX*Afy@z7BAp}8d%t5U=9 zF+LpEX@;uk+JT1d15j+*4eNWm7>*yTI)IV1&3F-GkIxaV_~~VXzgsM^)!7?6SFMA- z#Tqo3ZeTe6&a5>I$4`@V#wz|@IR0xp{0Fzguq_CedLz+sFBnBPJQyF}L!ax1jszjQ zmS*^xNPOJm2W>h_uxU#m&Ylg!_=!-A7zLsvIRXQw>k$_jic_=KBSvZ)Mr2pR|Mym? z&7j$rXBqBu0d-Soe?7bye65z4`@#|p&1U#`X$5+(QYd@80930Qf-{z4{IwGHT+qk& zWHW@G(Sd!a34DuYK*iq(io0q457C5YByGiQ4e5HXJ~Hie7>-}KXg-vespHDr`8Ym( z0opBQV86tCe6f_rUm0~Q{x%N*Bie|%Ys_#wPqzl3f^I##y z#}syG=XKqLEI9g%GI=1DZjJWot3bcawuEP89AaHltj z@$qh}y&*(%hsW$w44>!)ZIeudqg&3J$815VpNgnCU2lwI1eFyE(i!9yJ`7=mQp z8)yx%QKWYjJqA}W^X*02w_byC;w{wK-o@97ZdgV3VN>Hb>&Vl)x&JjjE5TP~D^GGKf*f${O*cLqXlRVc&pb5+A(znyy^wc*7pm!cZp(tz_`zp~l6y|LLFa_N-ZEl1e%OINUq2i}?QjJq(e?V< z+fLz*a2a?7&qB~I1#&afP$ig-8ydbiLF~YfWwsEr*oKdxo8Ys03;1gfLd)+cR^K@d z-e-k4k&=OVbLsju?c+HdA1i8u6z)yTz#**cHQKX=T z0@>fCL23l$mkuZVxpy6$DG6QcJA@y>7k?KRX_WGIKGFN8Yf}C|8WoN z%#9w_ol19_<0t&OZZgC1g#WGyZlCb+?nE!q!{XDQ;P!ugyerqlJgHsXtaHt71jonU zV|TM&mv^(e&v&y&kGc^&iQVi8AMZvqClnEy8@kyXjyGJ~%`(+>t7EBkv&?1Pi1H;x z1o?Y{zd7%p^Dw;c%Henx?c)jB$1^PafAjGZb8~SW)B2yw|2v-c@dW4j{aY?|oZ-J5 zZ=T{(rx-plZi3@oh`rV>w2yZol=M1T|K;N+SYK_(nc(>NOHCc@;QP)5?c>@1a(rBR z2Wwf3Gw~0{vkz|TU`=rR1mkmjJZIaqk7xbkl365`PDfEk&b9{W)SQ~p+ZyTHDcvj+LN1{BJ$3cd%5;)aqCcG`a;>&$sxjqe}wL;Wa|tCwC$XTaS% zNRE%cOAL{oMzn`#b&z5^M@g^a{IJR+c^F#<`QA^>C0pvGE64 zQ#D9F**ZX4o*pEpmA8|U^-bhD+xMiT<^aj@@v*%vWYau;)TWP+rlb8NCs%(-?HL&_ z!H@R~+exdseo{2`J4ybS3@Zl_2wxc|YxamiK|>g_U2Pb z8Ya0d$H@?a3yb@NaPi16*?43!+IH|l_^A*S^8_&3H3h5J4wD=o|6`QSz8Cb7UphXM z#|{YKxi}A#tEZMu#z6I7a<%s_(&5cl^4*cY9w^htcbX8Sq=Yc|Kn7o8xX~5$lWf2KiCnR(ms~Sf z6h(9&VkV3<#8G%h5Vz0pGaNrZR~~Qp#c^ey+<$yLsmP5V`N9xWn2PQT zN)X#Gh&qWOvVH*{g5F5rk*Ey18YJ-CkRLzy|0drY7sIQ5Y5Ja%A1>Y^AhiWyucn9@ z#!3*77sQdT({O_lK~teLldI3!KO4RWq_AIK73M}_sBn`)P1!U&Up^aijU*sYa` zG%PC^>W7)8DC;uBsZCn&UvG{LHs)ygW`cp7WjMV8_;g4g9`VXJ;Xv1F?U&$B z_;Tzr^+U#)m3S+>69u`eG5_yIjP>qdIDSXGJ4(d^k+>-d%}W#EAQ_8|O~-J6u1Q;a zq$2A;K14DzX?84NIR4np2ly0!2al>B;y2fGP~&~b)fi-ad{qw@)%l%=Qi%|!1azdS z$j=JY@>G6`OqU>^1QwkBmcG>@_n(4*S==2Kls3RJ;> z991r-L|w6wr8>?^QI*#OnOuE}^ABwD_<)%?k3bl{V>mw5@HRdg*27!79$G$SxaeF4 zz4UCXmBHhj1U>yP<3MrZhPcAtjlv`Q?utOm!&uXtXJ zdl&N2l6IHQqFrS;{^Hw8Y^|oD-{7W`)R*F{GVL?X^|65uD;AUisATwbM+6bqHu3d5*%n&7F-|C zaQr63p_PnB@tR~18nKwc zhmlwwhRbi`>GSpw#CS7s!SoEx^r<*ykqbYrEYx>oAo%fNEShm18g+*;b@(KL{^X$Y zNHP>&oq+AT)A*Zkh;~b{*u5f(@$tu3!~%<=|HJX~R3aH4Kgl2wYZo7ajdDCLh|~MT z6OFUlX^f9w)*gof<8xH;FY0$ft z%=q}fXX2q>bO zo<^guF&@4(v_Dz36XA79NUx%|n;*mE>JO;wL-Dj==(n!Ik?*@O91;oXW^WWc^hC`K zZ?u;WrzE(Jz(14fxB;F7>++!v>oveLZKHQjCU~tAEYR5hm6u%_{;&F?*RUOae&j%I;?rS6}!ZA8II@V>N!6C&wC?C zRa+snj=dysA(&kKD9yTRn)-M(MCXiXj=!==4&|f9SYob-@xSx%l}8ILw-@1JvmXAIErNW+ zGKS-i<`^@%`lGdTp&e=p4#(%*r9<@o3voA58)}i-co{wy$2aRiT4xcKX_>-#oMJeh zkTb@37YQS{Bkef}E(=H8zD(x{=v@6#d1r>>Ik|epsxTNI4}_ejCByOBx;D6IY=v0T zlFsi^P$Wr+1sFoc!3bYTGe{3F!)%}VjE@g%kcXd=36rar-L8q1Z7fW_1%~5m|2QJv z@c>kl{b5CazOOgOVQND>!|`%HN8#jk1d(Ye49ELgcw=m;KUxI*aedqmymetXo*Rjf zfCMN<)BP*CVUSDOgD}@1?7iiM1%ZKZ|L6qX8aEIN*I=#T9t>&hf%4cP@TQ-@gB7`$ ztWp3ip&}TB)?&or2G$Hc#wO4A5Me)qz{f|JO!qIP#8%?|iBfnSI)o>V8H|sgAGd?{ zgDDv84aNPY5X`TNLUdCo*cYPyldD&pWXI&{?~d$5(9v+5iroj;2jeagkKex!;Uj+v z;`QSoyD<{?KOBPHrF6_Wk%`RF3;5A{i}CTir|;uq%?I4v*9CUnJFpEKQTO^a!t&lC zYsM40E_WY|(R9|_p&qhFUNan@e7_HFtZ%sD^p)}P)lFQKm&i91+Hg@Ej_2g+Rj&WQ z*%B_Q(ovb}tkb4e?$@Kb4$P)1=4vw>-$=7~O@Ssg-X%vhe-@@r+VW76E!`+x{}mrU zwIk_KC)CHskR(2W8vn_Z86iy>yC_n5>8g}!s19{;g97!*R-B5fmS8yk-i5a?UeJhe z@n?*W|MjR2L3E$iyT)3SYCi;T@=g4fdx-djZ?SsDJKTEI#&G<|qeoag|28f!zDf6% zl%vAyBE#|5u2kV~+!gpYy+&Z-I~=$9$mHrd9N%R0f&RO*aZ>OJv^yKYrmr_MtJxUp z&BIOU^N38TgJ)kZ=Brm?8{OkokViZ6m*X8`J7B99YZb>et@Cj9Za4*dTQixpvnf<}d&UHy&!8j4b6cC`Xygh*I*mBq@%M zH~lpLmFh<9p|f&kRu^DBQUK@kr;)op105$0K_)C5^ZkSI#bE~)l_X&neJxhttHSr{ zi*!Br1d4t}GaR4fl?=1`SSDA`@$n`)?y!?i#Z8e^hU3|9J+NfA1D!$H%=q}<8Jl6V z>HwCthCw-dH#Qz!j#DMem|XpKH@eTJ)CpI+=)R<_XE5i=WkfgJ!tB68M8C*I$gTni zW@JNaECb6jv#{|1UB6OsLUxQN27fKZbt4Pto?5|hd?)t~6fR4GkWvcPf4_{4Ri|;- zF&-J09B8Mt9&`Fh#>c;kvx8HgJs!2%gK)QldCq2ZdwSuiCrN#IM{ei3O{V!bkqbsnl45>|Ny;5^_ph+^-|_5>nE6=_VMiJd8Kr&zMnOb ztM?+FhL>`DJd4BeB6|G{TXGm{B3D1*=R3Ok*#G3}3C=VQ$8&}>$FqF;O6Xj@XI;Rq zKGtQoJ{E`L7u$H!x%xgfovUYaIR5|X<2kwdOy?4!=y5OWAC70!98dfBUKZ`+89wCr z_z8~hWtlvcFt3`n!u+7<3Uj@y5~N-UMb-t|lLuEXqB-8(oU<(&<&oLoI;-Ue%iN!5XlQog6pWt=c$Fn%Odd@oOTs@oi@vPBZE_DrVE(9l6 z{}0F4{gbO_r#vel5`{b2w2%MKe$yP!qJ2E;-(3B_K7Nl&0nPEw#NpKi#KCP7d3rim z&unXx=ERWW<9&HLSjwYL#G~$ZHpj=)9M8$s(;V+aG}h!Zx%%5Q$8&sqWm-OwAD2(( z>f6~Ij*s-pCjz(U6S~Un?0<9hocBU=Jd69SBf;T#=^Ku9G{-aB<+ymuPCjt@~t}W$3TyP7M zt1mvug9k@>&~}LzI|&imuS`Z)$1o{^5@e@On zq?2r~>?Lb@KQTT&{sMgr4@zS0)juTPZf+!4iDUPBdFcEVM(gb%((Ab(IQ{WnaeP=5 z!3X(OpUAihx^KQ>lvIA91SPdkWb2_(lG7ibT=j!A5SRsOr5wVJ2;=5V8SvGMF&v+n zC4x77Qc(QOgUK^t_;)8{QPDjeRv)IrR6~vFkFVS&&G>jxZ&|2D>wq#?ghU5(gxhH$ zTUH&StwzYLF~swbB^aPR^M%B`#L2(7@juu80HID;I!GDsy|Vfy3O?514~&GFj2Mfl=z zmGSX{E3crVxd5BzUV`}c>)54FXY@KAqd%b@Yb|OqOSJ|nDft-tbA{=TuY6vGSo;41 z+|@gWy1WVu&O8S`#dCP9pNB`or}3UDgluINw(;g-v_G5HyD7sS*;>HEKk9F7s&CYaxtkGWB(|Ku=Q%4>8HMM4u3;TD^O%7ck`yeE;)m4-vi&mVMC2~1Y*K)JCeKCIl!aQujfD>V4s z@lwJc&l}d`-IFcIzOo*o)ms>jw@X@$!V(W?pYs48?NN>&^1{heK}>%pSTF?bklo^2i64x13=4ip~r?iDdfYIUHXf7KrTu`!Gu9v!!!mFn5z1&M(`EE(;fo z%nHVS!!4L*V25L>o3K)KHOhsypigWiti+a}=ld$KX3b$Zo|CJ8+D<~f*9e89n&9r! z!oypO(R0@j%U-GDM6n4H@2KPWB`pXSYauB^A2-wI<6MM3z8ze^aJ-GPAxdaxt!+p` zW0@hu+-Ac}iNF~#5{+~Z`B4{n5OVsk5z}Wle)`viOn?0EW@9Av=wjauQ!J}BMpTP7 z!mk)GKK@ss9)@RagqaI4pIDA%Dyx}Xebhl+JUccAcZ!uUu-Ftc78xVjbRpapEQHz{ zD>Sm^F}eDY+e@K4UtG~aTz^AiXXt=3{Q_hzCzD+L|vk3niE2&;{E;H8Bp9%*|+ zoc_G~x~Jg6np7rNe`#A1ayBGEl+Fl?9Sg$?<*iWqxgE+j`!FcH8RyqUV{C6I#@0n5 z!ZZdw&(bkJD;r}w=>H3%orhe7N4UE2CO$uU4)D_1r-Wu45xL87eCJRuB6@S_>p?2! zo=!sXXc)Hr4xxLq51>hMKiVTb8IG522*xYPwHR@6$M)|5On>})6>m85?ZA;sfegow z`$gftQZgjUjxiixOV=-*->0H|&v6*Koq)T_IcV&wLeJNm5a6vtTTlbziz z%o%G!#>h*?$FIFlGho&YxOXWbn{B{ehs@kY;kk$CzO?!xZ7oPSu9`tvebk_4XzEb8X)`HxD+8+I;#{iAWfoOoDnWgD zEK0dMPN7`R4KV%joLv24>J5Awf5LCc7j#r|Q6>Un6u-SB^*v6GvRX5T5?eo;nqe}X z65Th2+7|E)+HoUj48M=%ljwe~$Z~vGyU-NztX*@bztxhe0! zmsq=|ANiG!vG4C)Sl@mK&$Cxipi+XZ+BfhrjP5Bo{s8CR-DZ5e-dHyxEk{&+U4qKmIH`7r{?2 zF&sb3nC=$~&!auaMI60TPk-*~&~fJ_q!hklrQ2_qB??lD&hS%-8e-HkOKB?XvNYr4 z=UwEb@}~FUy;}#&cRYiALp3H_J%@C4HFn>*5C1J}aQj`y0Nq=5dZ+?Z=G5W*x37$k z_e&O|Hq$vjps`=dB@{KsVKXa6Vk9C(2#&ufv*E`j2LJjTb%FU`bM;dt7U$Dr;@ zAS9$?QA(sUxq5!Rv*0ht$FuE+@PFuf6Sx@P|BHKQQ;~fqA=#ppDC+J$mn9+lS|p_i zm3@n8UkRbIW{*lKTS8-BL)PqBLqzs1J)i3u`TG5z=b6{*Hq$gSjZ`{w&Uqi-=%v)h z3&)413z1)Ys>s!cB?KV2N+|JnB4BQ~91GS)Vu{U6wC_0_y@w8k^|Mhp-gG$TM7v_X z)^J3PcR|K57m=&KFvt~-BL-mIiD8i3y^$(K;BCo16vyPC^x{sDtG}0$LVcht+~^RG ztk|V!dNKh6Zc$dO{zOE!=!5ImN8;^lNAy3`51#^N!{cH+v0L`yX!;)9@12C|5!5N` zI|+K>9vEoVU+DNWxu59A&tEm}uZ}<8coBxA1fxd9LR7qQ!rtl45q;ShM>d%VKc4IO z5jI*PSKq$977m-=mBVXXk3aJGbFjGTKBsIslECR(=(_a zKUu{;=Cr};>;Bh|pDb1Cczzr1$A5HuMSlDqiTC4quHI;*zue_jBRQa5GVjMb@P0h` z@p;`>?BO~-@4sCAfBpEC-s)^U_2Y#f&ma4bj#p8xK9BqH>VAB2p4yKuc2N8AY8}u0 zcz&PCetc!F-om-qf&20NeU&G!e*Ou2gTSlo{fY4OLC4^?OENBgHyKVGfdd9J>qZ<=J{kS6u-k|ssXujxpBy!3gN zI#16ZYu;8A_Ssvx;9k`CCete#)rl*SQcz!?9@jO?r`Zrh4{dk_M=Q^I>hU<8qtEYav z>Q6uZZ?2xde}v{liTrp6%GFE!ZQhTse_Wlduk_>VmpqfhTlP!Q!R%{qWS4;&;Qjc}hV(kX zn3wYK>*ew~Vy@j7tc9|MpJm6XI{4J=rOY{ZT*tpEFO~fXx((i875LAzNd(@9F$bBq0cZQnv z(eH}}=CNFvbMB0vX~535Lhj$ADOyw3;c#jdjMT4?xgXCtcWK!gg-OuO8a~(~c1n z;Itw7U#TH-^({}-fN8rjdA+NS$kl5N(!pE5@ABRowS|u7{rF{DYr?N~N5Q$v8exbs z%Vt<}xGusZL*d7NA6x@&&Nc9}oj&eYG(}(MR%o`S-CrG_nA8D&V@(j3*BJ>x9gsh+ z9aaZ)K#dE{5xb@{;zoBsdR1dIPc%oTZ{47zKB(*5%S}!wyh>~Jd1cLICn{*7ht;OGHKp3 z*dCe>?Wk-#-*FIj9aC{>Bk}4UZxenz&($x|Ps5-S+Yq!m83$Xe!fS&}`rRiRM%D+g z@@YECCm%!oYWwi4@j=14^J{e&b#Cm1MjvrL893!hFp~1%l%-J6&`tk0* z;rMoW4FcWCXEvQDa`mGFVzIYFFsc~_{nhbD)=oo_StL5PjzHac(@u~$WM)C!Bh+O^Ivdtn_|0_8j%sWzW?%t4}Fm6U1?sp18iNYUmyjH+q zBJt};$1AEYhIP+nq951d|3M@N8KKH#S zyl=b_zSPU*I-Yax_MMDH{w3;zoLh;;SxaG(x)kPd;b^*Jx#-8AT)Tog=c~{s&L2DH zO-1@x>h2TgPT$=h>AgZku0FWBD{j{GK*6KIl*gPPa`mV0x}oCOaAK|b3?Ob6Op@lHp;dHqG*X9?CEnj%$K-Du6~H}3`Dnmp*XrL2;Z$|3mwn> zc;1iC*Pe^yA9J7|7$kK3j5D72oHYhFyh71otB26>tHVd(#llg-k5BL#fjOfF;f-@Y zyucuM+w{dg^AUJWJfi{Bue7MuA3L?j2_2tV=7J{Ae1#t$d3hq-iDmiuRuG3(w^Hw*Z;|E-{#>f-aIC|C;_fK?ylxq1u zI==71ZgBRphjN1v9*^jOWuQOA@xtnWkE^_r;$A@$=LF@N+nEj?Z zW}g9^F89QzLyqX%u8Gj`OFp(ix5AE4jdelI!)7?M#s+Ir%%J7pQ{?KoAJ6;o7e;l5 zQF3eH$JxnP7dSl?KVQ9H_5b>eLA$HaiG?>WiBL`a!Sy2(+v>1jDKC6xgPp;N0>2+@PDt)rXszqk-N;yrDjT+;t=t)eFYJuk@NvzbzQKC=pfrZ^G8$>oL`MEAF}_ zqDQakh|h>5#_23<%UUA(@eb*sc$gSSY}~mxv?LNwy_N{h-TYVHq8~p!EEs)bmC#G} z#iS)lB)Wwn*Ca;d>Ps%JCXY8ozudtLJ|FkbAq} zFlCM4+|3)B@K?v{nazVuz;qnF7y{dL%H2Fz2*nl;tXtzDbbM)bfBY&Q2JOinc-!9- zTg-`pHz5KSf2}}HyI5@NAAzvkHF#$nEp+^+bqPYp$M@QV&D%3j{ih1ALoeaS$P)T( z=M8k$KgRiE_Yt@56y7=(<460;LdSDI-u(U{w5`%f*-&OV4HVE9~m~UU2So^1q>ehlVUEyfHgz*Fxxc7b7Efw|Yyq z#+mpp$F-P4_C54=r*3?+0wjMwikllVpqZ0~QnzfpO*kQP^*=V>falmd81wQoJf^-z zm%PhD$8*lz=YGf0?rk={Kh4JSk9%>V>=bzbr(oabDx7CN`pdaH;r0+6#y`MBovY}c za0(LegoRDdvhAIF~kiV zUE>$p9eW6`!8fq)`FT_ucO1c~xv-2sf-5@?W9G74oM9PY#MNsNM}4!TLTKog;k|Y( z7G}_py?I%SSvRlGY}~)Xy}?i6$NTGFgp{yF+9Jd`r|?apD;0|=wn z%46ZUG#xRg4++kl>Ugx!@m?pR;Cd+%%F;zRVit^BUFHhTopBH97;cWl^i7L!$8ZvU zT6yBT${us>c+<~-aX6h7in;m0Xt{a}tc=}-AOES@V6+@zfeu-H@N64>|LzRJ7xTT~ z{dlJbJMhFN16;>fIk^LMt`nD)biDQ9XjlvhrhoSU%7XS2oV&5%J~;K<1DEf3;K^3f z?Y`U5*((dLOEyq{X)d1k?GKMDa>0B@X*;`%*vNSNfByS8+|m zPrM&LY?IobC(d0Szs_a8`faY``P=f)OwPH>>n3GN2_L6O3EeoSO409 z`|H7 zcy%W}T{A;fnXBi1{2}LL$GI&+=$&b%dbM7kr_~&`Ihoni|kLMqc_v86F*YSTiccLG!_T$yJ;kkO= zkLNo6|8nmB&DE2RSMgjue>|?^`8hw?4Dpmm$BXOL*Qozg`|+G}r_RumA205s=G>`u zJiq1tcRzk&r62D=e!NQki2viqtH_U+`1PdY^W2h(gNVDR{vyeF(ke!J?7JS-_k z`0?_%BKh;`GC61MGnwmn+g8+B>3Lml8hTs4qy1L4tV#E^uY$8TbrE{-vdncn@5e9o zdnt1r|LV3rct8HcNGESEnUH9%|+U6gN8fOGC*pZ}8MSJVXW$8%o&umQz#O4=_u>q>#p z@%KMAM4?+v>T%UTwl3*V;?-Z8@KA8>_D`&ex#l%dyg(B_29tkJx%$8clo8MTDtPrR zj_ILgbp=|UBVV03cOPFGfOGD6uAVu6mDirBka<6T+165->-g~a>XZenjxbgYwo2m8 zkJ7^>i)u(2Tp@EmKE{D~^u(**viXZ#XK5{TiGD6`TJlA3?sz{wGNz8;+{NB+1kS7H z{dn%ja~_2Kky#yNNG(sj|P3VHYUYmkSdft$a|WxJ)i z;C_6kdi6-pY2aHoEof&LVavC=hz!(6Iq?R#j@O%_FZ}qg#!bL={7IDwI(r%+)71zT z!;QdoJnzRBX4l8OR{B`>ttqVA8Hrpy=hctN)D*e;@XwZ_AMdiYG3FLD7dk#;RDFD$ zX)1I)=hZuWYz@DdUihA6jh?+6g&)s3cifNHDD45~0p@~p$NTX4Xz}i;>!lnkALl4{=q|dt-4~p@E|V4PZSW6lxTMFbp3!49$JSs@R(kBYZe7-3R|CfT z@!XGpd!-#)9np%JeD1_H-0sMhJ9J=OU)u>kzSFj@Y-aQ3Z08j-)^v7Twz7>8D=uox zMsL&>oI6jw_xSw!EgH7I3*L|Ce*9?4dfxhQRB-Ng#_xyS^ejyNxE)R1vamDcAR7DZ z$I8MJf>&=~z8V$zJ8@Af1IIe-gF)kL#J<@BgSQ#NkFVs_+xeV8jQ2@Y*_95StM{ef zS&pB`hGh#CrhUrAyy zVJLNp!w{$!ira3)&8tbCgXvOq51mW=t3c6@=Q`d>GZvXwL-4?SHrh6bhuxq}DA1wK zdTtojo{Gc}_Y@RYtPwgsxaSVsDNTU)m$fkLuoJlzo8ajmg114D*m86Yo~Et(+mE-M z9)m}L%Ta1K51q%wV#~m2WOWV4rf!jVK6WLt&ricv<4Cy6E5LQUjXCk~bt7@VLZu2bgm8?d_GQ>pBlhMiUp0`te)Z&p^xH z6Y<+H1dr>?!;Xt%g&%Kn*&FQcOpHG0jbQqn%_V6f#(EFH>;ym54j3A@g}5=kJg%vy7~*T^veuX^6F2_3`5!ZIS8FdeTF?VpxQD6S7S*7 z%^wCg`W_jkj7hw+%y$(w#| z3`U0e0O7|Uap;6h@E87+>htH`iuqESY zGQ!YW_J|$S3Z=K5vF$04(99Hi^-Q6n?G7uWR)TYP;E@H457`Mn-tx4C;MH?KUKwSF zYJ;7i)yE0dH+G`lyCd~}2jlf3Gwgpn0;dMp5Ri{dSfin$g&%L*6jh4`nKfR^}^M!cJ#gD0*4$R^z2l~FFm37 z9fptRDHlwyx$JotDtPrKYgUMUyc_K?bnWghICqb3FBG}@S5If7*BPbY)lZ;qOe2#u z@S72ZrD0)sus94WRt7;w3Phb1e#FTMg|=@93}*xh9iL?qjk2Q&7*F53yD|=oe*7KP zejNOH8Q+rbLJ@ZtRh^!o^R>%}t#cY>itQ*(+k;L)D^W0irRc}Mq}SuPAHQgVKU7_2 zAz61G?0fp-vSJQ)YR&>1`0?@y60bkSrpdiJhKg}xu*s5q1OP;=QU$yC!4aK`I_vW%XdVEJw)L76Bs!Cyy(aG z>~>J}<8||P<6+WqEPeVEBfSgZGV2!ZJuAZFy65nH4teSoXJIu`g?BYiqw~4*B3HjE z`@G=Qf8R(RKk4|GJGXGgr3k5-&v9YJ8R5szd3Fp@pN`-yW%s@PvxFaiwku`MKkN{> z`nrdAA$URt>}ICJW^*n|s%{ZFKDgaRyuGsz=Y}7p-qn8MR9?h_x7YD=?@OWMxgT%c zQ(NTf^C%B*L$ASi4sXf6mp5XIhUziRieJd>Q7roLZ--q+>%^ld?s^glYcJu@g>$$* zAOn%{hf%xmH0(~?!o?5Qpvcx@7bY}eJx@}%?m`_lp@%+ubEg*LI$r-}K9t?hVS2}1 zIIfXO`G-wtcVwgB+*Ri4qk1jDrianQX-GtC%FnHBvJH=GuNIs;M^7beddx>?*I@Lj zN!u>%g0bFdHmc}{5Lag509YD$AiV|cSlGxYTr~=g zyvp(`wwa--&aJw_^4Qcl0)%E&B0wNA<&^3U8db?t(|e zJ>hd%H7;54zU8V;n?_IDEaXgj-6}orCj|RiTC4quAX!5 zh*$qd$Co%%_T#x0I`xNF&o6UcJ#p^->N1|I|Id&2RaNrp`E98mpZ5>vPQ`ikq~j&Z z)vGG|@dv%9NTlQQ$dAwCyn24W|8n(e?%f|=y^8$!Ke>9&tLOcAHUEy^r;>BWFY^=U z)$@LQ*W*7y8i#~J`th7s??i@(R|MBCW<#Ap;f3Ezb&eaQE{om`9XfrN^;>-xemv*g&6%K!5B1*2oO9>$s48*S ztD?M4xx7WE{4cMb>-Zfj6lj=QA}?*I3#H#T`R)l#=xFH*Uj5k{+JbY}*Yumn)t4k{ z!sp!!dDO-y@{+qRWZsYe(Y_{EQz}O{(S+^DYB+4BjqAasa)OuMUmdSmn;7p)KgfoP*E09x zzs)xQ&(+s%SRsF+4g=TmJXgPP2KC!%caHPwzXnyomhZZVEi02JyeN|gq-de#7A>U4 z-IqVj(GdOkX^R_Rba@RJ1Zp8>lmjWx32bcVXr_=+;3;^y9moQy|sX2y+UnA|tylR*kBGvS~H( zVc@YxuZbQLO^}{uN^kcRete^2P0{vE3%tEVT0oOt2`|T5#?bweKMM@l%tW;0uV|InJw}G?Q3VgZjYh)Zo9l`ssCg!P|VC z@Zr~KWWizZI(zDf54 zum0uxSC|#|9k)vrZ27iwls&1=UK!S9Ch^*Wb64X-GnQYY9?Q@$Vl6G(u=voHY_~^8 zb~VnN-Pvo+vWA(mtVg!YZ%Y?;yjgqJeOm`++miHsd~+84vO0?$qR+0;>jK&HHJEeZ zYt%dWiWt93goc?y$GeyB!TXYQ(U0#-ESVV#kBi+o)t79TUnJk>@E$l;9K-{!)54F>-@6w} zsXNMZ_4hk&L(=fwyV>;8ou>*B5@zFaK~n$fo2L0PuP!o$4=npoWq!C zo`rnkJX;UTf$OVO(T~rS4x`nAlQ>j$pWxi39ZSQW*;`>jTD6(UTFm+skFf=DuqSTw z$vw0cw>d`e>SMzeQ+ij4)gy@&mlTHwo{_k39waz-=c>&UyK}eqPQ!JV7<_0=yE;1q z@O51P%0{igy^$;NkS!Pe_(d&Z@pxhqes_;Y>rN|i>tYZll*b4^zKLl9d8naSTpWu; zRX9fLt^xPsA52X{LDeJ-r_V#$DEj=BB?-=*=aVFntIzzt83hli!@oL?vd(Lf<`Dre zWgKPMkB6x zG~Rxg2=2$f+T|>E=ge-A54&}w*q!6N`dTZ;NgfA3+ysgh% zjLZo^&y#VuK#YnhDPf`?KY6uM?9OpNKKu6q)V}8-cIUVsKl7Lm)*hUW_1ipAopV*yi zcAIok{$v#7E`i^TD0I3SjGw1tP>~k}=c7T`ye|wLrupH(nE4`C&-?M*kMG@MB2KmU zMN30ptTY@COQWHJb9cPL6;G-=VXDFz?aKzDtco2mKl{P=ovq;9X&>*8D=#`D_gM#Y ze`^HS+-{hVV-3DL*ZEjGcy6&o=mvA7FSkU!?Z()WXo8Rz%hwFsnGL>t=?nYk{Y0*w_v5*a=eu*< zkLNmmOxPfNE3+1Ue5Cdy!MQ8F;fqGKT}7_`?%~Ne650b?$H$xX!>gwKVSkH18zJ?^C z@y&hM-7p2^O?F`W=(R{D{X9rX*_6Rku(A78w7;%ITU~!}Ki(!g7#ou4d!qkx;m0?; z=ZpKz-T&t5c|U%g>ul6?iiDjUDL8k>Tf|UDZ8g>uFGrN~c5Ho{iIvNaU_|G0=x2Nm zgVJuHf94I*kMI8QB8K!l2DiH@*z2(dcaB9N{9!13g5$7qnm^n>20~s>JDG0FVby#V zoE)bMKfX(h2g2$OhX3kuxYB15!aDkhTz$<&^U2|@Z*~_ z+5wx-Dad}4g2trr9K!QqbL%9+0`jp%Eul#DB{=a>9d`M8cdq;Md8r_ zbajm!#AxO~)_IJ~x{olJ_F0PGKSJ~DE4VR5g+3q83C`VijWcj?J%&!jSx~kj&iGu6`yOr=AtL`dWkE;aj^hXs3UJ zjhPPXTwRx)zEqvfDQhBf_10RoSncXHnORdU_I1EbWTzfQe2-K3A)WlotG}?|C=3b@ zV8E6f97xPT@rZmR*D9d=W(D}}+<fUIBy^$3)mx3-47<>DbX}MZeM@4`{GdLXRR{+3q}N$*xZ}m7 zNysjD#rHFdFot$Yt7S#N$uJ0BpXUf(ef)E8j8C73E#n4b?e??j!}cV*R(PP}^URX^pX!=DR3 zeqPF1IrM0{T<)?%PBWI|36BQ~KR#O8>$vM@p6JIn+gR7pW=&n8;q-V);6`AX9heI>PyS8Mk_I$m(@{@tD9emvJsq~r6{x%$@*mAi9l&fRN= z;Bs{yzEWfHTzzFfUcEc_+Ts7^>iKPt&drdBbN8noKbiaSd4D)}d3<-S^6}KU`pL9A zr`Gp;caHnVEv+ z-MKtTq2}ZB=fHFI{QN)8o%(Igt5@rI??3%`?|*jZyd~P5lQ`#&-&d{UEBo}|T)ldCPH^rhSFi5Q z^ZQb+K96(m_+_<@_u~C{%GE#Py!yOKUOj(5@5g(-HE<*yuk!Xw<-2o~tM`~azdD}3%};YhKYk+h<2kRM@6J8VySR6^G&Wkzwp-`-G*8`+pD3-I zy<4i}-0}PV>Br~s%e)`Y&tElB=kfXO9M9FKhy0MeZvGa$`XSO4xs~B7u{+mhZWVAp zzIDWJ`PJy_vS*8~cid3}+Fi=!IWf27 z`+aq34_+4s@~fiA=abysTo;^kclxd-G(WwQIj?>~ZC!9bp7ZMYetgxXRk7ISo6P(1 zwOf+Lmr8^m-=g+idG~;aveq_j;m30w&v)mzAJ233e0R>bO>LMs)}?%)CUL72&}#ot z==js+wIH7=6}q4Kp3(hKCEcS08a`k*aK4^*-`t;WLyC2W{@r*JMY?T@5pwBYb@lgxE%bat^ z{rD?74TK+m<9mIX^FcPZ84p7 z#`>YXkloM`)INy&ySdfqIt#2W)_g&GCuQ}~2W%2*( z$4|=BV}5D%ndO;AOtG&avn_1O;-w~RQ43>cH=sScUelP}dDDsgxMRw43QPsB{`R2G z?Dzr`7U9&2WjZ%teQoQrEA*OxQ)6uw=T(KxX!j8(imwSjp6htdt6#OhfcDqUBKOr% z!K>%{@k<)-Ms@#vNT@@sUGn3P_Roe(yG$Hm`(WQ|KT?0C3(g&zas-{G9L2bvIj~%M z5@~O*h~2s42QH$J*t}fF^L{+%)w}gO1&_oGT+E>z&32*9_O(LCxBj$4?9LsLvN31eA=I2jTleJ0x1isdKL5_ZBa1_Scjty&PDiW! zJ;IMKv|Ep#Ct`5XaTDx+u13n97(Ce-gIV;uNuTdYi2g=<0`nK6aOz^*=r~90$7{d! z!%Q2>hWDeKdA~@UXc&ZQdW#WXI2R|v*5GR4LY%gZ75nkLAHSnsl<3C~Bqsce9}yU# z6$6haw3|C_A=rfwk*hyqoPg<7S7Iw|yjgxC=HZh#^wmp3I&HRp8j*^ibE^WOJf@nudYR4(I(W4Uyd#Fi5tH%UhK~G9vTTfyHM;RrlPlHnDFC2TSOwK z2{G1aKmJmUP&l`Z#n5)k@QgO`ct5^hStw?8Un_Jx-;b~MDHgYHQ*QD_EW!)Oi@&!N zpI*ib&fUJN#MGl6@UlYc9v+GV=ha)Tix#>1r=bz(xi~^_?gnQr!u;_e;5z<}ufO2c za~=P}YY`MyQ{h9|bk4cs{rKQg@=1tuSO3O%q2qNAjD>D!9|V6=;$tfP&P0CvmK9zY zxN#;@WBd>{mAdI6^YJu46777cL+$Jf-j6qMBnJGha9Elz6#MZr8ZN|#m=G-ZFkNu& zw!K;mpZuwUbLV|x5%rmeiCn#Y{8XF|_Jq!~K|;r~@$Rq~M!&E1aYbJ{^5L6@AkbtK z3Y)nLKb~{$`u_}oN9hvGd@>&`C=crTG(_a;?e_VJTs_~Nb z+bzLp&}+8f-0@s}YZK}PgwpFpMsA2S89<$ki7;v02gC2q5IWxMH-px@zDRq`)cyFU zJ<#oJUj$t3A#(L258Ko0i@k9udLVJ|?Ztk4``w+fHp&)?%^k3OiX{B_MGMTaXMw56 z)n7C0FF1F5wFctm=yt>^x5oNWt%Q!(c-afxo0wx?+jf{}&>1~`SO^_&S;tuD_&ce@ z1(`EI^yBOJIU#JEGY0$gLjQiwxVy;=vuAY|oI54um-IKc5uCfC8J&ZtGDvL%P^= zG84Ia^YJn?`ZK|~^XtZNba)TJt9Mg06#e)u=M0gOU_xJO)QL)UK@u@cz2=x8Hq8m? z&a@k!=mh(D1CWtB756fxVoLYCN_Thn75TCyae z;{%#461;lbyTRCbJs8*?0p*!sxL$L`w8>6_SMTlTDR}kVkLNm``|-nLg5ga0qxv;z zr}BQh@Z4s29*B4mqSJbFuOMQPGc|O&$NN9_P?Hr5LVhKg51~TiZLh%?5_YMCW+ z^`j3^9$_V2r}+e(eDv5!G!wb{s~hOG74qXp-mahw$S0H!yp3xu4uk7>&a3y{umWR$ zti?R1aKWqpmN{SK>U+Dd#JfARf8db}&a3A-{zlY#BwMb;<~QNkn(YC-=hMLZ@m6us zfBW%HRp%mhRDkHmbIx60PY-a;-Ki&D&}!m=iSy@STvZP|>+cII7YSAB5R2#wVA|Og zM_pDS^)7AuEKbAygx&Oe-AekM>j-`mGuZxM3~_Z=pvLH6bX-4IaPBy-er#D^jPBeE zHY28>l5_WoHXYApv-`je~A ztIXA_`|)bd-On*oh;ygT*z?;~Z8wGY<5mCh>L+s@PrQ2W$8#OedG(WduAX@HYQ~*9 zSI;?jY8_wMkEh)^$DR-Oh+I9_@tjxRGHHd#)&ILYSIMjYcR!v#7k<)Yd&rM}>5yT* zN22|B;@thG<9V*0-=@-!kGhl2dG%7OGwH;+^XB{U66HZ@cTVib3%yCXdKK-*t15H# z#H-I!@5ifm=lJ7sKR(S!oz?%BSO1S6FF1Fk;~mud@jO>Q>7U&>u^%sT_5ATdC#C(_ zo%5nxy%+7rtG#&szJL4i+>ht`@oGQ*x!8|a_v5{2cg|C~w=Y$y)bWN1sgink?wNzS zAJ2L9&jja=_v86v^HU|~j$h{ecz#})tLK-wj_2p3MPsf68yMAK$U;v*6WtT&5xR`?#bMbzYzLaUQqT(=A1jutLM3T&a02YXE`|WxXg7t_v1r|b2m%% zUgkP}%9*Ef59zYt)pO3>vxl@D=dFwFitq9Q_wD~w{^@y3 z_SyMOUbt$_k{P_F659O|1OXPEh zKFal0QO4e_s@RY3HT#Lo`|*5tj{EU#`ahAcjnITczmM{hTbJa_hY#ez)e2<8Jxwuw zPBr9xe=KiXkt>h#eJ%FmKP6QM=iDvP`6>^$r=2b)M zR|or`CH(kP+pFNxBl6nk*Mjraw&1&SoLA3vy!Sc{SP!lR?#DkyE$Ce>lR4*(@6K_~ zUH!4YWx37|`E`yitlrlU`|ht`@i`U^gdfj2ciOg%V7Hrg z07%DoG}00}p8N5Z${Nsh(}zoyM$j3gFLXTT)$f>96L*i-N9BGz-<_MF(8Ykm4PlYp z5GCc!p*zz~?8nD%GsVNvbuq)B6}t8?fo(-gl!ZFtoUsX<-*pxJ_}?e31+Tv3d`oOz z*$4XMhgG*S#q@`5Fge^w^yB&N+yTom@EtQw@angw5dV6rhv>&Y{xlk@oZ&*p^M1Sw zZ3$nvAB@AN6JX{`J__l0zB^awzYFNR4K7U&<4NrTxUM=Qc=grhoECok&gdfO)GdXr z;d?Bruf@0@KRiTJ`0@Si^~HWX_v6d^88Cxi!~uTToVA@{By@bVcPD1AX~8c1G-s_X zZJ3RHcXlA$nq3`c{+DyNuunr4-l-aMxS-F{lPjQgy97V>Uq%Po$8Afwyd7@mXcP4S zj%iZ{abhOMpUOqFX&SoOQ1ATiHhL|J{{L2OMb|6D_Dwj7lkU`+%swG<_4Qiq!^Bqm z>EGZiKG)hWcIOfUPTesan7rc7DAK&bQ5{@mS5!ob~HWrp6_h&f9 zX-A{*cJN&DxH=YZx356Z)|Ie0l!!-HH{tn#b@t7X5fQtVHU_wQ%_z3LRn=-Pe!Bk%lYquKIGk{}C&6e90WzYO9`zxc4jI^J<02 z)mwc?gw?Y(q94zB_1?MkHN0mTItMDn?i|J;w zfla@unBLbPe0MGl^RS|h54tt>N2Ax{aj1AAbbkb3_XFY=zn%?~3^#Plos2{0=OUma zSnS6ye;AB;BV1rv6hm9^v`==NKJMsgsC$Okdi`c%YKoHjse|#n9d#B=0&(xRCyqrb z!8vzz7ZGdh`2ui1zM*Og?2gP5x%%Nro?<_~df*t;ojyeD$A5MA6n?w|2ExjmvOHEE z*l#=#b()Pqo7$sLq{YPUobG#Xu^-QQ^-C@WV9b#~Ot={iiygCZ;CwLJ99t^<_$tBU z|L(^}(Z>C>q;O1gn1*GxUc!%$=;(%Brh|naZ_}n1_Qj0SO3GfD+U)kkh+wRkYqxi%hmJYvNj0jgXlHI zkhIop@1>5SAHU#kJLXu{hP5{9$Sy2s&B9FTvXch7EMki`Yj$73 z%D3Nu^_aWDkDoB>4GtgriI_Gu1h4+$iq>q|+cr!yy@Sy4asI^2Tc^wR_xg#vIH`8naoUpL4? z{EhvvsY6`5eyK2=o(rzyJ9WN>XD?rYbMBVPVC0Y2~N`(zA z3t(tlBy#op&wiyXyK1cG%I}D)tziD&o3My^O_--gQ#Nr-Q}(=nRaWSsU`^*=L+0ZP z*j4o$>UkFk9nbsm-}P@|`OrL(tFOB-7xtUaA$ICLTrXF!n#*)px?M9yn`z9yjJ9vm zKH$uVV$qLx%gIEy@>Ha>+9memKkSP`#-*j$_$&tdl86USx%vq`!!Xu)3+8^@AvkyU zEaLG#CrRwb`;LvnH!b@AbC41yHx|L}*GlmHcZ2d*6uEkvUAx5YTw3Wi=(ubW zxq7}IpJGp4{^nuWk~JIG28~DG@PW8a{hZ)z;(ZY3Ze$KIZfozxkhy!2X1x{%w1Z)K zZ!o-ij>c0pDsTl{CLkvdu6^K&v)lcYpEQ)--kL%s)3GH z`v(fnT}q@_T$YQxgURO*<|V8Tz%#49C7X(`0kul&I-rpC&NXq-cPryTLrV1J`(5ElOLbQuUG5%%H286tA9zn`j`La>P0{PPp+OgcixhEcTVW| zfA-`5?#?-=Id|TF_T$w$UcEc_!hz@NiF4<`Id}ZySDq?)^_85v%IkSQo?qtucz(`v z_5A!FUVR?t+`YW&CDHDj=*N?e7k@X}os;-&$&Z)#?i|1TuOH9f=DB+He!N=8^Xqgs zr%HeN@eY-{bNqUzzq@m49nbyvY%8@FPrGw@7wV@<#JQ9H?9PdPJlF9p^SK`{c=f6V z+b2qDKc4T$t2I7KUmnTKeIuEwS0r;cqU(M{Fy+k% zW|SGhGNU7yVUiBOigK9m{F4Q0x~q3nIfQ0A^3%2Y)m%se-QEngkN z-us2HLH$Blrf~?XRT0eG?*ubdb}%zq7R>y8=&~HlYBi_pzXq|)f*__y3u1%9gIKbA z5G%3`V&?UOnEyK^%RH}S@3$(M`C=tK4|>~@-mb1>(&Z&g6}*HQ*(_nolEtiO(_$tK zTg;NHEoO?Nfy_N1kR`VXWQx0sn6zvWQ_722(U*nHD0Lx|#xG>bx(k`=>;k4(w163z zFJRKM`AnHOpC$L7&s2)}tZ3gnrkG4e^LfmuU@nuw=;%(z+c`|yOvi9Kbm=%cn@It5 zw3*F}?#yC}rF1yXqR;P4rb?N~l3iyqWv!V^IyHkC&6~j#rZbr8Q2alwxz2l_F|!jCDQPiM)A)0xzNIxDJ}#+2F9n8JG+OKvia8C{;r zR6$ee>x#}_`Z8&~FH^bDxdxpdn8J*vOkv5*r!YmqWab_^nW=0hGoun8rrhYmiU#{I zNsBHY@@7T8-pr_lH*>!>iK)USF-7-D%;=35bKm5}l81OPm6jJ%X$rum@9U(Q#-3Q~1(hNJoJ?Q-soCs(>!j-uvxiX{vu1rxemZ|oRW$vD1nNh>Btmyn0 zrd%|JNoHeM(c{rfxoR{s>NA?DzK>$=X`|@tcNA0B9>t7KjbzDlMlz%JBbn;n2>Q1l z!IGsB%;@8Amb`s9Q;ZtUlzPKi(UDQCRinX7ad8kc3LM1T%?2^$V;821cVR`nT$tk9KxVXiAd_4NGIs+y zR0EiD1|7z9-0IJgBk8cEM(=mXK-!@EzL~EvSvSvl!t(a=J6;qD2Vp4T0`u?_Ls%e%?X=ur$D;BJ1 zi3LkGw_ws^b7C%5)R`qWp!2hxSn}LX%%~lm z7n(AoC{y~`XUY_>J2LmR9hs_MNBSIgU`pDiDjM5?J|w}) zr8QG6p~H-hN3B?L932j=nDSjqrrgw$xx2JvQiTyS$}pm@Ya>=t#8Iu&dr$n&!$X~+LR^J@86PMQ&yDI zgt<>{!W500Fy*<%Ofj!9Q?{k^+l`nqyb&wv(ulb~Z^%@u8ZwDBWXT^JFvZpe%za1$ zmRyA{@2$_=-RraDn)O)`ZD~u>>oH~XdMx=;T~@TPE;H&_mq~Z)FlAI7`q@y2sfuf} zqBXUdqIYfj8m8^4ZFCH!PHa^=_SU4wqN64q#|)VJGy^6zqvK)?X0(8g_Oy>tSe+Gx zS7)j&)tT~{K68)PXGXF=rX0QE;Bk*jj1M8W6Ju~ z=<8R9sb=Uf_m(=W=!!N|F4ks7owQlGs?p_xRai3Rh>Ge~q5u07OzE#+DnkWRtgV3Z%WvYC{f0#Ttx~@n z3ZGw)PW^Fq=?M4;Mg9k< z+R+hO24&HED0;kyD&ZZJA4{QdDTQkHTS(e(p_=dp($NyA8kInr^%}}6ub}Ao3R3t> zC?6L?Mg3((;tNP0pF=s2j$O~7(xhYjQ>YFeYC3cCSC z(RE0+*P&c>4XV-tsQMN_vGpoF#uZ2->DYT2l0F??`H=E1LD86wnbecNbOB1r%}dJj zP~1HSmDM>Ym!5@Gdsa@$_{>$C8y$-C6-vCptoxLwPR_l4TqeQOlruv=qv&OCc?dg@W|5(l!Q) z712-?M?tcWf@)PH6eST*I!4gf61{$08V2S6!_Heq#nEl+!iBqgfZ*<~LAr5w3-0b3 z0%_bya0o8J2^t_cO|T$A69~cGo#44m-o4K~`;PDL`SqO|y?ol5^Qo%URozP_0^IXH z91NU>!R8SL+t)G_hRqOgk0LDPL%@*zH?VmI!~Q821e+^tU&QQJU_d?q3_0@y19`q+ z0Jb+TaOnkx6nKIG6%UvX+`y0`S6EzKz>sSvFr?TKhF1qLhy_#Uii8>f~uLiS6 z6$~6IgCXfkuvjR9Av^LgoXdd$FhK2Ij=T5Qr%3 zH~u1E$fOV$5+Dc$Xa&H)S3WS{$qNRa@qhu?Ge*FX6AZ!QfSt!|V8DVE3_)c919eQW zbA}NN{GkT}rF1ZRX~B>SYA__53buFOb1-C=0t|Ua4hDptfq^AbFeID=cK#B>^bmpp ze*!S%IX)Qj1s4oB;eY`=Y%tJ*3Bv>i7(hS=0~M$+U!cI`A;IQ`2%8TA7}$gZLt+6i zVEzaKD(^tRCuJHy_Q+JS&r3lM;74CB)S0Th}bpg+NFW`I%y#K11!Cl1|V;xf!E>Ez=@|cuwpC?%*jauL)_9p>oaMf98ntJ zUX%iWdMRKfTnaGffz=ye-yNyN0Z45(AjRDSXoU9w@9KJhiC;ZHG-5AM{;U_q-wW)> z^#Zsiy@0S+FHjxc3-rA01?JxO0tX*@Ve)zb(vDt$d$1Q!nCb=07kdH!on9dI97g-t z3v3`l0Z()&?3*VPNF;&+6y#8#ni>j-GeUu{Y*4_62MQbrLV?%fPyiDI1@h#f0Jka> zfNDVj9Rnz^Wda5KtzcsgP@upS3h;VEfq?)hVEhKw9|{FP5l{dt8VbzCLV@B0DB$`I z3W%gY0i<*&Fp&ubav*nPkfP9H=buMbj>-v=Ql?1MxQ^+Aq_`v4=-KHv*k zAB2;<56GhEgCIQbgRQOlfO+aZK!&yt@{z7j-#|th_H`a$M|;WvKto$rhX4uo{0e}B z6mmS3@DMmyzXO2qxAXxJA?UESqN|mgi@URpw~Y_L0r$iV{#V=D3*dxtH~>fxSTEoM zAVW}LrJ5VHtf!}&rw@SgA8knh6#^qh`#cfcw`h4`2jF_0Qn}4F9_dcmQAjtL_EhK^6f?03QO& z2q17?03pmGFMtU0=Z^zG3_*ZVdI2P`7ECLE0c*+FxO>}c+ju(Icu4}J5WpQEgFK0o z1hAhnoTu#BlM&<)TC68#3fQn0Kncqwf#(odMg@U60tH}%F$K!V%JK+Qqn0=!@j zer{PI4iRo{84h7tFuyG9#|9u-IpH9f-(j>*?xX&gTu!i<62Qi#eZ1Xd{oUO>z5j+3>}GA_1Pe3%e+=pP|1;_Y3;2JI zXuDhe<#U3C{>i<%Ua(E2^r&6_ia~?G+zEhfT)jPgyi_gR6; z1*Q)JLij`oo06@Ar;DG3rwz5Qji(pPSSJ`_@cuRC>gG*tg@!B(vw3J zUA=8Qo!zXQ!1fjnuF~GJuGUU4e8JH1e}~kS|Mn9674%;W>dLU`c)|n`LdgC*wFb;Q zcY7O83umu?i6i>!#s6Z|vT?Qu@cJu~_^&7apOJrwCHadM6E>-?^#m$d#9$L7 z|7(%9jfanotCh{a0jH|%1j9cGj8oUu>mObl>woLead3g@q5Ny`e;Zblfz8(n7IP=q z>GGu1$IC|6ALdh63ukQyJ6H}wg8SbKkkF&Rfs*S~y zTU=mfNk8Gv8%FuR$NzfZe?9R3QxB+X$*Vn0z3FK|1UsHqD6pjx1eO4!r;h)@xkqZ#mp--IJvqvrN(_PJzoo`@V);}1G+L_SseD*G z{aaoe7FRyC)xYahJ(W+l$EQQi*}~52pX&ce{?Gd**!|ny#>&ad$K`)S2~4Pttij)R zTlW7_pkwdgMGgB{P+LDO>ZqTV0XAOLF8}vhPi^IC16yENQ(Fd5d)wPk>pN)4Qvbd2 z|9=ttIlQv)wB`h0>uA*{6&v6GQS`Kq9);C?fL(;Axc!F!3o8#F2QP=GTaXX1hoBD| z{~xl;2iQk=T7Bz*wEy1pe54K#&ISQM*aZRb&pGH^@FbUSV7_$@lI5yCK^8`~NKUpv zTgX9+MdWyX-i$ibK$&RHAA&qTA|-^3^=>U^?oX51I8HT}SU`pH_YZLIlI_MMJaC&) zz4dJt8yXtUeyZHYO&X@_jRXje#ma6!mMS%;^m$$he{(w~R@`@In_rlpSN+&PTYEGW zWi3e7W#47b7}nmQw9?&gl74^xQ8H({QI?lo-msy%L6etrTk*Y4TodVsb4^|)5{G2$ zKV73$uahz&;FB1H9MW)7Dh@$f9CnSKUFFG!f+)<>9j&qoCw9pUv)mlXo;fA`bO333 zzi@X%EM0KDe~!hu0YW>2>9*{rOfIrVS`+Ks4Qn`HN>TSG^0al5dSQZdo*mIlo=O9a zFKnyeku9LtXfh^lr`iHW&4lKU8%Ho$gyG>gSyz! z^8f=1o?6tJw<+|JiyPFLU(U89Ei?@7TPZlJdc`RpEn?S`C)dOC_n{j1o~F{e$o_DfYoMIy!_ zGnY!oK9&ipzIF}8sc*}ClX*f$tiXf}j4Exlyd5h`QM`eW3S?WOa$S8c6f_T=m3rGS zzm^z?LCSAtoUS|7zod7K!JWeNt@;bH47v`$n zry<+A{y#~dr{(Vkq6$ga4R~0w_$vVVlw8yuHQu38NaZfPh6|R;K_>j?y0ncQc!MQU zuLlB1e8WNjGNowAa1o6qf+ly9QL}QBaZ|0V&_!zCf;hwniJ4CDgu(%7iVDurKZCf% z@U=|SQFB{0vVoxuMndC!8O7b4B6djPuksWicC^tk zzC|Qi4%9Kx0u*p&t$t)axJ)D;$e%&KbQv-;vKhM8~y=sJ3!U`PM;5!NOd5Ij%MD%ak~KWryLYgX9Y)$ zvy>C?Qr?*(XW!X@@mMK8WdZj+3z4mB>s|owkw!f+Ga`R_n0KEyR4WRxRj}Gxl-3}u zu`7p$_G-86HG1Inx=Gg%H_N1yM^N&Y=|CYSSb7m$#)Y0as`v_H zP7K~iK9KYp4S-i2O(`-yX~vyRMmy5aY-KPHrYsb?Q}&CIOU4Xf&fVxZPuzX`I!7f$ zx++3=!=!!UdcasEUE55R;8l@>#x9n-z;M4Vhe{tR8ez&s8LG1CgYs8 zcTOLxtet>V1gDy#G{@utja^&L`srJH3^o*;cUm;JR)N2xxAx+5?1uhO!h?C$lg5nn zO#H8YGicP5u!fwJ6l=PU?__Tta&TIjt}JaL@fB0x<02c4L0lla?>B_JSih_C77@1| z6?d#jpLNMNw`&RDYsyN4_IB$-7p;-GAjwj{W0T}9O>Fg(RQkzihgM6f8>R^>e;=n~ z_$!hJXI9*RMKw<`IAa@kxOe;Tv)&;U-=BC0!}XXLe8iu78%m=6?dmn!i)ZK6>+t() zaE&PY$`@g`vv-K`NF9faZ>)RBXRa{HAH8E_YC&oxU)?eV;B~4zgunUyJsw%eACT!$3Z@*T1U`-Nb&!3) zAUz_*VE8j}H^1C`b`adVtqmU*rTUJ`lmcF&ht~8@{9-7{o#J}?xT#&)`PJ+~O9IIr zwg6|+m-SR=!-+d1!^G~M$180$*09_(%ONhP zDVXYN<9eg%_v6a_PLm&lRM78NzgK3LCQk+LMScZe-tO4n9sWKxbv>q_dfxHk_3ypw z#Zy1H^2gQQn>^sG+3#22-JZGK-Q95@Ki{$oRmZD9%)VEh=dRmzEsb}3cfinQ|76s3 z9^~w7wtcDhx0{(u_v;6Ixyz$5PL=Wf#ls9YZ0yA4jsUNV+e0=IrQq3f5h{@;Ketzb z%blW2!2zva9S?i0esvE=cYYI}j!ip*121mJs)PnwOv$4&dVJXN3J-(BiA$fTEyo4#DxA}Xa?LV2LZ&3-bo+VLVg5zh!=K19w}Vr>6|OD6u8XY-I;!<;GLgq9k>8rN~ZR&2B49Ykf< zRH7?SwN0Y0Y7KSE+Kg70^~PT*iseU&%H!X)i$tUlW)rW0CQR;9!W~BnuY!vfUJ~U} z7MQt5tF6qkIm)=BHP;yRbl;<^_{Bn&5g`I3#dgmlPuog;Hn7Z&r0>4?a_Vva>CsOc zA}j|pd9r*-LG9O#mb#rpsMBb$zbyK!=vD~UP3L(_RK4ISp|;wyq;A>`@@_L2-%aNK z$(hkpT6z=S7A{;c?kFzFy5^dSIJXnerX9X@DL)fb$@g%al zpir9NN5(mOq)Ko-K`Zirpf2#SnwY2KbmWvW?MknSo?G7?Vn+1~C_@2PM3jHM-0~96 z{}WzwyI#@}lK6uh^@}oa$KH30V#>Z@fMMKdj*Umrw!Sp0D;y(I$ z@2*+z+LMfSpB-V8hjYD`!_;eIk{)#h*!ZI;JJ!h;T zy@TFrT3fF7r@_2Zw&ZGzwyzQ{;C9L4wrbo>W;K&B{k2m2uc|3Hk{}X#%orrOgzTu=g0W9>E1A57uy2VZ zuEYiwd!~;h%I=go49b-2NoLBjD&jl4AYlw#iD#>zqLSYOYb77jPk+?vsdY{JXbFFmuy3+eS3)3QRf_G}(mcTWOJ(>2V z>mr7f|704H>2eSUYEE=Xd0POzCh;mIzDid^@&tyoZprXobRH^ch95*}CT?X_qF%Ps zj5AQ5$*%JpuHiO!7jygzU%*(Sxt;sY22xi(=lB$@tmWK?DzAGJ=MSwE_*q8OOCztC z{eOM;>kl%-iNIbr;(mOIArD_V;IYl@>_@VMcq`R;+1rJSkC^sIxob`NGb@l>P!s zLdy$t!Q!#}qWI$#zD@NeeVp@BVscDge9E9_79rWO-_l@(HeAc+P}Bp)hSi}TP{YB5 zbv-+SK5uEGM!^uBqGt>@qjH~V>DO&|tVR4q>~4s6+l?dR;J%uU8t~oDDK;!@ELtTO zo+L5!qBd7Wl%cTSeIXXrVsR6ws)<}SvQnmO=Hko{(hw8T3n~$DbLo`o`NTB$qrXSX zhH!jw<-W(d@;s~LG`BWxL@{zhRnrgO*)WD;ak{C2{4H;)Jg3I9mo|tSYfQ+7J?}Ee zAWl1`vlNz2?P*X$k*`V&zvOh&9Tu;A6$Yzzo!!TaB+$&b%dtdNJG+?iC0tHQ0im_? zgXy&hTj<*T@74B(oF+lDNe;Mg7VtRgaSKMm26EqDd@WO)?;c?!@HwcW0wS$*NxTbP z1SA>`<)W4Zor9W`6yz2A#2i2Kt4jBYh%wY|U8991$m z?_r5+5TnWA+9W&YKJj2`>6pL1yLKmG=ho2Y?&Ez^<#Dv_@E-;2ySM*h@!t6o(XYRo>f!Oj{R|x%CE2+ zHhU*o0Kb()sD_G<&M{~;Lz|&OC~p<&AWgG|JtCr1v?2!j7~N!Ep%$eN*S5ccr4<(Z zDnGZda~$VQf(J*j<0~!9&-cWaODbF-v7)3a9V=$@w}KF^=8K9321e;O7fLMB2$LRf zuq7*b{5<-P+{PQDraOi%_GX3liG72O#d0U|Ba0~BH!0I5{$_%xJkvFI8eGQ|Dm5_! zOS)f9y`=8Sm1{4}y~~cv=Zqb;I>UPXBHV_N4)uPL={?Fwvf9sKp4|*W6X(;KUK&Dd z_g`z?<3_vBFk4r!TQmv{ z4dssXYx&I<^OqzHA`5O%OD}TxoP5x1V+oypfaG?8mmBKxlFd*u*U)XDr65bu-7X-{l6v*@8YYoFxe z5K}a>bjwev6^o{5y>+Bl?Ah3&MZZ?wH&fre)cLkeM`|DutOF$=ohY%Up%!c7W+&8p zkv{TS&wRy)V9PS{If4eB`LK~(TMThxm-)o6@2TDsvwgNW!!&=2GCUb>e!=HC&tcarjq>j#@g~nm~kj%Xeq@VlCG=D76;uT`m{p8hsqX6xAphSr9u*rxUZX(aY6S;(o81idIu8V)rH6cG~lCNK3(D!?OHcDzXbG z8dtrtaj!X#)gjs}nYkuncbXo4-y%A7su&k1siuBbgfyYxw>u)mGX#!i36$uB6i~K- zP#(HxvR<6<+_w-VK{{(F{Njkj^NU)p;7a{yVWa5ms&XW;gX=aSiEb$k?4V+w;2#N{ zF$fxw+e>9m%U>#k;3&~uF)l1=%fey}DT?{c_j}+ca!u(l_*DN;bA7eeRBH%`?JBks zt{hUdRcvNMRh=I&c%l5@92uM5hl3{@=W)iMd2VXA?MKRY6t?FV!cs{z5K+VRLsU>n z@RR-A+^~d=y5oyT1T*is8A#|u#Iq&mFQ2f;OH69absmY!$qA!lu7yyRrg!#-V$c?B z!)1IuE%QH3bdsf+2xa;D%vta1|7Jg$r%lV8oHyY!?vCU;5siJ5hBa~%jU(l&anWi2 zWoGd3);PI<K#|4_q`?mrtTzKtN3~752tC3BmGHEaTPJBcg2-mloyc8W4M@;9r;o{LQ2y<3 zmv8W?gt2!d8HxV#XW>65aeWc7WHP)zFP^*0o_?7z<*`^Aay)6LlvYQ8|G0pvpKuk` z%_w$s`2GjL@w|p5_H~&7g@h6Liy##5!0(@woEJ!0CCC>`-s9}n^ILb1HrHj89{o!ZdD4PUnd1F*xyMmaBy%%5gG~Kc z9Ryb@@5a|-YM$My=Hpvj@k%YWO=aE>DonTIoy~kp2p`Jx>Rf-CaGP@d2|84$-gtTM zeZy}v4Efe%J~SQ_U9M`NR+1p#9h$CpL}WF<4Jv*=XdR8-=~BRlvw4q$@Cmsy3jUJ- z`~p$S^}bp`$k+6<*L3@*V*{8qhq{h0dy-ktP%EYtD5fW)$M6Sk$`BJwu1t@+l#Ch! z0#cS#o}YS#UzHBrTvWfIfiMgsh%%?|BucPSp*s!I91&0|@|`?mTZR*-d5)nNZHK46 z2}XcC6ZqqJ=l!ziyYbxT*CT9N)RQSzsShtIhFUf(Kf7}+&ge>AR1tDS9J^f`*ofaS z&PR~r)1obURTF`QV+k^V<~(;=R|i_{L56`Q z_2&rIj`&5lfL;z%Glpm!^NMMMLuf~7M=3NE{q75G+rmhqBLas{SYRoKa!0ONi^~Dl zUUiVwyNR3&yxdK;o-RJ>TcuciF|O0q{zI$Bzz?ZG&6DCXRy6>xPw1#FIOC%$R!EFL zpKV4NRj{~7w}b>_TVBeZ{Z{Dbr!6*!n#zaKa1S$q{(x9-U1qMcdbfAqtLLz}a|`JD z=oTJqoWtah`Ul)m9_r zB`yxibJE`6??W&47q)R05iaJfHwETz8u5&O92X1 zQMN#tTGjlB+EgScc#Qo=>sj>j#lZEAzT9@l&nd#~T_HIl_L94`s@g9)EJ+_yFG^S| zhnnkxdt+vfj<~_UY;A0`#J|3O*?PyzYjaERd+(-fG5E!}`R1aZmtVl3exBd-Si`H0 zp5T+5FBOapHcE&(!-XF(JyC@)dri6!aEGQHr|g2+wrJ%V51PzxL(o(9usWaiK5oB<)k6bRBMS6b#_#&%crC|ttk0!G_ ziMO2D7g5XkzA!2ho>TUlPFi^Bv_YibY@|4`RKgjbnV~Kvs6-87WEeHU$L#GdG?+5lqgpR zl&S^V_y#_W@%Gx!2je2wW{ zd;?X#-Z~dmDH84Yk;qj{LV;`qmYH9fppX;lGanDl#G>?+@_u(aO@=sT9sx=+@4fd+ zTcosai9ME2;%$>!@O4(9X%-UROVDGE;4uHS4*Y1Vq*+hSqkxLH8dM7#vEO5Ey@v*q z+SFe$mR8kBscx$EX>&Y|uoA7a8&ffS9OeB85W#yeAgM!&tjEnBZV(21aCh#+DJv1o zYmhIePG2m=^Kg?WjNRVTVUV(qmxi+DY&weBB7{NttWx8`;cXvNUp%Wi`*=Q5H$i#-!IYlk4{epHGg%x$5f1(Y z@pJiv+{pHwDpk}Ru>?d$u4eH$f>j*^ao()rcQ}c{d%{uhN#`s~!Sy`(+w=ZKK{KhC z0A08pJ%O4;j(clEx)t8-s?7Tk%WhUMsbgq*kN45h0W-r4aD6?m zU+=|C7U9W`e@RH|PM((^x0xz%yc7Wc@nb70^Jvx^gf;?Dd1rCF;UDc_=>PGVF*1>r;~mbd3@%HD6e8p=n^saS#boD)7Sp`hEG?KlncBg%Rp|`(ur<6FT#?qMMqUwZ8$}Ct;4cfxYBvS@oY~nUkU3` z=^#%3WATXc%U1P8J>A!iRT}GTP@YK;1Dmv1()Y0a#FF%{a3!v16pK=bSLvo0^je^sKpitfV(=ce0GcKwTq ze60BEr6tOtIyJu)qblDjD=F_7P9D%KIQn)8kMhl6ZW>))zj8SWN_fqOp8=~ssM~Q= z#~*O+-joik$!j`U<7d^)Od_%5+yBJW62-!l!)anG&wcNhbEa3j$|qiAnLTu|HUEtH zhx3YZdBKKKYbGeibDdt_duswDCe<7$_@GcwD2V{M>CD#Tq8?K*<5>y@bhhxCDzw3)rZCH0#s zolw@Bk{opW-_((O%X#T^OYpoI8uFNS8V!r$%LY4eOjvC?Awy$^n@d;4p9}C6w|^;I zJ`xP(IdiOfIek3&eonXQpU@`bcY*W<`0UzFsx8AZ z-wB-?7g=N}Ah!^D0?2H&3_!EUy&c7OF>lBA^A_8T<11O{R!*=%6A}l$h6{hyGAX&t z{H#$@r8omF!u(-Jj`W8RSEZP6?Sjth>Fq`_%J$W)ir;%)nPsK7Bc}>=+Bk$Ixe-$b zKcf0d!c2@UMvWD{)ZgCy+#-u|u$IjmQ$#{}B|^ng7@Ts}&SH}!G(#osP=pXUHYdevsW_StYUc$! ztF)Ho$!;5yN<+8pV;#StlOCu{GPO+dowvCXv+GhO1x>OX2)P)BFV}b<2gO`%C|%@U zE}I$W=isa`e-^%X7y-4?%#$4b3`24lYG0-6V8=U24Cp(>G7@_!&LpP5-;gp5UWjy{ z`Qh`Cd*0RNeT-y_i;0a@!5hDe^wOT^X~@kWLFz-3(-)px>FM3~4vd09`GPT=rvkTM zRvjK7zE{;Twgh{x3tdY4hf85A=~O$qaj~*K8d(ZjCueePNEmKvhfDms$zx$|@gPk9 zb`2##{&SL0{5K}p#}G>44VfS#hrRCxhxFk2E8~zO0$ROHS7XoA=TedY20|C9)3=jm zT_Pn1F6_lgysx9-m99=Bqi{19S-QK(6D49;wgfu61*OxnvwGtM+UP(~7=DtX;ykkq zw!QCe#VH^$rK`W3qJ0x|7kMFcyQ(XItV118KaYASSz7C2Z8iyBO9H^&93S{1nQSs1++ z)8TBTo3<5*Ahj#~S7cxmQ}(9ZR5DW`(~ogQ4~9-J7m7b&YFAFFBDs=j!M8uZMEaz5 zo$NS#DkJK7l5FnoYs?$J`cXi<7jGPoVYKE)>(Yc?ftL|lVFHNR?+!I*letV+)rUWb zT4hiC6BbLrtE*FM_aL7WTf1+vK_XsK#D?xgffLU3%ZS=mW^YpX-!}*9CXJi7%HeGI zwBWt$m!gdipXUcITADrbaRUM-Okdj^d*B>I4j?-X;ljN^Oc|V9XIsK-4awAd_V~c_ z(i?YFelE^_t@N9|WkoBehjYNGS6(w3{1}(6Xz8yp99xXH+`2SL7Ha4Q;}(-L!Qta^ zTlz`lz4|BDRX)6QZ9e7LlOGVonr3?{zhP-0>UPJPVw@t~HY33~zv?zcp!0Em73AxF z{kYNbJMhn%|HAE>G&^!n-mTDdYGo633li*;o>(hdn% zi!%#(6d~oD7wgGZ^ScO2;%LP?;S75!C2^--gxa zxj+AoInSlz#BK4F(#C2kYlc)s_(boUjPvd~UxO>N0P-WxtSmtdy`g>r2W;QGUHwL! z%5Def(izWNPAld$t%~6!?yOo8eNUTHePK}{_FnrEzth%>7-_N6w7bo=Hv!zjBEl~6 zX})$$QTilyop9VZhb5=V#bdl8q#o-T8w(rVkNFjy{Si%rZ_ZR3?#N6YE?G<-W_^in zZ}%H-E5F_*hs?$PNpF(NYz*F4bXuB+U$kO;bJFnAr zy=9xr3mJFCIhy)#E&s(c;*Q|&_7wZ#ewQHu5J^_XyBH=6UqO+K64i0D{DwEhV9K1F z%Qd$Xp>7vyKf2$pZpq^(d6C~NR1a|V_S`6)TE=m^Ga$lAD$^EyH`{rQs`=B5#&@a`;Td-!G(%8jLoKK zHY=Pfe&Caq@RIw+sd&o=$=)2k(LB!ZuL!I1V+yMZt1_!a={BV;5`6(`HqQH{E@pnf zEoqxFtSfgz5;adkwKcf3%hIu9aygFURL4YGI$gYtGu@l{mVI0%kjImuReg0W*3cWC9p8~x zK3~hV?(TtcJ*hGw$|h~@3HVIJx#=O1zdNRx;5JC4K{@nDXx{b2k6lmTVkbG8U**>y zP1uXry}EdlJ0HpUrJ%~0xy(Z2dl^AlDJ1ypQoBBDCk8Ab*Dn{&E7o0Jbr4h^l+2`Y?w$*U#MP{IySe?pw8ACqTWSUdGxj#boAz`BVu`r7t zov`UJH87hjmDLzyWSs}*N4ON%H2aoj!pP3-eeX-O-7S(ePU>C$$=8R>iOFkWmNFq) z{h1}{EiqnnwwesE{XJWUM@Orz-fCNxvghndL`-Y42dSka=lqd}Usdr&%;vLm@Z<+u z5?_V(w+e9Oe+X}NUo9uTFZ)QC$vP<3o5_Gc^XY{FIqs$KjxU}6jDh+OH;K|TcV4{i zD=bFORV}5DSjot=jxjG;;d9R_e%-l7we%v0>lx9n4-XGm-JlYSI6T~q2(`PlP}Es_ zWvV4AeIaN2a>b|1U#r7S79-pGLM?TbIh!n*@=J02L#SWpq~}o_jn?X#bLS5x`$FCZ z|6$vH_bL^)xCWz*KDEQt9p+$5POIM->h}ak2IzUrt+pukGk>TR@8`JKT!>E2jd6-} zgHDplB;XYl=vCR1J=0&8WOqqx%34HNWW{zABs9+SMr@(!_5b9#>l12;tk}E7>8h&` z5Wov;8y^*=sG?lLy}><AwA{f2VwGv`t8q-)N4Nv%rmmE1EyM;x zEj6|a-{EJ(-$%WvV*PPq%X6_tq|-3lb`X7gA8c(U<>F_SxIQdPx`m|mBPs{S+ah#e z5_Py-1!GE3@s*a?%x!8S`I(9G??A zi`_;1pNITPXKE>ms9VbtJyl_D%9h`smu1hGLXXohs$bkjSxalA9xssS-}j9BQ75fW z*vyNM@5%6I>vx}BpZM|5F|e~%Pbv54ma$6VN$OO+n#xv-0yUxin9O@}mo;=}O6Ph$ zC=sy_!*qYhuu$ay_2{DT9dmxED&?0x#u@%B<_TnU-_UupU@x|?AyJwd>m$kKxF7pn zkIq}x%7I5&iOr1O3GqXF{Rh6{u9wIprY%1Yk*M^)$_mH39iY}4DcSa`3*TeB`J=I6 z?=~hm!CMmK1@X-BnvyxOqV#2DC4&y}{UkFTel5et=BPCypu4*m+g7%#Dp*kw7iOS& zSt2^&SqVr9d%s^0NGd^C`@uSw$UZ`zxPC8tbg zF!YlUMe^>#)+~*d7v#Xrh&L6Q*J{UZ!YyZ`SLjyBee*o2k(FY)PbHu;s5ggmK-l4Y z$MbSEa$+^_mupxGdkt-0Bkxbl7ng&)IgP?^v(XqTW?00ugQ|7R(hRDfeXBUm~$maflI~Px3{eiA<9aqJKtrrYsq=ZcZ#2l z>Ttod{BY~-SCfw^N|AxuNEcTiL8N_e z?IWtyk9X4$`m^wl8JDfisAHGa@esl<5=sca=KVmQe2r)YB%f-}x(pJXsR%>}1mw7i ztYE~rGb4&rE!ShdOgD??8p)x+Ql=b-W1@|$CJhDZ&Nq*$7Olz6x+H@?pFjMjKncmt z<{ccB&SX@>Pk0Aa5Ivw2R86DOekLv%KcJE{=QlhQEff1KkwAn?rG7rtLo z3%-o()u9s+ZBIqf7pk^VSwkNP&?SEZKUvl#PfM16pH-+Z#{Dh@B7#~1w>6)F1ZR7v z$URy;VZH7iVp3w^rW2A;Z$a!`tOQT+jMK8ZBJWEU@{F4nyogZUnC+dR;F```)!&Sz zGeoI_<+0axX5Zq*X2~f(5;V%gag$ixyS(IYf$OYo4H*)@-EDqf*Wc1T6IIX|MHg5U z+Q7W*ApS+OK(#*Gox9|RhEO-Qn2nmDze`T(8M?WJJ3%h%?~tN5IE$`WyXf)^VwlP9j-qd&F8(Cd0_5$m)uGRlS}hu%>g1F||VymASz< z3N0L-y?C$lOZ+(@vHJ>|j^iV3{lPFSFjqBb?66(*U?1aD}}4i zudpX`yN4Q*2DIUJDUGk9{K6-b;KUY<%3EIyW6fH)n8#Mlo)+Rql;_IDHb<3^TpHiz zG<=fBFeOh+C~-`g-a>d*vs@a1YN$e0F9B=_M>^n0jfLI7LKZu&mC=W$X>210MPT7MbsQAT zTm%tn6n)mak$?F?#&th4K?434DP@uo>$Cvxai29Oxw-5_F1dQ zi~CJ!vZdZTF`BSqJX?s&mS6!_*yEii?|VHp5~Pkh#3zkgP%RtC^h+2s&Kx(WT zWQ7Bl*H5i>+~b)v7eawHp&^XIB#ig%w>nVY34;blg9*AVU*=%U+%95nlZ|Q19Y0q~ zxGRY<{_Q>>&{N|Z#APmsXh<3Ro)kw@NlExd8`G7jBrx~q{v0864o<6{j7P*b9sh8 zn}8QwE0q-aqsTxr^4Q~)GBu8aMf^jozrJi%kil;rS2tC1%L7WX4)d;0Zz)3I*gU5ZTOU? z1wMMtKCNS!fhVGW*@D9O%%-I&mHz++(*XpR}<`yO2(U{(W39RNF5Yp^%OAQG3aLe ze10sSCK|D$;Dm8Wf2-ecjGM(dY~BzOokoHNEN}dd|`8t~6qG#S7*WVu?DT}z??p5^Hh05!}7wBy? zdXWjg?&Axo+B6Gx-on+I%%R>!fPI;>$i1^&cV8%Pnpt3HG3*(nPTTsyaMdT4MnNpHDJWSSmd{1d=25G7MvqThi+ouss z)%#W96k%ppn9ZY~xUl``j5%qf%P6}@SY&dRO10_VRbamK@9N=%sS7glZe*H zK(5OVGxAJ-+E%)7=6rtpyf`N_EM)HVQWn_XptU9sk!EM1v`_p!;k-a`e|`Eb#o)e9 z%Guzhih`EohyiwUbF@6Z$Viy~mdab@7&-#d)mIN%`@AGsp=>o>##wJVq2Gz|1*x!q z>zZZMK`hyiMbe5lgfe3d3fs~{8`J{vST^PAye(SJ*{;BpLv5^_D?3{is)b~P#65ZI zhK!0uUA6~}zNQc2EaPXxCHYvnUSUVrmDInExCT(pK2jIPwo7GxfTH>wJX*e#r%htT zV(wS{-kq(KA-mL?X-!Kd`7GgCjGdwA2#v(5pnxpi@*NvX+o$iZLM1$Mkf-z&a!Xi3 zh`&5)QoR=}lE_f8&n;UUsx=`br4EF%3 z)mKHP9CHq&)*XBnWlnUjJpS9S?F#6lio*$t{_OMvKjZ)vm8qD?4?n7N&9gd$m6M8` zJVY&Z4udQ;7^sZn-l8>tNZ8~d+1|t`An$)L9(VsVb#tQj@}pv7k%eLW`AY^_ z*BQL%MXU*ZU&F~y?n&a24X#PyFJ!PbJ%D-ttNKq1D+NS{vTFYaMnJj0S_hWH*KYui zl>V1x<-zk8ynbGF@?T0|ZH~|FMRQc@dO;PED7G0Bwm~ZD(fJ zuJwk8r{X-sP?CMq!8ifhv>c4?R4W=ex)Vx7%%lDyoArUKYhx_fuNYh%_@?|* zHhB>MZ6?3`>X0e@k!Q5Jj@OfKYae0z-=WA!{5Vc|4PL9dh_7gJ7n$OaL~!RhNjE8` zOIJRjoUoF7(TUcz=ac?+aixPFuj4t)*S|Oes6*A@IeY1F$X(;4qkejRr|xhuRy4Uj z{1)GY^&8M5;bxHlc@8EMXT!9l%|p2V^rTm02cUD4GJ!yPLKB}aPXJXB?K3&CWR88D`Z? z9J!8#o>sibgjqU8(tiSTM=4=aWpzczUh(*l+Q>T;46M%=g>9)2QJ&PJlIty#nVt$4Ls9^l=E z25kE1ovjfgm-=gjS2D`X`_O7dEb*g&P0&|eq}^k{Yp$LX6Q>igT~9n%HoqSsw${u8 z4A2!WQ$~X5>=^$J_ZFzVJ-nweVNtx?`lVfQ;jGEp7DZX#1)co{DTNtY;Y2z|H!^EF zJWkiwp50P*rs7sdcpa0RD%9*sV>`g5y`7yM zxaZ&Gi+MWFCyyO+kx!X#Mdt&IKr?J(7~8vYd5aDsI*se@&d%2{J_~)`Qq0|Y&v$CP zg%Rhi81clH*x86xm~{}4TQlY4GpX*XBOBFVu46C9T8CLBjj48osf;^_aYTR7W^IK?V1;8=LnYSC%xVyYW5s1PS@mq^xm`-@s2gq>>WH zkbi@-N2B2rP8_e5XNvfO?e|3leF0PsNgM^KH252XLPUr|GM*MsfQB_9(CSfY<-Ese z@Frq%awDgMpsW7wCyhq*JdYt4uys*&EIe=dM&(-~-8z1_jH4v)sJ~}A7Qz;{5YWt| zp1f|n!OU=vN|pUMn>#*)NO?>&B{`W}=wq*JY#QFE^ao4eN!fKlxTmT=B~E3SZ?Ceq z0;_5t4^&;!@y6C@Rc%-|ulmZBlvQat-O6j0I3Q-LNKr_GHncIEE+4bXsUz=G zX=15;L4Mpy7H!b~w z9YyxoTocGVvXkUMUpT}*hPEx|e*hQnz#~aPJAo@IhNhXLT{aFR2$kU0iYJ{d6A_jTC)nS(&PxJoB`g6r8xw<9y=^wIQ&dqPPJ}0U zi@I$W%PA!RZn$4`@lA|mExFV;x%xVv+aK;z&Gz*sjeh4ePm!yr7fMV>agI(`+W??A z{xq)^t`UF+z@Ac11mG*BV}q09Rp#mWlrJGhV z#e~G7^at3|Ma7n^sFugLm9;WsQO`Gm)pbMjSoLO8Ik0HM2GUCQow!;-B^RkQzfrLP z8(O8(Op^VA(hQa&4~pqg>~d#MBVk@G5R6c%C?zr$sH`S0VJxSd_WmuDga@(i@OTKO zX=QLFA?qZ+o<=(gXodBl3Acnlnp%(gkk7Lb%_cGaO-5uO+dXX`-K1TSiOBqv`+@fu z`gH1oI!6SB1ECxMst`N2V*D8!X+DVa?41{-e_*gd%fh)x@w2;ZV$_@b!zbDwIq+qN z-Zmm+Ek;IBA4wHDSJ^G{P_4%MpL)Mi+Sk+TvxCF;C+EH2`xnQ9j|W%1-}S-pJ01*s zzY~W39?m9(>Wye?xcAy=1RXSdmqq7vUQ^dJR}aJsAFI$|p4}#Y!0sLa)wS8B7TPnM zqR}aqPG)zYq)s%2ut5w$$f=B}tK5#)4_KT&11Fj>ez<-I_e4^9ln}e-;uV=m7~pO| zI6}*qtF4vaB2&y<$$x zQ?j|O$Y#*tu)ip0#xG8?GQPu{qcvcju10J|+^{1${%f)ui5uHYj#SW0ZJfu}Uvvmi z@R8X>hfwMyltqEePfWmuAhed8`VLi~5de4Nrk--QP~G(l!4~AeVt3mht1wg^cP3$} z40Z|w%jDYLuoIH)*o+?xhaOwv_&0`>hD}nq?TD}Bhkozs`rzHg)xhH@*SlHTsyHOF ziPFYTsf_@GK_!s3%$GDTofU-5E&!&jas76De%JH74AfIkSRb*n?(_>6Te*bJWe&Nz zlZrg|cz`W!7ZYrUa_)CLW+WpeEN9b&(=r>aVej2N@lRW1Z5S!mcbY!Z`oh_;eu?Ga zrvBg%iXPzqr2VM?B9o>?z5ZOUU&RP@JY*Wcj{F9Kc*-=vm2Q1CkhxB>VoKNek=4LO zXS%iPRsErqm;+Dh`o3=K;u!j~J z@q8|$9&j1hOxJ$Zh%3x{z9EAXgV}$fpL~dK!(C)6gbPfjhO58U>P>>mSJ~fnW&QOj zhCEcE5q1g_X^@aVl5J2$?{joQU+*UW^}i20jkc=6*Y=hf(Dngp5tobXw`^Xv{b_(z zu)T+?zMbEwv=KX2kqRFfICYzlqE)F}d5v#F%hwO@ zV9$+OTw^OS@*~qxIJT$ib;~X6t-P2A^Hn2XW%J@i7Q0RbA{R|S-B7-CO0mfdp;gI* zi|pZN8O}eT#$4bD2T$0Bg&w?9R|@;M_AYRh-RH^}cccT3!UZm$rVH)Zi}oQR6hJG7 zWvl1?k94?G1*GMoC|$s+(`<~f$i$C*hcfG^?ntBE)4`8t6i3lQ8u{0n_It+-T@n~A zixs?(X~!EV4uR8*_%Hq<2D*hul01;atNJ^3q%ChMB%nI7ReNYV`7#`hvlnQ~h-B7Y z$8EeT$t%eqSWZx^TKp8c8g{&^jO%$iQgK;KxMm#>xph3C7*ESWr(02BT`AJjmQJHR zut~H5CdGtiYxAQ%+kXvItYgfNtOLQ?Sqo#1w{H5&;SkQgb}~YTYIG9S6xgHGsHZ|g zt@Kq5=WRw&s=Dg$K4nznwB*Nq;AYQH&QAs>2d5|f-cd;1>J-YVDWSBBMhn*;-F8j+ zm3C7WfmXyOM^Y=5Un|0svuIgl_i~kp0!K``UKSn?X%PftqPYxS9kR#7EFdE;!(*n? zq(SEiPTBv!A9j6eQ$Mfk#Mis3rLXsr=GQNq0q3Lo)aGuqsdc#--57GJxRt6P={YtU zRF$nGw$0Sm4$-%EgEt2y=DotuM-O)ylfLmi^;gY(#O|$)1(Qy0T?g4vgE_IA67 zuSIE)xs6_0_xT7hZ-&|Ms)aU6B#_5jqMILy^seil?|L~f+(oc=KsSETXh?1o#u@_rnR^^W*5cz>Z&&acIL zIJ|~rwWDbq3^`4PcV&g91`b7fGDhNO;|Oi~D;-xplXkU~>Yx?2-+n46AUK1=^YS6M zzbvPQ>F&Bey@;qQBCl$iEa*Gi&E+Dk%0IHnC>q`-^A&$jTB_ZlMwH_6pI# zW-?SEgLN`P6(xVhAsUhoC(v`~Qwz;*=wgJf{U1(#m-;3sAOsa?b#Qx)4@DW?c^F1l z*%&;*Agcptf|>3Rq#7rHVcTkzzewvXrJeqyENAgt-%bL_RzsVbHnupUY+q?w^}X(_ z53MVWZxy_=Y=B8f*lKAk`oZWT$-}K>O_5=Rd~FC@B>T^<-S`F(C^3WPEic4`n+h$m zeemP+A1H~xoRi{iD-y$rY`~AoWEB;Nfn8ye0+m-XMflMvcX?+XAzdRfMn^1O6E(-C z!b$-)${7bVb!OHwtH032J`O5g>xz)!mZbWxy&GIy87E;f(Ce`H) zWv~HM%;5?)$?j2LLf&fHoJvD++tM&AxSe47T;Xdk2_Mt_|49b5V@jGyk`+u}4C8kZ zrs>2P!2WT_3H*XNU6xfgUb>>S%_yk~coUM+En`s~Bc@67#I68;(=118j7zz~6uPs{tvb0*Xuy1<;5XIP?ZzQscO;>W&Ued`#N#R3cV*{v%Y zMW*GdY?PszZ2@y5tc?C8@?EN24+@3q(I>LL=Ib{R$LjEeiJUpBE>Hh@EGT}~u%WNN z3>*3yPVgMvS@;ee+Bk)#sL(NM^okX}MT<(b+EfYaRT6qM`sDt!G7GkngjdWq$Lh+x28**4phcondy>(XCs)D&67zcq~W2Y85TnN>dTIN%YDUS zRi_cKh=!b{wLE%!-{&d_oJt)J{2{sj#29dYd7p4g4;V_INk*#1MAH6@QOdQ4RP*C6 zM_0YGi=TS{j!y6{6mrh+bcPXy<~a;+;`%i;z4q5rVpCe>F`?`zK2=sR7#-m8X|=Zy zfnIA)Kb%_NChone0D z`@cr?yr?qmU`pbDKMkZa^2iw2w@G^;>xpjjJmaGsILvn1iaLHawB4sb!%D5%9&PV# zDZ4mwv*yzL{JD^KeaPei{v^n~3H@v}(7V#&)s}CVt5(}(q=FOcUiqMR&9}OKg*Dmv zylcMI&6SH_rx}WEQFFq4(@hC9R44ar`Uw|wPhuHhbX)w+ZG{j1#9)Ll`+sjt;{->Vt{BOkP|CIvzTEi?#-a=y>d`V4tX0WULWJ9ig+t@JYMhC=)TY zEL$8{NqLC5i%JBU2Mpt-K+CFDTd$3OfJZ$Y9S>k91Sd;pM4!{;r+kv5@&gCXF^&BA z7|}WQ!_LrRB`7i0mMzn4k>dA|q?SkhF8Oo77w^6u-y693ybT3a9jc0(5^c%$!eS)G z9;q^9c87EUbxOx4r@i-Z!#?d@k)d(FcX)Ar)L#PvkFPoigOjt~n(BO7Ab>`1lFsnD zq*&;xu}SB5YnndnogSWi*rfAy5C*-o%ih(&;KS8sW7y>jpv9(dlfNB$bShl@gR4rd zK75NW|Bu2FZlB+FAM z8kYDOHK=4nSzW8|VNe$u$a%Ig2`!g0M zr%GWBPz}qXfQ>Iq{}LrpW!@m;Ci8_&WWo`N7gaVHCrTx+!<=9!n-`HtA5L1}HM;uM zi6Ta@C*fk!#eb}h1Z8t7@=HrOZ1b-zf5FJ^<45Jg!M)Joe26acNSNW73*S3zx8(Bb zU{RG#AKsVnXQU{z=c&|xe{p?@4qR~FVEB#`c+ubLV9=I#k0(d1#y_%od79m4lj~7B z|3kU_qC#DarE_xR*bjJ)4na%;D55%vi7E5>qdLWQl}wiw8VOF1OW8u*ZuVPmaDCQ0 z=);+hwl%@Iq9HR|dQspiDKX9>X4)jDzwbcHh=nCGo3s>{mFUVpYM+pMN+#b-hnLBP_WP zChEEO^hSxD=UUTgU(NL!@41Tdffcq zwk7lqI|9H>Lk2yJFBLwB&#he+e@gjjSATipbec+alQ}mHZ!(8&AHKj8+HvE|3B-)g zThr+BN{oC9Rl~VplePzI1;Vz=rSOA6h7tqlku?{&&y=I( zBgNVH`A7V}4wEd_@{bNahHeF_+Kr`e5|D9}Ptm5yAi;K1`tpX5DL%@~8)MZTH8zU0XwP7K!OUGMnfsz(d) zl73$d-n+BD9`78}a@mI`P_IFWlqZV$)_@bq#5C)u3s0a#Bn7Q|Ox7kUxN%5_cdUgA zFN#Mv8U3LQjtiAzMTH5UaT~+|v*{y_!jg!&KAJ;dheGWV3u8=M@6HH@qEHYGLuML3 z1II-lg(&~A%(DgDdbg9k?_YLyzpEGY+diT+uC{-y)|pMpx@_fI7>`imy|hAkEVJG| z*7odu`2bA#oHFR3DJQ4i(ZoA9X>Ectm}9|L@oNQs5G5jq_X@p4w4z#^h4j z-A2*`=}*lRj-v)+_*p-HwG)9KK0gEx9VOsq@NqXP{EYYrPV5;hfUYL-@`F>&08lXI zA6azqhu7EV7w5e-$cUd+sX<~H-Q<*Q*b_coKD#fWiN`bga&SE8U43>}!hB)RnHD%? zKf5O{>w6L!M9U9vSc!n{iLQN}>PBz`BRDxfJmqjaY-zH37+;{R4W|q}4=B-xbG|0X ziDIbiCY%8LDknk6tgIptFhijK7aHe{fL%;5O@>X2%!cE14(II6g~?!r(-4`jl`3pE zcBP)U;paJL1b)`jIV!b^*5QXpKhe2Rau5Yrk7!R$85-fXT}Cdt_MUY1W16GMq&tC8 zmJZ9q&2(=CPSq?%3niFPJx=Yjl`9e1ncgE~C${n$duXn)!gPJ%Ij~@C?(%ky3AME= zDJcfvst;ufJ~q|uS6Tgzb5v9Yt_!MFxa77;0FUwyr=Dh`5>H}eSbWcHvgP`vtz@p7 z3E$mNF~ZC=2fb%T+N6ViZG6a8mL% zzsa(K369YQDfcH19PI%&5?c~2SRW9+ZcmkKa4HC!wboXx2>W46fxKq=E^&lqbqZ`k z1vZ$}7m3H3<6c$pe)i(R;HyQsNGC_xWRV81V2|h;-cZ;B6lE@1X3`_js8H^3pUop= zMV>~q$5~5Jdj?$%uCETxE^Drmc0^CRPvOT78R{O~R4pOMMRlG59nAx;o0Q`Lf>9T2 zO(XiVCgACq+=5Z=zd+&FEOZ~ReUHb-T<^5*p`|H2cYx;PP{SAUIli<{o6IU!tH z5oMTV_EP(Og~c){tIo}>D-Y;qCm9?iA_}k_0$X#Q8KI zp*Rnza9TWy>l$g5g8CtWuru((e&mwq@&qwXB?K8JV_oU&UN4H`(BhixwFGK82h}qv zN8OpG1thomy>kDL?OWsK)}S7ebGoW*mb0D&(9-|c|2{-tifD2C=ZZWTxZt zXKy@aw=pOUFf<-V<)2N~D?9+7gYvt8wZ+hS3uc36PJAYJv*2v$z2f7uMlw7$R$$C# zz+9|ye}98~C7=?8=dF+!;a1y1xgTl^$jz4o1&DW?YG zbH0v24ObHFizqPzGtIQiWdtc)2fD$diysHzTGuT!O6pjWdU#SS>X?#kWLG3wXf8$B zVE55i1*R%UXb>Svgh^7_z6u$4Hbu0M`WQ@W(xuwOLLr&3y_vLmMup=Zlq3C&xn}O5 zK^;A6qaLb|_nHw#c-_N?5)5g2M6s>-oicja7h9m_zEN}6@a`Gd28_;UEC@i z+#226+N-!LM4IVPr*~e8KjYM@nbBvTV9%ao?=PUPF{3jV=;*+(e=!j6S*pzn(HKfK3@WjA1OGd8!>C}@EFU? zH83VF-tbU)rnbI!e)O+n7w|MbWTipS210*ZbW`E~x_-t@PF5IstYM|1Bq=ieKm2v| z{G*u>X+7i1+_HxhT7Y9gh2oz0)S*GR5lqEcUP%4nU2ogCBMz=24o(|R`kGDsiSo6i zb4>+F`0wFr!$)k#m$-&}#!euTZN(Rqs9>Sud{9pUn{l0HK^lSs3#&wYBG)*F>PRL) zchILyh6G-fkpPo2tjNc(F84u01axX%{?tWt@s#pirNh7Ln7<|FK+04m7DV7zZZMsg z?YT5PXOwf?dz%Rbh9mPHU&`s0jD?R6JcXy%HlK%+ddn@^3j&V#mHFE@PP!Er1S`Ot zXVEz9o~R&5{-E0O6HFPl$!Vc3d}v*3oYu91#k^WxHbC@2&gvt>D$xg!tqV(#(U;yTn^QbR+lYM5DbX#9?oQNBp z=%6w4bRU_vX+>0f5k+7hjbfak(luvYs8_| zVQI}^zTU-q1h)M39!AT%uJ7X}V-Ao#?0h6t0e_?R#5IC!C`30I|^ov>1j2-L>0 zl7%g;{g(?Rxh9O{-ezGW)iD@^kKA*km}7l*eIdf0-8;+y>hg6uFjb>6#mMqh9NHqM zbr2dD4Yq=KX>Qw{PA48a-7BXLp=irqUE4a}95(5O>k4k}7xt<0o-*w}H}vAD-Yt|a z(d7zLPc4!jYn4{Qu^>tVVS1T;egCO$1y%H!q7#nTbx+C8;1F-=Z@E% zEG~Aqvxdf%v~i8^pCpB31*Els>12U1UKTic*aAx|1GsUsoG5ZoBwig{_+qYeN5f6v zhrh;R4p4W|D2XNTq;yDU_$1X~7PMiejv2vE(FK$#5r!zPXh_SdlMFB;L{9Ign(4a> z)X5|~n3Yu+=T|wCBf~f-&(awVIH;ja;2fYpRzx3rP{P+6GS2z>)xl;Sw5VX)ezRP8-42AA>oXw5DgvvW4586{J8(? z_0_?f9f~wq|IQctJQ4cd3g|Io{hBeltMB@(Um_E9db85 zr8H5O7e^mXdos%!`acTPBC`v)=1%Q=@m!G8wk4xL;gGgtgmWmj|mG`H898*euBDs zD_?>2N2Sbz%3cu_-lpMLr=H!ACzs*Z5eu9s1+kTlq6wmbz_Bg8C>D9vEWi)-Q3U?a zSOGN=X3WqKZXP>*S~|5=%i`2t#}CMGkCLV#xLEp|y2P9;+YE|}?5u3D8sGjwAOzXHrphcTVNnG$|{izC#7Q3CTWHEd$(L59_IxtXF6iXLg3bi zBlD_sqUdj{_#`6ZS)|njIQ?xDozJzxyFvpGx*L+EJ<(zt00qxIqd_B=vIx*h#17F0 zNj~6k;xTB(i4MwofFi8$mTh1mCXf2q4JIF%P7g1bnptstTvK5?*EzrzHt5WS zgFS#=wvBdjN;s&7n9vyKq#L+N+3?HA0ai>)xJ)ts&oviDpP2qQPj5Ir&2C$t%4g}P ze476wgN?k%Ci(5%Vhgiuq>nc?iVX+PP_cd-mRKejIm-)zMB`UOX>BDsO-!KYbm|7Z zSY3W?v=tw#{H)HLUot=!szWeraoP$5L_a88*^-qC>{Wl z>DHX`IO{_awZCOmOx6{Ub3Qva&F;C>?Z0mU#wJb zFpmXBe8Gi;tDMS)pv0RjtT-J9$*flb3o0F5IdL0`?EZ51SQ&-d{ zY+_I@tV2AI@K%SEtXPZ)TREfboqIdUlqRoAXqR^k!n+5D$r?#pbvauYz0X3$Q*I_X zN|C)z{H>Px2q95+RO+B`95*O|8u`ezp+R^lHGgWQnTd;79?G@UTKQV>0i|SwnQ>~t z(bn3-Ro~hY!>6A6vl?DU_2%3UBI)=iQVk-=(~S>g#KUYgIh-Q}lkh>|LE;#PU64-bU5b z&0$xceMtkunFe4Viu~_O`hht!T6HB?q;uM+cNef%9_DZv-M}b}1Ki>Y&=o@6@auXO zw;{pL$}6TwA-BDqsLXELm}c0^PKfTtJg+y|1h%0{`Iceyp%IHoPV7#i9ho3J>O5X% zWPacC%CTCrWlTZ76`+s{-@mh!SB-kvXSWkkq#=MgvoS<+|g?HChbQ za16+_2$7x{=V6MHm%K83%N|L0Qc6NjPVH&Zyu|R<6=nSyWksHinyR*8e;ob@DxoRB zl(K$Oh$=-Xb4-s=Vw&<;cPL9It0G1zAnDrga3;YV&qWDfE0xCj^OYg%O$4(W*I|fV+e}#1M`5{ zb@lqR6_^Fy-y8z5&4;LW@j8bjW=|rL%md{+U^QJi-=RwMa+}U?C@eSMSNr;XnBnW;I&*;cV`YiMx{g*ct+JteQ!cI8H&Fr1#gl!YSP#jeePZg0 zQXSRE$!L-(H4Og&3XTo7;gpr#5DALH=0rlR*t+7Fj%c=8^zWA7&u@?`A6+cNgvq&* zS5h7TwtB*BAs-L=*O>j|`rzHg)gTtU*sBy8tN?_IpXyh=ne<6w!yy7WI%y0o)W+x) zLp;yX?vG50*eHs!V`~#I9(*VHh+c1$O@Z@T5tHi~hoKpi4)LKU^du@g@1yvO$2gmp z3r~&;Q)>&L%Gb&3ptNdmmK117H_FFA1zCkr`o3~Al*3^@20qa$1J%Jv(HhC4+sT9_ zZt0}=X@;xZPDc94`4OCdLy4NiPF-cQEQP(LzPJG!umH4S%9!s?v9&3-oqC6-#cged zY&)P!Gre3hk|Mh$;MN-qu$mwOSEsbLP$C+)Bpl_Bp9ca|MLKAyld@P1LhPl_|xH~nnrdT@ux z1QmVVW{7A!AncBIoQm|Ft@(h}RGWl%2yF2Cbe^NFNu~BlXsl#~*D7RH1r=)t~{BE8wJ)ke+*sb#7>Z7h7@?v5~a8lW=?n;x{8E zxQ7SUFcWxB6u{`KcY=5Cb0;f3 z66J@{IC^8OJp~4A@wdu(2#xQ6nY9bq?XK?qm>sUR8Qo~si62#G3B01D$7nNXj})aZ z=|ud1>SRYzY6Q*BdHTQJ1;zn2`^#za_1+eWLwRA^g1km19S{3%>KegaOO9Jl>adzEF5ZT{9?ed%41W$s0=GlppoG;$Zqt zSMWC(+O6(JXyzGqV0-sTi=#bXOeQ%SN{uFzPPK|{m9!av1AwYy2ZU)uyrq9RO9T9@ zu{LKa7^hQPDjP%Z6F_Sv%MD5nEGoj>luuJJDSfF#^U zBM~WiGu~dq3gfZ#@%*U0@iz5P=s4F`CTv0H1t51+r)1#ZiO_Xulh_^$+ClFS!)T!l zSRdCm12}2#ez5*rg6aR3!bc$g`&Z=XGE9OhIq&k=dZ)` zhz_72yd09j(#WOWv2my++TBC%Yz$;VS9_05-vSi0?~D{cltMnB8h^!mY=%;UgPWL>?SIz zIcN-nO#a4VQ(;e`pV7S`FKR&w1pKHOrM(_wl-82Bm|E3jH`@xfe6azF!hS zXC(ne&tqwu7&iVxdMZOH6uinUL#m{pjCbD;iD+Z!SCN;08oJ1EA2zUM{6iy++q}iT zJ+>AYZN9YVKSPv#Vv>;1@t1SwdW!Liy5RMIiwUqrM|@+T7GmrB|k6&ACf zilP_Qrb1$O?rMbkE9K)@kMFrrAvqk;p_F_pGEOztUO^fOZ2~&aNa!67l#_)Q*$_pJ z^T%)nYxNAP<@N@Ea!eI!REc-gf8Vv|6kQMi$526wE`@n#(kpbRM)D=7s;gr2%F@IE!lJtGGucWZ=&D>1e*n7X3g1A1gQvZYCuaBwbQ+9c4j=n{zprKD87d$6QZgSm}M4^VNfW+~;J@ z-U$e)kQ%j0Pi%DeP>kAz5)^UDZHM-ufo!{)58EhMF>hiD6N!x8T_<#?=i^pOPEDt% zH}#x7>c~V-+qGt-!S1LrBXbsa$i~m29MH)5P(4zvrd^USsb@8=rjg+Xcda@rG{PGr zqNLf!PP7we{@2*5acU=Yi#$xH**xXTj(33Ga0wWZ{^slMR;tBpr8@oBJm`TqJkTpb zL5RmsDY@cD*eC!|kct}P6*i)CD=A|$=V63|xwXitKj!owqc~(1jd;RNdoG}G2|Qu0 zy%$%prPa7_i2h`&`6XkW_#@222lQIjp>$N-H@W)J_rdBDtDxU1Lq1Vh#Swa6!k>}i z8?yfUi|gJmgX_W7$&WwwuKHV1XwJjxr|eN;Ma$lebZfC#&S=U`an-Rt%8yOy4m=2t zRoQ*K$C%P2AV45tcmb5Zsd9i6v*kk9uZ^82Yka7(wdGf>MpQhbKhhlFqdEZaXwZR| z779*0yaRqXymT4v2YR`23T}rccx1`gkm3VA0M#)_WA2yZ#X~yJBvv%nV6|7rH`$Dp z2@reTDTX0%)5*e^yceU16X1ui7TVBjbqwdSdFj6}LQ1b!eJ6+s;7w4G;yCpOW;0!% zoRdG5`y{+g*33cRzE*=mx@H80yyf?fFax+r2Jm=mkIkTKTuhHZ z*3;DHE(@Pq-DZ6~nzFE>>PUS(oI$6WH`!2Q@M+OC-x7+_uFIfwl7?htRid2v`i(MW z!jh;!Yfv(s#0A4c+z1X8Wk6iZg+ycC2oRQJI8?_4Ls+vB7@9l;Ber3@e52!)Frn{t zA$pCXxUu8>j@uQpEvnQeCM(g$_ZO!NATm3TD+JM6`DcBLzJonLsTR?Rrep`M|DU5_&~0jdE+0n2o? z6@4@V+?Ig0z74ASvQv}?;}5_P({bk>g14 zv*%az;=&8i0wI#4ku6#r2L=Q+xZ%qLsgZQPU^Iay*)bp*Mg!!~8~4ZVa41)tgNhjj`A5QftRw{XY+S?smqtDzj9hGbkWc*eh{8xCdh88 zI&-CtOc(Iz&YV1EPU082dT{=TX@``E?7_dnL~KnhzP6%nFE{nJrdI!o9w6%4B-2Ji_PFA2zawy9H8hCX!1WYb1*Y@Q~Ihr$Hyx3+h- z_{>m$wYRpm?$rK1>#tDyUCKwXFv$OT5UDi^VBH*aI!%e%r^?UdS zM!T=wluHWDgRpq$T|j(-8wR?=RC-}xQlrByX|09?=5Fz|0?wsE>;lCQ49ukN9eV!ZssU>iKL;OykeCUW~ zmIiZvrynw~6QQaWh~Oxl83!t_H+(kCrz6Pnwou~qZsge>j2J70Dk1MC@T?Ev!G*;9J^FP}2UCohF zi6kxI4zC7E4`-8Qi38EtgQuQGsD;4w0k#az zQ~U?$U$C@O%Q*n`wk8RKraR+aP=h z9vmT_6+M7-gk~tr?hf7e1@6GnjEN6{@GwlWH+K?oSd|Hn9KG|NeK=5%myk4WqH)_i!3UH(-UkR|J}DR z=Fh59oD~!kQJb3@jN&zMQ5i0`9rGUA93#GRYh(JW@PFz-no*6_Zmn?USbq z?75QM5bo60_x2thmik1Hk|@L_kzYk1f`)|e29=%jVU&HGVfvL%o`K;VbTYxM;S=VR z{I3 z4l*6q6@z%Kb8q;SEIwWt6)K*YcB;=7?aD=kWXoMjV58=2=CvX;XJ8%ciE;%RcRR?v7& z`gMr(isxV7uP(3{dXiin4Nq|P<6ZIh6J3r(4^>V_G2}R2N6~Cz5;F2bTrI>Y#!^$s zEeKB2@WT;%j$Gj2T;iX|!pciyHGmjPO|?>Rc% z%6u)DL{jZbyfW>Izd>Lo6b&=A_0+s?G7f)&eXIso@>mNj5kPwr6{?_3V~zT?^we|e z=Cmy4PGn_GbdchinC%*XC3MX*tLb1>pY2p~iSR@DN zB{FhxkQn$IRV!Xa!c>%Kd&@p?t}Q^}b|<^&kYs(9nIuM)lzm-*{mW0di_~q2n+cmL zFGc23bt;-nLnx#`wW9H6J~cwF5`LGXj0@LEck-)8a0I#r=}{k3oJ!Bsi})}F@8NOr zFtvGxnqkPs@jg)d0`m+_mQ)x}!5Q6lAXrzUDdd;x{CZr22Q z2V-}8lRiu0ZvbL@>2iNH98x=Qo!0Vj+T^Qmf{{!_)KO^Fu_zaJ0;ffw-4|YZE7wsTb71i~1vg5FJ`ReOEXM}-3 zSd~W2f^eGG1eR62u}-QdmoiNTI9W+cfT#PzT_7h!nL+ir)l9k*Ae%>BU9O1il1)l6 zf;EuGCXZW$NNQ;$Y6Tk~LR`u5K9)(@b-%il1cyKW8q3DUgKze?{JJlFpt$u`w#LVhCphCafGj|2>- zN$WPhxdktt&Z|Es6Rdo)y|qEsHth}H9W9`$vZ+$QJ{9PwRGNBZHl`czrNTE2mGle? z^1Vw;VKyl3=Qq!1GqB_nP>k&a4GX%Pi^V?I?>96G62|#ByG}9aO>NX~U%h(y$_+Nt zRh4YfH%EMH6}B#dJ$~bDqXOI%gS@iE0HRCVNy%S#&0nBG=0CR@&Z`r3b34q-MY(JL z1kWHlBEH+5Tmu07y`{l!NZX8wJYiziR@NZy->Z`czFT-S=2I*auz1um^Xn*?n=Z%E zWCa|*8%t0iC$(;7f-$)=xed&(UnFHgQh82T0!9=9Loo0}IT$}b0f{;C45><2ppKty z*U*TiF(i3mKvyi=L>9O(jSHrU11)*z4^=OY_xf2Xf6^l zv{of<7F59fgN?1)4WM#ISDwr3(ZR0u*E>JE=*Ojj%DAQ{1~1tPaqz7-Nx=)QnQT7S z=S4G-0x%a6{zu9MDxR^Q7cX%1LDGHAwZVkq3W(m%m@Fp)*)L~zTKn65PmTJD)+_ez zeLd)_v*mOMH8B>UfdNN-X)q(pV|imrOt!?TXjp*K@~7FhfyZ_n(r=su7xtCUKXR#={1)JwQfL%4tcWRgz^nXl+avF`;mAUFwI~e_>>bf6+acUAC zDljttVO%UnHQ;G6UQV*pbaAVAgsq)>bTK?nu)OnRVyt%{H&HaOse41ecS1qG8g%PQ ziOlA!QzOLrMYGi|>f*x58t7ocK7HT2JnTYJyJ7d}6ykK&z+eIyP0I0>k-aPgdN<4z`{0T$)At)XF z1gUi}g^yA9`EH{jri7Js^-^q!lIO^|qQXrNF@^T-aJyzPsuP zCh>OsRyV#hxeKtAe$N3;kKB!*{-VbR^WqLrq<@pI@NRL^ttb+9!14=pX@&gFr@c$q zE`f*}!ydL=2ZNTDz%j>xyUu_JUvV8sbX>eDPa2va^~l#iikXSaa}{pl)RlYB23kew z62x^egB`J}|A>;9>f|VgT>veBW1;ud{GP75usrJEaKK(cmn8J<$}dsy;qd(~ChDoq z2I{Jh&9orS(SBL0AKP4XrzJw{^2#n??X6Yb!obW=q=a^UxH$EH?q8k``n~>;?{=3z z4)*&ebs!@+*^5!yQ(#E+RFa3(i+}F@3as}B4@Z^0Be1(?7URq`??*Uqe?~`E)C*hNHx1S(2^j$IXzl8`8A_%EoVuvRZzr!}YLptP6>k+)e z9X|!;zlx+{@Gb7dJ@73Y-Y#Jy)mpQt$;m7Wokei9C5$RF0lfX>z8o|5+Fb<=@$Cx9 z>x4YxmFP9Wgxn>)_slj09?fT%78t^pvoJ)l_A6No40-~?L3C}|fVrm8shJuxFXeXo zB1WiYiM^;!o|$xbvY$vc!P`x8eC4sCjPf`g-Oi`rypH*5An_n5Q4NaH#Ke)9nAtl* zNM|KQNn>+p4FYGNe0rZzIlFu`FUnCdgZ$%aB_Gujh%?uT@WurPy6Om8)~6>nsXVKF zjiVS9rZMKFD=I4GoRGvePYw=*W8a+04=$)IIOR|<99E&ss^~Gdhso;}rB)j;U!!oW zxHu`9{D0g*1&~L@Sky;h=C}D|L+VOKH&a(@E*GFQLzS%Y4pjQ0+#jJ&-LbdY^Q~3sJu;il@vQGNSRK|76AX>g zRYH<0AcO=dL9%o*Cf?`ctPtgyQY@9IB&l=^m3z5byyCA=y_h-X(T5%GM8I>0$6n0f zO^YedN7fOIbu4Y-Xnhv7dG} zSDO8*4(5LWqu&=jT>o=e?f$Fo`Tt~wzcmyOlZm*^{i{mbPvdn+sD>QJ@;8QD&}K{istyp@sGEwZPUJBwx`ZY;-L;OsSyLKaXoRf#J$OG z@~Iw9@RqA+tO}Y!m17uW^%TYC;COg`c5&1{KEE8Ech7~2vpOED5`Oi&Hh!vlaDGJ9 zN)}I1W`4+CB+9IFmZf9A*(V$fcDelpt3M9JVEy>Rs?kQ{6hC#ej!J0taI2(Pmw@|N zL8*p(t9E6;3T%~EuTD-$A#I*?`ek>73 z03-`@6RK5@^2zCZ!?eV5336D1imbjgWJ($2rY1!apdY*>#Icd-6uQ(eE_!wTTf6=yG2Sd?AVT!6ZI3LOQOLG`?3%tCp0%F!hF#QPof- z!TWE0AI+?|4ymV=a_2LWd1SFHsKkmI*4TT!7b!PI1nKQ*hLyREI?Rc$O)-SJ$VGO5?3x`Mmm!jf)IapkNi} zk_k>*u~1q_thVt_-NEi;5AtbV-g=4Q{aIJ?^E!0INWzr*JE;O|#%T1Bl+*S-lwEk2^K-#d!3BbasyAK8d_*Zp3T*yx4;yAjjFJJ_G%vQA7DCK z6D6s?ix-`%Ev6&=d3fGGLS-ggbk#e9!}FlX`~C6$kWKx&@(adM>TbY)r+cqZ0l6Lw zN=wL_0q%}FmJCd4kv1lSaQhmwb^3UJSNt8vU6?Absjg$mLaovb`5fq6m0+A-H`9zJ4}XiMO7haKI64BzluGbk$EOku!F11m_&Kto`$|re-V+yqin|6S z$rc)}-Q5W6P2PX#w+F6qN+96k1QRq{Xd0*#We%K*weTOGk08aqPwz8|*o6H)4Ty?nupTsqm5lL)!Q43W#3 zO?mL5`J!pc1dqYHsLa6Mase(qCXq6gK#tx?9*X$~lMW|`GE9*5YgRzUOf>>f%%>l~ zF9=J5P6}{af}<|i&PfK|EjoLakXurZm&5&=xWnEks>igI>|1-cEEmEFOC3%3d9f_P z1DRzPt+^!t{E;dz<5^nrb(LLOJI}ftIv3c@hFfY4C;}n;}Q z=vJ4Y3RF>r#Dq5tlR?3NJaQ6DXc$+f{e3n*%l^KEOZ0C1aT3c|D27eR6nN~XTqYwN zy6$>>p3ZNwFty=I&9we70KhmTJ?*LP=Gjl(k9k>>zqmrV>%lt6=jEaXRFx7p99pkH z-|_PTxG`6n$foTyOAg7DG^Y7jVPOCi(;9=Mc0WK---|yONmtVSz?E z#Nn@|VUoJ2iz%LM8&N|_f|KeE2~|$`ReXg71FMFVyvUzLYt=05!Of^7hxmD+`n=N{ zU4K<1h9%#b(KOC7xT>cQ9Z2bWF?_@Adc_@E%giEn$K#!u`t?!_km!`fieNZub=#(A z2N;o(fe5OFlPRy6Y>)q`X5v;-t}+Um#(;ah25LEezny$Nx@)xEnyw*2B}NBQW8<0o z)JTrAhagX;XweC+uC9qSID<%#HFgtd-NE;iZm}rO`^DLGw%Ld%R8eE-7+!-o0wAF))DQn^5 zD{MmwtI*au!k|e|N(pA!o=6Jqy2T9Y?mZ92$RX&Th_C}Lp|n1(>@~28P5lrDLPy8? zyviIXLjDDAeUN|5#=q#(mu%Qd-lmg4M#ITKjel(NDOTgq)1=e5%s9Wj1mxW{B4Dmj zP234izh7{@K*;%Mb7v*3YyQevv!3;ot4UV&#LQ$Ck_41gXD5T0hT(blhyLX+m))NR zLq2i}?~DY|@sQHz8I%}|_{Z!z=c_(jD+q+n)?e-P3rDfm@fUgRH+}R~A^W#*^i>nW zZ{X;w7~Hy?JW40-*~b+Ui+f(T)1GuGF2fHrfm*JITUJZ*v#iVm9>;84h_2i<#{sz^Qnejs4b)H|e1 zH=;w%)2@~Fw;6aBhes11O20T4VA!5Zo(6DZ#SomYQW1jg>Z0MbXtmX0@8n9IhFc+4 zzKcr!s```$#|>5tjDu@j0j~)s)MkO_+0C zp@{`>ihoeD=8KZRjc(KFO*V$tLBFeT2I2p^N|;P1V&FQhzfcyN%(xFJI42hn8f*2f z*Pww)ZnCMaiphbaI7r|uwvvQ1=BnSSGRM7;@YJAO*UN5(TOIN$2IUHHEksol%ZHaf*F-2z@lHB-{fgi&DG;^ zpg*1W&yKr?G`o-)*J6AjgFnUU`&`LQJRdH94RR`%5>V{CCp0mUH7SEdsdO+SW!#{5 zb~5apoc7tNtYU@A=n|FHYZM!|d{Y*3usGRxu>!??7sVmRcPzo|4>U%@ZAKk%wFK7hml0OKb}q)c(08Ld~Kg)%l!#x8`8=MxoK6KIR`?C_8FCo;^- zHBj^Ok|`BZxy@y#3Em9AjC;3zRp%Jdq1 ztXiN1z-L&g{yOQ7)J09#YrFyh6I zo`=ym(&;-Q9|kHOGcMp>yO?A0DZ)M#Tn_gQSY0KlR3V%g6DMJZqTAup>%4DRp4$VDuyI&eL(vWu z`L`Y@npq)$V7{CI|5XP}uA~)T_B^^a&8&HcHt$u03AyA&B@t0%Ck3@S7;5?;l2(Ey;>S zPrGCUW&reius{Qkce&r;c#0f6AxjG=4$eY%t&#OB;lfBR%h}C5MRiUpXejYuw~&!4 zAto3IGEy&zJ9JX&a^UvWC=afD6@#X;zl-->p&;_IvkIk0P5RplNgEDGKuv`+Sd1cS z9Gxn+8(TetZX6X}k?39IE%jVc_~g>{=H0*_U-4<>2mXk$bD z4OJoPiBAJl(n7B5oQfRI7fI~~Uk4;vb3AmL_zd-|Lmh#IEuEk|WG4|Yq`I>W1DHHv zU~sK#I)ZJBMIAh!O057hNp>P~Mv?@%SA?&ORf@#*^i6dLoMq)qUFZ}DuU?+)F;6$zUZ8}*HXAe$i zVC$Vm6!NFCn#w7{=X3c`C4I;7AjP;(Mg)AcQkNV%I8Z9S>&x7cKE#p7sBF(I1{ic+zLx4H*ShW)Jb_dN^0G0HkXlNnY3j*mAUk z5_Br8Uugr5a47(>K2+Ckeg251mk`o9@a(&VHlm{A5FQfCe1=*|h&csj z&VEYtmXZ$~4H1jXQ|1G69Uco`i<$A+$;;U!g@QYLaE#S33-&$2k;uoSq*3!wk{Q5R zf^7lCWlR!_Q|T@gw>-J-jz-J7<%E5=<_SK;m_7p_KX>Z2U39u`=FpHhCiLWJ0PdJDaf`M>|u zY5!~hIYD=zv%8se9Er-1&6s!_k!oXCCSRD1BM?KNo4Uda_{lxY!O_vk@(@~`XW=zw z-9@SqYb#{dr|VY5tZS90`r{FL^jZs_RmxWVi7H*?j$ff_l^-yxtjTF2(_dH2<0LIl zl1X^c`e4DKQMi zGfJx_N=B-HnvRsLq2$VTG5|4S`XSU}g3AKSQNTC;j6My%EwP$*nu!%7LoA{1ww7Dy z8mQwU0jjo}YO7Qk*F60CYZp->$b%O?t4~IK%kveEH)cEZilsW9UMD+MC!3L?Q8b_D zEc7A4zr^(_LBWljQ{5gwz9b&V0w|~uP}x)@6VM7^OprEUKR%?7A;MRSW=X5^I@|rB zJ2*~SdT7()VWR_Kyd$H32Ge2CXH0BZ#CG#%9HvpVdlO25E9#uEbi|j7aW(=+_y^Q# zVA)E(7vyCJZ#~=&t)j4d==RVVE=wiEk8ymh@aP4xKA&Q642G~dNF@)0IfR+1>q>Tg z4VeM&!GA}IGE?E9#C?;U9@xU1G}8H1KJlv+>Rz!3D6`#;DPITn`nC|8^hQ;L1~kAf zM^pF0RLsK*6C`yC85?xWcKz!DE``MNeVu$(?nYlt{9W31+iZ%P4~}p>D3{%XbNIX0 zYZDahEtO9j9zikd$@9BMEz0GG=?gD zfvyoRCPnd~OrQd! zK-~`2cn-RU!+u=0UGKgukhtE3b@4eDR`@)FEvDkiQyoF{6v@i)aNy2_Z?zu*kRnN~ zI7-Eyk9nRviEWu4H$1i#C+k3Z8PqF*L^IH#xlN6M7-idpUG7*&|4S76$oVQ2u2Fld z{u?=*?H~@GeavyTwM#`1t=zem^)0pmoD*qL%(}2I)vRK6J9mMeM9X%eKY<`Y8~{Jk z3W5yKxhCzbx9_9dqw-X-o}p0T;!f+YEAIo$ABm&@^+PTEVk(Jjs*`mJuMgW;t&_WJ z>YQLcVP68`{f)_(n+Dsd`m~AC*f6#UD!AKcURcCzVjME)1qn0QElrN4HYRFT@sb%W zp5f5|c*4$ZdOI4#*-`}7y(1WzW>&ZE4OXQcv zS|5w~GieuujHy$(p0SbBQ=98MjY+>?MFWUL_fi~(;_F&Rc)dlT>BDfu6Y}BcPw;E2 z0Xa-k)3^a?Rh#dB+CS?bo&4BWYju9Ie`0h(l&?Wrzt|(s?{UraE;*)mk=;vM(*WF1 z8cO(f@GHU{oL;$DLN=c(>Xof5L2pDC+BMVqE97s{)--#d{^CX!X>P7up-;sQSopbn zxm~AXyH*;Hs{84V#|{m&-A=?29LwAq!2ljn4qBVf?EHdznDiF9K9l;dVjnY5Ah359 z5)Bl{Eao3X76>$W!u#*leTbAX)BS9cKDMb=?EF5RSnm;CP01~g;+i##YFX44vcEfd ztgs`8C)Q-Laz_K7HM3X+k@Ml4kII{H+U=wS{rSX@cQpp6JWP4hBbKl$zw9O3TU)<% zMDhNi2CwDt0|Q1yoe@YW^XI)fi-!|M=Dn^|uw$3A@XgCGVeX%zwrWr#wsA-!Hx711CzJv!@b49-z5V~OBrWCkW ziTEfYndI?r7@MU@yM!;AUVLzq=A%voJ!^E|DM*0S(}eU^dZsWr3=M1wUl@s$mPJbG zfFZ>27eR4_dfL`q(&HwUZ9qjGNwPdTJ3DSJ$y17FZqFMpG+548iV8PHDB}#2H*|w{ zx-dZL1wFNJ0#v{TX)Znx`9?4~c=^oF76~<6lEKB663K$+Gs|EjaQ_gKLHJ#T zts|rWBakag%hLv+9aewisU7bbM-`)&0Ag%3_;%;VRNW+|3uxWiYA2i9fTAM$g9`(% zwo$0K+B}aHXpyYOSzi`$Eqye_!TU9xyk8(Gfe58kTnBjg!jyi-&~b$g?Vyc0^X}B? zIe+A;(3E)qPSX7PG4S7?sI|sYBidMqz<@>DM0}20pc>4jR52=ghRBrp@=mx8d6x{j zV48MFnfi-)r$U42d1?4g*Xr-1VvJ!0`jsL2F(ncA6`5h^C@{ByXDpyI(A)#HUChQP z|11}BbZs#a>lqs0h=ZVAZPQb_iJ;5Vg03v`QK!ps6(vv6u;3q|=}%O%U!^{Z>3}5a zK3Vx=By@&es|<>5b9jcTpf41yag@$NB2iNH%@j(Y@s>>KAZO`C=M_lI9cD3hc zq$v(}s@F%bS)aO5tF|A6?bPo-bwNk%F*qVYB^QWwv_HB!<0GC>m<57DPD%sm3VfR| zNy$`}Nd3Ya9do`X#S9a><78WThTk{Iw#crOjVMEWLRa{{qxr7g=NiY5{qzb$@yAL8 zTF$L4Hk~A8ius5sx6H4Cu+H<~!5^oaBF>DI3#+aka;v7pjgFiCw^DPsf~%U#P;566 z|7x^C&bCp>*|y2Bs3LB!!zWNVeT&feC%wDsXm*Ln zLVPOyeBqTRVgrgmfkC=3 z*yLe+jiP>n`D`Gv>SkJ~i~p$eD@$h*7gjzCkoF}dv=}=Xg6B7+*lukE>;QgGOoM-G z1Q=7&jk-1+sA*9n_3U(BEJ*c$HRU$G)V?|>M$7Vio{m1aj$d+;w#_B}sTXG4x*9Gq z`B*y{Qz5@L-pCJ79qP~0f-x&w^L_;H&ph>BIvJ5}S$mnwqB?=UBs!=Lsk}!gVp0Vq z{CQ!7V3Ynb9`ot$0EdaVfLK2@W+C12mzQ=NLqLsEJ+;|Ay(^LMUc^{XH*%F+;m>b>Eaf>=jNPEEU z%ji|FI5wPQ)?tj^am!$6z6N&TAEn{2+{FY)^jPCipYD{hrTR!SH+Yk@RV=3m}`<=Jx#CY9=rmoV|qCua0^lp@%a?IwfW`Z&jMLR%2VXYqE6bvBS?I7gf$QibX zDmDBwsH#kdK`^p+ceu6pFY=+&bhPIz>6gv?&?_)=$_Nw&%W3}iWriXFrac55!S!|i zk%Gd;`Hjtk&F*FkN@IGZWDAlrQI14Sl=t~)xr{Q0cu^l3Undn)04Bn@{n`{9s-;b{i1qusWC9V8GuVEYnyx3~M z>^21FiDQM%Y=}D}JGAI(En2h7XJ1^C*t}PT8S(RJ4q-ty^-6LR$pq?%E~GHms9lQ1 z(o3Sh#vtLc2p3VUW4LmOFQPBdD&*;ep0{>vP4qZw1Prwff5UaHSRZ$bhWXfCLUsq94rh}b4hH^X zFTsDQSsS{cwi1SFM6FvFZ zKa}n|Q4rGQ+m!S?gaF4SJU{ExgIFL?)GaX*WmOI_*x?l&jiVbT^+B;YwnfEEPR99`boM$**?#)^dNPZp6>rS7Ko+hV_1) zFBVkH?o`R%=eNZp)L-Zov&T7&Lzx^?P`2-JmMNmxMUVFVLHCE_li~TG_qKm{+CNi2s0+cC+C~5I z?RUNHKOMY!wfV=boo_e4**o&Aya*ca@me1 zDD1A@`6-x@y(ieAVcubpwOusP!^FG>3iX#q{qFEW$-({Ob8}e*YB$0IZ+OkP(8UW+ zq78MJa&MyRU4)=pMJ+pQ_TG4z^fb*CJY_nae^Zg-*JFu;%g32!jt0^ z2&Bh#BZTEA%Lc1@Mj?>5jdP_+4N+_l~ljXSykWP^PFFzZqFoDqUs%<{2Z4BZtqxSHGVeh)1!Kv-r2yF zxsqs%zTcL5!nSBRrLWPpCEX*j?TKo82ZOPh{R%xCtbyXado^67nEzM33+73kbJ)HF zQJ>Vtf0gnzVYfq_7f4C?2!`tjgK0o((^nsfBL4{;iW*~X88q=bV~LF?;vA=aRGJj` zm1`tVMS~1KDVdq9{J~`p!L&@$RjG3~fTYtM*-xs6=mWGv2CaH6s{-7HmH7a9M_I?# zGNZpLAvq^3wZ+5LDTroQYmKATzG+6s@Z_Rb0DOu(!otWCq!GT`?+WrEExvxf-dO06 zfcBbl%-XLz90EZv(SQsUQi)LeQ?2NMR4w+FG;mmSTLY4PR-3_XrNgO#)2X2At+qz8 z^WGq+ydKRS=j_*LBDY|}8IG}f&oR<=6Ws@>P-3I4O08ejGkZ9$)pwnDtKO^wulRxd z9UYL82Ev1w-k*yl6;^`s4mXr!hL?J#DGxX|v~07%svx&;xNH6w3HA?mymW+Mg-3%&=xjfv#Jy*aS)2sXwHt~6PR+^ zwNNh+TsTH|YQLha+EdFuPv6f$J@6E2DaKEyN!o=@~X}NHJ`~>lly7t6JXR9?6`#$18 z`|VILN2)it6uj3FuVm+%QC$q*2wc$44e&sE{=;H=(_OScNaKQ;+dFc@yXZmTB977O z$@2Vol;QjwS&do=4TwL-xDq4=?gtA_12Rx7#0a2DXreTiJZGGOUGm7GE}RpGdle^+ z!CvJUY#CzI)2W@QZja;Ebg*v+;rs+S^5*jt!VLG@ApETR|8_9ePDAU7_7&W^#Z)n# z1!izd6Gds@afO|x&?TB$u)yez-Uj|G0F<6X%73^;?t!tJ2RdeX3xX`SUpeQdt?_0P zryKN$cy~u)6#c{N6;loz`{wB=d$YX0&gLz5v}x~d4CY4oXU|)s@4mBbp+O773wv)K z7g=lP)hqAEoeh)Vz|er{MG3#{MCSz5F|8>MOMM537T6g&t{Zae^gm{!7IfiAN6@Ub zwGr{fh0j@r-C_s2@-(8#7hX(wJHk^F{3>bTpK3aPNdEnHdyC($22UW|`~*&fEiOKH zG+bn}Wcy_x3R@KNYq0GUlH(0uiiTgSX5WU**1)1OSXcu?s5?O$#ya=;6}SK11mnkR z$eYnHrkuAoG}iIkS1-eX1$QOqfhX1r8VA7mwQ74r4B>p1q{w>|O``aFT9g|}6N;Zs zl(YgOz`|4=HMYM~gK8$j{$DSL=iT#*;ZF7Ls}Q9jhM-~9#3fuS9N`HA z{2*utYaQld)SRf3Wi&0ORNp45)d4KMpFx3#CnCz?X9%8yE`D}2cFy|4{`qeiH?;~b zF$6`K%d6llj3dgK;u;4KoZiOZIdtCkkEG}*s2elXRV?v0xC&7Ul}TK@JBWY4>(cM3 z*}g_zcIB2Jla6}5{r8HCme`U0+vy7-wUd|Li~Z9b z#of&y<=5Eje{^3jZPH0Acd4a>@F=?HYxD_e!M0>43%BKVtR++6apAVziM2h*r|Cq| zpWF0LHDCw(Zrhj9w!QrX@w&@dcNonv3?LS-b!3vnYNWNfG-<$9?egpEpq;uf2N%}5 zyP5|*B=ora3lDxx9m-EX|3bwwzKHrnLRSbBDw0u^ibOLYs+W}k2+&{LWcv0hOm=03 z&CYWrYqTUjHBK zX0l%UM%*oh)B=#bhH2rsUAclK4eFrK(mrdau|C`dW*yk3`pbM zu{kOxV_s!h`{h1QDMk8l=jBdv3OPla82EgCH2m)6*WZ1^3Rgu45D}T(%ro_850z%- z+t+vbw1djEXzTQb6`;1CQQ7nD)1x8^cy;2% z&q191d)VTxOfvlv*CTL?3P6=~ENQUNIs4Q;o@+T!wP&E+@jb|aW3lFn z@)z@Bg4NsEyQ;IEZWSH(L2!gfI`G-YzlDf(tN@!iWanL2##bfMi8YN9uwxwaz|v|Yq~FWhPTDT#ej8ZD=%I^^QaSsKS-yI zf34}Nhp$T?y(z=#?O#ov?np=ldBBB`bFAwfxVGycWswyFy3(M?d|x*tI?VDZ#HE@F zqQHg?R}8Q93$eqx<>Pe3JzVC~ciH_UpNfU*Pq8wXN&2-&=a7>J`5>hpz_X30=>PrS zp0ferAl7_x*ZHOm0j5iJ^2-BGh{Z0UgoBrGW4!7q_;J=nbX1L?XPV}x&etv!WTz#G zS4cm%N+^=vP{5UtzwXjMqzsT9irxfp2(1Q-AO+lluC2^QM`rB8y8)iB&Be<))bNM)Eq@>TFY!T<1$g?cbKe<0#4Vw)=36Nz3MDCj5sE zRfG>_)3q4l?4Ulv$-jf<9&=5o7=`N6r)o{vIv;(E73i$;HH7_xN7!Pk*NrEaGLKa_ zJ0q@n{i~4bpEiD>?McUfUFO+h@+ihGo`R20FuzKbF#4Uq%|QV3<^eyud_~-?wfG&C zx`pSZDSRu$w2KlGprsSgY?P5->2`y^9+AD?Z3Ed4?tFnzJv-Hv6T|@2V@0A6pXm1l zZ@g6!t~&QW7@J?MfN3&j#|2OCp}JQ4kIu7laik-b>~nYwh$Ib${B4E!8Xz;FH1c+< zVZO765~_))Q*6#jW-f&Du^dLgs)R3IIn1xH$QsyJ)h!$3 zV(J_({PrdQMZ_@ZKAn8f!S^E)gD0-fDu)$-i1pn!jl};nv2jW^w$HeGn>pKsJZ z-@OvBG}T}Te&o|scirn0QV1zVpsq^WavXxRU^v^~sr!2Sxc#U0cEf!a&)+g>>F;n_ z>1((*7(tM5*YyUowW}^C)2y9r?Br*wx}EIoCi6u(T9gtLOLop&P4<#Kc{~uM z`W}LUSoLbZaF{_rswKrEuw&CsOXNBgDLkTi>LTJxjk~aGk%84afi#0pcfW-ElDYz{ zk@HBC0>$I$L`(R&$O9qM>No%m$Fx1{Q@OBlUh?*`onRh1I%1}f;bEFx=cp^0XERKi zqj)+bOvFK`-ba(7 zT}vnhh8?(_YQ$D#sbSUq6ujuEU@eH#I2eUiP%#nDcr(w(H`!tOn9YSNmq{G!jd^5p zTz5ndiY#&nam`9dK*NQTuufZB#MC)D6AV3B4pVv@YVvSTH8Ig^x{DAgHCb+ zB>#{@@zs0*N=UumKoP2rH+QGT4o^Ehy1GvbJ3)3k^P^RK4m78I|-km1(_Zrf3jqlobP+=NS~ksNiI6L$56Qc%4&k zZBTvX3!S%=Ujg1lOwL_wu6tO0Lc+)V8mW=TKv+TuogLgZ-iE^7qRruhhFM?2uYi34 zXIF&Oqw|rU(k1am2qRuW6h6_Yg*xDhP=U^93$t%wSp$C>R#lWAAxc5rKI=pPW>vv} z!RQJumM2I218zP@X@Q1gBU`iQGds4!Pc?|Io%|C=2E4aoDC@3Aq?~2NXO0YgwBWJ}^NBx^p&$E!HJ;>=R`#^!vx-J~v z%?QW}efk!@=F~J0ne7oi_A)QY#8o0X*Zo%w9W7=b!$bTu`Do+nbi;>X*#HsGPGTBDSqY((p*(a#cW5AZy7>~l&UbOr z?PQYQ<#-7mqkaRDWLt%%L^rUWGb$Y>b)m8ejoej*ho~W!1RZKIn`P;|q>PqIC`M1D zG3vu+b9PN31i0=~=y(gG0v(J{boC~Rkqhm8cT|(h)9?eKC?Fy#HqfZ3sDKc< ziu6&GCZJ+BLV%EvKu9Ro*d@JS?_IHX#ol`_Sg(q`kDd1RXE$BJ>>s^yRc4D3 z-Goz#dJRe35><6uax1b5=vJi}b!QAaq#ImCgrm z?9*Cqh%!?`10pPS~hv#ls=-)grjJuW?|`9xk}wj)h>^=-2uHsz~>W2`R6 zZF&Fa`m*y|^>cPzcON)p>4s^;TOMls>WuK+mya_A3AZiIBtP0eN}F^cyX~y$%@&r9 z-u2+sv9H^kj4WIB`qRvE9$&w2Q-MxjG+8li<@1tm-M#y_8qvFSex{N;DeTkHMa_p# zEQ{dUx^5Y$*qnNF^1#vM?X6q2IQ#0s-9D+Ma~Bo_oSWCNiTjAN_AB>{Kl!Zg$qj`K z`)j_roINKf@1Is4u)E*;yc&*4--lO;7@I*$lyo$SZH+#a)I+w;>|Dxlc{^nB!;%^ivlGB^oS7qp z>&lkj^~@hWha>A?esg{wYI<;|IdR)|ZD=>DMMAS)se0=bog9SMXEFTtK$DzM zKeKc2#P&CO^*NH372Isq(5E)vp1OrrMi*|oGpFuz%N{aefK!imJDT2f;`rB}IP`K^ z>k+k+Zl1~8cDvZAeAvnb+xtErJV2qFQj|G@*=fV31WCo+aIf3WbCVQNEAD8<*zN!G z>W-G%88sWQnssPkdGAicJBJnwFLe77vhLhsJ3Ht4`PQR9&T^|Yo&B`V)J$O9mwvhx zI4RT3>yOBU#CG14BU&F0?QJsnc7v8?^ZRCw%scE{n%7Pj`08q0>L>5``)R>@+Ds}- z?SE+87PWoQL(4Idhgi2ZUpE(DzwqSAtq~8~E_**&FiL5*;_<+eKI;cQah|tZ!oMY1 zd)D!~gL!+qo-I?&kC(M`+A30dKiR)ke}0|&5O1lU{p!8;(jC_Y{C!)-Jzb$Mj;OW& z)B&@y>(_S1n~U$w?;C8}DD*vN-0DxwGU{$#c?piLE4w_c#unMzI<>Zl?yj2u&b`F% zMD)&MHEgye#hQ1Tb?9wIr^?bbFYI>>idegRLi^?`?6RyoML&&I^;}}p z;?)#!SwbJGP5UvU8vO}&k}heYr=#Y5Z#hR@zky}_Qyw3eZ@8J#a4x%~UwK2uwyu*3 zS6kE{Ub}YiQ*%kyFuq??$*hTalcP3VTJP(b8nEcl#F@uF+a1Von;*TPwtVi2f;NY0 zoX9Utism_T*Te<02Ikvpp0DXT)^>kF%H_}oGY1LxD8D~%x8AAQBA;3ZFTZ$v@Hyjc zt*qL$5nesZ@&-T$dMcN48z|1yx zFW`d=W{aD;tPmb_{&V=Dg-7hUtCkNRUZQ?5p(1@FKWACmnsMSyZV#R$kM|5&-u%{` z{Q27?lFBpNdN*p}(tTNJ@aGy`o1O1ZJNv$qn_sl=5dUn6?cwa6n-|{gZMmi+EB0)g z3l8>OnF%ewWPEm?7gI8S>-3GMueO*z_Qa_^>9 zsci$UJ*X)A>>72uc7oZ`IVE2*8S2;BIn8&rA5x=Vh1=s{$EPM=X;pfs)<%`{q~IP6 zwzi7OU%7GSE@|JKp3-$auZ|h_PS@~rt7~WLerV{b+Zj%N{jt=cp zr2AO^)&%p_oswrZUETeOdfJ2Vy_+|??o6_KczJC7*XzP>-nxB|%B%O~%@DhRRyXDQ zou2#|l2dc;a_*Rqyu7-zPqeW~O(=g^-%7huF6v_4b+FykWesPIX;^n4i|c*_j;*Xx;#du_~W?(EfmL_fu} zx-#3w5&kv$y-R*u>&*9xJqrcvtv?Q^B_Gx|XVl2Ab9W64+x~Kg*UnXyX-A&Sn^#hQ z@AW=w2420{AxpkXw6m8(-rQE#VlM1?o>y;7fP67))}Cy)YbmXKK7DVwbo2KRkFH9) zf?*p*=Uypzp1W>=>byE+sea(AfX7F;3&c$Qwem{$2?wIqzg;Yu^KjHa`gOeAG zK6}if#N+DcJ7>(YI(v4tDQM>MWPXxu;JzIt2bRRCI_bK1-o0homsh>gM&)OhA8qyW zoO_v9@0%Cydq3=SdXnpIf433M$3MKI^cq|ch4Uxpx=^ylOto#? zdGf{+++p(`)OE;=@~nT$;kIs;?Kan)eLBRpTo;pC$p{PM)<2p%JB+hy=fyXz8(l6R z^8Pu0-*Ap^gO(#y8&q%AHTO5oSp05Y^ycr(A!#g;-v{lNT+3y?!Ka*U4|E)Obx!hy zB?p*eJyOmMKRK-JG}i|QPR(6?`j0R18`_+@`0eTQsJWlkZnqvcfwMK~rg#_sgZcO? zE}lcSF1zHicf$TBMU|~2x0+TAyn5C4qM5_kfEk$+2h?!)>M%y$_Q@5m>(a7%f~nmf zWNX4o=7xp!8W7X|_FI-p8I-wJ{7LUI8cF48c@87(za6CJ5$+s`< z`Hl|fZ`(dk^xGyrzh+YY+)liHVWpoA_&VJ9(r!@h?o}lpYaOb!>Ds;}oks_i|7lfv z*L_A4`-eLiTLuV23%8t`9Q(2EW4kxcWM9TGyY--EM$R#(hIZJuEp1F?hR|% zySQZSVq3w1cI`S(UcBIb_NyVudyYkPe-|}j`N~cATJ1XAbKIo9V_QiVvd{3&UJwp> z`^B6iS>ZSS&vsWnPRTf3*?h1PY2*f=7Sz<;=Hat?B60ewME+Hp%u4! z7Z(p6eR89MQwXlaIc+sJtx1l|r2xy=VR7L4nW*onKhfp}@}%ocMhdUf1a(f_ie{>!K) z^2T3BS>Jt@u8)cSHl~Kl@%jB`i@U3W4;_HtGc5Jm#E}g#oM7f3M zHM{pqhfK11L5ZgxK6+qUeC4@>;0LT@$$|#LNxO7&;*$y}&dVWxSiFKcPWGjL-WGpv zDtK7q+}^!Adri=;Om~jpX%nnYMl|yuZyDri5qxH^`_!VzqIlu0p4VS5QMIly?m7NT zLiUs$XLnbGa1*nJwssG&4c>I9w;--!dbCx==;8YxExz9Ix#n}^#7pT`<#7VfBT?Sg zZBD;;{bnavY9qW{w6W1{kHW@*3!-eC4&3@S$FIcR;@!?9`&q1;U8cX=?|yXa_{o3x z=U=fIFJ^zf(!6lky8HQ(r{)yEE~O)4`hk*e9lRoqa3v1H-M++B==gtwy~bH+S;OZM6j4`Dc>f zY@Ed(x%1lvNtw^L7yUoI`p(E5Fe{BqJ!dc|ijq(-NI-F^myDWph_0D*`YVP9h7lbH z^vdIi@&+Wbv)O_HVL3UGY%zO4j4+kWACSz+&KV%$2qL0b;hqI)=^+#)r9vn(DV0OL zX3W8MB$T6#!9Q5bisYOEIM8 zbmtb*+89O)^28!ZHb)>%60^me9O@mTzgd+=gQ9$Vy@R|0J-{c(%fr_H^?v2Bg~f-?tuds;2$0o?j02#Ffx~FO1DIXN<(v~X3~F) z^jwN*BwcgkkhCyV9fynR?m)`$p?@bD-f zkBA^|zX+siRG?2dJu9#hw(BRQQYnA(CxCPlNV*9!cGJ;_RVO2qo$2&cDwq`NVk{J5 z3`6NKhtiw1t~yC0yaIy!g8W$?;SrJE9=;LYQ652I{*fM$ELKDW%O@Z_z$bzRr#?lH zu#h@n7?zqV;pTD0T%jOBC=rOcR85M|g0NB@3{&~SJWdRmMoF0xv6v$Yp&TeP2^A)_ z!`uiF2d9uwkj%~Ih$#dnFD>_U;x)_dyM4dh%=KoA}%M- z*kcq;eF+89x>%=5K5zwWeu6MBmpXwNhM$D$`fIUh0m7Bf=I2t!Or@~5Zod{oDI^lp ztsSpYESjFGQORLq%8HWOS8g`I-<_UF-TK5ua;YBDFXj%|VvLkXjhJ4^gTo|ZVICDL z{r+7-#nHkpmc!1Ih&V*rB^TvzBvdWR77JHFMnc6)3FWi70&X^Un1QHKg*ie}FN8`U z6zWO$5TCs5NGmSc4Yc5-O1Crhysl}Gi$P(t;V5+HLW90{izTad=*aH&4@ zuuG^EntUQoUXDQ={!%P7K-$DmD1jqN7m9G1O6ByFV0Y=aCBjQo*rOud?$Ij<4$&$$o%VQ=X(0mRv zDTk9v)-@h-n5m5b9!d=|ArL_%M?VfflAX$<2L9i0<54Z7wXj-PYN{k#!l&`g<58{X z36U)naFMv!E02o$$Em}k>eIdA9OI-5QzdzWqXj(5QcAW0F&sf&5=R^z$-`BQ@NFKS zD-P$fvH3dm5aW=>u(NZpiWrM>#YH@ng=7Naj)9rLE#&Z-+@D#_KX66j+0Z@2MdFa9 zBE$1gpb_L_+2Yj9_BFzEkZauSbfKoiU`fC+4ICXcFZNZ|@Hg?tW8mLR&1sMxM- zuAn!YFX8ZTxu>@UX*^s_NN>H6;0TSs6gixVpe0`t2t|n;HWmq?ag5~Vr6LD1MZy9s zIp8Nx1@UmrBoea zCPuNCkz6)%QJ$Eaiaj(o3??@tQ=CI1miV(vUmnUr6ASwDsLs+x*i;f*EHTW~1d)*R zh;uduB@PZ#L{*{8!<__SlthUjiOZ*lvG32ek)wOj14xjM7w{1aZ<+u}!gO)6h?|jt z+e#kAqWc$%klT4wJ(|LC9PE!URy^_|Wgt3AIgvP7n3NE~Ln-)U9h`Fu(C zuiThSw@t4RJX`~RrEMx%u6R`R4+;eGP$E~A&^#2X)k<6*)tpAZh#Afm2@TB{oRZ9=TGFtamLVot$a%Q9o3O(XYD3cy5n)CM z^Rl_A%tUq(Gm(=m%*T@Ubc@EV0j^s(Y+8`o&E7I4y- z-EemDD08~zxiMsI;^Er&gL{xK!+&&vtmN`zkIthyN}D2rNr(+QJ1IvfM%f_B;6(5_ zY*BJ1F0LrOu(3Wg7*&>O9_~ecw2utpMsraFn+A#qI3r99nrjE6SmR_P^?LuTUe}NT*vepLEg8J1ErW1J8BDDw zgC|Zh=+a0Ax+XGs=putoOc`ie$l#u<41(IoK-E?TciYP#&{GCVFO0J=?uY3C7!SfS zon@fzB7^&(ST{lj+9+(NE4JBP1|=~vcp8U&^_0Q%BpJNyg)pSZU`{_7yd8kB4VJ+o zwhTU}${;aa2Bn!YFz3mjU$zW32xVX+lEF~140hzppl*>2_`_vzXrv5Wq%z1KD}yuR zW#BeR24jn5a9u6~Ulqco!TxnJ2%926@ZXj}?YlC_zAuC053%eM8BBbJG<$)ty^_KF z*E0C_Rt5t;$e`?#3>tlvLE(29TsD(~pM@MstmHUma!9n5!-iUNsN*1q97j2vttSVs z26E6el0#(^IV3cd!#buM99m+2D>Ds zhR9)PsvHh+`%0d2J4)4qqz_w7pNh<{e)KtJiTLswLE1<|h0rwph(6hb*b~aFeTVn-G zaaMp?GX-QfSHQKF3V_xM*yyGJW_txFJQVP$g90*G3b^R2fUp1stPN6tb7uv}x+vg7 zr~=X>FfU30;oTIlzPkdN#VSA*uYfN-6~IfzHhU`|x~~GZ^;bZfK?;~QL;+T*3drXu z;6a80dSxl#5MKd)LIo_%Re+ON0Tc5T@V-z1nZp%uWhB;@Dxhqv0^BDkV9q4OMWz6$ zLIJN-3P{x|;F4YeU8gEw`*a1gpQV7ga}?k(UjgG5D&Xy6Y-gDQuCGu)+$sg^TZ4VA zSHSX(3TU!L0qSiEVC+yp!7c?n-J^iP`xS8EAhve|X>?2h-X|5X3Br}FRv*e?Iz-PO9Al}h{HVvbb5$1c#L>HLpWb3px!H_(`%&fI|USez&f9hUSAb( z^Sc6)&6RM%LJ1MpN+_$T1eTo=O6`@VR|lM>_&mB47Cgb^-Ec+*S?*)5dt zz*Px@+bH2mTO}mAV;i1Ii1b#%ZXYEC`YT~Wpc33WDPd`_5}2V%m=&&s`cX>IbX9^) zcO^`SQ34gOgyB7v@IFZiqTWh)k)i}{es08*9CEOgUguWalT*^>F&nzXJ<}0DQ zPzgtJu`jU__T?*~OOXsz=vr1@vK?%z*DWUBZC9J%r1dp3aSbGcUTA_qZ z_mtrOPzl=~E1~lYiAWGo2tOExeBJWRDnxt6)bX7fm?eOtnpBR zpO*@Duv8G{r-GvaDv0Z-f=iuMFd#$)cf(YW6{&((fNe&rfQnJUq&OAS?ul(Bsi0YJ z6)a0pL5Kb-*fvlFp+i(~Y^Vx)auB`@6{KdV;2B>9`9c+Z&s9OO7~v>T!R#Uxv>u^? zb)!_!af}KMj>CQ@s^IEm6{N{k@ItAAB8>_x^eRwKQ9+aGDp)#G1>SR1uxp+Qq8B2J zi&em0rh?}yu;o$Jd`Jb8kE+1w zgbEg)QUU9%3ih5?LHs2Z+$>iC|C$Ou-9R{Rsi0v6;&xAkW1@n?k5$m?nF{XX!#3hq z2-h1Gl)O`c>qixA`mBPeZwL>ihIED+-dn0+QVlgUv{gfCEj4sbj_5Ni#JBv{1t_S2YZ1qlOo4G0$BM^*X3wnYS7``l{iSzZ!-Fso_m0Y_p3R znuMugZG;*kKn>;H)WGYZhVQXzC`nL*Tap@f^-@D(iW(mFQ^SaXYN#_r4NHfrp)*Gf z=QGrhk)?*Od^PBWYG^A`!(Oo(dKakSd662%jZj15(P~&fMh)G@tKrr}H53%9!CrxF zsMHXwRig}3L-tfPSk6$xoLOq{pR0z`^ReAUYM_>?Va9SbuvV(!#A-F9t;4=HsA1Y> zHF$4R!|^gq+ogtYd$8PoHTWDt{Enz0^SBxqr_?a_j2eQ@tKs5BH3-YqU~>)oy`hG% zTWYv@M-4^y)KLE+!uMDWanIE7~JZ z0sER7SY@k$?)DmZ3mx@o|ry#{uA zXkdVs2EMQ~FxyW9odY#+qa)@AV}7Uxc86rhx_BH4q-FfxGb7h){f9X z(r695AESX;<24X63EM5!z$AqR+|?R5r`14yi3XZX)4<*t8sN;(dLpmJPz{$fJ$UBDZokW_P z)&TFE2I^kaz>dorNV|&g-_XG3KQu7tjs~pmVLuNv(D$(h%%34$UudA$D-C>qt$`Kq z5blo}`1)A`%f4wK(M$_p7+P3nrG>ZFC@wAh2~?ME06 zAwEa7aQ3(sCY-{4&uXFaycU*R(n9|$*v572>!ub)-qwQ8UBv0W7Un@f!2qA-o^7u<$d|@tYPL&2(^_p@XqjI_Owa2d`~)u+m-!X?1kqQcnk$ophjX zq=RT@9hf)M!OrG7$amF2hc-HR(pCqH-E}aigAN+8ba25}hvyY6+ffG=!8+IzqJ!b# zI`EIu!Jl1qu%^2XvSM}6Iza~&i8`3qO9%Z^bkMNB4lWJUfo_Nn;!|~CmyR%H>R=L2 z2T=kYFmiRUFHZ-f^L4mR>)^|99qbsTgJENI5I9~3A13NxYcay5z`oQvc&pXH<`Nx9 zrs=?MrVifC*1?u}Iw)9(<(FXjWjZJ;Mc7vBpz}H%eA|GqZq~t=Z92fWkil}d4vy^A zf$V?|Vh-z|_AwotKcRyur*)8WPKSG29o)LCgGE<$ka0r??QiMe(-xHI(dr==dY)zHIeTRlYC>%p#$9xl|? z!%QbVup8;Y%~=nXO|eXKEaR$&u5I+-&`u9m-1RWOgC4jnJ+S=r@Hs#a2RrIP8LWri zp?Y9O=;29}9yWH8RIoAt17n;x_~^e}Wc_Onk9 z7698J_9@9hNNj*fL(L@_{^Q4!ADdZ>99@wu;uwU6{L_9^21 zLJw_U>EY{ZJ)C=oFnrWQ(HA|$eb++^^AdP(Q35BeOJIRb2^7>Sff$DpXi*o_>z4pI z(^viVNr^}z-&@-nQWKJ*lILMO2em@8@Ze9vqnIVZ5Dht0pqCf1Ef}N3wo7zE#B4+q z2DD)n)td&5CR0K^W)R&Ys)t0vyi{Qhhsn)mXAqrcI-BS`C8(7q`YKeMqt?hw>WXnc z9~M)P&E%wIaNJR$B;e*{vJ03IM7JZ!V|w@O6BCHKW^-DFf$A4FYRz~E25BZ6)!01B zf{w@1aZ6f7LbOzAhN3xGb0HQb_z=i_yzs}xL{eXZ?GVkQB4@eC?86Y)4ZGhC`?~pR23$P{^AvFMJ{&(OA zb%$|8LL(HdnQ3ehkC`eFp#fh^&>@P?&cdNtsPOGV;37Duv-x>6xqs5k0F?m}2CaIo zmhyiClnfk!BeuFxCZqNLPDL`Pq}hJep+|;t5gHf>i+;j_B;14{As8_pkx?buVcG!! zQRPw=v~h~q^(~RLiCv$lw}@Sz!iY%`N>qZ(p+d1(h{hZP&FLgQC!OXrVh5!YP~Dx0 zDHb%5=@hXLEgY7#GLdFj4l{>XOo&N=@Kp+Ps7a}8O(*pInP4XM#}c$j)!UtE@J$D=`cG)!t#?Dr1_Vn30YKvk0wHC3Ga#X=5=ujg%#va01f_ zV)fQ^y)&$y1;S>~yk>LKxRPvw8X0Rc1&C!0jlKe$Drx_NQDR7mJ({4jm<&}*R~jWF zxoMs@;zMXm50!{5$l&}B#>hf?$B5CXljQg8Z`?<)BSX}}gi=-RqwRw@H`9a?@n5l| zxJVm=la67y|7C8plwLO?x~=Jir#-j*6E_+}x`nhcmi)nu|9eoh`hg-n-`4z%J5AdH z7t>H0xbtrqGJgn^|7oLWEv+z)q75xAAO4M9iLHgOYbz6uzp^X3K`{T3V?*cB9BY!i z|1!VUklrw2nn*M3Kk+Ns#1LG`c>aZ7|Ciuc^9RGCFvZ2s83(|ykHyuCh*PptA_z9a zu!=Y(4Ew(VMG5*}{1=-530f_GZ2lyaos=-IVKV< zJj^3J2tU!uGc4TCD+(Ztg)TE@^xnm|2s6>nXxJo~5^#5J*hZTY&{}NqmnvxOGIl`Y`r$ys zM~+Ec^wTN(PCU#UtEAPHI-o2eLqo=x>_yS8Yhdq)2y_K7SbU9#C^8&mV~n)L6AUwz zn3d@Tl?)33l{OU{PH0RwvNLQ;#)nk-OAbyP%&?|6dEGcMafrY)+1L{dpM%j=j0_vuHzpc)0PI38;#li1 zX=z*$ZaeL1h)m_uMZ#=m3NmGF+NZ?ml)z;33)n?@Ogxif4F|f0DJusp?8q9#BZ^QX z*>KK7TRE}=@uI;ktm6-tC>r6aE>U%9hmJl5%#7q9JMjB>_ghzZf zZ@cDp2w}!gb&g$>K6>1RFk_R$wX!=}Rbsl0l&k;zay%m%(`}`%lJ{{Zu{&YhPC7<^ z_>bbTqk?mhs5?{EeT}m;$GE)|;iG@KNI^r4wBulmlYZ+|i91Ro=LH=+^)_Y?;YND@ z|MSdDCK%yM;z$L=+nkC1Xc@=?C`1^4PsKqqY}bwF9cH{Jjm%a{x~M4;DJPa7D_GIT z#jYYDx}Y#44bE#!H{t}B*Ma6RYueu`T9C?@q@iOC?Y8HZ$WHsouEaYI9j%D-BJqzR zVAZ7g_}2`C%;q06P}bT0V+P7RyMN3;*;nfyGf)QF)7+gPDX!7k5tjckYOXNYN9&2NSX$i3MRMo{3vAG9h9>d}GOCZ};*0_gujMHUG3P@wSIWBIK3=t|fybcNW=PBKR2i>^OMd%!*;fM((R|SB7lu{V3wrNIR`lPse$}o9 zo;LpyJPy^s)6NKw^jeki@|ybF$E!{?@U%6;)9`K;c)Bh9?fmRm4Loi}cm`ar0#D4G z-%g*p)xhKa&;0Y-_doTjfv4ra1P{I!h&`Kj?mzd>?f)fsoT`C`Tuk~0`SaVyt3fsJ z)ccpV9DGn^{bDMAJAIl}0}oM<68fC7Iq>uR zMY;a=^^{o+JVb>;;CXqq%6Sl#=D%B?(7YOWh~kmJ6B<#KJ_)~>J_K7r336kMiI?io z_kgG|&FMtc&!hK_AdkMOOEgSG&mM)|PUOln({NLdNozv?DFYRKmj74-wS87}`zD25 zEYV~Ubv?O#-A%|BqOvb16EE(?3Gsfeu_L0lCy*KxZ$^bUQG$}hxJV{m9cGeSbOa!q zza=6t2761@lCD5LNhsJaUPi=t9XIyT6r5r>{n$+Y29ErpQH9a^y0V zBA39C1|$o$f_*YM#J8s&ZFVyj!8^0uRMZ@!iV*KI6W6r*Ra>AhF#fFtCt7ojxlhrCOj*P>L|;_>o1*zIPcVq7wPt6Bwp&78h#;+#X-X_`sK>^49ysV{Bp}zv z|6+}+fn|&n6SFL&L_bc?Is7urXPQpTh@Z9-GM|ZYj}SMnPf9e|+EENbT>KHY&8H~b z2==5)d5W}eV~mq@TT{A}7??1d{t*L{F^cI11CzA% z0c_J~U~<}p@gD{zEb~_blc6qYzpk;J8lky34e9htn=xMBm`=(#8RC0WF};Byes&nf z8yeycZ7|-*5Kkz?xZ&*_%4-D1n@H2i&#E5Aouw;FOH27r7h>E++6#XHsf#dfd>Mx# zxHmJjLs8OPynQ`$2KjB@lZ$uCF`N9P|5*zDvlP$|iZK7P6#V~R3K-JCILS=%PI_z~ z&QGJfDBr2*g~wpB^z~u+;VOsbLE>IS)jpg^x5Fw~}(c8>sGUw;#z4nHf@`5ASjmoCFDRf6)E zEM!Ue%m*LcS?U>(^$QF#wNam5oalv4f`XZ>)82l8rrZW}f-mbw@rHB*-rk;m_>>k+ z(MEI;pPwxn9zyZ;^ENfo#L%gymzO`1l#GHiO;46D%iA-+n@QhA5FkBWjLCuCp8i#m zo6;!6ktc`v5)|lUyga;qR%6m;bq_DkDs`IwQtCfK{y#$gKSG{GzSHV==oX0#NpvMp!wQQDD}f;e*jnqJi}%y6)$-uC2PLsbj< zfcUqJs&x(ClH>^`V|NAwjDQK~TNT!ZbDDwg$RnOVoYiX3*cuRJifI=t!)rXo`Xng_ z2|%7UlTcsiCo)Z*pTp_<3^hJ!_liWiS#A9 z6B6l{gNxr=jF5{!gjFQ?&?h+o8V_8ANk}=O2O=R^l}Wbo*hd(QYzBVuY9dTSPHm_) zpnPnIJKhU2lX)md=M%xk2uh^A|Mko z8CZ>|pNI<1U{GSl(XTXNFEwZ$qEEI&*+dwMRtpd%6EUeI6`^_|8(*3dq;oTj&zSX# ziHyK3%8!g8{>Ty71>@uaOb&jN(I?l!$Z(TcOon?Z4hR`;dY6K1$VZYJ4$Q{)#0)-e z^hY%PW*?yd0mt|>oMHcl5Ba6BQ;C}xkv+uenz+=(kc)s}g0x7yf)`=Bfj7(Szs#;O zRs>G;>qW=jb!PM?(Uaxr" + + class TestPSETMetadata: data_directory = ( Path(__file__).parent / "data" / "renishaw" / "generated_files" From 1d3c400272da5e6b087468b4e3b9800e968bf4c4 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 2 Feb 2024 12:01:59 +0000 Subject: [PATCH 027/174] Set default of `use_uniform_signal_axis` to False to be consistent with docstring --- rsciio/renishaw/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index c18b81cde..8bc657ab6 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -1357,7 +1357,7 @@ def _get_WHTL(self): def file_reader( filename, lazy=False, - use_uniform_signal_axis=True, + use_uniform_signal_axis=False, load_unmatched_metadata=False, ): """ From f73ae9ed679dc44d3352ae550e005712d013cd4d Mon Sep 17 00:00:00 2001 From: Alexander Clausen Date: Thu, 22 Feb 2024 18:41:23 +0100 Subject: [PATCH 028/174] Handle deprecation warnings from numpy 1.25 This handles instances of deprecated conversion of single-element arrays to Python `Number`s: DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated, and will error in future. Ensure you extract a single element from your array before performing this operation. (Deprecated NumPy 1.25.) See also: https://numpy.org/doc/stable/release/1.25.0-notes.html#deprecations --- rsciio/blockfile/_api.py | 12 ++++++------ rsciio/mrc/_api.py | 20 ++++++++++---------- rsciio/nexus/_api.py | 6 +++++- rsciio/tia/_api.py | 4 ++-- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/rsciio/blockfile/_api.py b/rsciio/blockfile/_api.py index 55fdb3809..83082626b 100644 --- a/rsciio/blockfile/_api.py +++ b/rsciio/blockfile/_api.py @@ -186,7 +186,7 @@ def get_header_from_signal(signal, endianess="<"): DP_SZ = DP_SZ[0] SDP = 100.0 / sig_axes[1]["scale"] - offset2 = NX * NY + header["Data_offset_1"] + offset2 = NX * NY + header["Data_offset_1"][0] # Based on inspected files, the DPs are stored at 16-bit boundary... # Normally, you'd expect word alignment (32-bits) ¯\_(°_o)_/¯ offset2 += offset2 % 16 @@ -409,11 +409,11 @@ def file_writer( # Write header header.tofile(f) # Write header note field: - if len(note) > int(header["Data_offset_1"]) - f.tell(): - note = note[: int(header["Data_offset_1"]) - f.tell() - len(note)] + if len(note) > int(header["Data_offset_1"][0]) - f.tell(): + note = note[: int(header["Data_offset_1"][0]) - f.tell() - len(note)] f.write(note.encode()) # Zero pad until next data block - zero_pad = int(header["Data_offset_1"]) - f.tell() + zero_pad = int(header["Data_offset_1"][0]) - f.tell() np.zeros((zero_pad,), np.byte).tofile(f) # Write virtual bright field if navigator is None: @@ -440,11 +440,11 @@ def file_writer( navigator = navigator.astype(endianess + "u1") np.asanyarray(navigator).tofile(f) # Zero pad until next data block - if f.tell() > int(header["Data_offset_2"]): + if f.tell() > int(header["Data_offset_2"][0]): raise ValueError( "Signal navigation size does not match " "data dimensions." ) - zero_pad = int(header["Data_offset_2"]) - f.tell() + zero_pad = int(header["Data_offset_2"][0]) - f.tell() np.zeros((zero_pad,), np.byte).tofile(f) file_location = f.tell() diff --git a/rsciio/mrc/_api.py b/rsciio/mrc/_api.py index 56d096e4b..ba89bc2a5 100644 --- a/rsciio/mrc/_api.py +++ b/rsciio/mrc/_api.py @@ -134,7 +134,7 @@ def get_data_type(mode): 12: np.float16, } - mode = int(mode) + mode = int(mode[0]) if mode in mode_to_dtype: return np.dtype(mode_to_dtype[mode]) else: @@ -345,25 +345,25 @@ def file_reader( # The scale is in Angstroms, we convert it to nm scales = [ ( - float(std_header["Zlen"] / std_header["MZ"]) / 10 - if float(std_header["Zlen"]) != 0 and float(std_header["MZ"]) != 0 + float((std_header["Zlen"] / std_header["MZ"])[0]) / 10 + if float(std_header["Zlen"][0]) != 0 and float(std_header["MZ"][0]) != 0 else 1 ), ( - float(std_header["Ylen"] / std_header["MY"]) / 10 - if float(std_header["MY"]) != 0 + float((std_header["Ylen"] / std_header["MY"])[0]) / 10 + if float(std_header["MY"][0]) != 0 else 1 ), ( - float(std_header["Xlen"] / std_header["MX"]) / 10 - if float(std_header["MX"]) != 0 + float((std_header["Xlen"] / std_header["MX"])[0]) / 10 + if float(std_header["MX"][0]) != 0 else 1 ), ] offsets = [ - float(std_header["ZORIGIN"]) / 10, - float(std_header["YORIGIN"]) / 10, - float(std_header["XORIGIN"]) / 10, + float(std_header["ZORIGIN"][0]) / 10, + float(std_header["YORIGIN"][0]) / 10, + float(std_header["XORIGIN"][0]) / 10, ] else: diff --git a/rsciio/nexus/_api.py b/rsciio/nexus/_api.py index 00cc8d44b..1782fdfff 100644 --- a/rsciio/nexus/_api.py +++ b/rsciio/nexus/_api.py @@ -227,7 +227,11 @@ def _get_nav_list(data, dataentry): if ax != ".": index_name = ax + "_indices" if index_name in dataentry.attrs: - ind_in_array = int(dataentry.attrs[index_name]) + ind_in_array = dataentry.attrs[index_name] + if len(ind_in_array.shape) > 0: + ind_in_array = int(ind_in_array[0]) + else: + ind_in_array = int(ind_in_array) else: ind_in_array = i axis_index_list.append(ind_in_array) diff --git a/rsciio/tia/_api.py b/rsciio/tia/_api.py index 4fff26669..ed0cf04c9 100644 --- a/rsciio/tia/_api.py +++ b/rsciio/tia/_api.py @@ -510,7 +510,7 @@ def ser_reader(filename, objects=None, lazy=False, only_valid_data=True): """ header, data = load_ser_file(filename) record_by = guess_record_by(header["DataTypeID"]) - ndim = int(header["NumberDimensions"]) + ndim = int(header["NumberDimensions"][0]) date, time = None, None if objects is not None: objects_dict = convert_xml_to_dict(objects[0]) @@ -712,7 +712,7 @@ def load_only_data( # dimensions we must fill the rest with zeros or (better) nans if the # dtype is float if np.prod(array_shape) != np.prod(data["Array"].shape): - if int(header["NumberDimensions"]) == 1 and only_valid_data: + if int(header["NumberDimensions"][0]) == 1 and only_valid_data: # No need to fill with zeros if `TotalNumberElements != # ValidNumberElements` for series data. # The valid data is always `0:ValidNumberElements` From 5483e47be5c0bbc60d0ba12a69ad0c9e93791e1d Mon Sep 17 00:00:00 2001 From: Alexander Clausen Date: Thu, 22 Feb 2024 19:01:04 +0100 Subject: [PATCH 029/174] Add changelog entry --- upcoming_changes/230.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/230.maintenance.rst diff --git a/upcoming_changes/230.maintenance.rst b/upcoming_changes/230.maintenance.rst new file mode 100644 index 000000000..fa7b2a909 --- /dev/null +++ b/upcoming_changes/230.maintenance.rst @@ -0,0 +1 @@ +Fix deprecation warnings introduced with numpy 1.25 ("Conversion of an array with ndim > 0 to a scalar is deprecated, ..."). From ce3a7668b8eeb712b26dee91c6341aa786e12b3c Mon Sep 17 00:00:00 2001 From: Alexander Clausen Date: Thu, 22 Feb 2024 21:13:54 +0100 Subject: [PATCH 030/174] Fix CodeQL complaint --- rsciio/blockfile/_api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rsciio/blockfile/_api.py b/rsciio/blockfile/_api.py index 83082626b..d8cac6ea4 100644 --- a/rsciio/blockfile/_api.py +++ b/rsciio/blockfile/_api.py @@ -179,6 +179,8 @@ def get_header_from_signal(signal, endianess="<"): SY = SX elif len(nav_axes) == 0: NX = NY = SX = SY = 1 + else: + raise ValueError("Number of navigation axes has to be 0, 1 or 2") DP_SZ = [axis["size"] for axis in sig_axes][::-1] if DP_SZ[0] != DP_SZ[1]: From 9a0f5925e3931fae4dfb7d7a13530a548c92a7d2 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 23 Feb 2024 17:55:54 +0000 Subject: [PATCH 031/174] Fix setting beam energy for XRF maps --- rsciio/bruker/_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rsciio/bruker/_api.py b/rsciio/bruker/_api.py index d20bde1a9..b2d9222a2 100644 --- a/rsciio/bruker/_api.py +++ b/rsciio/bruker/_api.py @@ -619,6 +619,10 @@ def get_acq_instrument_dict(self, detector=False, **kwargs): det = gen_detector_node(eds_metadata) det["EDS"]["real_time"] = self.calc_real_time() acq_inst["Detector"] = det + # In case of XRF, the primary energy is only defined in + # the spectrum metadata + acq_inst["beam_energy"] = eds_metadata.hv + return acq_inst def _parse_image(self, xml_node, overview=False): @@ -1409,7 +1413,7 @@ def bcf_hyperspectra( ): """Returns list of dict with eds hyperspectra and metadata.""" global warn_once - if (fast_unbcf == False) and warn_once: + if (fast_unbcf is False) and warn_once: _logger.warning( """unbcf_fast library is not present... Parsing BCF with Python-only backend, which is slow... please wait. From 4f7a6e77cdc735c11928cbae000dff4f5e83d98f Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 23 Feb 2024 19:10:46 +0000 Subject: [PATCH 032/174] Add changelog entry --- upcoming_changes/231.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/231.bugfix.rst diff --git a/upcoming_changes/231.bugfix.rst b/upcoming_changes/231.bugfix.rst new file mode 100644 index 000000000..ded58ac51 --- /dev/null +++ b/upcoming_changes/231.bugfix.rst @@ -0,0 +1 @@ +Fix setting beam energy for XRF maps in ``bcf`` files. \ No newline at end of file From d5d8d35ae8ee8c0f537af0e8aed62dc2902cfbc7 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 23 Feb 2024 19:17:50 +0000 Subject: [PATCH 033/174] Raise `ValueError` when saving a blockfile with more than 2 navigation dimension Co-authored-by: Alexander Clausen --- rsciio/blockfile/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsciio/blockfile/_api.py b/rsciio/blockfile/_api.py index d8cac6ea4..1fa5c9e76 100644 --- a/rsciio/blockfile/_api.py +++ b/rsciio/blockfile/_api.py @@ -180,7 +180,7 @@ def get_header_from_signal(signal, endianess="<"): elif len(nav_axes) == 0: NX = NY = SX = SY = 1 else: - raise ValueError("Number of navigation axes has to be 0, 1 or 2") + raise ValueError("Number of navigation axes has to be 0, 1 or 2") # pragma: no cover DP_SZ = [axis["size"] for axis in sig_axes][::-1] if DP_SZ[0] != DP_SZ[1]: From daefed158d5bb7eb9de5c4c5ddcc713eb5fc9f28 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 23 Feb 2024 19:40:36 +0000 Subject: [PATCH 034/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/blockfile/_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rsciio/blockfile/_api.py b/rsciio/blockfile/_api.py index 1fa5c9e76..57e5dcda8 100644 --- a/rsciio/blockfile/_api.py +++ b/rsciio/blockfile/_api.py @@ -180,7 +180,9 @@ def get_header_from_signal(signal, endianess="<"): elif len(nav_axes) == 0: NX = NY = SX = SY = 1 else: - raise ValueError("Number of navigation axes has to be 0, 1 or 2") # pragma: no cover + raise ValueError( + "Number of navigation axes has to be 0, 1 or 2" + ) # pragma: no cover DP_SZ = [axis["size"] for axis in sig_axes][::-1] if DP_SZ[0] != DP_SZ[1]: From eb6acbc1923ec17a15c8d32df56c96cd5abbab19 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 28 Feb 2024 09:11:00 +0000 Subject: [PATCH 035/174] Add "making test data files" section to contributing guide --- CONTRIBUTING.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 93a0e91aa..312d702b5 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -16,6 +16,33 @@ useful: - give a minimal example demonstrating the bug, - copy and paste the error traceback. +.. _making_test_files: + +Making test data files +====================== + +Test data fales are typically generated using third party software, for example using a proprietary +software on a scientific instrument. These files are added to the `test suite `_ +of RosettaSciIO to make sure that future code development will not introduce bug or feature +regression. It is important that the test data files area as small as possible to avoid to work +with GBs large repository of test data. Indeed, the test suite is made of severals hundreds of +test data files and this number of files will keep growing as new features and formats are added +to RosettaSciIO. + +User can contribute by generating these files on softwares they have access to and by making these +files available openly; then a RosettaSciIO developer will help with adding these data to the test suite. + +What makes good test data files: + +- Relevant features: the test data files doesn't need to contains any meaningfull data but they need to + cover as much as possible of the format functionalities. +- Small in size: + + - Acquire minimum number of pixels or channels; in case of images or spectrum images acquire non-square + (e.g. "x" and "y" have different length). + - Generate containing no signal (e.g. zeros) as files containing only very few values will compress very well. + + Pull Requests ============= From c7393bd6ce85137a1527d61c2c9f6e9212295365 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 28 Feb 2024 09:25:48 +0000 Subject: [PATCH 036/174] Add changelog entry --- upcoming_changes/233.enhancements.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/233.enhancements.rst diff --git a/upcoming_changes/233.enhancements.rst b/upcoming_changes/233.enhancements.rst new file mode 100644 index 000000000..b592db8b0 --- /dev/null +++ b/upcoming_changes/233.enhancements.rst @@ -0,0 +1 @@ +Add :ref:`making test data files ` section to contributing guide, explain what are "good" test data files and the reasoning. \ No newline at end of file From 0a1a89aebe4876075f246b776244f9ca3882880d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20L=C3=A4hnemann?= Date: Wed, 28 Feb 2024 18:26:53 +0100 Subject: [PATCH 037/174] Update CONTRIBUTING.rst --- CONTRIBUTING.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 312d702b5..14a363eff 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -21,7 +21,7 @@ useful: Making test data files ====================== -Test data fales are typically generated using third party software, for example using a proprietary +Test data files are typically generated using third party software, for example using a proprietary software on a scientific instrument. These files are added to the `test suite `_ of RosettaSciIO to make sure that future code development will not introduce bug or feature regression. It is important that the test data files area as small as possible to avoid to work From ca8bd82928245e14b789ce6c84aaf43372f1d7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20L=C3=A4hnemann?= Date: Wed, 28 Feb 2024 18:29:38 +0100 Subject: [PATCH 038/174] Apply suggestions from code review --- CONTRIBUTING.rst | 20 ++++++++++---------- upcoming_changes/233.enhancements.rst | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 14a363eff..26e72947b 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -23,24 +23,24 @@ Making test data files Test data files are typically generated using third party software, for example using a proprietary software on a scientific instrument. These files are added to the `test suite `_ -of RosettaSciIO to make sure that future code development will not introduce bug or feature -regression. It is important that the test data files area as small as possible to avoid to work -with GBs large repository of test data. Indeed, the test suite is made of severals hundreds of +of RosettaSciIO to make sure that future code development will not introduce bugs or feature +regressions. It is important that the test data files area as small as possible to avoid working +with a repository that contains GBs of test data. Indeed, the test suite is made of severals hundreds of test data files and this number of files will keep growing as new features and formats are added to RosettaSciIO. -User can contribute by generating these files on softwares they have access to and by making these +Users can contribute by generating these files on softwares they have access to and by making these files available openly; then a RosettaSciIO developer will help with adding these data to the test suite. -What makes good test data files: +What characterizes good test data files: -- Relevant features: the test data files doesn't need to contains any meaningfull data but they need to +- Relevant features: the test data files do not need to contain any meaningful data, but they need to cover as much as possible of the format functionalities. -- Small in size: +- Small size: - - Acquire minimum number of pixels or channels; in case of images or spectrum images acquire non-square - (e.g. "x" and "y" have different length). - - Generate containing no signal (e.g. zeros) as files containing only very few values will compress very well. + - Acquire minimum number of pixels or channels. In case of maps or spectrum images acquire a non-square grid + (e.g. "x" and "y" have different lengths). + - If possible, generate data that contains no signal (e.g. zeros) as files containing only very few values will compress very well. Pull Requests diff --git a/upcoming_changes/233.enhancements.rst b/upcoming_changes/233.enhancements.rst index b592db8b0..ceff7c484 100644 --- a/upcoming_changes/233.enhancements.rst +++ b/upcoming_changes/233.enhancements.rst @@ -1 +1 @@ -Add :ref:`making test data files ` section to contributing guide, explain what are "good" test data files and the reasoning. \ No newline at end of file +Add :ref:`making test data files ` section to contributing guide, explain characteristics of "good" test data files. \ No newline at end of file From 2b8457430ffd70f643f462cc23931ab960856b5d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 18 Feb 2024 17:13:20 +0000 Subject: [PATCH 039/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/tests/registry.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/rsciio/tests/registry.txt b/rsciio/tests/registry.txt index d3573b9a2..85ad366d8 100644 --- a/rsciio/tests/registry.txt +++ b/rsciio/tests/registry.txt @@ -266,6 +266,7 @@ 'renishaw/renishaw_test_exptime1_acc1.wdf' bc23e1f2644d37dd5b572e587bbcf6db08f33dc7e1480c232b04ef17efa63ba6 'renishaw/renishaw_test_exptime1_acc2.wdf' 7fb5fb09a079d1af672d3d37c5cbf3d950a6d0783791505c6f42d7d104790711 'renishaw/renishaw_test_focustrack.wdf' 73fce4347ece1582afb92cb8cd965e021c825815746037eb7cca7af9133e2350 +'renishaw/renishaw_test_focustrack_invariant.wdf' e2a6d79ab342e7217ed8025c3edd266675112359540bb36a026726bc2513a61a 'renishaw/renishaw_test_linescan.wdf' 631ac664443822e1393b9feef384b5cf80ad53d07c1ce30b9f1136efa8d6d685 'renishaw/renishaw_test_map.wdf' 92f9051e9330c9bb61c5eca1b230c1d05137d8596da490b72a3684dc3665b9fe 'renishaw/renishaw_test_map2.wdf' 72484b2337b9e95676d01b1a6a744a7a82db72af1c58c72ce5b55f07546e49c6 From 50fd088dc12a373a274ee4e3569230a24d03c964 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 1 Mar 2024 13:32:56 +0000 Subject: [PATCH 040/174] Read calibration when reading jpg files saved with Renishaw wire software --- rsciio/image/_api.py | 11 +++ rsciio/renishaw/_api.py | 67 +++------------ rsciio/tests/data/image/renishaw_wire.jpg | Bin 0 -> 79680 bytes rsciio/tests/test_image.py | 19 +++++ rsciio/tests/test_renishaw.py | 7 +- rsciio/utils/image.py | 97 ++++++++++++++++++++++ 6 files changed, 142 insertions(+), 59 deletions(-) create mode 100644 rsciio/tests/data/image/renishaw_wire.jpg create mode 100644 rsciio/utils/image.py diff --git a/rsciio/image/_api.py b/rsciio/image/_api.py index 4ea52f91f..4401d87e2 100644 --- a/rsciio/image/_api.py +++ b/rsciio/image/_api.py @@ -20,6 +20,7 @@ import logging import imageio.v3 as iio +from PIL import Image import numpy as np from rsciio._docstrings import ( @@ -28,6 +29,7 @@ RETURNS_DOC, SIGNAL_DOC, ) +from rsciio.utils.image import _parse_axes_from_metadata, _parse_exif_tags from rsciio.utils.tools import _UREG @@ -230,13 +232,22 @@ def file_reader(filename, lazy=False, **kwds): dc = from_delayed(val, shape=dc.shape, dtype=dc.dtype) else: dc = _read_data(filename, **kwds) + + om = {} + + im = Image.open(filename) + om["exif_tags"] = _parse_exif_tags(im) + axes = _parse_axes_from_metadata(om["exif_tags"], dc.shape) + return [ { "data": dc, + "axes": axes, "metadata": { "General": {"original_filename": os.path.split(filename)[1]}, "Signal": {"signal_type": ""}, }, + "original_metadata": om, } ] diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index 8bc657ab6..96cd393de 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -77,13 +77,12 @@ _logger = logging.getLogger(__name__) -## PIL alternative: imageio.v3.immeta extracts exif as binary -## but then this binary string needs to be parsed + try: from PIL import Image except ImportError: PIL_installed = False - _logger.warning("Pillow not installed. Cannot load whitelight image into metadata") + _logger.warning("Pillow not installed. Cannot load whitelight image.") else: PIL_installed = True @@ -326,21 +325,6 @@ class DataType(IntEnum, metaclass=DefaultEnumMeta): ) -# for wthl image -class ExifTags(IntEnum, metaclass=DefaultEnumMeta): - # Standard EXIF TAGS - ImageDescription = 0x10E # 270 - Make = 0x10F # 271 - ExifOffset = 0x8769 # 34665 - FocalPlaneXResolution = 0xA20E # 41486 - FocalPlaneYResolution = 0xA20F # 41487 - FocalPlaneResolutionUnit = 0xA210 # 41488 - # Customized EXIF TAGS from Renishaw - FocalPlaneXYOrigins = 0xFEA0 # 65184 - FieldOfViewXY = 0xFEA1 # 65185 - Unknown = 0xFEA2 # 65186 - - class WDFReader(object): """Reader for Renishaw(TM) WiRE Raman spectroscopy files (.wdf format) @@ -1270,41 +1254,22 @@ def _parse_TEXT(self): def _get_WHTL(self): if not self._check_block_exists("WHTL_0"): - return + return None pos, size = self._block_info["WHTL_0"] jpeg_header = 0x10 self._file_obj.seek(pos) img_bytes = self._file_obj.read(size - jpeg_header) img = BytesIO(img_bytes) - whtl_metadata = {} - ## extract EXIF tags and store them in metadata + ## extract and parse EXIF tags if PIL_installed: + from rsciio.utils.image import _parse_axes_from_metadata, _parse_exif_tags + pil_img = Image.open(img) + original_metadata = {} data = rgb_tools.regular_array2rgbx(np.array(pil_img)) - ## missing header keys when Pillow >= 8.2.0 -> does not flatten IFD anymore - ## see https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html#image-getexif-exif-and-gps-ifd - ## Use fall-back _getexif method instead - exif_header = dict(pil_img._getexif()) - whtl_metadata["FocalPlaneResolutionUnit"] = str( - UnitType(exif_header.get(ExifTags.FocalPlaneResolutionUnit)) - ) - whtl_metadata["FocalPlaneXResolution"] = exif_header.get( - ExifTags.FocalPlaneXResolution - ) - whtl_metadata["FocalPlaneYResolution"] = exif_header.get( - ExifTags.FocalPlaneYResolution - ) - whtl_metadata["FocalPlaneXYOrigins"] = exif_header.get( - ExifTags.FocalPlaneXYOrigins - ) - whtl_metadata["ImageDescription"] = exif_header.get( - ExifTags.ImageDescription - ) - whtl_metadata["Make"] = exif_header.get(ExifTags.Make) - whtl_metadata["Unknown"] = exif_header.get(ExifTags.Unknown) - whtl_metadata["FieldOfViewXY"] = exif_header.get(ExifTags.FieldOfViewXY) - + original_metadata["exif_tags"] = _parse_exif_tags(pil_img) + axes = _parse_axes_from_metadata(original_metadata["exif_tags"], data.shape) metadata = { "General": {"original_filename": os.path.split(self._filename)[1]}, "Signal": {"signal_type": ""}, @@ -1334,20 +1299,10 @@ def _get_WHTL(self): metadata["Markers"] = {"Map": marker_dict} return { - "axes": [ - { - "name": name, - "units": whtl_metadata["FocalPlaneResolutionUnit"], - "size": size, - "scale": whtl_metadata["FieldOfViewXY"][i] / size, - "offset": whtl_metadata["FocalPlaneXYOrigins"][i], - "index_in_array": i, - } - for i, name, size in zip([1, 0], ["y", "x"], data.shape) - ], + "axes": axes, "data": data, "metadata": metadata, - "original_metadata": whtl_metadata, + "original_metadata": original_metadata, } else: # pragma: no cover # Explicit return for readibility diff --git a/rsciio/tests/data/image/renishaw_wire.jpg b/rsciio/tests/data/image/renishaw_wire.jpg new file mode 100644 index 0000000000000000000000000000000000000000..27a24af9bb334529d2394936af019f551b9b12d7 GIT binary patch literal 79680 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 S2|gM->BOJgr$T. from packaging.version import Version +from pathlib import Path import numpy as np import pytest imageio = pytest.importorskip("imageio") +testfile_dir = (Path(__file__).parent / "data" / "image").resolve() + from rsciio.image import file_writer @@ -264,3 +267,19 @@ def test_error_library_no_installed(tmp_path): file_writer( tmp_path / "test_image_error.jpg", signal_dict, imshow_kwds={"a": "b"} ) + + +def test_renishaw_wire(): + hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + s = hs.load(testfile_dir / "renishaw_wire.jpg") + assert s.data.shape == (480, 752) + for axis, scale, offset, name in zip( + s.axes_manager.signal_axes, + [2.42207446, 2.503827], + [19105.5, -6814.538], + ["y", "x"], + ): + np.testing.assert_allclose(axis.scale, scale) + np.testing.assert_allclose(axis.offset, offset) + axis.name == name + axis.units == "µm" diff --git a/rsciio/tests/test_renishaw.py b/rsciio/tests/test_renishaw.py index 871fca310..745f2f790 100644 --- a/rsciio/tests/test_renishaw.py +++ b/rsciio/tests/test_renishaw.py @@ -1205,10 +1205,11 @@ def test_WHTL(self): reader="Renishaw", )[1] expected_WTHL = { - "FocalPlaneResolutionUnit": "µm", + "FocalPlaneResolutionUnit": 5, "FocalPlaneXResolution": 445.75, "FocalPlaneYResolution": 270.85, "FocalPlaneXYOrigins": (-8325.176, -1334.639), + "ExifOffset": 114, "ImageDescription": "white-light image", "Make": "Renishaw", "Unknown": 20.0, @@ -1218,13 +1219,13 @@ def test_WHTL(self): for i, (axis, scale) in enumerate( zip(s.axes_manager._axes, (22.570833, 23.710106)) ): - assert axis.units == expected_WTHL["FocalPlaneResolutionUnit"] + assert axis.units == "µm" np.testing.assert_allclose(axis.scale, scale) np.testing.assert_allclose( axis.offset, expected_WTHL["FocalPlaneXYOrigins"][::-1][i] ) - metadata_WHTL = s.original_metadata.as_dictionary() + metadata_WHTL = s.original_metadata.as_dictionary()["exif_tags"] assert metadata_WHTL == expected_WTHL md = s.metadata.Markers.as_dictionary() diff --git a/rsciio/utils/image.py b/rsciio/utils/image.py new file mode 100644 index 000000000..0d58b1316 --- /dev/null +++ b/rsciio/utils/image.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# Copyright 2007-2024 The HyperSpy developers +# +# This file is part of RosettaSciIO. +# +# RosettaSciIO is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# RosettaSciIO is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with RosettaSciIO. If not, see . + +from PIL.ExifTags import TAGS + + +CustomTAGS = { + **TAGS, + # Customized EXIF TAGS from Renishaw + 0xFEA0: "FocalPlaneXYOrigins", # 65184 + 0xFEA1: "FieldOfViewXY", # 65185 + 0xFEA2: "Unknown", # 65186 +} + + +# from https://exiftool.org/TagNames/EXIF.html +# For tag 0x9210 (37392) +FocalPlaneResolutionUnit_mapping = { + "": "", + 1: "", + 2: "inches", + 3: "cm", + 4: "mm", + 5: "µm", +} + + +def _parse_axes_from_metadata(exif_tags, sizes): + if exif_tags is None: + return [] + offsets = exif_tags.get("FocalPlaneXYOrigins", [0, 0]) + # jpg files made with Renishaw have this tag + scales = exif_tags.get("FieldOfViewXY", [1, 1]) + + unit = FocalPlaneResolutionUnit_mapping[ + exif_tags.get("FocalPlaneResolutionUnit", "") + ] + + axes = [ + { + "name": name, + "units": unit, + "size": size, + "scale": scales[i] / size, + "offset": offsets[i], + "index_in_array": i, + } + for i, name, size in zip([1, 0], ["y", "x"], sizes) + ] + + return axes + + +def _parse_exif_tags(im): + """ + Parse exif tags from a pillow image + + Parameters + ---------- + im : :class:`PIL.Image` + The pillow image from which the exif tags will be parsed. + + Returns + ------- + exif_dict : None or dict + The dictionary of exif tags. + + """ + exif_dict = None + try: + # missing header keys when Pillow >= 8.2.0 -> does not flatten IFD anymore + # see https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html#image-getexif-exif-and-gps-ifd + # Use fall-back _getexif method instead + # Not all format plugin have the private method + # prefer to use that method as it returns more items + exif_dict = im._getexif() + except AttributeError: + exif_dict = im.getexif() + if exif_dict is not None: + exif_dict = {CustomTAGS.get(k, "unknown"): v for k, v in exif_dict.items()} + + return exif_dict From 82ba5e966b0922e4942b763352c96bd10a9c85e2 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 2 Mar 2024 10:25:09 +0000 Subject: [PATCH 041/174] Tweak logger --- rsciio/renishaw/_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index 96cd393de..a1ef35fcb 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -1011,7 +1011,7 @@ def _set_nav_via_WMAP(self, wmap_dict, units): if flag == MapType.xyline.name: result = self._set_wmap_nav_linexy(result["X"], result["Y"]) elif flag == DefaultEnum.Unknown.name: - _logger.warning(f"Unknown flag ({wmap_dict['flag']}) for WMAP mapping.") + _logger.info(f"Unknown flag ({wmap_dict['flag']}) for WMAP mapping.") return result def _set_wmap_nav_linexy(self, x_axis, y_axis): @@ -1163,7 +1163,7 @@ def _map_signal_md(self): signal = {} if importlib.util.find_spec("lumispy") is None: _logger.warning( - "Cannot find package lumispy, using BaseSignal as signal_type." + "Cannot find package lumispy, using generic signal class BaseSignal." ) signal["signal_type"] = "" else: From c5f04bdd7353541ab8acdd5aff4233828282ac14 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 18 Feb 2024 16:44:44 +0000 Subject: [PATCH 042/174] Add changelog entry --- upcoming_changes/227.enhancements.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 upcoming_changes/227.enhancements.rst diff --git a/upcoming_changes/227.enhancements.rst b/upcoming_changes/227.enhancements.rst new file mode 100644 index 000000000..56e577a57 --- /dev/null +++ b/upcoming_changes/227.enhancements.rst @@ -0,0 +1,5 @@ +:ref:`Renishaw wdf `: + +- return survey image instead of saving it to the metadata and add marker of the mapping area on the survey image. +- Add support for reading data with invariant axis, for example when the values of the Z axis doesn't change. +- Parse calibration of ``jpg`` images saved with Renishaw Wire software. \ No newline at end of file From 1e42c8e552cda67f1cd35cc8ab97e860e2e263ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 2 Mar 2024 12:28:57 +0000 Subject: [PATCH 043/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/tests/registry.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/rsciio/tests/registry.txt b/rsciio/tests/registry.txt index 85ad366d8..c7134027f 100644 --- a/rsciio/tests/registry.txt +++ b/rsciio/tests/registry.txt @@ -175,6 +175,7 @@ 'hspy/test_marker_point_y2_data_deleted.hdf5' 11f24a1d91b3157c12e01929d8bfee9757a5cc29281a6220c13f1638cc3ca49c 'hspy/test_rgba16.hdf5' 5d76658ae9a9416cbdcb239059ee20d640deb634120e1fa051e3199534c47270 'hspy/with_lists_etc.hdf5' 16ed9d4bcb44ba3510963c102eab888b89516921cd4acc4fdb85271407dae562 +'image/renishaw_wire.jpg' 21d34f130568e161a3b2c8a213aa28991880ca0265aec8bfa3c6ca4d9897540c 'impulse/NoMetadata_Synchronized data.csv' 3031a84b6df77f3cfe3808fcf993f3cf95b6a9f67179524200b3129a5de47ef5 'impulse/StubExperiment_Heat raw.csv' 114ebae61321ceed4c071d35e1240a51c2a3bfe37ff9d507cacb7a7dd3977703 'impulse/StubExperiment_Metadata.log' 4b034d75685d61810025586231fb0adfecbbacd171d89230dbf82d54dff7a93c From 5387581f074fa177f021cbb9f443afa96e50e328 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 4 Mar 2024 19:31:48 +0000 Subject: [PATCH 044/174] Document returning list of dictionary when loading several signals --- doc/user_guide/supported_formats/renishaw.rst | 4 ++++ rsciio/_docstrings.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/doc/user_guide/supported_formats/renishaw.rst b/doc/user_guide/supported_formats/renishaw.rst index a062e7701..eb646dee9 100644 --- a/doc/user_guide/supported_formats/renishaw.rst +++ b/doc/user_guide/supported_formats/renishaw.rst @@ -5,6 +5,10 @@ Renishaw Reader for spectroscopy data saved using Renishaw's WiRE software. Currently, RosettaSciIO can only read the ``.wdf`` format from Renishaw. +When reading spectral images, the white light image will be returned along the +spectral images in the list of dictionary. The position of the mapped area +is returned in the metadata dictionary of the white light image and this will +be displayed when plotting the image with hyperspy. If `LumiSpy `_ is installed, ``Luminescence`` will be used as the ``signal_type``. diff --git a/rsciio/_docstrings.py b/rsciio/_docstrings.py index f173c3478..f453de987 100644 --- a/rsciio/_docstrings.py +++ b/rsciio/_docstrings.py @@ -132,4 +132,6 @@ containing the full axes vector - 'metadata' – dictionary containing the parsed metadata - 'original_metadata' – dictionary containing the full metadata tree from the input file + + When the file contains several datasets, each dataset will be loaded as separate dictionaries. """ From d5a7ef9ec2c9774bbe223d18b9d0c11ba18fed4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20L=C3=A4hnemann?= Date: Mon, 4 Mar 2024 23:51:02 +0100 Subject: [PATCH 045/174] Apply suggestions from code review --- doc/user_guide/supported_formats/renishaw.rst | 4 ++-- rsciio/_docstrings.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/user_guide/supported_formats/renishaw.rst b/doc/user_guide/supported_formats/renishaw.rst index eb646dee9..12ce16249 100644 --- a/doc/user_guide/supported_formats/renishaw.rst +++ b/doc/user_guide/supported_formats/renishaw.rst @@ -6,9 +6,9 @@ Renishaw Reader for spectroscopy data saved using Renishaw's WiRE software. Currently, RosettaSciIO can only read the ``.wdf`` format from Renishaw. When reading spectral images, the white light image will be returned along the -spectral images in the list of dictionary. The position of the mapped area +spectral images in the list of dictionaries. The position of the mapped area is returned in the metadata dictionary of the white light image and this will -be displayed when plotting the image with hyperspy. +be displayed when plotting the image with HyperSpy. If `LumiSpy `_ is installed, ``Luminescence`` will be used as the ``signal_type``. diff --git a/rsciio/_docstrings.py b/rsciio/_docstrings.py index f453de987..97097c7f3 100644 --- a/rsciio/_docstrings.py +++ b/rsciio/_docstrings.py @@ -133,5 +133,5 @@ - 'metadata' – dictionary containing the parsed metadata - 'original_metadata' – dictionary containing the full metadata tree from the input file - When the file contains several datasets, each dataset will be loaded as separate dictionaries. + When the file contains several datasets, each dataset will be loaded as separate dictionary. """ From f95b54e438896ad4f08a759a475dfdb80d92e1b8 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 27 Feb 2024 20:14:30 +0000 Subject: [PATCH 046/174] Fix reading EDS maps with emd velox v11 --- rsciio/emd/_emd_velox.py | 72 ++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index a5a1e2c1d..f0a6f935f 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -46,6 +46,10 @@ _logger = logging.getLogger(__name__) +def _parse_json(v, encoding="utf-8"): + return json.loads(v.decode(encoding)) + + def _get_detector_metadata_dict(om, detector_name): detectors_dict = om["Detectors"] # find detector dict from the detector_name @@ -100,15 +104,23 @@ def __init__( self.lazy = lazy self.detector_name = None self.original_metadata = {} + # UUID: label mapping + self._map_label_dict = {} def read_file(self, f): self.filename = f.filename + self.version = _parse_json(f["Version"][0])["version"] self.d_grp = f.get("Data") self._check_im_type() - self._parse_metadata_group(f.get("Operations"), "Operations") + for key in ["Displays", "Operations", "SharedProperties", "Features"]: + # In Velox emd v11, the "Operation" group is removed: + # 'operation settings' are moved to \SharedProperties + # \Features link to \SharedProperties\DataReference + # in version <11 \Features linked to \Operations + if key in f.keys(): + self._parse_metadata_group(f.get(key), key) if self.im_type == "SpectrumStream": - self.p_grp = f.get("Presentation") - self._parse_image_display() + self._parse_image_display(f) self._read_data(self.select_type) def _read_data(self, select_type): @@ -241,7 +253,12 @@ def _read_image(self, image_group, image_sub_group_key): # Can be used in more recent version of velox emd files self.detector_information = self._get_detector_information(original_metadata) - self.detector_name = self._get_detector_name(image_sub_group_key) + try: + self.detector_name = self._get_detector_name(image_sub_group_key) + except KeyError: + # File version >= 11 doesn't have the "Operations" group anymore + if self.detector_information is not None: + self.detector_name = self.detector_information["DetectorName"] read_stack = self.load_SI_image_stack or self.im_type == "Image" h5data = image_sub_group["Data"] @@ -366,9 +383,8 @@ def _read_image(self, image_group, image_sub_group_key): original_metadata["DetectorMetadata"] = _get_detector_metadata_dict( original_metadata, self.detector_name ) - if hasattr(self, "map_label_dict"): - if image_sub_group_key in self.map_label_dict: - md["General"]["title"] = self.map_label_dict[image_sub_group_key] + if image_sub_group_key in self._map_label_dict: + md["General"]["title"] = self._map_label_dict[image_sub_group_key] return { "data": data, @@ -463,17 +479,31 @@ def _parse_frame_time(self, original_metadata, factor=1): frame_time, time_unit = self._convert_scale_units(frame_time, time_unit, factor) return frame_time, time_unit - def _parse_image_display(self): - try: - image_display_group = self.p_grp.get("Displays/ImageDisplay") + def _parse_image_display(self, f): + if int(self.version) >= 11: + # - /Displays/ImageDisplay contains the list of all the image displays. + # A EDS Map is just an image display. + # - These entries contain a json encoded dictionary that contains + # 'data', 'id', 'settings' and 'title'. + # - The 'id' is the name of the element. 'data' is pointing to the + # data reference in SharedProperties/ImageSeriesDataReference/ + # which in turn is pointing to the /Data/Image/ where the image + # data is located. + om_image_display = self.original_metadata["Displays"]["ImageDisplay"] + self._map_label_dict = {} + for v in om_image_display.values(): + if "data" in v.keys(): + data_key = _parse_json(f.get(v["data"])[0])["dataPath"] + self._map_label_dict[data_key.split("/")[-1]] = v["id"] + + else: + image_display_group = f.get("Presentation/Displays/ImageDisplay") key_list = _get_keys_from_group(image_display_group) - self.map_label_dict = {} + for key in key_list: - v = json.loads(image_display_group[key][0].decode("utf-8")) + v = _parse_json(image_display_group[key][0]) data_key = v["dataPath"].split("/")[-1] # key in data group - self.map_label_dict[data_key] = v["display"]["label"] - except KeyError: - _logger.warning("The image label can't be read from the metadata.") + self._map_label_dict[data_key] = v["display"]["label"] def _parse_metadata_group(self, group, group_name): d = {} @@ -483,10 +513,10 @@ def _parse_metadata_group(self, group, group_name): if hasattr(subgroup, "keys"): sub_dict = {} for subgroup_key in _get_keys_from_group(subgroup): - v = json.loads(subgroup[subgroup_key][0].decode("utf-8")) + v = _parse_json(subgroup[subgroup_key][0]) sub_dict[subgroup_key] = v else: - sub_dict = json.loads(subgroup[0].decode("utf-8")) + sub_dict = _parse_json(subgroup[0]) d[group_key] = sub_dict except IndexError: _logger.warning("Some metadata can't be read.") @@ -500,9 +530,9 @@ def _read_spectrum_stream(self): try: sig = self.d_grp["SpectrumImage"] self.number_of_frames = int( - json.loads( - sig[next(iter(sig))]["SpectrumImageSettings"][0].decode("utf8") - )["endFramePosition"] + _parse_json(sig[next(iter(sig))]["SpectrumImageSettings"][0])[ + "endFramePosition" + ] ) except Exception: _logger.exception( @@ -851,7 +881,7 @@ def __init__(self, stream_group, reader): self.stream_group = stream_group # Parse acquisition settings to get bin_count and dtype acquisition_settings_group = stream_group["AcquisitionSettings"] - acquisition_settings = json.loads(acquisition_settings_group[0].decode("utf-8")) + acquisition_settings = _parse_json(acquisition_settings_group[0]) self.bin_count = int(acquisition_settings["bincount"]) if self.bin_count % self.reader.rebin_energy != 0: raise ValueError( From e5f3dac6dcb5954c50e3aa9d6d4f9efcbc6f008e Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 4 Mar 2024 21:06:52 +0000 Subject: [PATCH 047/174] Add some Velox emd v11 test files provided by @ThorstenBASF --- rsciio/emd/_emd_velox.py | 23 ++++++++---- rsciio/tests/data/emd/velox_emd_version11.zip | Bin 0 -> 147888 bytes rsciio/tests/test_emd_velox.py | 35 +++++++++++++++++- 3 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 rsciio/tests/data/emd/velox_emd_version11.zip diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index f0a6f935f..8d044c0e0 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -59,6 +59,16 @@ def _get_detector_metadata_dict(om, detector_name): return None +PRUNE_WARNING = ( + "No spectrum stream is present in the file. It " + "is possible that the file has been pruned: use " + "Velox to read the spectrum image (proprietary " + "format). If you want to open Velox emd file with " + "HyperSpy don't prune the file when saving it in " + "Velox." +) + + class FeiEMDReader(object): """ Class for reading FEI electron microscopy datasets. @@ -549,14 +559,7 @@ def _read_spectrum_stream(self): spectrum_stream_group = self.d_grp.get("SpectrumStream") if spectrum_stream_group is None: - _logger.warning( - "No spectrum stream is present in the file. It " - "is possible that the file has been pruned: use " - "Velox to read the spectrum image (proprietary " - "format). If you want to open FEI emd file with " - "HyperSpy don't prune the file when saving it in " - "Velox." - ) + _logger.warning(PRUNE_WARNING) return def _read_stream(key): @@ -564,6 +567,10 @@ def _read_stream(key): return stream subgroup_keys = _get_keys_from_group(spectrum_stream_group) + if len(subgroup_keys) == 0: + _logger.warning(PRUNE_WARNING) + return + if self.sum_EDS_detectors: if len(subgroup_keys) == 1: _logger.warning("The file contains only one spectrum stream") diff --git a/rsciio/tests/data/emd/velox_emd_version11.zip b/rsciio/tests/data/emd/velox_emd_version11.zip new file mode 100644 index 0000000000000000000000000000000000000000..56ae933c34022b795d86596917c33217f220c03f GIT binary patch literal 147888 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

{~7JigMmu6tf(qZ@ju-uvjRhuj3QOJQ_V` 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

C&Ttm}!?eI=)$2Ohm6_4wYolT$R#~RiL@YG6 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 literal 0 HcmV?d00001 diff --git a/rsciio/tests/test_emd_velox.py b/rsciio/tests/test_emd_velox.py index 8001534e3..7194118a3 100644 --- a/rsciio/tests/test_emd_velox.py +++ b/rsciio/tests/test_emd_velox.py @@ -23,6 +23,7 @@ import os from pathlib import Path +import logging import pytest @@ -499,8 +500,6 @@ def test_fei_dpc_loading(): @pytest.mark.parametrize("fname", ["FFTComplexEven.emd", "FFTComplexOdd.emd"]) def test_velox_fft_odd_number(fname): - print("0", fname) - print(TEST_DATA_PATH / fname) s = hs.load(TEST_DATA_PATH / fname) assert len(s) == 2 @@ -510,3 +509,35 @@ def test_velox_fft_odd_number(fname): assert s[1].axes_manager.signal_shape == (128, 128) assert np.issubdtype(s[1].data.dtype, float) + + +class TestVeloxEMDv11: + fei_files_path = TEST_DATA_PATH / "velox_emd_version11" + + @classmethod + def setup_class(cls): + import zipfile + + zipf = TEST_DATA_PATH / "velox_emd_version11.zip" + with zipfile.ZipFile(zipf, "r") as zipped: + zipped.extractall(cls.fei_files_path) + + @classmethod + def teardown_class(cls): + gc.collect() + shutil.rmtree(cls.fei_files_path) + + @pytest.mark.parametrize("lazy", (True, False)) + def test_spectrum_images(self, lazy): + s = hs.load(self.fei_files_path / "Test SI 16x16 215 kx.emd", lazy=lazy) + assert len(s) == 10 + for i, v in enumerate(["C", "Ca", "O", "Cu", "HAADF", "EDS"]): + assert s[i + 4].metadata.General.title == v + + assert s[-1].data.shape == (16, 16, 4096) + + def test_prune_data(self, caplog): + with caplog.at_level(logging.WARNING): + _ = hs.load(self.fei_files_path / "Test SI 16x16 ReducedData 215 kx.emd") + + assert "No spectrum stream is present" in caplog.text From dbe3c4954ececc8d23e963e85be253fedce63035 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Mar 2024 21:11:33 +0000 Subject: [PATCH 048/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/tests/registry.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/rsciio/tests/registry.txt b/rsciio/tests/registry.txt index c7134027f..b8b9f1f36 100644 --- a/rsciio/tests/registry.txt +++ b/rsciio/tests/registry.txt @@ -157,6 +157,7 @@ 'emd/fei_example_complex_fft.emd' eec20bd422428dc498334143e2a721aa793e79f399bfe16c21cb8b0313ff0c07 'emd/fei_example_dpc_titles.emd' c06422c623e7a7b18ed8864c99d34590488b98fef85423ac33e1ea10bef66b2f 'emd/fei_example_tem_stack.emd' 397d5076b0133db608abd985985fad275bf6594823393f72a069020e47c21a1e +'emd/velox_emd_version11.zip' 125f0f6b1517e6bb2a1c44f2157b874fe244bb7716f37a9efbda853f16e395c1 'empad/map128x128_version1.2.0.xml' b1cd0dfedc348c9e03ac10e32e3b98a0a0502f87e72069423e7d5f78d40ccae5 'empad/map4x4.xml' ff1a1a6488c7e525c1386f04d791bf77425e27b334c4301a9e6ec85c7628cbeb 'empad/stack_images.xml' 7047717786c3773735ff751a3ca797325d7944fe7b70f110cdda55d455a38a55 From 6391d98afa7f9db66abd37650d3477c7b0e65ce2 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 5 Mar 2024 20:03:18 +0000 Subject: [PATCH 049/174] Improve warning --- rsciio/emd/_emd_velox.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index 8d044c0e0..1e599fc37 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -60,12 +60,14 @@ def _get_detector_metadata_dict(om, detector_name): PRUNE_WARNING = ( - "No spectrum stream is present in the file. It " - "is possible that the file has been pruned: use " - "Velox to read the spectrum image (proprietary " - "format). If you want to open Velox emd file with " - "HyperSpy don't prune the file when saving it in " - "Velox." + "No spectrum stream is present in the file and the " + "spectrum images are saved in a proprietary format, " + "which is not supported by RosettaSciIO. This is " + "because it has been 'pruned' or saved a different " + "software than Velox, e.g. bcf to emd converter. " + "If you want to open this data don't prune the " + "file or read bcf file directly (in case the bcf " + "to emd converter was used)." ) @@ -558,19 +560,23 @@ def _read_spectrum_stream(self): ) spectrum_stream_group = self.d_grp.get("SpectrumStream") - if spectrum_stream_group is None: + if spectrum_stream_group is None: # pragma: no cover + # "Pruned" file, EDS SI data are in the + # "SpectrumImage" group _logger.warning(PRUNE_WARNING) return - def _read_stream(key): - stream = FeiSpectrumStream(spectrum_stream_group[key], self) - return stream - subgroup_keys = _get_keys_from_group(spectrum_stream_group) if len(subgroup_keys) == 0: + # "Pruned" file: in Velox emd v11, the "SpectrumStream" + # group exists but it is empty _logger.warning(PRUNE_WARNING) return + def _read_stream(key): + stream = FeiSpectrumStream(spectrum_stream_group[key], self) + return stream + if self.sum_EDS_detectors: if len(subgroup_keys) == 1: _logger.warning("The file contains only one spectrum stream") From 394a7e817f916e22e2c53901f40fedeadb46e53c Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 4 Mar 2024 21:25:23 +0000 Subject: [PATCH 050/174] Add changelog entry --- upcoming_changes/232.enhancements.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/232.enhancements.rst diff --git a/upcoming_changes/232.enhancements.rst b/upcoming_changes/232.enhancements.rst new file mode 100644 index 000000000..1c471e75d --- /dev/null +++ b/upcoming_changes/232.enhancements.rst @@ -0,0 +1 @@ +Add support for reading :ref:`emd ` Velox version 11. \ No newline at end of file From 49bbadd52d13f4f616bcaf4b4ab11475b9e142d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 08:02:47 +0000 Subject: [PATCH 051/174] Bump softprops/action-gh-release from 1 to 2 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/de2c0eb89ae2a093876385947365aca7b0e5f844...3198ee18f814cdf787321b4a32a26ddbf37acc52) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dd6ed46e..5298aa97d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,4 +126,4 @@ jobs: uses: actions/checkout@v4 - name: Create Release id: create_release - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 + uses: softprops/action-gh-release@9d7c94cfd0a1f3ed45544c887983e9fa900f0564 From bc9d4ac6943dc0565898a21ace9a007ebe1e13b9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 22:05:41 +0000 Subject: [PATCH 052/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 24.2.0 → 24.3.0](https://github.com/psf/black/compare/24.2.0...24.3.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 154832478..1fd8e48d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/psf/black # Version can be updated by running "pre-commit autoupdate" - rev: 24.2.0 + rev: 24.3.0 hooks: - id: black - repo: local From 03ad533a03db5a2e7e09fe9301f4f5f275cdc428 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 20 Mar 2024 19:44:06 +0000 Subject: [PATCH 053/174] Replace deprecated `np.string_` by `np.bytes_` --- rsciio/_hierarchical.py | 2 +- rsciio/edax/_api.py | 2 +- rsciio/nexus/_api.py | 2 +- rsciio/quantumdetector/_api.py | 2 +- rsciio/utils/tools.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index d22b067ed..d3a75162f 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -561,7 +561,7 @@ def _group2dict(self, group, dictionary=None, lazy=False): for key, value in group.attrs.items(): if isinstance(value, bytes): value = value.decode() - if isinstance(value, (np.string_, str)): + if isinstance(value, (np.bytes_, str)): if value == "_None_": value = None elif isinstance(value, np.bool_): diff --git a/rsciio/edax/_api.py b/rsciio/edax/_api.py index 0298e7ea9..9a5ca6d80 100644 --- a/rsciio/edax/_api.py +++ b/rsciio/edax/_api.py @@ -860,7 +860,7 @@ def spd_reader( # see https://github.com/hyperspy/hyperspy/pull/2007 and # https://github.com/h5py/h5py/issues/289 for context original_metadata["ipr_header"]["charText"] = [ - np.string_(i) for i in original_metadata["ipr_header"]["charText"] + np.bytes_(i) for i in original_metadata["ipr_header"]["charText"] ] else: _logger.warning( diff --git a/rsciio/nexus/_api.py b/rsciio/nexus/_api.py index 1782fdfff..3eec4f480 100644 --- a/rsciio/nexus/_api.py +++ b/rsciio/nexus/_api.py @@ -132,7 +132,7 @@ def _parse_to_file(value): toreturn = totest if isinstance(totest, str): toreturn = totest.encode("utf-8") - toreturn = np.string_(toreturn) + toreturn = np.bytes_(toreturn) return toreturn diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index 039f27e35..bf608d1d1 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -242,7 +242,7 @@ def load_mib_data( data_dtype = np.dtype(mib_prop.dtype).newbyteorder(">") merlin_frame_dtype = np.dtype( [ - ("header", np.string_, mib_prop.head_size), + ("header", np.bytes_, mib_prop.head_size), ("data", data_dtype, mib_prop.merlin_size), ] ) diff --git a/rsciio/utils/tools.py b/rsciio/utils/tools.py index 74e9ae2c0..7c0e52e37 100644 --- a/rsciio/utils/tools.py +++ b/rsciio/utils/tools.py @@ -482,7 +482,7 @@ def get_object_package_info(obj): def ensure_unicode(stuff, encoding="utf8", encoding2="latin-1"): - if not isinstance(stuff, (bytes, np.string_)): + if not isinstance(stuff, (bytes, np.bytes_)): return stuff else: string = stuff From bf1fc341d1e2aa70e9b544482535aaf5103543d1 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 20 Mar 2024 20:13:12 +0000 Subject: [PATCH 054/174] Replace deprecated `np.product` by `np.prod` --- rsciio/blockfile/_api.py | 2 +- rsciio/tests/test_ripple.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rsciio/blockfile/_api.py b/rsciio/blockfile/_api.py index 57e5dcda8..567851930 100644 --- a/rsciio/blockfile/_api.py +++ b/rsciio/blockfile/_api.py @@ -471,7 +471,7 @@ def file_writer( ("IMG", endianess + "u1", pixels), ] magics = np.full(records, 0x55AA, dtype=endianess + "u2") - ids = np.arange(np.product(records), dtype=endianess + "u4").reshape(records) + ids = np.arange(np.prod(records), dtype=endianess + "u4").reshape(records) file_memmap = np.memmap( filename, dtype=record_dtype, mode="r+", offset=file_location, shape=records ) diff --git a/rsciio/tests/test_ripple.py b/rsciio/tests/test_ripple.py index 35c39d399..b6a5051f0 100644 --- a/rsciio/tests/test_ripple.py +++ b/rsciio/tests/test_ripple.py @@ -102,7 +102,7 @@ def _get_filename(s, metadata): def _create_signal(shape, dim, dtype, metadata): - data = np.arange(np.product(shape)).reshape(shape).astype(dtype) + data = np.arange(np.prod(shape)).reshape(shape).astype(dtype) if dim == 1: if len(shape) > 2: s = exspy.signals.EELSSpectrum(data) From fd8b1d544c4bbb8254b1edf1b7e3c37d5005ba55 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 20 Mar 2024 20:18:39 +0000 Subject: [PATCH 055/174] Add changelog entry --- upcoming_changes/238.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/238.maintenance.rst diff --git a/upcoming_changes/238.maintenance.rst b/upcoming_changes/238.maintenance.rst new file mode 100644 index 000000000..b2c617455 --- /dev/null +++ b/upcoming_changes/238.maintenance.rst @@ -0,0 +1 @@ +Fix numpy 2.0 removal (``np.product`` and ``np.string_``). \ No newline at end of file From 1dc2c54267497292eb41a0222a4ccb6598798d8b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 16 Mar 2024 09:34:11 +0000 Subject: [PATCH 056/174] Add support for getting the number of frames per line based on the timestamps --- rsciio/quantumdetector/_api.py | 45 +++++++++++++++++++++------- rsciio/tests/test_quantumdetector.py | 16 +++++++--- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index bf608d1d1..921bf8a0f 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -22,6 +22,7 @@ import logging import os from pathlib import Path +import warnings import dask.array as da import numpy as np @@ -130,14 +131,14 @@ def parse_file(self, path): self.file_size = f.tell() self.buffer = False self.path = path - except: # pragma: no cover + except BaseException: # pragma: no cover raise RuntimeError("File does not contain MIB header.") elif isinstance(path, bytes): try: head = path[:384].decode().split(",") self.file_size = len(path) self.buffer = True - except: # pragma: no cover + except BaseException: # pragma: no cover raise RuntimeError("Buffer does not contain MIB header.") else: # pragma: no cover raise TypeError("`path` must be a str or a buffer.") @@ -401,7 +402,7 @@ def parse_exposures(headers, max_index=10000): from the headers. By default, reads only the first 10 000 frames. >>> from rsciio.quantumdetector import load_mib_data, parse_exposures - >>> data, headers = load_mib_data(path, return_header=True, return_mmap=True) + >>> data, headers = load_mib_data(path, return_headers=True, return_mmap=True) >>> exposures = parse_exposures(headers) All frames can be parsed by using ``max_index=-1``: @@ -517,13 +518,37 @@ def file_reader( hdr = None _logger.warning("`hdr` file couldn't be found.") - if navigation_shape is None and hdr is not None: - # Use the hdr file to find the number of frames - navigation_shape = ( - int(hdr["Frames per Trigger (Number)"]), - int(hdr["Frames in Acquisition (Number)"]) - // int(hdr["Frames per Trigger (Number)"]), - ) + frame_per_trigger = 1 + headers = None + if navigation_shape is None: + if hdr is not None: + # Use the hdr file to find the number of frames + frame_per_trigger = int(hdr["Frames per Trigger (Number)"]) + frames_number = int(hdr["Frames in Acquisition (Number)"]) + else: + _, headers = load_mib_data(filename, return_headers=True) + frames_number = len(headers) + + if frame_per_trigger == 1: + if headers is None: + _, headers = load_mib_data(filename, return_headers=True) + # Use parse_timestamps to find the number of frame per line + # we will get a difference of timestamps at the beginning of each line + with warnings.catch_warnings(): + # Filter warning for converting timezone aware datetime + # The time zone is dropped + # Changed from `DeprecationWarning` to `UserWarning` in numpy 2.0 + warnings.simplefilter("ignore") + times = np.array(parse_timestamps(headers)).astype(dtype="datetime64") + + times_diff = np.diff(times).astype(float) + if len(times_diff) > 0: + # Substract the mean and take the first position above 0 + indices = np.argwhere(times_diff - np.mean(times_diff) > 0) + if len(indices) > 0 and len(indices[0]) > 0: + frame_per_trigger = indices[0][0] + 1 + + navigation_shape = (frame_per_trigger, frames_number // frame_per_trigger) data = load_mib_data( filename, diff --git a/rsciio/tests/test_quantumdetector.py b/rsciio/tests/test_quantumdetector.py index 3056af5fc..c47074ccf 100644 --- a/rsciio/tests/test_quantumdetector.py +++ b/rsciio/tests/test_quantumdetector.py @@ -120,7 +120,12 @@ def test_single_chip(fname, reshape): def test_quad_chip(fname): s = hs.load(TEST_DATA_DIR_UNZIPPED / fname) if "9_Frame" in fname: - navigation_shape = (9,) + if "24_Rows_256" in fname: + # Unknow why the timestamps of this file are not consistent + # with others + navigation_shape = (3, 3) + else: + navigation_shape = (9,) else: navigation_shape = () assert s.data.shape == navigation_shape + (512, 512) @@ -180,11 +185,14 @@ def test_interrupted_acquisition_first_frame(): assert s.axes_manager.navigation_shape == (7,) -def test_non_square(): +@pytest.mark.parametrize("navigation_shape", (None, (8,), (4, 2))) +def test_non_square(navigation_shape): fname = TEST_DATA_DIR_UNZIPPED / "001_4x2_6bit.mib" - s = hs.load(fname, navigation_shape=(4, 2)) + s = hs.load(fname, navigation_shape=navigation_shape) assert s.axes_manager.signal_shape == (256, 256) - assert s.axes_manager.navigation_shape == (4, 2) + if navigation_shape is None: + navigation_shape = (4, 2) + assert s.axes_manager.navigation_shape == navigation_shape def test_no_hdr(): From d5ab8921c6e56fb9ca6f3b666320d69bbd1753c5 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 16 Mar 2024 09:00:26 +0000 Subject: [PATCH 057/174] Fix chunking when `chunks` is specified as a tuple --- rsciio/quantumdetector/_api.py | 11 ++++++++++- rsciio/tests/test_quantumdetector.py | 6 ++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index 921bf8a0f..8bd346091 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -327,13 +327,22 @@ def load_mib_data( data = data["data"] if not return_mmap: if lazy: - data = da.from_array(data, chunks=chunks) + if isinstance(chunks, tuple) and len(chunks) > 2: + # Since the data is reshaped later on, we set only the + # signal dimension chunks here + _chunks = ("auto",) + chunks[-2:] + else: + _chunks = chunks + data = da.from_array(data, chunks=_chunks) else: data = np.array(data) # remove navigation_dimension with value 1 before reshaping navigation_shape = tuple(i for i in navigation_shape if i > 1) data = data.reshape(navigation_shape + mib_prop.merlin_size) + if lazy and isinstance(chunks, tuple) and len(chunks) > 2: + # rechunk navigation space when chunking is specified as a tuple + data = data.rechunk(chunks) if return_headers: return data, headers diff --git a/rsciio/tests/test_quantumdetector.py b/rsciio/tests/test_quantumdetector.py index c47074ccf..eca37b205 100644 --- a/rsciio/tests/test_quantumdetector.py +++ b/rsciio/tests/test_quantumdetector.py @@ -139,7 +139,9 @@ def test_quad_chip(fname): assert axis.units == "" -@pytest.mark.parametrize("chunks", ("auto", (9, 128, 128), ("auto", 128, 128))) +@pytest.mark.parametrize( + "chunks", ("auto", (3, 3, 128, 128), ("auto", "auto", 128, 128)) +) def test_chunks(chunks): fname = TEST_DATA_DIR_UNZIPPED / "Quad_9_Frame_CounterDepth_24_Rows_256.mib" s = hs.load(fname, lazy=True, chunks=chunks) @@ -201,7 +203,7 @@ def test_no_hdr(): shutil.copyfile(fname, fname2) s = hs.load(fname2) assert s.axes_manager.signal_shape == (256, 256) - assert s.axes_manager.navigation_shape == (8,) + assert s.axes_manager.navigation_shape == (4, 2) @pytest.mark.parametrize( From ffdec7b60c4a960e5a058052d94ced5cf4f90483 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 16 Mar 2024 15:59:52 +0000 Subject: [PATCH 058/174] Update documentation --- .../supported_formats/quantumdetector.rst | 11 ----------- rsciio/quantumdetector/_api.py | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/doc/user_guide/supported_formats/quantumdetector.rst b/doc/user_guide/supported_formats/quantumdetector.rst index ff948fc70..5be01929e 100644 --- a/doc/user_guide/supported_formats/quantumdetector.rst +++ b/doc/user_guide/supported_formats/quantumdetector.rst @@ -9,17 +9,6 @@ store a series of diffraction patterns from scanning transmission electron diffraction measurements. It supports reading data from camera with one or four quadrants. -If a ``hdr`` file with the same file name was saved along the ``mib`` file, -it will be used to infer the navigation shape of the providing that the option -"line trigger" was used for the acquisition. Alternatively, the navigation -shape can be specified as an argument: - -.. code-block:: python - - >>> from rsciio.quantumdetector import file_reader - >>> s_dict = file_reader("file.mib", navigation_shape=(256, 256)) - - API functions ^^^^^^^^^^^^^ diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index 8bd346091..8be61ec13 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -219,7 +219,7 @@ def load_mib_data( print_info : bool, default=False If True, display information when loading the file. return_mmap : bool - If True, return the py:func:`numpy.memmap` object. Default is True. + If True, return the :class:`numpy.memmap` object. Default is True. Returns ------- @@ -495,6 +495,9 @@ def file_reader( """ Read a Quantum Detectors ``mib`` file. + If a ``hdr`` file with the same file name was saved along the ``mib`` file, + it will be used to read the metadata. + Parameters ---------- %s @@ -513,6 +516,20 @@ def file_reader( In case of interrupted acquisition, only the completed lines are read and the incomplete line are discarded. + When the scanning shape (i. e. navigation shape) is not available from the + metadata (for example with acquisition using pixel trigger), the timestamps + will be used to guess the navigation shape. + + Examples + -------- + In case, the navigation shape can't read from the data itself (for example, + type of acquisition unsupported), the ``navigation_shape`` can be specified: + + .. code-block:: python + + >>> from rsciio.quantumdetector import file_reader + >>> s_dict = file_reader("file.mib", navigation_shape=(256, 256)) + """ mib_prop = MIBProperties() mib_prop.parse_file(filename) From 126e26c64599161cca19f7334a33a23f4e615569 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 16 Mar 2024 16:25:41 +0000 Subject: [PATCH 059/174] Add changelog entries --- upcoming_changes/235.bugfix.rst | 1 + upcoming_changes/235.enhancements.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 upcoming_changes/235.bugfix.rst create mode 100644 upcoming_changes/235.enhancements.rst diff --git a/upcoming_changes/235.bugfix.rst b/upcoming_changes/235.bugfix.rst new file mode 100644 index 000000000..c15db8c62 --- /dev/null +++ b/upcoming_changes/235.bugfix.rst @@ -0,0 +1 @@ +:ref:`Quantum Detector ` reader: fix setting chunks. \ No newline at end of file diff --git a/upcoming_changes/235.enhancements.rst b/upcoming_changes/235.enhancements.rst new file mode 100644 index 000000000..f97ade010 --- /dev/null +++ b/upcoming_changes/235.enhancements.rst @@ -0,0 +1 @@ +:ref:`Quantum Detector ` reader: use timestamps to get navigation shape when the navigation shape is not available - for example, acquisition with pixel trigger or scan shape not in metadata. \ No newline at end of file From baf4a372b804178ff4de65c074dda00a97921950 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 26 Mar 2024 17:20:40 +0000 Subject: [PATCH 060/174] Fix setting output size image --- rsciio/image/_api.py | 25 +++++++++++-------------- rsciio/tests/test_image.py | 13 +++++++++---- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/rsciio/image/_api.py b/rsciio/image/_api.py index 4401d87e2..d3d952ac5 100644 --- a/rsciio/image/_api.py +++ b/rsciio/image/_api.py @@ -66,16 +66,16 @@ def file_writer( output_size : {2-tuple, int, None}, Default=None The output size of the image in pixels (width, height): - * if ``int``, defines the width of the image, the height is - determined from the aspec ratio of the image - * if ``2-tuple``, defines the width and height of the - image. Padding with white pixels is used to maintain the aspect - ratio of the image. - * if ``None``, the size of the data is used. + * if ``int``, defines the width of the image, the height is + determined from the aspec ratio of the image + * if ``2-tuple``, defines the width and height of the + image. Padding with white pixels is used to maintain the aspect + ratio of the image. + * if ``None``, the size of the data is used. For output sizes larger than the data size, "nearest" interpolation is used by default and this behaviour can be changed through the - *imshow_kwds* dictionary. + ``imshow_kwds`` dictionary. imshow_kwds : dict, optional Keyword arguments dictionary for :py:func:`~.matplotlib.pyplot.imshow`. @@ -136,17 +136,14 @@ def file_writer( else: raise RuntimeError("This dimensionality is not supported.") - aspect_ratio = imshow_kwds.get("aspect", None) - if not isinstance(aspect_ratio, (int, float)): - aspect_ratio = data.shape[0] / data.shape[1] - + aspect_ratio = imshow_kwds.get("aspect", 1) if output_size is None: - # fall back to image size taking into account aspect_ratio + # fall back to image size taking into account aspect ratio = (1, aspect_ratio) - output_size = [axis["size"] * r for axis, r in zip(axes, ratio)] + output_size = [axis["size"] * r for axis, r in zip(axes[::-1], ratio)] elif isinstance(output_size, (int, float)): + aspect_ratio *= data.shape[0] / data.shape[1] output_size = [output_size, output_size * aspect_ratio] - fig = Figure(figsize=[size / dpi for size in output_size], dpi=dpi) # List of format supported by matplotlib diff --git a/rsciio/tests/test_image.py b/rsciio/tests/test_image.py index 4865d271d..3536ee751 100644 --- a/rsciio/tests/test_image.py +++ b/rsciio/tests/test_image.py @@ -197,16 +197,21 @@ def test_export_output_size(output_size, tmp_path): assert s_reload.data.shape == (512, 512) -@pytest.mark.parametrize("output_size", (512, (512, 512))) -def test_export_output_size_non_square(output_size, tmp_path): +@pytest.mark.parametrize("scalebar", [True, False]) +@pytest.mark.parametrize("output_size", (None, 512, (512, 512))) +def test_export_output_size_non_square(output_size, tmp_path, scalebar): hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") pixels = (8, 16) - s = hs.signals.Signal2D(np.arange(np.multiply(*pixels)).reshape(pixels)) + s = hs.signals.Signal2D( + np.arange(np.multiply(*pixels), dtype=np.uint8).reshape(pixels) + ) fname = tmp_path / "test_export_size_non_square.jpg" - s.save(fname, output_size=output_size) + s.save(fname, output_size=output_size, scalebar=scalebar) s_reload = hs.load(fname) + if output_size is None: + output_size = (8, 16) if isinstance(output_size, int): output_size = (output_size * np.divide(*pixels), output_size) From bad0f6ca47c68a3e09b78ab6581a7b0a9df9b5bb Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 26 Mar 2024 17:42:10 +0000 Subject: [PATCH 061/174] Raise error message when `output_size` is an iterable of length different from 2 because matplotlib error message is not clear --- rsciio/image/_api.py | 4 ++++ rsciio/tests/test_image.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/rsciio/image/_api.py b/rsciio/image/_api.py index d3d952ac5..ef1eb2d38 100644 --- a/rsciio/image/_api.py +++ b/rsciio/image/_api.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +from collections.abc import Iterable import os import logging @@ -144,6 +145,9 @@ def file_writer( elif isinstance(output_size, (int, float)): aspect_ratio *= data.shape[0] / data.shape[1] output_size = [output_size, output_size * aspect_ratio] + elif isinstance(output_size, Iterable) and len(output_size) != 2: + # Catch error here, because matplotlib error is not obvious + raise ValueError("If `output_size` is an iterable, it must be of length 2.") fig = Figure(figsize=[size / dpi for size in output_size], dpi=dpi) # List of format supported by matplotlib diff --git a/rsciio/tests/test_image.py b/rsciio/tests/test_image.py index 3536ee751..931b63b0f 100644 --- a/rsciio/tests/test_image.py +++ b/rsciio/tests/test_image.py @@ -288,3 +288,13 @@ def test_renishaw_wire(): np.testing.assert_allclose(axis.offset, offset) axis.name == name axis.units == "µm" + + +def test_export_output_size_iterable_length_1(tmp_path): + hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + pixels = (256, 256) + s = hs.signals.Signal2D(np.arange(np.multiply(*pixels)).reshape(pixels)) + + fname = tmp_path / "test_export_output_size_iterable_length_1.jpg" + with pytest.raises(ValueError): + s.save(fname, output_size=(256,)) From 0f8daf089b178a24a167b34e964be1efbf296295 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 30 Mar 2024 08:40:57 +0000 Subject: [PATCH 062/174] Add changelog entry --- upcoming_changes/244.enhancements.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/244.enhancements.rst diff --git a/upcoming_changes/244.enhancements.rst b/upcoming_changes/244.enhancements.rst new file mode 100644 index 000000000..87c274cda --- /dev/null +++ b/upcoming_changes/244.enhancements.rst @@ -0,0 +1 @@ +Improve setting output size image. \ No newline at end of file From f0439c0406fbe9b845d0025c7daa7d7ebafd7eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20L=C3=A4hnemann?= Date: Sat, 30 Mar 2024 16:25:13 +0100 Subject: [PATCH 063/174] Apply suggestions from code review --- rsciio/image/_api.py | 2 +- upcoming_changes/244.enhancements.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rsciio/image/_api.py b/rsciio/image/_api.py index ef1eb2d38..df851092a 100644 --- a/rsciio/image/_api.py +++ b/rsciio/image/_api.py @@ -68,7 +68,7 @@ def file_writer( The output size of the image in pixels (width, height): * if ``int``, defines the width of the image, the height is - determined from the aspec ratio of the image + determined from the aspect ratio of the image * if ``2-tuple``, defines the width and height of the image. Padding with white pixels is used to maintain the aspect ratio of the image. diff --git a/upcoming_changes/244.enhancements.rst b/upcoming_changes/244.enhancements.rst index 87c274cda..f07116d65 100644 --- a/upcoming_changes/244.enhancements.rst +++ b/upcoming_changes/244.enhancements.rst @@ -1 +1 @@ -Improve setting output size image. \ No newline at end of file +Improve setting output size for an image. \ No newline at end of file From a5ce1c615ed6601f21d9f57ad58ed50ee349990c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:57:00 +0000 Subject: [PATCH 064/174] Bump pypa/cibuildwheel from 2.16.4 to 2.17.0 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 2.16.4 to 2.17.0. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v2.16.4...v2.17.0) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5298aa97d..48efdb68f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels for CPython - uses: pypa/cibuildwheel@v2.16.4 + uses: pypa/cibuildwheel@v2.17.0 env: CIBW_ARCHS: ${{ matrix.CIBW_ARCHS }} From 5a12626f46c9157abd46ea6efc34a71f1b763b7a Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 24 Mar 2024 09:46:58 +0000 Subject: [PATCH 065/174] Display output directory in dist / pure python wheel workflow --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48efdb68f..540d31bbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,10 @@ jobs: - name: Build SDist run: pipx run build --sdist + - name: List SDist + run: | + ls ./dist + - uses: actions/upload-artifact@v4 with: path: dist/*.tar.gz @@ -82,6 +86,10 @@ jobs: - name: Build pure python wheel run: DISABLE_C_EXTENTIONS=1 pipx run build --wheel + - name: List SDist + run: | + ls ./dist + - uses: actions/upload-artifact@v4 with: path: dist/*.whl From 1d3d5a130362362212df34f66e5b175c21447e47 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 24 Mar 2024 09:49:24 +0000 Subject: [PATCH 066/174] Tweak indentation --- .github/workflows/release.yml | 76 +++++++++++++++++------------------ 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 540d31bbe..e42004cb4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: push: # Sequence of patterns matched against refs/tags tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 workflow_dispatch: jobs: @@ -63,36 +63,36 @@ jobs: name: Make SDist runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Build SDist - run: pipx run build --sdist + - name: Build SDist + run: pipx run build --sdist - - name: List SDist - run: | - ls ./dist + - name: List SDist + run: | + ls ./dist - - uses: actions/upload-artifact@v4 - with: - path: dist/*.tar.gz + - uses: actions/upload-artifact@v4 + with: + path: dist/*.tar.gz pure_python_wheel: # Build pure python without C extention to be used by pyodide name: Make pure python wheel runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Build pure python wheel - run: DISABLE_C_EXTENTIONS=1 pipx run build --wheel + - name: Build pure python wheel + run: DISABLE_C_EXTENTIONS=1 pipx run build --wheel - - name: List SDist - run: | - ls ./dist + - name: List SDist + run: | + ls ./dist - - uses: actions/upload-artifact@v4 - with: - path: dist/*.whl + - uses: actions/upload-artifact@v4 + with: + path: dist/*.whl upload_to_pypi: needs: [build_wheels, make_sdist] @@ -101,25 +101,25 @@ jobs: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - - name: Download dist - uses: actions/download-artifact@v4 - with: - name: artifact - path: dist - - - name: Download wheels - uses: actions/download-artifact@v4 - with: - name: wheels - path: dist - - - name: Display structure of downloaded files - run: ls -R - working-directory: dist - - - uses: pypa/gh-action-pypi-publish@release/v1 - if: ${{ startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'hyperspy' }} - # See https://docs.pypi.org/trusted-publishers/using-a-publisher/ + - name: Download dist + uses: actions/download-artifact@v4 + with: + name: artifact + path: dist + + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: wheels + path: dist + + - name: Display structure of downloaded files + run: ls -R + working-directory: dist + + - uses: pypa/gh-action-pypi-publish@release/v1 + if: ${{ startsWith(github.ref, 'refs/tags/') && github.repository_owner == 'hyperspy' }} + # See https://docs.pypi.org/trusted-publishers/using-a-publisher/ create_release: # TODO: once we are happy with the workflow From 9f21c0192b77905b7fec644024ddc8d046a8728f Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 24 Mar 2024 10:15:39 +0000 Subject: [PATCH 067/174] Enable native `osx-arm64` build and fix for `actions/upload@v4` --- .github/workflows/release.yml | 39 +++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e42004cb4..ab50ff5b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ on: jobs: build_wheels: name: Build wheels on ${{ matrix.os }} ${{ matrix.CIBW_ARCHS }} - runs-on: ${{ matrix.os }}-latest + runs-on: ${{ matrix.os }} env: CIBW_ENVIRONMENT: POOCH_BASE_URL=https://github.com/${{ github.repository }}/raw/${{ github.ref_name }}/rsciio/tests/data/ CIBW_TEST_COMMAND: "pytest --pyargs rsciio" @@ -26,14 +26,16 @@ jobs: fail-fast: false matrix: include: - - os: "ubuntu" + - os: "ubuntu-latest" CIBW_ARCHS: "x86_64" - - os: "ubuntu" + - os: "ubuntu-latest" CIBW_ARCHS: "aarch64" - - os: "windows" + - os: "windows-latest" CIBW_ARCHS: "AMD64" - - os: "macos" - CIBW_ARCHS: "x86_64 universal2 arm64" + - os: "macos-13" + CIBW_ARCHS: "x86_64" + - os: "macos-14" + CIBW_ARCHS: "arm64" steps: - name: Set up QEMU @@ -55,7 +57,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: wheels + name: artifacts-${{ matrix.os }}-${{ matrix.CIBW_ARCHS }} path: ./wheelhouse/*.whl if-no-files-found: error @@ -74,6 +76,7 @@ jobs: - uses: actions/upload-artifact@v4 with: + name: artifacts-${{ matrix.os }}-sdist path: dist/*.tar.gz pure_python_wheel: @@ -92,25 +95,31 @@ jobs: - uses: actions/upload-artifact@v4 with: + name: artifacts-${{ matrix.os }}-pure_python path: dist/*.whl + # Merge all disttribution files into the same directory + merge_artifacts: + runs-on: ubuntu-latest + needs: [ build_wheels, make_sdist, pure_python_wheel ] + steps: + - name: Merge Artifacts + uses: actions/upload-artifact/merge@v4 + with: + name: artifacts + pattern: artifacts-* + upload_to_pypi: - needs: [build_wheels, make_sdist] + needs: merge_artifacts runs-on: ubuntu-latest permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - - name: Download dist - uses: actions/download-artifact@v4 - with: - name: artifact - path: dist - - name: Download wheels uses: actions/download-artifact@v4 with: - name: wheels + name: artifacts path: dist - name: Display structure of downloaded files From 4838205354be11279db82f75e849742fe745884b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 24 Mar 2024 10:03:43 +0000 Subject: [PATCH 068/174] Fix downloading data when using `pytest --pyargs rsciio -n 2` --- conda_environment_dev.yml | 1 + pyproject.toml | 1 + rsciio/tests/conftest.py | 40 +++++++++++++++++++++++++++++---------- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/conda_environment_dev.yml b/conda_environment_dev.yml index 22d412c03..42e4bd323 100644 --- a/conda_environment_dev.yml +++ b/conda_environment_dev.yml @@ -9,3 +9,4 @@ dependencies: - pytest-rerunfailures - hyperspy-base - setuptools-scm +- filelock diff --git a/pyproject.toml b/pyproject.toml index 40267c465..26616ea05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ tiff = ["tifffile>=2020.2.16", "imagecodecs>=2020.1.31"] usid = ["pyUSID", "sidpy<=0.12.0"] zspy = ["zarr", "msgpack"] tests = [ + "filelock", "pooch", "pytest>=3.6", "pytest-xdist", diff --git a/rsciio/tests/conftest.py b/rsciio/tests/conftest.py index d1958dae4..860dd6905 100644 --- a/rsciio/tests/conftest.py +++ b/rsciio/tests/conftest.py @@ -17,9 +17,12 @@ # along with RosettaSciIO. If not, see . import os +import json from packaging.version import Version -from rsciio.tests.registry_utils import download_all +from filelock import FileLock +import pytest + try: import hyperspy @@ -33,13 +36,30 @@ pass -def pytest_configure(config): - # Run in pytest_configure hook to avoid capturing stdout by pytest and - # inform user that the test data are being downloaded +def _download_test_data(): + from rsciio.tests.registry_utils import download_all + + print("Checking if test data need downloading...") + download_all() + print("All test data available.") + + +# From https://pytest-xdist.readthedocs.io/en/latest/how-to.html#making-session-scoped-fixtures-execute-only-once +@pytest.fixture(scope="session", autouse=True) +def session_data(tmp_path_factory, worker_id): + if worker_id == "master": + # not executing in with multiple workers, just produce the data and let + # pytest's fixture caching do its job + return _download_test_data() + + # get the temp directory shared by all workers + root_tmp_dir = tmp_path_factory.getbasetemp().parent - # Workaround to avoid running it for each worker - worker_id = os.environ.get("PYTEST_XDIST_WORKER") - if worker_id is None: - print("Checking if test data need downloading...") - download_all() - print("All test data available.") + fn = root_tmp_dir / "data.json" + with FileLock(str(fn) + ".lock"): + if fn.is_file(): + data = json.loads(fn.read_text()) + else: + data = _download_test_data() + fn.write_text(json.dumps(data)) + return data From 154ba32e9d75c98ca9a704145a5655a5a65332be Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 24 Mar 2024 10:36:56 +0000 Subject: [PATCH 069/174] Test with passing `PYTEST_ARGS` to `package_and_test.yml` workflow --- .github/workflows/package_and_test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/package_and_test.yml b/.github/workflows/package_and_test.yml index eb2a30f8d..d8fc74366 100644 --- a/.github/workflows/package_and_test.yml +++ b/.github/workflows/package_and_test.yml @@ -12,3 +12,5 @@ jobs: # "github.event.pull_request.head.repo.full_name" is for "pull request" event while github.repository is for "push" event # "github.event.pull_request.head.ref" is for "pull request" event while "github.ref_name" is for "push" event POOCH_BASE_URL: https://github.com/${{ github.event.pull_request.head.repo.full_name || github.repository }}/raw/${{ github.event.pull_request.head.ref || github.ref_name }}/rsciio/tests/data/ + # "-s" is used to show of output when downloading the test files + PYTEST_ARGS: "-n 2 -s" From ac1d5d79d87b5fcf255f0ce459a53ef6a4d20b6b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 31 Mar 2024 12:07:34 +0100 Subject: [PATCH 070/174] Fix xml test to use fixture --- rsciio/tests/utils/test_utils.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/rsciio/tests/utils/test_utils.py b/rsciio/tests/utils/test_utils.py index 5bc573693..478818288 100644 --- a/rsciio/tests/utils/test_utils.py +++ b/rsciio/tests/utils/test_utils.py @@ -13,15 +13,15 @@ dt = [("x", np.uint8), ("y", np.uint16), ("text", (bytes, 6))] -MY_PATH = Path(__file__).parent -TEST_XML_PATH = MY_PATH / ".." / "data" / "ToastedBreakFastSDD.xml" +@pytest.fixture +def XML_TEST_NODE(): + MY_PATH = Path(__file__).parent + TEST_XML_PATH = MY_PATH / ".." / "data" / "ToastedBreakFastSDD.xml" + with open(TEST_XML_PATH, "r") as fn: + weird_but_valid_xml_str = fn.read() -with open(TEST_XML_PATH, "r") as fn: - weird_but_valid_xml_str = fn.read() - - -XML_TEST_NODE = ET.fromstring(weird_but_valid_xml_str) + yield ET.fromstring(weird_but_valid_xml_str) # fmt: off @@ -42,7 +42,7 @@ def test_msxml_sanitization(): assert et[3].text == "0,2,3" # is not float -def test_default_x2d(): +def test_default_x2d(XML_TEST_NODE): """test of default XmlToDict translation with attributes prefixed with @, interchild_text_parsing set to 'first', no flattening tags set, and dub_text_str set to '#value' @@ -59,7 +59,7 @@ def test_default_x2d(): assert pynode["TestXML"]["Main"]["ClassInstance"]["Sample"]["#value"] == t -def test_skip_interchild_text_flatten(): +def test_skip_interchild_text_flatten(XML_TEST_NODE): """test of XmlToDict translation with interchild_text_parsing set to 'skip', three string containing list set to flattening tags. Other kwrds - default. """ @@ -72,7 +72,7 @@ def test_skip_interchild_text_flatten(): assert pynode["Main"]["Sample"].get("#value") is None -def test_concat_interchild_text_val_flatten(): +def test_concat_interchild_text_val_flatten(XML_TEST_NODE): """test of XmlToDict translator with interchild_text_parsing set to 'cat' (concatenation), four flattening tags set, and dub_text_str set to '#text' @@ -91,7 +91,7 @@ def test_concat_interchild_text_val_flatten(): assert pynode["Sample"]["#interchild_text"] == t -def test_list_interchild_text_val_flatten(): +def test_list_interchild_text_val_flatten(XML_TEST_NODE): """test of XmlToDict translator interchild_text_parsing set to 'list' """ x2d = XmlToDict( @@ -107,7 +107,7 @@ def test_list_interchild_text_val_flatten(): ] -def x2d_subclass_for_custom_bool(): +def x2d_subclass_for_custom_bool(XML_TEST_NODE): """test subclass of XmlToDict with updated eval function""" class CustomXmlToDict(XmlToDict): @@ -390,6 +390,7 @@ def test_get_chunk_slice(shape): assert chunk_arr.shape == (1,)*len(shape)+(len(shape), 2) assert chunk == tuple([(i,)for i in shape]) + @pytest.mark.parametrize("shape", ((10, 20, 30, 512, 512),(20, 30, 512, 512), (10, 512, 512), (512, 512))) def test_get_chunk_slice(shape): chunks =(1,)*(len(shape)-2) +(-1,-1) From 8c65a552e3d1e2bbf02d672f9cab52ad094a9752 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 31 Mar 2024 16:26:26 +0100 Subject: [PATCH 071/174] Show downloading output without pytest `-s` argument --- .github/workflows/package_and_test.yml | 2 +- rsciio/tests/conftest.py | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/package_and_test.yml b/.github/workflows/package_and_test.yml index d8fc74366..d52b6cfac 100644 --- a/.github/workflows/package_and_test.yml +++ b/.github/workflows/package_and_test.yml @@ -13,4 +13,4 @@ jobs: # "github.event.pull_request.head.ref" is for "pull request" event while "github.ref_name" is for "push" event POOCH_BASE_URL: https://github.com/${{ github.event.pull_request.head.repo.full_name || github.repository }}/raw/${{ github.event.pull_request.head.ref || github.ref_name }}/rsciio/tests/data/ # "-s" is used to show of output when downloading the test files - PYTEST_ARGS: "-n 2 -s" + PYTEST_ARGS: "-n 2" diff --git a/rsciio/tests/conftest.py b/rsciio/tests/conftest.py index 860dd6905..875e3673c 100644 --- a/rsciio/tests/conftest.py +++ b/rsciio/tests/conftest.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import os import json from packaging.version import Version @@ -36,17 +35,21 @@ pass -def _download_test_data(): - from rsciio.tests.registry_utils import download_all +# From https://pytest-xdist.readthedocs.io/en/latest/how-to.html#making-session-scoped-fixtures-execute-only-once +@pytest.fixture(scope="session", autouse=True) +def session_data(request, tmp_path_factory, worker_id): + capmanager = request.config.pluginmanager.getplugin("capturemanager") - print("Checking if test data need downloading...") - download_all() - print("All test data available.") + def _download_test_data(): + from rsciio.tests.registry_utils import download_all + with capmanager.global_and_fixture_disabled(): + print("Checking if test data need downloading...") + download_all() + print("All test data available.") + + return "Test data available" -# From https://pytest-xdist.readthedocs.io/en/latest/how-to.html#making-session-scoped-fixtures-execute-only-once -@pytest.fixture(scope="session", autouse=True) -def session_data(tmp_path_factory, worker_id): if worker_id == "master": # not executing in with multiple workers, just produce the data and let # pytest's fixture caching do its job From 8de9d5a2f39d76f57d27ba9180a2b39ce846f411 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 31 Mar 2024 16:38:15 +0100 Subject: [PATCH 072/174] Add changelog entry --- upcoming_changes/245.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/245.maintenance.rst diff --git a/upcoming_changes/245.maintenance.rst b/upcoming_changes/245.maintenance.rst new file mode 100644 index 000000000..14ab0d5de --- /dev/null +++ b/upcoming_changes/245.maintenance.rst @@ -0,0 +1 @@ +Fix download test data when using ``pytest --pyargs rsciio -n``. \ No newline at end of file From ee999abb392ab956edd1222298c5afbc040a315d Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 29 Mar 2024 10:30:33 +0000 Subject: [PATCH 073/174] Cover case with "Frames per Trigger (Number): 0" in hdr file --- rsciio/quantumdetector/_api.py | 8 +++++++- rsciio/tests/test_quantumdetector.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index 8be61ec13..899759800 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -574,7 +574,13 @@ def file_reader( if len(indices) > 0 and len(indices[0]) > 0: frame_per_trigger = indices[0][0] + 1 - navigation_shape = (frame_per_trigger, frames_number // frame_per_trigger) + if frames_number == 0: + # Some hdf files have the "Frames per Trigger (Number)": 0 + # in this case, we don't reshape + # Possibly for "continuous and indefinite" acquisition + navigation_shape = None + else: + navigation_shape = (frame_per_trigger, frames_number // frame_per_trigger) data = load_mib_data( filename, diff --git a/rsciio/tests/test_quantumdetector.py b/rsciio/tests/test_quantumdetector.py index eca37b205..e14a7c3fd 100644 --- a/rsciio/tests/test_quantumdetector.py +++ b/rsciio/tests/test_quantumdetector.py @@ -30,6 +30,7 @@ MIBProperties, load_mib_data, parse_exposures, + parse_hdr_file, parse_timestamps, ) @@ -373,3 +374,23 @@ def test_load_save_cycle(tmp_path): assert s.axes_manager.navigation_shape == s2.axes_manager.navigation_shape assert s.axes_manager.signal_shape == s2.axes_manager.signal_shape assert s.data.dtype == s2.data.dtype + + +def test_frames_in_acquisition_zero(): + # Some hdr file have entry "Frames per Trigger (Number): 0" + # Possibly for "continuous and indefinite" acquisition + # Copy and edit a file with corresponding changes + base_fname = TEST_DATA_DIR_UNZIPPED / "Single_1_Frame_CounterDepth_6_Rows_256" + fname = f"{base_fname}_zero_frames_in_acquisition" + # Create test file using existing test file + shutil.copyfile(f"{base_fname}.mib", f"{fname}.mib") + hdf_dict = parse_hdr_file(f"{base_fname}.hdr") + hdf_dict["Frames in Acquisition (Number)"] = 0 + with open(f"{fname}.hdr", "w") as f: + f.write("HDR\n") + for k, v in hdf_dict.items(): + f.write(f"{k}:\t{v}\n") + f.write("End\t") + + s = hs.load(f"{fname}.mib") + assert s.axes_manager.navigation_shape == () From 468482c3b5078dcac7fc8c7b773b52b2288800fd Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 31 Mar 2024 14:58:51 +0100 Subject: [PATCH 074/174] Fix reshape with interrupted acquisition --- rsciio/quantumdetector/_api.py | 6 +++--- rsciio/tests/test_quantumdetector.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index 899759800..d198cc244 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -268,9 +268,9 @@ def load_mib_data( # Reshape only when the slice from zeros if first_frame == 0 and len(navigation_shape) > 1: navigation_shape = ( - navigation_shape[1], - frame_number_in_file // navigation_shape[1], - ) + navigation_shape[0], + frame_number_in_file // navigation_shape[0], + )[::-1] else: navigation_shape = (number_of_frames_to_load,) elif number_of_frames_to_load < frame_number: diff --git a/rsciio/tests/test_quantumdetector.py b/rsciio/tests/test_quantumdetector.py index e14a7c3fd..f8ff9a91a 100644 --- a/rsciio/tests/test_quantumdetector.py +++ b/rsciio/tests/test_quantumdetector.py @@ -167,7 +167,7 @@ def test_mib_properties_quad__repr__(): def test_interrupted_acquisition(): fname = TEST_DATA_DIR_UNZIPPED / "Single_9_Frame_CounterDepth_1_Rows_256.mib" # There is only 9 frames, simulate interrupted acquisition using 10 lines - s = hs.load(fname, navigation_shape=(10, 2)) + s = hs.load(fname, navigation_shape=(4, 3)) assert s.axes_manager.signal_shape == (256, 256) assert s.axes_manager.navigation_shape == (4, 2) From 257ab548754088b7133cbb66f284d7ec3d496880 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 1 Apr 2024 18:49:02 +0100 Subject: [PATCH 075/174] Prepare release 0.4 --- CHANGES.rst | 38 +++++++++++++++++++++++++++ pyproject.toml | 2 +- upcoming_changes/200.maintenance.rst | 1 - upcoming_changes/206.bugfix.rst | 1 - upcoming_changes/210.maintenance.rst | 1 - upcoming_changes/211.bugfix.rst | 1 - upcoming_changes/217.bugfix.rst | 1 - upcoming_changes/222.maintenance.rst | 1 - upcoming_changes/227.enhancements.rst | 5 ---- upcoming_changes/230.maintenance.rst | 1 - upcoming_changes/231.bugfix.rst | 1 - upcoming_changes/232.enhancements.rst | 1 - upcoming_changes/233.enhancements.rst | 1 - upcoming_changes/235.bugfix.rst | 1 - upcoming_changes/235.enhancements.rst | 1 - upcoming_changes/238.maintenance.rst | 1 - upcoming_changes/244.enhancements.rst | 1 - upcoming_changes/245.maintenance.rst | 1 - 18 files changed, 39 insertions(+), 21 deletions(-) delete mode 100644 upcoming_changes/200.maintenance.rst delete mode 100644 upcoming_changes/206.bugfix.rst delete mode 100644 upcoming_changes/210.maintenance.rst delete mode 100644 upcoming_changes/211.bugfix.rst delete mode 100644 upcoming_changes/217.bugfix.rst delete mode 100644 upcoming_changes/222.maintenance.rst delete mode 100644 upcoming_changes/227.enhancements.rst delete mode 100644 upcoming_changes/230.maintenance.rst delete mode 100644 upcoming_changes/231.bugfix.rst delete mode 100644 upcoming_changes/232.enhancements.rst delete mode 100644 upcoming_changes/233.enhancements.rst delete mode 100644 upcoming_changes/235.bugfix.rst delete mode 100644 upcoming_changes/235.enhancements.rst delete mode 100644 upcoming_changes/238.maintenance.rst delete mode 100644 upcoming_changes/244.enhancements.rst delete mode 100644 upcoming_changes/245.maintenance.rst diff --git a/CHANGES.rst b/CHANGES.rst index e000255dc..e8d73b84f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,44 @@ https://rosettasciio.readthedocs.io/en/latest/changes.html .. towncrier release notes start +0.4 (2024-04-02) +================ + +Enhancements +------------ + +- :ref:`Renishaw wdf `: + + - return survey image instead of saving it to the metadata and add marker of the mapping area on the survey image. + - Add support for reading data with invariant axis, for example when the values of the Z axis doesn't change. + - Parse calibration of ``jpg`` images saved with Renishaw Wire software. (`#227 `_) +- Add support for reading :ref:`emd ` Velox version 11. (`#232 `_) +- Add :ref:`making test data files ` section to contributing guide, explain characteristics of "good" test data files. (`#233 `_) +- :ref:`Quantum Detector ` reader: use timestamps to get navigation shape when the navigation shape is not available - for example, acquisition with pixel trigger or scan shape not in metadata. (`#235 `_) +- Improve setting output size for an image. (`#244 `_) + + +Bug Fixes +--------- + +- Fix saving ``hspy`` file with empty array (signal or metadata) and fix closing ``hspy`` file when a error occurs during reading or writing. (`#206 `_) +- Fix saving ragged arrays of vectors from/to a chunked ``hspy`` and ``zspy`` store. Greatly increases the speed of saving and loading ragged arrays from chunked datasets. (`#211 `_) +- Fix saving ragged array of strings in ``hspy`` and ``zspy`` format. (`#217 `_) +- Fix setting beam energy for XRF maps in ``bcf`` files. (`#231 `_) +- :ref:`Quantum Detector ` reader: fix setting chunks. (`#235 `_) + + +Maintenance +----------- + +- Add ``POOCH_BASE_URL`` to specify the base url used by pooch to download test data. This fixes the failure of the ``package_and_test.yml`` workflow in pull requests where test data are added or updated. (`#200 `_) +- Fix documentation links following release of hyperspy 2.0. (`#210 `_) +- Run test suite on osx arm64 on GitHub CI and speed running test suite using all available CPUs (3 or 4) instead of only 2. (`#222 `_) +- Fix deprecation warnings introduced with numpy 1.25 ("Conversion of an array with ndim > 0 to a scalar is deprecated, ..."). (`#230 `_) +- Fix numpy 2.0 removal (``np.product`` and ``np.string_``). (`#238 `_) +- Fix download test data when using ``pytest --pyargs rsciio -n``. (`#245 `_) + + 0.3 (2023-12-12) ================ diff --git a/pyproject.toml b/pyproject.toml index 26616ea05..4a803a791 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ include = ["rsciio*"] [tool.setuptools_scm] # Presence enables setuptools_scm, the version will be determine at build time from git # The version will be updated by the `prepare_release.py` script -fallback_version = "0.4.dev0" +fallback_version = "0.5.dev0" [tool.towncrier] directory = "upcoming_changes/" diff --git a/upcoming_changes/200.maintenance.rst b/upcoming_changes/200.maintenance.rst deleted file mode 100644 index 8479e2bc6..000000000 --- a/upcoming_changes/200.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Add `POOCH_BASE_URL` to specify the base url used by pooch to download test data. This fixes the failure of the ``package_and_test.yml`` workflow in pull requests where test data are added or updated. \ No newline at end of file diff --git a/upcoming_changes/206.bugfix.rst b/upcoming_changes/206.bugfix.rst deleted file mode 100644 index a3c9f46e8..000000000 --- a/upcoming_changes/206.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix saving ``hspy`` file with empty array (signal or metadata) and fix closing ``hspy`` file when a error occurs during reading or writing. \ No newline at end of file diff --git a/upcoming_changes/210.maintenance.rst b/upcoming_changes/210.maintenance.rst deleted file mode 100644 index 433ada278..000000000 --- a/upcoming_changes/210.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Fix documentation links following release of hyperspy 2.0. \ No newline at end of file diff --git a/upcoming_changes/211.bugfix.rst b/upcoming_changes/211.bugfix.rst deleted file mode 100644 index fd8dffc84..000000000 --- a/upcoming_changes/211.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix saving ragged arrays of vectors from/to a chunked ``hspy`` and ``zspy`` store. Greatly increases the speed of saving and loading ragged arrays from chunked datasets. \ No newline at end of file diff --git a/upcoming_changes/217.bugfix.rst b/upcoming_changes/217.bugfix.rst deleted file mode 100644 index 3f3de20ba..000000000 --- a/upcoming_changes/217.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix saving ragged array of strings in ``hspy`` and ``zspy`` format. \ No newline at end of file diff --git a/upcoming_changes/222.maintenance.rst b/upcoming_changes/222.maintenance.rst deleted file mode 100644 index 5638f2a57..000000000 --- a/upcoming_changes/222.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Run test suite on osx arm64 on GitHub CI and speed running test suite using all available CPUs (3 or 4) instead of only 2. \ No newline at end of file diff --git a/upcoming_changes/227.enhancements.rst b/upcoming_changes/227.enhancements.rst deleted file mode 100644 index 56e577a57..000000000 --- a/upcoming_changes/227.enhancements.rst +++ /dev/null @@ -1,5 +0,0 @@ -:ref:`Renishaw wdf `: - -- return survey image instead of saving it to the metadata and add marker of the mapping area on the survey image. -- Add support for reading data with invariant axis, for example when the values of the Z axis doesn't change. -- Parse calibration of ``jpg`` images saved with Renishaw Wire software. \ No newline at end of file diff --git a/upcoming_changes/230.maintenance.rst b/upcoming_changes/230.maintenance.rst deleted file mode 100644 index fa7b2a909..000000000 --- a/upcoming_changes/230.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Fix deprecation warnings introduced with numpy 1.25 ("Conversion of an array with ndim > 0 to a scalar is deprecated, ..."). diff --git a/upcoming_changes/231.bugfix.rst b/upcoming_changes/231.bugfix.rst deleted file mode 100644 index ded58ac51..000000000 --- a/upcoming_changes/231.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix setting beam energy for XRF maps in ``bcf`` files. \ No newline at end of file diff --git a/upcoming_changes/232.enhancements.rst b/upcoming_changes/232.enhancements.rst deleted file mode 100644 index 1c471e75d..000000000 --- a/upcoming_changes/232.enhancements.rst +++ /dev/null @@ -1 +0,0 @@ -Add support for reading :ref:`emd ` Velox version 11. \ No newline at end of file diff --git a/upcoming_changes/233.enhancements.rst b/upcoming_changes/233.enhancements.rst deleted file mode 100644 index ceff7c484..000000000 --- a/upcoming_changes/233.enhancements.rst +++ /dev/null @@ -1 +0,0 @@ -Add :ref:`making test data files ` section to contributing guide, explain characteristics of "good" test data files. \ No newline at end of file diff --git a/upcoming_changes/235.bugfix.rst b/upcoming_changes/235.bugfix.rst deleted file mode 100644 index c15db8c62..000000000 --- a/upcoming_changes/235.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`Quantum Detector ` reader: fix setting chunks. \ No newline at end of file diff --git a/upcoming_changes/235.enhancements.rst b/upcoming_changes/235.enhancements.rst deleted file mode 100644 index f97ade010..000000000 --- a/upcoming_changes/235.enhancements.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`Quantum Detector ` reader: use timestamps to get navigation shape when the navigation shape is not available - for example, acquisition with pixel trigger or scan shape not in metadata. \ No newline at end of file diff --git a/upcoming_changes/238.maintenance.rst b/upcoming_changes/238.maintenance.rst deleted file mode 100644 index b2c617455..000000000 --- a/upcoming_changes/238.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Fix numpy 2.0 removal (``np.product`` and ``np.string_``). \ No newline at end of file diff --git a/upcoming_changes/244.enhancements.rst b/upcoming_changes/244.enhancements.rst deleted file mode 100644 index f07116d65..000000000 --- a/upcoming_changes/244.enhancements.rst +++ /dev/null @@ -1 +0,0 @@ -Improve setting output size for an image. \ No newline at end of file diff --git a/upcoming_changes/245.maintenance.rst b/upcoming_changes/245.maintenance.rst deleted file mode 100644 index 14ab0d5de..000000000 --- a/upcoming_changes/245.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Fix download test data when using ``pytest --pyargs rsciio -n``. \ No newline at end of file From 4434c21ac6b457691ddb5f58f2c151d963ccfc75 Mon Sep 17 00:00:00 2001 From: wieczoth Date: Fri, 5 Apr 2024 12:45:33 +0200 Subject: [PATCH 076/174] fix for issue #243 (EMD axes data) --- rsciio/emd/_emd_velox.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index 1e599fc37..a0f722675 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -358,14 +358,14 @@ def _read_image(self, image_group, image_sub_group_key): scale_x = self._convert_scale_units( pix_scale["width"], original_units, data.shape[i + 1] ) - scale_y = self._convert_scale_units( - pix_scale["height"], original_units, data.shape[i] + scale_y = self._convert_scale_units_to( + pix_scale["height"], original_units, scale_x[1] ) - offset_x = self._convert_scale_units( - offsets["x"], original_units, data.shape[i + 1] + offset_x = self._convert_scale_units_to( + offsets["x"], original_units, scale_x[1] ) - offset_y = self._convert_scale_units( - offsets["y"], original_units, data.shape[i] + offset_y = self._convert_scale_units_to( + offsets["y"], original_units, scale_x[1] ) axes.extend( [ @@ -616,14 +616,14 @@ def _read_stream(key): scale_x = self._convert_scale_units( pixel_size["width"], original_units, spectrum_image_shape[1] ) - scale_y = self._convert_scale_units( - pixel_size["height"], original_units, spectrum_image_shape[0] + scale_y = self._convert_scale_units_to( + pixel_size["height"], original_units, scale_x[1] ) - offset_x = self._convert_scale_units( - offsets["x"], original_units, spectrum_image_shape[1] + offset_x = self._convert_scale_units_to( + offsets["x"], original_units, scale_x[1] ) - offset_y = self._convert_scale_units( - offsets["y"], original_units, spectrum_image_shape[0] + offset_y = self._convert_scale_units_to( + offsets["y"], original_units, scale_x[1] ) i = 0 @@ -722,6 +722,15 @@ def _convert_scale_units(self, value, units, factor=1): converted_units = "{:~}".format(converted_v.units) return converted_value, converted_units + def _convert_scale_units_to(self, value, units, to_units): + if units is None: + return value, units + converted_v = float(value) * _UREG(units) + converted_v = converted_v.to(_UREG(to_units)) + converted_value = float(converted_v.magnitude) + converted_units = "{:~}".format(converted_v.units) + return converted_value, converted_units + def _get_metadata_dict(self, om): meta_gen = {} meta_gen["original_filename"] = os.path.split(self.filename)[1] From 7eabc84fb4317af668dedb5918cce0f146c1372d Mon Sep 17 00:00:00 2001 From: thorwiec Date: Sun, 7 Apr 2024 11:54:45 +0200 Subject: [PATCH 077/174] used convert of tools.py / added comments --- rsciio/emd/_emd_velox.py | 66 ++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index a0f722675..e162761dc 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -40,6 +40,7 @@ _parse_sub_data_group_metadata, ) from rsciio.utils.tools import _UREG +from rsciio.utils.tools import convert_units from rsciio.utils.elements import atomic_number2name @@ -358,21 +359,31 @@ def _read_image(self, image_group, image_sub_group_key): scale_x = self._convert_scale_units( pix_scale["width"], original_units, data.shape[i + 1] ) - scale_y = self._convert_scale_units_to( - pix_scale["height"], original_units, scale_x[1] - ) - offset_x = self._convert_scale_units_to( - offsets["x"], original_units, scale_x[1] + # to avoid mismatching units between x and y axis, use the same unit as x + # x is chosen as reference, because scalebar used (usually) the horizonal axis + # and the units conversion is tuned to get decent scale bar + scale_y = [ + convert_units( + float(pix_scale["height"]), original_units, scale_x[1] + ), + scale_x[1] + ] + # Because "axes" only allows one common unit for offset and scale, + # offset_x is converted to the same unit as scale_x + offset_x = convert_units( + float(offsets["x"]), original_units, scale_x[1] ) - offset_y = self._convert_scale_units_to( - offsets["y"], original_units, scale_x[1] + # Because "axes" only allows one common unit for offset and scale, + # offset_y is converted to the same unit as scale_y + offset_y = convert_units( + float(offsets["y"]), original_units, scale_y[1] ) axes.extend( [ { "index_in_array": i, "name": "y", - "offset": offset_y[0], + "offset": offset_y, "scale": scale_y[0], "size": data.shape[i], "units": scale_y[1], @@ -381,7 +392,7 @@ def _read_image(self, image_group, image_sub_group_key): { "index_in_array": i + 1, "name": "x", - "offset": offset_x[0], + "offset": offset_x, "scale": scale_x[0], "size": data.shape[i + 1], "units": scale_x[1], @@ -616,14 +627,24 @@ def _read_stream(key): scale_x = self._convert_scale_units( pixel_size["width"], original_units, spectrum_image_shape[1] ) - scale_y = self._convert_scale_units_to( - pixel_size["height"], original_units, scale_x[1] - ) - offset_x = self._convert_scale_units_to( - offsets["x"], original_units, scale_x[1] + # to avoid mismatching units between x and y axis, use the same unit as x + # x is chosen as reference, because scalebar used (usually) the horizonal axis + # and the units conversion is tuned to get decent scale bar + scale_y = [ + convert_units( + float(pixel_size["height"]), original_units, scale_x[1] + ), + scale_x[1] + ] + # Because "axes" only allows one common unit for offset and scale, + # offset_x is converted to the same unit as scale_x + offset_x = convert_units( + float(offsets["x"]), original_units, scale_x[1] ) - offset_y = self._convert_scale_units_to( - offsets["y"], original_units, scale_x[1] + # Because "axes" only allows one common unit for offset and scale, + # offset_y is converted to the same unit as scale_y + offset_y = convert_units( + float(offsets["y"]), original_units, scale_y[1] ) i = 0 @@ -650,7 +671,7 @@ def _read_stream(key): { "index_in_array": i, "name": "y", - "offset": offset_y[0], + "offset": offset_y, "scale": scale_y[0], "size": spectrum_image_shape[i], "units": scale_y[1], @@ -659,7 +680,7 @@ def _read_stream(key): { "index_in_array": i + 1, "name": "x", - "offset": offset_x[0], + "offset": offset_x, "scale": scale_x[0], "size": spectrum_image_shape[i + 1], "units": scale_x[1], @@ -722,15 +743,6 @@ def _convert_scale_units(self, value, units, factor=1): converted_units = "{:~}".format(converted_v.units) return converted_value, converted_units - def _convert_scale_units_to(self, value, units, to_units): - if units is None: - return value, units - converted_v = float(value) * _UREG(units) - converted_v = converted_v.to(_UREG(to_units)) - converted_value = float(converted_v.magnitude) - converted_units = "{:~}".format(converted_v.units) - return converted_value, converted_units - def _get_metadata_dict(self, om): meta_gen = {} meta_gen["original_filename"] = os.path.split(self.filename)[1] From fc449b410a2704ab7451506eb9e022c5baeec825 Mon Sep 17 00:00:00 2001 From: wieczoth Date: Mon, 8 Apr 2024 08:22:30 +0200 Subject: [PATCH 078/174] upcoming changes log --- upcoming_changes/243.bugfix.rst | 1 + upcoming_changes/243.enhancements.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 upcoming_changes/243.bugfix.rst create mode 100644 upcoming_changes/243.enhancements.rst diff --git a/upcoming_changes/243.bugfix.rst b/upcoming_changes/243.bugfix.rst new file mode 100644 index 000000000..1f18e0350 --- /dev/null +++ b/upcoming_changes/243.bugfix.rst @@ -0,0 +1 @@ +:ref:`emd_fei-format`: Fix conversion of offset units which can sometimes mismatch the scale units. \ No newline at end of file diff --git a/upcoming_changes/243.enhancements.rst b/upcoming_changes/243.enhancements.rst new file mode 100644 index 000000000..24809d507 --- /dev/null +++ b/upcoming_changes/243.enhancements.rst @@ -0,0 +1 @@ +:ref:`emd_fei-format`: Enforce setting identical units for the ``x`` and ``y`` axes, as convenience to use the scalebar in HyperSpy. \ No newline at end of file From a0982fbc4457ec79f63ca5ec37bf28ed840e3c54 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 8 Apr 2024 18:07:54 +0100 Subject: [PATCH 079/174] Simplify code for converting units --- rsciio/emd/_emd_velox.py | 59 +++++++++++++--------------------------- 1 file changed, 19 insertions(+), 40 deletions(-) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index e162761dc..cb32347b0 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -356,46 +356,36 @@ def _read_image(self, image_group, image_sub_group_key): } ) i = 1 - scale_x = self._convert_scale_units( + scale_x, x_unit = self._convert_scale_units( pix_scale["width"], original_units, data.shape[i + 1] ) # to avoid mismatching units between x and y axis, use the same unit as x # x is chosen as reference, because scalebar used (usually) the horizonal axis # and the units conversion is tuned to get decent scale bar - scale_y = [ - convert_units( - float(pix_scale["height"]), original_units, scale_x[1] - ), - scale_x[1] - ] - # Because "axes" only allows one common unit for offset and scale, - # offset_x is converted to the same unit as scale_x - offset_x = convert_units( - float(offsets["x"]), original_units, scale_x[1] - ) + scale_y = convert_units(float(pix_scale["height"]), original_units, x_unit) # Because "axes" only allows one common unit for offset and scale, - # offset_y is converted to the same unit as scale_y - offset_y = convert_units( - float(offsets["y"]), original_units, scale_y[1] - ) + # offset_x, offset_y is converted to the same unit as x_unit + offset_x = convert_units(float(offsets["x"]), original_units, x_unit) + offset_y = convert_units(float(offsets["y"]), original_units, x_unit) + axes.extend( [ { "index_in_array": i, "name": "y", "offset": offset_y, - "scale": scale_y[0], + "scale": scale_y, "size": data.shape[i], - "units": scale_y[1], + "units": x_unit, "navigate": False, }, { "index_in_array": i + 1, "name": "x", "offset": offset_x, - "scale": scale_x[0], + "scale": scale_x, "size": data.shape[i + 1], - "units": scale_x[1], + "units": x_unit, "navigate": False, }, ] @@ -624,28 +614,17 @@ def _read_stream(key): pixel_size, offsets, original_units = streams[0].get_pixelsize_offset_unit() dispersion, offset, unit = self._get_dispersion_offset(original_metadata) - scale_x = self._convert_scale_units( + scale_x, x_unit = self._convert_scale_units( pixel_size["width"], original_units, spectrum_image_shape[1] ) # to avoid mismatching units between x and y axis, use the same unit as x # x is chosen as reference, because scalebar used (usually) the horizonal axis # and the units conversion is tuned to get decent scale bar - scale_y = [ - convert_units( - float(pixel_size["height"]), original_units, scale_x[1] - ), - scale_x[1] - ] + scale_y = convert_units(float(pixel_size["height"]), original_units, x_unit) # Because "axes" only allows one common unit for offset and scale, - # offset_x is converted to the same unit as scale_x - offset_x = convert_units( - float(offsets["x"]), original_units, scale_x[1] - ) - # Because "axes" only allows one common unit for offset and scale, - # offset_y is converted to the same unit as scale_y - offset_y = convert_units( - float(offsets["y"]), original_units, scale_y[1] - ) + # offset_x, offset_y is converted to the same unit as x_unit + offset_x = convert_units(float(offsets["x"]), original_units, x_unit) + offset_y = convert_units(float(offsets["y"]), original_units, x_unit) i = 0 axes = [] @@ -672,18 +651,18 @@ def _read_stream(key): "index_in_array": i, "name": "y", "offset": offset_y, - "scale": scale_y[0], + "scale": scale_y, "size": spectrum_image_shape[i], - "units": scale_y[1], + "units": x_unit, "navigate": True, }, { "index_in_array": i + 1, "name": "x", "offset": offset_x, - "scale": scale_x[0], + "scale": scale_x, "size": spectrum_image_shape[i + 1], - "units": scale_x[1], + "units": x_unit, "navigate": True, }, { From f1190ef0c8497b6a17b742cb29533c8786f2fbb4 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 6 Apr 2024 10:43:18 +0100 Subject: [PATCH 080/174] Add `assert_deep_almost_equal` copied from hyperspy --- rsciio/utils/tests.py | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/rsciio/utils/tests.py b/rsciio/utils/tests.py index 75872b30b..de07fb1cd 100644 --- a/rsciio/utils/tests.py +++ b/rsciio/utils/tests.py @@ -16,6 +16,8 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import numpy as np + def expected_is_binned(): """ @@ -31,3 +33,54 @@ def expected_is_binned(): binned = False return binned + + +# Adapted from: +# https://github.com/gem/oq-engine/blob/master/openquake/server/tests/helpers.py +def assert_deep_almost_equal(actual, expected, *args, **kwargs): + """Assert that two complex structures have almost equal contents. + Compares lists, dicts and tuples recursively. Checks numeric values + using :func:`numpy.testing.assert_allclose` and + checks all other values with :func:`numpy.testing.assert_equal`. + Accepts additional positional and keyword arguments and pass those + intact to assert_allclose() (that's how you specify comparison + precision). + + Parameters + ---------- + actual: list, dict or tuple + Actual values to compare. + expected: list, dict or tuple + Expected values. + *args : + Arguments are passed to :func:`numpy.testing.assert_allclose` or + :func:`assert_deep_almost_equal`. + **kwargs : + Keyword arguments are passed to + :func:`numpy.testing.assert_allclose` or + :func:`assert_deep_almost_equal`. + """ + is_root = "__trace" not in kwargs + trace = kwargs.pop("__trace", "ROOT") + try: + if isinstance(expected, (int, float, complex)): + np.testing.assert_allclose(expected, actual, *args, **kwargs) + elif isinstance(expected, (list, tuple, np.ndarray)): + assert len(expected) == len(actual) + for index in range(len(expected)): + v1, v2 = expected[index], actual[index] + assert_deep_almost_equal(v1, v2, __trace=repr(index), *args, **kwargs) + elif isinstance(expected, dict): + assert set(expected) == set(actual) + for key in expected: + assert_deep_almost_equal( + expected[key], actual[key], __trace=repr(key), *args, **kwargs + ) + else: + assert expected == actual + except AssertionError as exc: + exc.__dict__.setdefault("traces", []).append(trace) + if is_root: + trace = " -> ".join(reversed(exc.traces)) + exc = AssertionError("%s\nTRACE: %s" % (exc, trace)) + raise exc From 1832fccc5ccc9a61bb30b75efe025cc17aac3335 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 6 Apr 2024 11:21:01 +0100 Subject: [PATCH 081/174] Fix linter using `ruff check .` in tests folder --- pyproject.toml | 14 +++ rsciio/tests/conftest.py | 5 +- rsciio/tests/generate_renishaw_test_file.py | 5 +- rsciio/tests/registry.py | 3 +- rsciio/tests/registry_utils.py | 2 +- rsciio/tests/test_blockfile.py | 13 +- rsciio/tests/test_bruker.py | 9 +- rsciio/tests/test_digitalmicrograph.py | 13 +- rsciio/tests/test_digitalsurf.py | 30 ++--- rsciio/tests/test_edax.py | 3 +- rsciio/tests/test_emd_ncem.py | 9 +- rsciio/tests/test_emd_prismatic.py | 3 +- rsciio/tests/test_emd_velox.py | 24 ++-- rsciio/tests/test_empad.py | 7 +- rsciio/tests/test_fei_stream_readers.py | 3 +- rsciio/tests/test_hamamatsu.py | 21 ++-- rsciio/tests/test_hspy.py | 125 ++++++++++---------- rsciio/tests/test_image.py | 12 +- rsciio/tests/test_import.py | 42 +++---- rsciio/tests/test_io.py | 11 +- rsciio/tests/test_jeol.py | 2 +- rsciio/tests/test_jobinyvon.py | 6 +- rsciio/tests/test_lazy_not_implemented.py | 2 +- rsciio/tests/test_mrc.py | 3 +- rsciio/tests/test_mrcz.py | 17 +-- rsciio/tests/test_msa.py | 4 +- rsciio/tests/test_nexus.py | 31 ++--- rsciio/tests/test_pantarhei.py | 3 +- rsciio/tests/test_phenom.py | 4 +- rsciio/tests/test_protochips.py | 3 +- rsciio/tests/test_quantumdetector.py | 4 +- rsciio/tests/test_renishaw.py | 24 ++-- rsciio/tests/test_tia.py | 3 +- rsciio/tests/test_tiff.py | 12 +- rsciio/tests/test_trivista.py | 11 +- rsciio/tests/test_tvips.py | 32 ++--- rsciio/tests/test_usid.py | 7 +- rsciio/tests/utils/test_utils.py | 25 ++-- 38 files changed, 264 insertions(+), 283 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4a803a791..0a8950d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -189,3 +189,17 @@ omit = [ [tool.coverage.report] precision = 2 + +[tool.ruff.lint] +select = [ + # Pyflakes + "F", + # Pycodestyle + "E", + "W", + # isort + "I001" +] +exclude = [ + "examples", + ] \ No newline at end of file diff --git a/rsciio/tests/conftest.py b/rsciio/tests/conftest.py index 875e3673c..7896b076c 100644 --- a/rsciio/tests/conftest.py +++ b/rsciio/tests/conftest.py @@ -17,11 +17,10 @@ # along with RosettaSciIO. If not, see . import json -from packaging.version import Version -from filelock import FileLock import pytest - +from filelock import FileLock +from packaging.version import Version try: import hyperspy diff --git a/rsciio/tests/generate_renishaw_test_file.py b/rsciio/tests/generate_renishaw_test_file.py index 160aea842..41c67208d 100644 --- a/rsciio/tests/generate_renishaw_test_file.py +++ b/rsciio/tests/generate_renishaw_test_file.py @@ -20,12 +20,13 @@ """ import numpy as np + from rsciio.renishaw._api import ( - WDFReader, + MetadataFlags, MetadataTypeMulti, MetadataTypeSingle, - MetadataFlags, TypeNames, + WDFReader, ) # logging.basicConfig(level=10) diff --git a/rsciio/tests/registry.py b/rsciio/tests/registry.py index 0c618d487..d37d63841 100644 --- a/rsciio/tests/registry.py +++ b/rsciio/tests/registry.py @@ -17,14 +17,13 @@ # along with RosettaSciIO. If not, see . import os -from packaging.version import Version from pathlib import Path import pooch +from packaging.version import Version import rsciio - version = rsciio.__version__ diff --git a/rsciio/tests/registry_utils.py b/rsciio/tests/registry_utils.py index 294832abb..fa74bf4e0 100644 --- a/rsciio/tests/registry_utils.py +++ b/rsciio/tests/registry_utils.py @@ -16,9 +16,9 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -from pathlib import Path import sys import warnings +from pathlib import Path import pooch diff --git a/rsciio/tests/test_blockfile.py b/rsciio/tests/test_blockfile.py index 112d48422..af008a963 100644 --- a/rsciio/tests/test_blockfile.py +++ b/rsciio/tests/test_blockfile.py @@ -18,27 +18,24 @@ import gc -import os -from pathlib import Path -import tempfile import warnings +from pathlib import Path import numpy as np import pytest -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - -from hyperspy.misc.test_utils import assert_deep_almost_equal - from rsciio.blockfile._api import get_default_header -from rsciio.utils.tools import sarray2dict from rsciio.utils.date_time_tools import serial_date_to_ISO_format +from rsciio.utils.tests import assert_deep_almost_equal +from rsciio.utils.tools import sarray2dict try: WindowsError except NameError: WindowsError = None +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + TEST_DATA_DIR = Path(__file__).parent / "data" / "blockfile" FILE1 = TEST_DATA_DIR / "test1.blo" diff --git a/rsciio/tests/test_bruker.py b/rsciio/tests/test_bruker.py index ec0ad7fdb..397d03983 100644 --- a/rsciio/tests/test_bruker.py +++ b/rsciio/tests/test_bruker.py @@ -4,11 +4,10 @@ import numpy as np import pytest -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - -from hyperspy.misc.test_utils import assert_deep_almost_equal - from rsciio.bruker import file_reader +from rsciio.utils.tests import assert_deep_almost_equal + +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") test_files = [ @@ -115,7 +114,7 @@ def test_hyperspy_wrap(): np.testing.assert_allclose(hype.axes_manager[2].scale, 0.009999) np.testing.assert_allclose(hype.axes_manager[2].offset, -0.47225277) assert hype.axes_manager[2].units == "keV" - assert hype.axes_manager[2].is_binned == True + assert hype.axes_manager[2].is_binned is True md_ref = { "Acquisition_instrument": { diff --git a/rsciio/tests/test_digitalmicrograph.py b/rsciio/tests/test_digitalmicrograph.py index e57e40954..1ca12838f 100644 --- a/rsciio/tests/test_digitalmicrograph.py +++ b/rsciio/tests/test_digitalmicrograph.py @@ -370,7 +370,6 @@ def test_read_MonarcCL_ccd_metadata(): assert md.Acquisition_instrument.Detector.processing == "Dark Subtracted" assert md.Acquisition_instrument.Detector.sensor_roi == (0, 0, 100, 1336) assert md.Acquisition_instrument.Detector.pixel_size == 20.0 - # assert md.Acquisition_instrument.Spectrometer.entrance_slit_width == 1 def test_read_MonoCL_SI_metadata(): @@ -449,16 +448,12 @@ def test_read_MonarcCL_SI_metadata(): md.Acquisition_instrument.Detector.integration_time, 0.05 ) assert md.Acquisition_instrument.Detector.pixel_size == 20.0 - # np.testing.assert_allclose( - # md.Acquisition_instrument.Spectrometer.central_wavelength, 869.9838) np.testing.assert_allclose( md.Acquisition_instrument.Detector.saturation_fraction[0], 0.004867628 ) assert md.Acquisition_instrument.Detector.binning == (2, 400) assert md.Acquisition_instrument.Detector.processing == "Dark Subtracted" assert md.Acquisition_instrument.Detector.sensor_roi == (0, 0, 400, 1340) - # assert md.Acquisition_instrument.Spectrum_image.drift_correction_periodicity == 1 - # assert md.Acquisition_instrument.Spectrum_image.drift_correction_units == "second(s)" assert md.Acquisition_instrument.Spectrum_image.mode == "2D Array" @@ -500,7 +495,7 @@ def test_location(): assert s.metadata.General.time == "20:55:20" s = hs.load(TEST_DATA_PATH / fname_list[2]) assert s.metadata.General.date == "2016-08-27" - # assert_equal(s.metadata.General.time, "20:55:20") # MX not working + assert s.metadata.General.time == "20:55:59" s = hs.load(TEST_DATA_PATH / fname_list[3]) assert s.metadata.General.date == "2016-08-27" assert s.metadata.General.time == "20:52:30" @@ -611,8 +606,8 @@ def test_multi_signal(): assert len(json.dumps(s2.original_metadata.as_dictionary())) == 15024 # test axes - assert s1.axes_manager[-1].is_binned == False - assert s2.axes_manager[-1].is_binned == False + assert s1.axes_manager[-1].is_binned is False + assert s2.axes_manager[-1].is_binned is False # simple tests on the data itself: assert s1.data.sum() == 949490255 @@ -712,7 +707,7 @@ def test_load_stackbuilder_imagestack(): assert md.Sample.description == "DWNC" assert md.Signal.quantity == "Electrons (Counts)" assert md.Signal.signal_type == "" - assert am.signal_axes[0].is_binned == False + assert am.signal_axes[0].is_binned is False np.testing.assert_allclose( md.Signal.Noise_properties.Variance_linear_model.gain_factor, 0.15674974 ) diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 70e993541..a49c5dc9f 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -183,7 +183,7 @@ def test_load_profile(): assert s.axes_manager[0].name == "Width" assert s.axes_manager[0].units == "mm" assert s.axes_manager[0].size == 128 - assert s.axes_manager[0].navigate == False + assert s.axes_manager[0].navigate is False # Metadata verification md = s.metadata @@ -212,9 +212,9 @@ def test_load_RGB(): assert s.axes_manager[1].name == "Y" assert s.axes_manager[1].units == "mm" assert s.axes_manager[0].size == 200 - assert s.axes_manager[0].navigate == False + assert s.axes_manager[0].navigate is False assert s.axes_manager[1].size == 200 - assert s.axes_manager[1].navigate == False + assert s.axes_manager[1].navigate is False md = s.metadata assert md.Signal.quantity == "Z" @@ -247,9 +247,9 @@ def test_load_spectra(): assert s.axes_manager[1].name == "Wavelength" assert s.axes_manager[1].units == "mm" assert s.axes_manager[0].size == 65 - assert s.axes_manager[0].navigate == True + assert s.axes_manager[0].navigate is True assert s.axes_manager[1].size == 512 - assert s.axes_manager[1].navigate == False + assert s.axes_manager[1].navigate is False omd = s.original_metadata assert list(omd.as_dictionary().keys()) == [ @@ -281,11 +281,11 @@ def test_load_spectral_map_compressed(): assert s.axes_manager[2].name == "Wavelength" assert s.axes_manager[2].units == "mm" assert s.axes_manager[0].size == 10 - assert s.axes_manager[0].navigate == True + assert s.axes_manager[0].navigate is True assert s.axes_manager[1].size == 12 - assert s.axes_manager[1].navigate == True + assert s.axes_manager[1].navigate is True assert s.axes_manager[2].size == 281 - assert s.axes_manager[2].navigate == False + assert s.axes_manager[2].navigate is False omd = s.original_metadata assert list(omd.as_dictionary().keys()) == [ @@ -329,11 +329,11 @@ def test_load_spectral_map(): assert s.axes_manager[2].name == "Wavelength" assert s.axes_manager[2].units == "mm" assert s.axes_manager[0].size == 10 - assert s.axes_manager[0].navigate == True + assert s.axes_manager[0].navigate is True assert s.axes_manager[1].size == 12 - assert s.axes_manager[1].navigate == True + assert s.axes_manager[1].navigate is True assert s.axes_manager[2].size == 310 - assert s.axes_manager[2].navigate == False + assert s.axes_manager[2].navigate is False omd = s.original_metadata assert list(omd.as_dictionary().keys()) == [ @@ -373,7 +373,7 @@ def test_load_spectrum_compressed(): # assert s.axes_manager[0].size == 1 # assert s.axes_manager[0].navigate == True assert s.axes_manager[0].size == 512 - assert s.axes_manager[0].navigate == False + assert s.axes_manager[0].navigate is False omd = s.original_metadata assert list(omd.as_dictionary().keys()) == ["Object_0_Channel_0"] @@ -400,7 +400,7 @@ def test_load_spectrum(): # assert s.axes_manager[0].size == 1 # assert s.axes_manager[0].navigate == True assert s.axes_manager[0].size == 512 - assert s.axes_manager[0].navigate == False + assert s.axes_manager[0].navigate is False omd = s.original_metadata assert list(omd.as_dictionary().keys()) == ["Object_0_Channel_0"] @@ -424,9 +424,9 @@ def test_load_surface(): assert s.axes_manager[1].name == "Height" assert s.axes_manager[1].units == "mm" assert s.axes_manager[0].size == 128 - assert s.axes_manager[0].navigate == False + assert s.axes_manager[0].navigate is False assert s.axes_manager[1].size == 128 - assert s.axes_manager[1].navigate == False + assert s.axes_manager[1].navigate is False omd = s.original_metadata assert list(omd.as_dictionary().keys()) == ["Object_0_Channel_0"] diff --git a/rsciio/tests/test_edax.py b/rsciio/tests/test_edax.py index be7196083..bba302ba6 100644 --- a/rsciio/tests/test_edax.py +++ b/rsciio/tests/test_edax.py @@ -19,9 +19,9 @@ import gc import hashlib import os -from pathlib import Path import tempfile import zipfile +from pathlib import Path import numpy as np import pytest @@ -29,7 +29,6 @@ from rsciio.edax import file_reader from rsciio.utils.tests import expected_is_binned - hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") requests = pytest.importorskip("requests", reason="requests not installed") diff --git a/rsciio/tests/test_emd_ncem.py b/rsciio/tests/test_emd_ncem.py index 6d65fe3e5..32920c1a7 100644 --- a/rsciio/tests/test_emd_ncem.py +++ b/rsciio/tests/test_emd_ncem.py @@ -22,18 +22,15 @@ # NOT to be confused with the FEI EMD format which was developed later. import os +import tempfile from pathlib import Path -import pytest - -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - import dask.array as da -from datetime import datetime import h5py import numpy as np -import tempfile +import pytest +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") TEST_DATA_PATH = Path(__file__).parent / "data" / "emd" diff --git a/rsciio/tests/test_emd_prismatic.py b/rsciio/tests/test_emd_prismatic.py index 4ded753b4..58ca2d650 100644 --- a/rsciio/tests/test_emd_prismatic.py +++ b/rsciio/tests/test_emd_prismatic.py @@ -20,11 +20,10 @@ import numpy as np import pytest +import traits.api as t hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") -import traits.api as t - TEST_DATA_PATH = Path(__file__).parent / "data" / "emd" diff --git a/rsciio/tests/test_emd_velox.py b/rsciio/tests/test_emd_velox.py index 7194118a3..ec51ca602 100644 --- a/rsciio/tests/test_emd_velox.py +++ b/rsciio/tests/test_emd_velox.py @@ -21,25 +21,19 @@ # National Lab (see https://emdatasets.com/ for more information). # NOT to be confused with the FEI EMD format which was developed later. -import os -from pathlib import Path +import gc import logging +import shutil +from datetime import datetime +from pathlib import Path +import numpy as np import pytest - -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - -import dask.array as da -from datetime import datetime from dateutil import tz -import gc -import h5py -import numpy as np -import tempfile -import shutil -from hyperspy.misc.test_utils import assert_deep_almost_equal +from rsciio.utils.tests import assert_deep_almost_equal +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") pytest.importorskip("sparse") @@ -130,11 +124,11 @@ def test_fei_emd_image(self, lazy): fei_image = np.load(self.fei_files_path / "fei_emd_image.npy") assert signal.axes_manager[0].name == "x" assert signal.axes_manager[0].units == "µm" - assert signal.axes_manager[0].is_binned == False + assert signal.axes_manager[0].is_binned is False np.testing.assert_allclose(signal.axes_manager[0].scale, 0.00530241, rtol=1e-5) assert signal.axes_manager[1].name == "y" assert signal.axes_manager[1].units == "µm" - assert signal.axes_manager[1].is_binned == False + assert signal.axes_manager[1].is_binned is False np.testing.assert_allclose(signal.axes_manager[1].scale, 0.00530241, rtol=1e-5) np.testing.assert_allclose(signal.data, fei_image) assert_deep_almost_equal(signal.metadata.as_dictionary(), md) diff --git a/rsciio/tests/test_empad.py b/rsciio/tests/test_empad.py index 5c2a308fe..3eb15306d 100644 --- a/rsciio/tests/test_empad.py +++ b/rsciio/tests/test_empad.py @@ -16,18 +16,17 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import gc from pathlib import Path import numpy as np import pytest -import gc - -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - import traits.api as t from rsciio.empad._api import _parse_xml +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + DATA_DIR = Path(__file__).parent / "data" / "empad" FILENAME_STACK_RAW = DATA_DIR / "series_x10.raw" diff --git a/rsciio/tests/test_fei_stream_readers.py b/rsciio/tests/test_fei_stream_readers.py index cf1523c69..9ad48d5a8 100644 --- a/rsciio/tests/test_fei_stream_readers.py +++ b/rsciio/tests/test_fei_stream_readers.py @@ -24,13 +24,14 @@ in order to mimic the usage in the FEI EMD reader. """ + import numpy as np import pytest pytest.importorskip("h5py") pytest.importorskip("sparse") -from rsciio.utils.fei_stream_readers import ( +from rsciio.utils.fei_stream_readers import ( # noqa: E402 array_to_stream, stream_to_array, stream_to_sparse_COO_array, diff --git a/rsciio/tests/test_hamamatsu.py b/rsciio/tests/test_hamamatsu.py index 22a31a2d1..30f0401c0 100644 --- a/rsciio/tests/test_hamamatsu.py +++ b/rsciio/tests/test_hamamatsu.py @@ -17,9 +17,11 @@ # along with RosettaSciIO. If not, see . import gc +import importlib +from pathlib import Path + import numpy as np import pytest -from pathlib import Path hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") @@ -287,12 +289,11 @@ def test_metadata(self): assert metadata.General.title == metadata.General.original_filename[:-4] assert metadata.Signal.quantity == "Intensity (Counts)" - try: - import lumispy - - signal_type = "Luminescence" - except ImportError: + if importlib.util.find_spec("lumispy") is None: signal_type = "" + else: + signal_type = "Luminescence" + assert metadata.Signal.signal_type == signal_type assert isinstance(detector.binning, tuple) @@ -303,10 +304,10 @@ def test_metadata(self): assert detector.model == "C5680" assert detector.frames == 60 np.testing.assert_allclose(detector.integration_time, 300) - assert detector.processing.background_correction == True - assert detector.processing.curvature_correction == False - assert detector.processing.defect_correction == False - assert detector.processing.shading_correction == False + assert detector.processing.background_correction is True + assert detector.processing.curvature_correction is False + assert detector.processing.defect_correction is False + assert detector.processing.shading_correction is False np.testing.assert_allclose(detector.time_range, 20) assert detector.time_range_units == "µs" np.testing.assert_allclose(detector.mcp_gain, 50) diff --git a/rsciio/tests/test_hspy.py b/rsciio/tests/test_hspy.py index 74862d404..5ea72a697 100644 --- a/rsciio/tests/test_hspy.py +++ b/rsciio/tests/test_hspy.py @@ -16,39 +16,40 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import importlib import logging -from pathlib import Path import sys import time - -import pytest - -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +from pathlib import Path import dask.array as da import h5py import numpy as np - -from hyperspy.axes import DataAxis, UniformDataAxis, FunctionalDataAxis, AxesManager -from hyperspy.decorators import lazifyTestClass -from hyperspy.misc.test_utils import assert_deep_almost_equal -from hyperspy.misc.test_utils import sanitize_dict as san_dict +import pytest from rsciio._hierarchical import get_signal_chunks +from rsciio.utils.tests import assert_deep_almost_equal from rsciio.utils.tools import get_file_handle +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + +from hyperspy.axes import ( # noqa: E402 + AxesManager, + DataAxis, + FunctionalDataAxis, + UniformDataAxis, +) +from hyperspy.decorators import lazifyTestClass # noqa: E402 +from hyperspy.misc.test_utils import sanitize_dict as san_dict # noqa: E402 TEST_DATA_PATH = Path(__file__).parent / "data" / "hspy" TEST_NPZ_DATA_PATH = Path(__file__).parent / "data" / "npz" -try: - # zarr (because of numcodecs) is only supported on x86_64 machines - import zarr - - zspy_marker = pytest.mark.parametrize("file", ["test.hspy", "test.zspy"]) -except ImportError: +if importlib.util.find_spec("zarr") is None: zspy_marker = pytest.mark.parametrize("file", ["test.hspy"]) +else: + zspy_marker = pytest.mark.parametrize("file", ["test.hspy", "test.zspy"]) data = np.array( @@ -204,11 +205,11 @@ def test_save_unicode(self, tmp_path, file): s.metadata.set_item("test", ["a", "b", "\u6f22\u5b57"]) fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert isinstance(l.metadata.test[0], str) - assert isinstance(l.metadata.test[1], str) - assert isinstance(l.metadata.test[2], str) - assert l.metadata.test[2] == "\u6f22\u5b57" + s2 = hs.load(fname) + assert isinstance(s2.metadata.test[0], str) + assert isinstance(s2.metadata.test[1], str) + assert isinstance(s2.metadata.test[2], str) + assert s2.metadata.test[2] == "\u6f22\u5b57" @pytest.mark.xfail(reason="osx is slow occasionally") @zspy_marker @@ -228,10 +229,10 @@ def test_numpy_only_inner_lists(self, tmp_path, file): s.metadata.set_item("test", [[1.0, 2], ("3", 4)]) fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert isinstance(l.metadata.test, list) - assert isinstance(l.metadata.test[0], list) - assert isinstance(l.metadata.test[1], tuple) + s2 = hs.load(fname) + assert isinstance(s2.metadata.test, list) + assert isinstance(s2.metadata.test[0], list) + assert isinstance(s2.metadata.test[1], tuple) @pytest.mark.xfail(sys.platform == "win32", reason="randomly fails in win32") @zspy_marker @@ -240,8 +241,8 @@ def test_numpy_general_type(self, tmp_path, file): s.metadata.set_item("test", np.array([[1.0, 2], ["3", 4]])) fname = tmp_path / file s.save(fname) - l = hs.load(fname) - np.testing.assert_array_equal(l.metadata.test, s.metadata.test) + s2 = hs.load(fname) + np.testing.assert_array_equal(s2.metadata.test, s.metadata.test) @pytest.mark.xfail(sys.platform == "win32", reason="randomly fails in win32") @zspy_marker @@ -250,11 +251,11 @@ def test_list_general_type(self, tmp_path, file): s.metadata.set_item("test", [[1.0, 2], ["3", 4]]) fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert isinstance(l.metadata.test[0][0], float) - assert isinstance(l.metadata.test[0][1], float) - assert isinstance(l.metadata.test[1][0], str) - assert isinstance(l.metadata.test[1][1], str) + s2 = hs.load(fname) + assert isinstance(s2.metadata.test[0][0], float) + assert isinstance(s2.metadata.test[0][1], float) + assert isinstance(s2.metadata.test[1][0], str) + assert isinstance(s2.metadata.test[1][1], str) @pytest.mark.xfail(sys.platform == "win32", reason="randomly fails in win32") @zspy_marker @@ -263,11 +264,11 @@ def test_general_type_not_working(self, tmp_path, file): s.metadata.set_item("test", (hs.signals.BaseSignal([1]), 0.1, "test_string")) fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert isinstance(l.metadata.test, tuple) - assert isinstance(l.metadata.test[0], hs.signals.Signal1D) - assert isinstance(l.metadata.test[1], float) - assert isinstance(l.metadata.test[2], str) + s2 = hs.load(fname) + assert isinstance(s2.metadata.test, tuple) + assert isinstance(s2.metadata.test[0], hs.signals.Signal1D) + assert isinstance(s2.metadata.test[1], float) + assert isinstance(s2.metadata.test[2], str) @zspy_marker def test_unsupported_type(self, tmp_path, file): @@ -275,8 +276,8 @@ def test_unsupported_type(self, tmp_path, file): s.metadata.set_item("test", hs.roi.Point2DROI(1, 2)) fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert "test" not in l.metadata + s2 = hs.load(fname) + assert "test" not in s2.metadata @zspy_marker def test_date_time(self, tmp_path, file): @@ -286,9 +287,9 @@ def test_date_time(self, tmp_path, file): s.metadata.General.time = time fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert l.metadata.General.date == date - assert l.metadata.General.time == time + s2 = hs.load(fname) + assert s2.metadata.General.date == date + assert s2.metadata.General.time == time @zspy_marker def test_general_metadata(self, tmp_path, file): @@ -301,10 +302,10 @@ def test_general_metadata(self, tmp_path, file): s.metadata.General.doi = doi fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert l.metadata.General.notes == notes - assert l.metadata.General.authors == authors - assert l.metadata.General.doi == doi + s2 = hs.load(fname) + assert s2.metadata.General.notes == notes + assert s2.metadata.General.authors == authors + assert s2.metadata.General.doi == doi @zspy_marker def test_quantity(self, tmp_path, file): @@ -313,8 +314,8 @@ def test_quantity(self, tmp_path, file): s.metadata.Signal.quantity = quantity fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert l.metadata.Signal.quantity == quantity + s2 = hs.load(fname) + assert s2.metadata.Signal.quantity == quantity @zspy_marker def test_save_axes_manager(self, tmp_path, file): @@ -322,9 +323,9 @@ def test_save_axes_manager(self, tmp_path, file): s.metadata.set_item("test", s.axes_manager) fname = tmp_path / file s.save(fname) - l = hs.load(fname) + s2 = hs.load(fname) # strange becuase you need the encoding... - assert isinstance(l.metadata.test, AxesManager) + assert isinstance(s2.metadata.test, AxesManager) @zspy_marker def test_title(self, tmp_path, file): @@ -332,8 +333,8 @@ def test_title(self, tmp_path, file): fname = tmp_path / file s.metadata.General.title = "__unnamed__" s.save(fname) - l = hs.load(fname) - assert l.metadata.General.title == "" + s2 = hs.load(fname) + assert s2.metadata.General.title == "" @zspy_marker def test_save_empty_tuple(self, tmp_path, file): @@ -341,9 +342,9 @@ def test_save_empty_tuple(self, tmp_path, file): s.metadata.set_item("test", ()) fname = tmp_path / file s.save(fname) - l = hs.load(fname) + s2 = hs.load(fname) # strange becuase you need the encoding... - assert l.metadata.test == s.metadata.test + assert s2.metadata.test == s.metadata.test @zspy_marker def test_save_bytes(self, tmp_path, file): @@ -352,14 +353,14 @@ def test_save_bytes(self, tmp_path, file): s.metadata.set_item("test", byte_message) fname = tmp_path / file s.save(fname) - l = hs.load(fname) - assert l.metadata.test == s.metadata.test.decode() + s2 = hs.load(fname) + assert s2.metadata.test == s.metadata.test.decode() def test_metadata_binned_deprecate(self): with pytest.warns(UserWarning, match="Loading old file"): s = hs.load(TEST_DATA_PATH / "example2_v2.2.hspy") - assert s.metadata.has_item("Signal.binned") == False - assert s.axes_manager[-1].is_binned == False + assert s.metadata.has_item("Signal.binned") is False + assert s.axes_manager[-1].is_binned is False def test_metadata_update_to_v3_1(self): md = { @@ -448,8 +449,8 @@ def test_nonuniformaxis(tmp_path, file, lazy): np.testing.assert_array_almost_equal( s.axes_manager[0].axis, s2.axes_manager[0].axis ) - assert s2.axes_manager[0].is_uniform == False - assert s2.axes_manager[0].navigate == False + assert s2.axes_manager[0].is_uniform is False + assert s2.axes_manager[0].navigate is False assert s2.axes_manager[0].size == data.size @@ -469,8 +470,8 @@ def test_nonuniformFDA(tmp_path, file, lazy): np.testing.assert_array_almost_equal( s.axes_manager[0].axis, s2.axes_manager[0].axis ) - assert s2.axes_manager[0].is_uniform == False - assert s2.axes_manager[0].navigate == False + assert s2.axes_manager[0].is_uniform is False + assert s2.axes_manager[0].navigate is False assert s2.axes_manager[0].size == data.size diff --git a/rsciio/tests/test_image.py b/rsciio/tests/test_image.py index 931b63b0f..83b256b4b 100644 --- a/rsciio/tests/test_image.py +++ b/rsciio/tests/test_image.py @@ -16,17 +16,18 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -from packaging.version import Version +import importlib from pathlib import Path import numpy as np import pytest +from packaging.version import Version imageio = pytest.importorskip("imageio") -testfile_dir = (Path(__file__).parent / "data" / "image").resolve() +from rsciio.image import file_writer # noqa: E402 -from rsciio.image import file_writer +testfile_dir = (Path(__file__).parent / "data" / "image").resolve() @pytest.mark.skipif( @@ -260,9 +261,8 @@ def test_error_library_no_installed(tmp_path): } signal_dict = {"data": np.arange(128 * 128).reshape(128, 128), "axes": [axis, axis]} - try: - import matplotlib - except Exception: + matplotlib = importlib.util.find_spec("matplotlib") + if matplotlib is None: # When matplotlib is not installed, raises an error to inform user # that matplotlib is necessary with pytest.raises(ValueError): diff --git a/rsciio/tests/test_import.py b/rsciio/tests/test_import.py index 94dd6b7be..770014183 100644 --- a/rsciio/tests/test_import.py +++ b/rsciio/tests/test_import.py @@ -24,7 +24,7 @@ def test_import_version(): - from rsciio import __version__ + from rsciio import __version__ # noqa def test_rsciio_dir(): @@ -42,45 +42,37 @@ def test_import_all(): plugin_name_to_remove = [] # Remove plugins which require not installed optional dependencies - try: - import h5py - except Exception: + h5py = importlib.util.find_spec("h5py") + if h5py is None: plugin_name_to_remove.extend(["EMD", "HSPY", "NeXus"]) - try: - import imageio - except Exception: + imageio = importlib.util.find_spec("imageio") + if imageio is None: plugin_name_to_remove.extend(["Image"]) - try: - import sparse - except Exception: + sparse = importlib.util.find_spec("sparse") + if sparse is None: plugin_name_to_remove.extend(["EMD", "JEOL"]) - try: - import skimage - except Exception: + skimage = importlib.util.find_spec("skimage") + if skimage is None: plugin_name_to_remove.append("Blockfile") - try: - import mrcz - except Exception: + mrcz = importlib.util.find_spec("mrcz") + if mrcz is None: plugin_name_to_remove.append("MRCZ") - try: - import tifffile - except Exception: + tifffile = importlib.util.find_spec("tifffile") + if tifffile is None: plugin_name_to_remove.append("TIFF") plugin_name_to_remove.append("Phenom") - try: - import pyUSID - except Exception: + pyUSID = importlib.util.find_spec("pyUSID") + if pyUSID is None: plugin_name_to_remove.append("USID") - try: - import zarr - except Exception: + zarr = importlib.util.find_spec("zarr") + if zarr is None: plugin_name_to_remove.append("ZSPY") IO_PLUGINS_ = list( diff --git a/rsciio/tests/test_io.py b/rsciio/tests/test_io.py index 6290e1653..ca30c8688 100644 --- a/rsciio/tests/test_io.py +++ b/rsciio/tests/test_io.py @@ -16,9 +16,9 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import os import hashlib import logging +import os import tempfile from pathlib import Path from unittest.mock import patch @@ -30,8 +30,7 @@ hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") -from hyperspy.axes import DataAxis - +from hyperspy.axes import DataAxis # noqa: E402 TEST_DATA_PATH = Path(__file__).parent / "data" FULLFILENAME = Path(__file__).parent / "test_io_overwriting.hspy" @@ -104,21 +103,21 @@ def setup_method(self, method): # make sure we start from a clean state def test_io_nonuniform(self, tmp_path): - assert self.s.axes_manager[0].is_uniform == False + assert self.s.axes_manager[0].is_uniform is False self.s.save(tmp_path / "tmp.hspy") with pytest.raises(TypeError, match="not supported for non-uniform"): self.s.save(tmp_path / "tmp.msa") def test_nonuniform_writer_characteristic(self): for plugin in IO_PLUGINS: - if not "non_uniform_axis" in plugin: + if "non_uniform_axis" not in plugin: print( f"{plugin.name} IO-plugin is missing the " "characteristic `non_uniform_axis`" ) def test_nonuniform_error(self, tmp_path): - assert self.s.axes_manager[0].is_uniform == False + assert self.s.axes_manager[0].is_uniform is False incompatible_writers = [ plugin["file_extensions"][plugin["default_extension"]] for plugin in IO_PLUGINS diff --git a/rsciio/tests/test_jeol.py b/rsciio/tests/test_jeol.py index d770467bb..fa96aba04 100644 --- a/rsciio/tests/test_jeol.py +++ b/rsciio/tests/test_jeol.py @@ -17,8 +17,8 @@ # along with RosettaSciIO. If not, see . import gc -from pathlib import Path import zipfile +from pathlib import Path import numpy as np import pytest diff --git a/rsciio/tests/test_jobinyvon.py b/rsciio/tests/test_jobinyvon.py index ec46ca2e1..79a08beae 100644 --- a/rsciio/tests/test_jobinyvon.py +++ b/rsciio/tests/test_jobinyvon.py @@ -21,12 +21,12 @@ # and https://ami.scripps.edu/software/mrctools/mrc_specification.php import gc -import pytest import importlib.util -from pathlib import Path from copy import deepcopy +from pathlib import Path import numpy as np +import pytest hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") @@ -1013,4 +1013,4 @@ def test_metadata(self): np.testing.assert_allclose( metadata["Acquisition_instrument"]["Detector"]["glued_spectrum_windows"], 4 ) - assert metadata["Acquisition_instrument"]["Detector"]["glued_spectrum"] == True + assert metadata["Acquisition_instrument"]["Detector"]["glued_spectrum"] is True diff --git a/rsciio/tests/test_lazy_not_implemented.py b/rsciio/tests/test_lazy_not_implemented.py index e19e99e5d..38abc22fd 100644 --- a/rsciio/tests/test_lazy_not_implemented.py +++ b/rsciio/tests/test_lazy_not_implemented.py @@ -17,8 +17,8 @@ # along with RosettaSciIO. If not, see . import importlib -import pytest +import pytest PLUGIN_LAZY_NOT_IMPLEMENTED = [ # "bruker", # SPX only diff --git a/rsciio/tests/test_mrc.py b/rsciio/tests/test_mrc.py index 1e2a17167..0821c6d05 100644 --- a/rsciio/tests/test_mrc.py +++ b/rsciio/tests/test_mrc.py @@ -17,8 +17,9 @@ # along with RosettaSciIO. If not, see . from pathlib import Path -import pytest + import numpy as np +import pytest hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") diff --git a/rsciio/tests/test_mrcz.py b/rsciio/tests/test_mrcz.py index a3a38c0d5..96faeaae9 100644 --- a/rsciio/tests/test_mrcz.py +++ b/rsciio/tests/test_mrcz.py @@ -16,22 +16,22 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import importlib import os import tempfile +from datetime import datetime from time import perf_counter, sleep import numpy as np import numpy.testing as npt import pytest -from datetime import datetime +from rsciio.utils.tests import assert_deep_almost_equal hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") mrcz = pytest.importorskip("mrcz", reason="mrcz not installed") -from hyperspy.misc.test_utils import assert_deep_almost_equal - # ============================================================================== # MRCZ Test # @@ -191,16 +191,9 @@ def compareSaveLoad( ("dtype", "compressor", "clevel", "lazy"), _generate_parameters() ) def test_MRC(self, dtype, compressor, clevel, lazy): - t_start = perf_counter() - - try: - import blosc - - blosc_installed = True - except Exception: - blosc_installed = False + blosc = importlib.util.find_spec("blosc") - if not blosc_installed and compressor is not None: + if blosc is None and compressor is not None: with pytest.raises(ImportError): self.compareSaveLoad( [2, 64, 32], diff --git a/rsciio/tests/test_msa.py b/rsciio/tests/test_msa.py index caeb57773..eee9c53c4 100644 --- a/rsciio/tests/test_msa.py +++ b/rsciio/tests/test_msa.py @@ -3,9 +3,9 @@ import pytest -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +from rsciio.utils.tests import assert_deep_almost_equal -from hyperspy.misc.test_utils import assert_deep_almost_equal +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") TEST_DATA_PATH = Path(__file__).parent / "data" / "msa" diff --git a/rsciio/tests/test_nexus.py b/rsciio/tests/test_nexus.py index a9fbceb77..8793defca 100644 --- a/rsciio/tests/test_nexus.py +++ b/rsciio/tests/test_nexus.py @@ -23,27 +23,28 @@ hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") -import traits.api as t -import h5py +import h5py # noqa: E402 +import traits.api as t # noqa: E402 +from hyperspy.exceptions import VisibleDeprecationWarning # noqa: E402 +from hyperspy.signals import BaseSignal # noqa: E402 -from hyperspy.exceptions import VisibleDeprecationWarning - -from rsciio.nexus import file_writer -from rsciio.utils.hdf5 import list_datasets_in_file, read_metadata_from_file -from rsciio.nexus._api import ( +from rsciio.nexus import file_writer # noqa: E402 +from rsciio.nexus._api import ( # noqa: E402 _byte_to_string, + _check_search_keys, + _find_data, _fix_exclusion_keys, - _is_int, - _is_numeric_data, _get_nav_list, _getlink, - _check_search_keys, - _parse_from_file, + _is_int, + _is_numeric_data, _nexus_dataset_to_signal, - _find_data, + _parse_from_file, +) +from rsciio.utils.hdf5 import ( # noqa: E402 + list_datasets_in_file, + read_metadata_from_file, ) -from hyperspy.signals import BaseSignal - TEST_DATA_PATH = Path(__file__).parent / "data" / "nexus" @@ -541,7 +542,7 @@ def test_check_search_keys_input_None(self): assert _check_search_keys(None) is None def test_check_search_keys_input_str(self): - assert type(_check_search_keys("[1234]")) is list + assert isinstance(_check_search_keys("[1234]"), list) def test_check_search_keys_input_list_all_str(self): assert _check_search_keys(["[1234]", "[5678]"])[0] == "[1234]" diff --git a/rsciio/tests/test_pantarhei.py b/rsciio/tests/test_pantarhei.py index ee9a5c408..1a4d845d9 100644 --- a/rsciio/tests/test_pantarhei.py +++ b/rsciio/tests/test_pantarhei.py @@ -22,9 +22,10 @@ import numpy as np import pytest +from rsciio.utils.tests import assert_deep_almost_equal + hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") exspy = pytest.importorskip("exspy", reason="exspy not installed") -from hyperspy.misc.test_utils import assert_deep_almost_equal TEST_DATA_PATH = Path(__file__).parent / "data" / "pantarhei" diff --git a/rsciio/tests/test_phenom.py b/rsciio/tests/test_phenom.py index 06a79a33a..53582b74f 100644 --- a/rsciio/tests/test_phenom.py +++ b/rsciio/tests/test_phenom.py @@ -629,7 +629,7 @@ def test_elid(pathname): "is_binned": False, }, } - assert not "acquisition" in s[7].original_metadata + assert "acquisition" not in s[7].original_metadata assert s[8].metadata["General"]["title"] == "385test - spectrum, MSA 1" assert s[8].data.shape == (2048,) @@ -678,7 +678,7 @@ def test_elid(pathname): "is_binned": False, }, } - assert not "EDS" in s[9].original_metadata["acquisition"]["scan"]["detectors"] + assert "EDS" not in s[9].original_metadata["acquisition"]["scan"]["detectors"] assert s[10].metadata["General"]["title"] == "Image 1, Map 1" assert s[10].data.shape == (16, 16, 2048) diff --git a/rsciio/tests/test_protochips.py b/rsciio/tests/test_protochips.py index 20c24b1fb..bccaffd7e 100644 --- a/rsciio/tests/test_protochips.py +++ b/rsciio/tests/test_protochips.py @@ -21,10 +21,9 @@ import numpy as np import pytest -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - from rsciio.protochips._api import ProtochipsCSV, invalid_file_error +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") TEST_DATA_PATH = Path(__file__).parent / "data" / "protochips" diff --git a/rsciio/tests/test_quantumdetector.py b/rsciio/tests/test_quantumdetector.py index f8ff9a91a..c7bd18fa1 100644 --- a/rsciio/tests/test_quantumdetector.py +++ b/rsciio/tests/test_quantumdetector.py @@ -17,14 +17,14 @@ # along with RosettaSciIO. If not, see . import gc -from pathlib import Path import shutil import zipfile +from pathlib import Path import dask.array as da -from dask.array.core import normalize_chunks import numpy as np import pytest +from dask.array.core import normalize_chunks from rsciio.quantumdetector._api import ( MIBProperties, diff --git a/rsciio/tests/test_renishaw.py b/rsciio/tests/test_renishaw.py index 745f2f790..68c21bc9d 100644 --- a/rsciio/tests/test_renishaw.py +++ b/rsciio/tests/test_renishaw.py @@ -17,17 +17,18 @@ # along with HyperSpy. If not, see . import gc -import pytest -from pathlib import Path -from copy import deepcopy +import importlib import shutil +from copy import deepcopy +from pathlib import Path import numpy as np - -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +import pytest from rsciio.tests.generate_renishaw_test_file import WDFFileGenerator, WDFFileHandler +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + testfile_dir = Path(__file__).parent / "data" / "renishaw" testfile_spec = (testfile_dir / "renishaw_test_spectrum.wdf").resolve() @@ -812,7 +813,10 @@ def test_original_metadata_WARP(self): assert expected_WARP1 == self.s.original_metadata.WARP_1.as_dictionary() def test_original_metadata_TEXT(self): - expected_TEXT = "A single scan measurement generated by the WiRE spectral acquisition wizard." + expected_TEXT = ( + "A single scan measurement generated by the " + "WiRE spectral acquisition wizard." + ) assert expected_TEXT == self.s.original_metadata.TEXT_0 def test_original_metadata_ORGN(self): @@ -845,12 +849,10 @@ def test_metadata(self): assert metadata["General"]["title"] == "Single scan measurement 7" assert metadata["Signal"]["quantity"] == "Intensity (Counts)" - try: - import lumispy - - signal_type = "Luminescence" - except ImportError: + if importlib.util.find_spec("lumispy") is None: signal_type = "" + else: + signal_type = "Luminescence" assert metadata["Signal"]["signal_type"] == signal_type assert metadata["Acquisition_instrument"]["Detector"]["detector_type"] == "CCD" diff --git a/rsciio/tests/test_tia.py b/rsciio/tests/test_tia.py index c3fedb8cd..3f62e15f9 100644 --- a/rsciio/tests/test_tia.py +++ b/rsciio/tests/test_tia.py @@ -25,8 +25,7 @@ import traits.api as t -from rsciio.tia._api import load_ser_file, file_reader - +from rsciio.tia._api import file_reader, load_ser_file TEST_DATA_PATH = Path(__file__).parent / "data" / "tia" TEST_DATA_PATH_NEW = TEST_DATA_PATH / "new" diff --git a/rsciio/tests/test_tiff.py b/rsciio/tests/test_tiff.py index 2e7c991f8..1e1fe55d6 100644 --- a/rsciio/tests/test_tiff.py +++ b/rsciio/tests/test_tiff.py @@ -18,25 +18,23 @@ import os -from packaging.version import Version -from pathlib import Path import tempfile import warnings import zipfile +from pathlib import Path import numpy as np import pytest +from packaging.version import Version tifffile = pytest.importorskip("tifffile", reason="tifffile not installed") hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") import traits.api as t - from hyperspy.misc.test_utils import assert_deep_almost_equal import rsciio.tiff - TEST_DATA_PATH = Path(__file__).parent / "data" / "tiff" TEST_NPZ_DATA_PATH = Path(__file__).parent / "data" / "npz" TMP_DIR = tempfile.TemporaryDirectory() @@ -886,20 +884,20 @@ def test_axes_metadata(): s2 = hs.load(fname) assert s2.axes_manager.navigation_axes[0].name == "image series" assert s2.axes_manager.navigation_axes[0].units == nav_unit - assert s2.axes_manager.navigation_axes[0].is_binned == False + assert s2.axes_manager.navigation_axes[0].is_binned is False fname2 = os.path.join(tmpdir, "axes_metadata_IYX.tif") s.save(fname2, metadata={"axes": "IYX"}) s3 = hs.load(fname2) assert s3.axes_manager.navigation_axes[0].name == "image series" assert s3.axes_manager.navigation_axes[0].units == nav_unit - assert s3.axes_manager.navigation_axes[0].is_binned == False + assert s3.axes_manager.navigation_axes[0].is_binned is False fname2 = os.path.join(tmpdir, "axes_metadata_ZYX.tif") s.save(fname2, metadata={"axes": "ZYX"}) s3 = hs.load(fname2) assert s3.axes_manager.navigation_axes[0].units == nav_unit - assert s3.axes_manager.navigation_axes[0].is_binned == False + assert s3.axes_manager.navigation_axes[0].is_binned is False def test_olympus_SIS(): diff --git a/rsciio/tests/test_trivista.py b/rsciio/tests/test_trivista.py index 1b02b5ee1..ccae1d08d 100644 --- a/rsciio/tests/test_trivista.py +++ b/rsciio/tests/test_trivista.py @@ -17,11 +17,12 @@ # along with RosettaSciIO. If not, see . import gc -import numpy as np -import pytest import importlib.util -from pathlib import Path from copy import deepcopy +from pathlib import Path + +import numpy as np +import pytest hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") @@ -148,7 +149,7 @@ def test_metadata(self): else: assert metadata["Signal"]["signal_type"] == "" - assert metadata["Acquisition_instrument"]["Detector"]["glued_spectrum"] == False + assert metadata["Acquisition_instrument"]["Detector"]["glued_spectrum"] is False assert ( metadata["Acquisition_instrument"]["Detector"]["processing"]["calc_average"] == "True" @@ -2397,7 +2398,7 @@ def test_metadata(self): assert original_metadata_glued["Skipped Pixel Left"] == "0" assert original_metadata_glued["Skipped Pixel Right"] == "0" - assert metadata_detector.glued_spectrum == True + assert metadata_detector.glued_spectrum is True assert np.isclose(metadata_detector.glued_spectrum_overlap, 15) assert np.isclose(metadata_detector.glued_spectrum_windows, 19) diff --git a/rsciio/tests/test_tvips.py b/rsciio/tests/test_tvips.py index 858dc3061..66201094a 100644 --- a/rsciio/tests/test_tvips.py +++ b/rsciio/tests/test_tvips.py @@ -18,33 +18,32 @@ import gc -from packaging.version import Version from pathlib import Path +import dask import numpy as np import pytest -import dask +import traits.api as t +from packaging.version import Version -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +from rsciio.utils.tools import dummy_context_manager -import traits.api as t +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") -from hyperspy.misc.utils import DictionaryTreeBrowser +from hyperspy.misc.utils import DictionaryTreeBrowser # noqa: E402 -from rsciio.tvips._api import ( - _guess_image_mode, - _get_main_header_from_signal, - _get_frame_record_dtype_from_signal, - _is_valid_first_tvips_file, +from rsciio.tvips._api import ( # noqa: E402 + TVIPS_RECORDER_FRAME_HEADER, + TVIPS_RECORDER_GENERAL_HEADER, _find_auto_scan_start_stop, + _get_frame_record_dtype_from_signal, + _get_main_header_from_signal, + _guess_image_mode, _guess_scan_index_grid, - TVIPS_RECORDER_GENERAL_HEADER, - TVIPS_RECORDER_FRAME_HEADER, - file_writer, + _is_valid_first_tvips_file, file_reader, + file_writer, ) -from rsciio.utils.tools import dummy_context_manager - try: WindowsError @@ -324,7 +323,8 @@ def test_guess_scan_index_grid(rotators, startstop, expected): def _dask_supports_assignment(): - # direct assignment as follows is possible in newer versions (>2021.04.1) of dask, for backward compatibility we use workaround + # direct assignment as follows is possible in newer versions (>2021.04.1) of dask, + # for backward compatibility we use workaround return Version(dask.__version__) >= Version("2021.04.1") diff --git a/rsciio/tests/test_usid.py b/rsciio/tests/test_usid.py index 2230c5554..0f8f5b7f8 100644 --- a/rsciio/tests/test_usid.py +++ b/rsciio/tests/test_usid.py @@ -18,16 +18,15 @@ import tempfile +import dask.array as da +import numpy as np import pytest hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") usid = pytest.importorskip("pyUSID", reason="pyUSID not installed") sidpy = pytest.importorskip("sidpy", reason="sidpy not installed") -import dask.array as da -import h5py -import numpy as np - +import h5py # noqa: E402 # ##################### HELPER FUNCTIONS ###################################### diff --git a/rsciio/tests/utils/test_utils.py b/rsciio/tests/utils/test_utils.py index 478818288..97364db5a 100644 --- a/rsciio/tests/utils/test_utils.py +++ b/rsciio/tests/utils/test_utils.py @@ -1,14 +1,12 @@ -from dateutil import parser, tz from pathlib import Path import numpy as np import pytest +from dateutil import parser, tz -from rsciio.utils.tools import DTBox, dict2sarray, XmlToDict, ET -from rsciio.utils.tools import sanitize_msxml_float -from rsciio.utils.distributed import get_chunk_slice import rsciio.utils.date_time_tools as dtt - +from rsciio.utils.distributed import get_chunk_slice +from rsciio.utils.tools import ET, DTBox, XmlToDict, dict2sarray, sanitize_msxml_float dt = [("x", np.uint8), ("y", np.uint16), ("text", (bytes, 6))] @@ -384,16 +382,19 @@ def test_get_date_time_from_metadata(): ) -@pytest.mark.parametrize("shape", ((10, 20, 30, 512, 512),(20, 30, 512, 512), (10, 512, 512), (512, 512))) +@pytest.mark.parametrize( + "shape", + ((10, 20, 30, 512, 512),(20, 30, 512, 512), (10, 512, 512), (512, 512)) +) def test_get_chunk_slice(shape): chunk_arr, chunk = get_chunk_slice(shape=shape, chunks=-1) # 1 chunk assert chunk_arr.shape == (1,)*len(shape)+(len(shape), 2) assert chunk == tuple([(i,)for i in shape]) - -@pytest.mark.parametrize("shape", ((10, 20, 30, 512, 512),(20, 30, 512, 512), (10, 512, 512), (512, 512))) -def test_get_chunk_slice(shape): - chunks =(1,)*(len(shape)-2) +(-1,-1) - chunk_arr, chunk = get_chunk_slice(shape=shape, chunks=chunks) # Eveythin is 1 chunk + chunks = (1,)*(len(shape)-2) +(-1,-1) + # Eveything is 1 chunk + chunk_arr, chunk = get_chunk_slice(shape=shape, chunks=chunks) assert chunk_arr.shape == shape[:-2]+(1, 1) + (len(shape), 2) - assert chunk == tuple([(1,)*i for i in shape[:-2]])+tuple([(i,) for i in shape[-2:]]) + assert chunk == ( + tuple([(1,)*i for i in shape[:-2]])+tuple([(i,) for i in shape[-2:]]) + ) From 02a6c45865f729b57dc986db5f70c86926db0fc9 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 6 Apr 2024 12:04:01 +0100 Subject: [PATCH 082/174] fix formatting using `ruff format .` in tests folder --- rsciio/tests/generate_dm_testing_files.py | 3 +- rsciio/tests/generate_renishaw_test_file.py | 3 +- rsciio/tests/test_blockfile.py | 3 +- rsciio/tests/test_edax.py | 79 +++++++++++---------- rsciio/tests/test_emd_ncem.py | 2 +- rsciio/tests/test_emd_prismatic.py | 3 +- rsciio/tests/test_empad.py | 2 +- rsciio/tests/test_ripple.py | 3 +- rsciio/tests/test_tia.py | 5 +- rsciio/tests/test_tiff.py | 40 +++++------ rsciio/tests/test_trivista.py | 5 +- rsciio/tests/test_tvips.py | 2 +- 12 files changed, 74 insertions(+), 76 deletions(-) diff --git a/rsciio/tests/generate_dm_testing_files.py b/rsciio/tests/generate_dm_testing_files.py index 0e9353d1a..32a8c75c7 100644 --- a/rsciio/tests/generate_dm_testing_files.py +++ b/rsciio/tests/generate_dm_testing_files.py @@ -16,8 +16,7 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -"""Creates Digital Micrograph scripts to generate the dm3 testing files -""" +"""Creates Digital Micrograph scripts to generate the dm3 testing files""" import numpy as np diff --git a/rsciio/tests/generate_renishaw_test_file.py b/rsciio/tests/generate_renishaw_test_file.py index 41c67208d..ef7634197 100644 --- a/rsciio/tests/generate_renishaw_test_file.py +++ b/rsciio/tests/generate_renishaw_test_file.py @@ -16,8 +16,7 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -"""Creates files replicating Renishaw's .wdf files metadata structure (PSET Blocks). -""" +"""Creates files replicating Renishaw's .wdf files metadata structure (PSET Blocks).""" import numpy as np diff --git a/rsciio/tests/test_blockfile.py b/rsciio/tests/test_blockfile.py index af008a963..a9195f1f8 100644 --- a/rsciio/tests/test_blockfile.py +++ b/rsciio/tests/test_blockfile.py @@ -24,7 +24,6 @@ import numpy as np import pytest -from rsciio.blockfile._api import get_default_header from rsciio.utils.date_time_tools import serial_date_to_ISO_format from rsciio.utils.tests import assert_deep_almost_equal from rsciio.utils.tools import sarray2dict @@ -34,8 +33,10 @@ except NameError: WindowsError = None +pytest.importorskip("skimage", reason="scikit-image not installed") hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +from rsciio.blockfile._api import get_default_header # noqa: E402 TEST_DATA_DIR = Path(__file__).parent / "data" / "blockfile" FILE1 = TEST_DATA_DIR / "test1.blo" diff --git a/rsciio/tests/test_edax.py b/rsciio/tests/test_edax.py index bba302ba6..3eec183f6 100644 --- a/rsciio/tests/test_edax.py +++ b/rsciio/tests/test_edax.py @@ -145,9 +145,7 @@ def test_parameters(self): ] sem_dict = TestSpcSpectrum_v061_xrf.spc.metadata.as_dictionary()[ "Acquisition_instrument" - ][ - "SEM" - ] # this will eventually need to + ]["SEM"] # this will eventually need to # be changed when XRF-specific # features are added eds_dict = sem_dict["Detector"]["EDS"] @@ -326,43 +324,46 @@ def test_data(self): assert np.uint16 == TestSpdMap_070_eds.spd.data.dtype # test d_shape assert (200, 256, 2500) == TestSpdMap_070_eds.spd.data.shape - assert [ - [ - [0, 0, 0, 0, 0], # test random data - [0, 0, 1, 0, 1], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - ], - [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 1, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - ], - [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 1], - [0, 1, 1, 0, 0], - [0, 0, 0, 0, 0], - ], - [ - [0, 1, 0, 0, 0], - [0, 0, 0, 1, 0], - [0, 0, 0, 0, 0], - [0, 0, 1, 0, 0], - [0, 0, 0, 1, 0], - ], + assert ( [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 1, 0, 1], - [0, 0, 0, 1, 0], - [0, 0, 0, 0, 0], - ], - ] == TestSpdMap_070_eds.spd.data[15:20, 15:20, 15:20].tolist() + [ + [0, 0, 0, 0, 0], # test random data + [0, 0, 1, 0, 1], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 1], + [0, 1, 1, 0, 0], + [0, 0, 0, 0, 0], + ], + [ + [0, 1, 0, 0, 0], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 0, 1], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 0], + ], + ] + == TestSpdMap_070_eds.spd.data[15:20, 15:20, 15:20].tolist() + ) def test_parameters(self): elements = TestSpdMap_070_eds.spd.metadata.as_dictionary()["Sample"]["elements"] diff --git a/rsciio/tests/test_emd_ncem.py b/rsciio/tests/test_emd_ncem.py index 32920c1a7..d649e774a 100644 --- a/rsciio/tests/test_emd_ncem.py +++ b/rsciio/tests/test_emd_ncem.py @@ -26,10 +26,10 @@ from pathlib import Path import dask.array as da -import h5py import numpy as np import pytest +h5py = pytest.importorskip("h5py", reason="h5py not installed") hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") TEST_DATA_PATH = Path(__file__).parent / "data" / "emd" diff --git a/rsciio/tests/test_emd_prismatic.py b/rsciio/tests/test_emd_prismatic.py index 58ca2d650..dca914934 100644 --- a/rsciio/tests/test_emd_prismatic.py +++ b/rsciio/tests/test_emd_prismatic.py @@ -20,10 +20,9 @@ import numpy as np import pytest -import traits.api as t hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - +t = pytest.importorskip("traits.api", reason="traits not installed") TEST_DATA_PATH = Path(__file__).parent / "data" / "emd" diff --git a/rsciio/tests/test_empad.py b/rsciio/tests/test_empad.py index 3eb15306d..5594d7641 100644 --- a/rsciio/tests/test_empad.py +++ b/rsciio/tests/test_empad.py @@ -21,11 +21,11 @@ import numpy as np import pytest -import traits.api as t from rsciio.empad._api import _parse_xml hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +t = pytest.importorskip("traits.api", reason="traits not installed") DATA_DIR = Path(__file__).parent / "data" / "empad" diff --git a/rsciio/tests/test_ripple.py b/rsciio/tests/test_ripple.py index b6a5051f0..18b602af4 100644 --- a/rsciio/tests/test_ripple.py +++ b/rsciio/tests/test_ripple.py @@ -5,10 +5,11 @@ import numpy.testing as npt import pytest +from rsciio.ripple import _api as ripple + hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") exspy = pytest.importorskip("exspy", reason="exspy not installed") -from rsciio.ripple import _api as ripple # Tuple of tuples (data shape, signal_dimensions) SHAPES_SDIM = ( diff --git a/rsciio/tests/test_tia.py b/rsciio/tests/test_tia.py index 3f62e15f9..95b083c53 100644 --- a/rsciio/tests/test_tia.py +++ b/rsciio/tests/test_tia.py @@ -20,13 +20,12 @@ import numpy as np import pytest - -hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") - import traits.api as t from rsciio.tia._api import file_reader, load_ser_file +hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + TEST_DATA_PATH = Path(__file__).parent / "data" / "tia" TEST_DATA_PATH_NEW = TEST_DATA_PATH / "new" TEST_DATA_PATH_OLD = TEST_DATA_PATH / "old" diff --git a/rsciio/tests/test_tiff.py b/rsciio/tests/test_tiff.py index 1e1fe55d6..9b89b1072 100644 --- a/rsciio/tests/test_tiff.py +++ b/rsciio/tests/test_tiff.py @@ -25,15 +25,14 @@ import numpy as np import pytest +import traits.api as t from packaging.version import Version tifffile = pytest.importorskip("tifffile", reason="tifffile not installed") hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") -import traits.api as t -from hyperspy.misc.test_utils import assert_deep_almost_equal - -import rsciio.tiff +import rsciio.tiff # noqa: E402 +from rsciio.utils.tests import assert_deep_almost_equal # noqa: E402 TEST_DATA_PATH = Path(__file__).parent / "data" / "tiff" TEST_NPZ_DATA_PATH = Path(__file__).parent / "data" / "npz" @@ -500,9 +499,9 @@ def test_read_FEI_SEM_scale_metadata_8bits(self): assert s.data.dtype == "uint8" # delete timestamp from metadata since it's runtime dependent del s.metadata.General.FileIO.Number_0.timestamp - self.FEI_Helios_metadata["General"][ - "original_filename" - ] = "FEI-Helios-Ebeam-8bits.tif" + self.FEI_Helios_metadata["General"]["original_filename"] = ( + "FEI-Helios-Ebeam-8bits.tif" + ) assert_deep_almost_equal(s.metadata.as_dictionary(), self.FEI_Helios_metadata) def test_read_FEI_SEM_scale_metadata_16bits(self): @@ -517,9 +516,9 @@ def test_read_FEI_SEM_scale_metadata_16bits(self): assert s.data.dtype == "uint16" # delete timestamp from metadata since it's runtime dependent del s.metadata.General.FileIO.Number_0.timestamp - self.FEI_Helios_metadata["General"][ - "original_filename" - ] = "FEI-Helios-Ebeam-16bits.tif" + self.FEI_Helios_metadata["General"]["original_filename"] = ( + "FEI-Helios-Ebeam-16bits.tif" + ) assert_deep_almost_equal(s.metadata.as_dictionary(), self.FEI_Helios_metadata) def test_read_FEI_navcam_metadata(self): @@ -535,9 +534,9 @@ def test_read_FEI_navcam_metadata(self): # delete timestamp and version from metadata since it's runtime dependent del s.metadata.General.FileIO.Number_0.timestamp del s.metadata.General.FileIO.Number_0.hyperspy_version - self.FEI_navcam_metadata["General"][ - "original_filename" - ] = "FEI-Helios-navcam.tif" + self.FEI_navcam_metadata["General"]["original_filename"] = ( + "FEI-Helios-navcam.tif" + ) assert_deep_almost_equal(s.metadata.as_dictionary(), self.FEI_navcam_metadata) def test_read_FEI_navcam_no_IRBeam_metadata(self): @@ -553,9 +552,9 @@ def test_read_FEI_navcam_no_IRBeam_metadata(self): # delete timestamp and version from metadata since it's runtime dependent del s.metadata.General.FileIO.Number_0.timestamp del s.metadata.General.FileIO.Number_0.hyperspy_version - self.FEI_navcam_metadata["General"][ - "original_filename" - ] = "FEI-Helios-navcam-with-no-IRBeam.tif" + self.FEI_navcam_metadata["General"]["original_filename"] = ( + "FEI-Helios-navcam-with-no-IRBeam.tif" + ) assert_deep_almost_equal(s.metadata.as_dictionary(), self.FEI_navcam_metadata) def test_read_FEI_navcam_no_IRBeam_bad_floats_metadata(self): @@ -566,11 +565,12 @@ def test_read_FEI_navcam_no_IRBeam_bad_floats_metadata(self): # delete timestamp and version from metadata since it's runtime dependent del s.metadata.General.FileIO.Number_0.timestamp del s.metadata.General.FileIO.Number_0.hyperspy_version - self.FEI_navcam_metadata["General"][ - "original_filename" - ] = "FEI-Helios-navcam-with-no-IRBeam-bad-floats.tif" + self.FEI_navcam_metadata["General"]["original_filename"] = ( + "FEI-Helios-navcam-with-no-IRBeam-bad-floats.tif" + ) - # working distance in the file was a bogus value, so it shouldn't be in the resulting metadata + # working distance in the file was a bogus value, + # so it shouldn't be in the resulting metadata del self.FEI_navcam_metadata["Acquisition_instrument"]["SEM"][ "working_distance" ] diff --git a/rsciio/tests/test_trivista.py b/rsciio/tests/test_trivista.py index ccae1d08d..9ab908219 100644 --- a/rsciio/tests/test_trivista.py +++ b/rsciio/tests/test_trivista.py @@ -2386,9 +2386,8 @@ def test_axes_stack(self): ) def test_metadata(self): - original_metadata_glued = ( - self.glued.original_metadata.Document.InfoSerialized.Experiment.as_dictionary() - ) + original_metadata_glued = self.glued.original_metadata.Document.InfoSerialized.Experiment.as_dictionary() # noqa: E501 + metadata_detector = self.glued.metadata.Acquisition_instrument.Detector assert original_metadata_glued["From"] == "900.000 nm" diff --git a/rsciio/tests/test_tvips.py b/rsciio/tests/test_tvips.py index 66201094a..e759e44a0 100644 --- a/rsciio/tests/test_tvips.py +++ b/rsciio/tests/test_tvips.py @@ -23,12 +23,12 @@ import dask import numpy as np import pytest -import traits.api as t from packaging.version import Version from rsciio.utils.tools import dummy_context_manager hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +t = pytest.importorskip("traits.api", reason="traits not installed") from hyperspy.misc.utils import DictionaryTreeBrowser # noqa: E402 From 7ce8846e3ab8e0043ad4dc2d7ddd44f3da634c14 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 6 Apr 2024 12:26:00 +0100 Subject: [PATCH 083/174] Fix linter using `ruff check . --fix` in tests folder --- rsciio/tests/test_hspy.py | 5 +++-- rsciio/tests/test_nexus.py | 4 ++-- rsciio/tests/test_tia.py | 2 +- rsciio/tests/test_tiff.py | 2 +- rsciio/tests/test_usid.py | 3 +-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rsciio/tests/test_hspy.py b/rsciio/tests/test_hspy.py index 5ea72a697..22131b255 100644 --- a/rsciio/tests/test_hspy.py +++ b/rsciio/tests/test_hspy.py @@ -23,15 +23,14 @@ from pathlib import Path import dask.array as da -import h5py import numpy as np import pytest -from rsciio._hierarchical import get_signal_chunks from rsciio.utils.tests import assert_deep_almost_equal from rsciio.utils.tools import get_file_handle hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +h5py = pytest.importorskip("h5py", reason="h5py not installed") from hyperspy.axes import ( # noqa: E402 AxesManager, @@ -42,6 +41,8 @@ from hyperspy.decorators import lazifyTestClass # noqa: E402 from hyperspy.misc.test_utils import sanitize_dict as san_dict # noqa: E402 +from rsciio._hierarchical import get_signal_chunks # noqa: E402 + TEST_DATA_PATH = Path(__file__).parent / "data" / "hspy" TEST_NPZ_DATA_PATH = Path(__file__).parent / "data" / "npz" diff --git a/rsciio/tests/test_nexus.py b/rsciio/tests/test_nexus.py index 8793defca..f2d83a62d 100644 --- a/rsciio/tests/test_nexus.py +++ b/rsciio/tests/test_nexus.py @@ -22,9 +22,9 @@ import pytest hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +h5py = pytest.importorskip("h5py", reason="h5py not installed") +t = pytest.importorskip("traits.api", reason="traits not installed") -import h5py # noqa: E402 -import traits.api as t # noqa: E402 from hyperspy.exceptions import VisibleDeprecationWarning # noqa: E402 from hyperspy.signals import BaseSignal # noqa: E402 diff --git a/rsciio/tests/test_tia.py b/rsciio/tests/test_tia.py index 95b083c53..199802578 100644 --- a/rsciio/tests/test_tia.py +++ b/rsciio/tests/test_tia.py @@ -20,11 +20,11 @@ import numpy as np import pytest -import traits.api as t from rsciio.tia._api import file_reader, load_ser_file hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +t = pytest.importorskip("traits.api", reason="traits not installed") TEST_DATA_PATH = Path(__file__).parent / "data" / "tia" TEST_DATA_PATH_NEW = TEST_DATA_PATH / "new" diff --git a/rsciio/tests/test_tiff.py b/rsciio/tests/test_tiff.py index 9b89b1072..4d4f60a0e 100644 --- a/rsciio/tests/test_tiff.py +++ b/rsciio/tests/test_tiff.py @@ -25,11 +25,11 @@ import numpy as np import pytest -import traits.api as t from packaging.version import Version tifffile = pytest.importorskip("tifffile", reason="tifffile not installed") hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") +t = pytest.importorskip("traits.api", reason="traits not installed") import rsciio.tiff # noqa: E402 from rsciio.utils.tests import assert_deep_almost_equal # noqa: E402 diff --git a/rsciio/tests/test_usid.py b/rsciio/tests/test_usid.py index 0f8f5b7f8..6e5b2f206 100644 --- a/rsciio/tests/test_usid.py +++ b/rsciio/tests/test_usid.py @@ -25,8 +25,7 @@ hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") usid = pytest.importorskip("pyUSID", reason="pyUSID not installed") sidpy = pytest.importorskip("sidpy", reason="sidpy not installed") - -import h5py # noqa: E402 +h5py = pytest.importorskip("h5py", reason="h5py not installed") # ##################### HELPER FUNCTIONS ###################################### From 735bae5cdc17a072ad12b141cc581214b93dcbc7 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 6 Apr 2024 11:39:29 +0100 Subject: [PATCH 084/174] Update pre-commit configuration --- .pre-commit-config.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1fd8e48d7..f195d2309 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,13 @@ repos: - - repo: https://github.com/psf/black - # Version can be updated by running "pre-commit autoupdate" - rev: 24.3.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.3.5 hooks: - - id: black + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format - repo: local hooks: - id: registry From 66057fdccee4d7cabbfb0a60c97d17dc3255a224 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 6 Apr 2024 12:48:47 +0100 Subject: [PATCH 085/174] Fix linter using `ruff check . --fix` in root folder --- doc/conf.py | 1 - pyproject.toml | 8 ++++-- rsciio/__init__.py | 4 +-- rsciio/_docstrings.py | 2 +- rsciio/_hierarchical.py | 3 +-- rsciio/blockfile/__init__.py | 1 - rsciio/blockfile/_api.py | 25 +++++++++++-------- rsciio/bruker/__init__.py | 1 - rsciio/bruker/_api.py | 29 +++++++++++----------- rsciio/dens/__init__.py | 1 - rsciio/dens/_api.py | 3 ++- rsciio/digitalmicrograph/__init__.py | 1 - rsciio/digitalmicrograph/_api.py | 27 +++++++++++--------- rsciio/digitalsurf/__init__.py | 1 - rsciio/digitalsurf/_api.py | 23 +++++++---------- rsciio/edax/__init__.py | 1 - rsciio/edax/_api.py | 8 +++--- rsciio/emd/__init__.py | 1 - rsciio/emd/_api.py | 2 +- rsciio/emd/_emd_ncem.py | 16 ++++++------ rsciio/emd/_emd_velox.py | 12 ++++----- rsciio/empad/__init__.py | 1 - rsciio/empad/_api.py | 11 ++++----- rsciio/hamamatsu/__init__.py | 1 - rsciio/hamamatsu/_api.py | 6 ++--- rsciio/hspy/__init__.py | 1 - rsciio/hspy/_api.py | 14 +++++------ rsciio/image/__init__.py | 1 - rsciio/image/_api.py | 11 ++++----- rsciio/impulse/__init__.py | 1 - rsciio/impulse/_api.py | 6 ++--- rsciio/jeol/__init__.py | 1 - rsciio/jeol/_api.py | 5 ++-- rsciio/jobinyvon/__init__.py | 1 - rsciio/jobinyvon/_api.py | 5 ++-- rsciio/mrc/__init__.py | 1 - rsciio/mrc/_api.py | 12 ++++----- rsciio/mrcz/__init__.py | 1 - rsciio/mrcz/_api.py | 10 ++++---- rsciio/msa/__init__.py | 1 - rsciio/msa/_api.py | 8 +++--- rsciio/netcdf/__init__.py | 1 - rsciio/netcdf/_api.py | 2 +- rsciio/nexus/__init__.py | 1 - rsciio/nexus/_api.py | 13 +++++----- rsciio/pantarhei/__init__.py | 1 - rsciio/pantarhei/_api.py | 6 ++--- rsciio/phenom/__init__.py | 1 - rsciio/phenom/_api.py | 37 +++++++++++++++++----------- rsciio/protochips/__init__.py | 1 - rsciio/protochips/_api.py | 8 +++--- rsciio/quantumdetector/__init__.py | 1 - rsciio/quantumdetector/_api.py | 3 +-- rsciio/renishaw/__init__.py | 1 - rsciio/renishaw/_api.py | 11 ++++----- rsciio/ripple/__init__.py | 1 - rsciio/ripple/_api.py | 6 ++--- rsciio/semper/__init__.py | 1 - rsciio/semper/_api.py | 23 +++++++++-------- rsciio/tia/__init__.py | 1 - rsciio/tia/_api.py | 12 ++++----- rsciio/tiff/__init__.py | 1 - rsciio/tiff/_api.py | 15 ++++++----- rsciio/trivista/__init__.py | 1 - rsciio/trivista/_api.py | 10 ++++---- rsciio/tvips/__init__.py | 1 - rsciio/tvips/_api.py | 15 ++++++----- rsciio/usid/__init__.py | 1 - rsciio/usid/_api.py | 8 +++--- rsciio/utils/array.py | 3 +-- rsciio/utils/date_time_tools.py | 2 +- rsciio/utils/distributed.py | 2 +- rsciio/utils/fei_stream_readers.py | 2 +- rsciio/utils/hdf5.py | 7 +++--- rsciio/utils/image.py | 1 - rsciio/utils/readfile.py | 2 +- rsciio/utils/rgb_tools.py | 1 - rsciio/utils/skimage_exposure.py | 4 +-- rsciio/utils/tests.py | 10 ++++---- rsciio/utils/tools.py | 8 +++--- rsciio/zspy/__init__.py | 1 - rsciio/zspy/_api.py | 7 +++--- setup.py | 3 ++- 83 files changed, 233 insertions(+), 271 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index b567ba3da..eb7457626 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -15,7 +15,6 @@ # sys.path.insert(0, os.path.abspath('.')) import numpydoc -import pydata_sphinx_theme from packaging.version import Version # -- Project information ----------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 0a8950d1b..a98d4b0bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,7 @@ all = [ "rosettasciio[zspy]", ] dev = [ - "black", + "ruff", "rosettasciio[doc]", "rosettasciio[all]", "rosettasciio[tests]" @@ -202,4 +202,8 @@ select = [ ] exclude = [ "examples", - ] \ No newline at end of file + ] +# Rely on the formatter to define line-length +# and avoid conflicting lint rules +# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules +extend-ignore = ["E501"] diff --git a/rsciio/__init__.py b/rsciio/__init__.py index 9e99e5f7a..093c05096 100644 --- a/rsciio/__init__.py +++ b/rsciio/__init__.py @@ -16,14 +16,14 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -from importlib.metadata import version import os +from importlib.metadata import version from pathlib import Path + import yaml from ._logger import set_log_level - # Default to warning set_log_level("WARNING") diff --git a/rsciio/_docstrings.py b/rsciio/_docstrings.py index 97097c7f3..807d4ef53 100644 --- a/rsciio/_docstrings.py +++ b/rsciio/_docstrings.py @@ -112,7 +112,7 @@ DISTRIBUTED_DOC = """distributed : bool, default=False - Whether to load the data using memory-mapping in a way that is + Whether to load the data using memory-mapping in a way that is compatible with dask-distributed. This can sometimes improve performance when reading large files. And splitting the data loading/processing over multiple workers. diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index d3a75162f..5bf1e9851 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -19,17 +19,16 @@ import ast import datetime import logging -from packaging.version import Version import warnings import dask.array as da import h5py import numpy as np +from packaging.version import Version from rsciio._docstrings import SHOW_PROGRESSBAR_DOC from rsciio.utils.tools import ensure_unicode - version = "3.3" default_version = Version(version) diff --git a/rsciio/blockfile/__init__.py b/rsciio/blockfile/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/blockfile/__init__.py +++ b/rsciio/blockfile/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/blockfile/_api.py b/rsciio/blockfile/_api.py index 567851930..0ba0fd073 100644 --- a/rsciio/blockfile/_api.py +++ b/rsciio/blockfile/_api.py @@ -16,33 +16,38 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import os +import datetime import logging +import os import warnings -import datetime -import dateutil -import numpy as np import dask +import dateutil +import numpy as np from dask.diagnostics import ProgressBar from skimage import dtype_limits from rsciio._docstrings import ( + ENDIANESS_DOC, FILENAME_DOC, LAZY_DOC, - ENDIANESS_DOC, MMAP_DOC, RETURNS_DOC, - SIGNAL_DOC, SHOW_PROGRESSBAR_DOC, + SIGNAL_DOC, ) -from rsciio.utils.skimage_exposure import rescale_intensity -from rsciio.utils.tools import DTBox, sarray2dict, dict2sarray from rsciio.utils.date_time_tools import ( - serial_date_to_ISO_format, datetime_to_serial_date, + serial_date_to_ISO_format, +) +from rsciio.utils.skimage_exposure import rescale_intensity +from rsciio.utils.tools import ( + DTBox, + convert_units, + dict2sarray, + dummy_context_manager, + sarray2dict, ) -from rsciio.utils.tools import dummy_context_manager, convert_units _logger = logging.getLogger(__name__) diff --git a/rsciio/bruker/__init__.py b/rsciio/bruker/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/bruker/__init__.py +++ b/rsciio/bruker/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/bruker/_api.py b/rsciio/bruker/_api.py index b2d9222a2..ecbacc92f 100644 --- a/rsciio/bruker/_api.py +++ b/rsciio/bruker/_api.py @@ -23,25 +23,24 @@ # SFS (Single File System) (used in bcf technology) is present in # the same library. -from os.path import splitext, basename -from math import ceil -import logging -from zlib import decompress as unzip_block -from struct import unpack as strct_unp -from datetime import datetime -from ast import literal_eval import codecs -import xml.etree.ElementTree as ET import io +import logging +import xml.etree.ElementTree as ET +from ast import literal_eval +from datetime import datetime +from math import ceil +from os.path import basename, splitext +from struct import unpack as strct_unp +from zlib import decompress as unzip_block -from rsciio.utils.date_time_tools import msfiletime_to_unix -from rsciio.utils.tools import sanitize_msxml_float, XmlToDict - -import dask.delayed as dd +import dask import dask.array as da import numpy as np from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC +from rsciio.utils.date_time_tools import msfiletime_to_unix +from rsciio.utils.tools import XmlToDict, sanitize_msxml_float _logger = logging.getLogger(__name__) @@ -981,7 +980,9 @@ def parse_hypermap(self, index=None, downsample=1, cutoff_at_kV=None, lazy=False index=index, downsample=downsample, for_numpy=True ) if lazy: - value = dd(parse_func)(vrt_file_hand, shape, dtype, downsample=downsample) + value = dask.delayed(parse_func)( + vrt_file_hand, shape, dtype, downsample=downsample + ) result = da.from_delayed(value, shape=shape, dtype=dtype) else: result = parse_func(vrt_file_hand, shape, dtype, downsample=downsample) @@ -1204,7 +1205,7 @@ def py_parse_hypermap(virtual_file, shape, dtype, downsample=1): "<" + channels * st[size_p], buffer1[offset : offset + length], ) - pixel += [l + gain for l in temp] + pixel += [l_ + gain for l_ in temp] offset += length if chan2 < chan1: rest = chan1 - chan2 diff --git a/rsciio/dens/__init__.py b/rsciio/dens/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/dens/__init__.py +++ b/rsciio/dens/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/dens/_api.py b/rsciio/dens/_api.py index a42f2bfed..94ab766eb 100644 --- a/rsciio/dens/_api.py +++ b/rsciio/dens/_api.py @@ -17,10 +17,11 @@ # along with RosettaSciIO. If not, see . -import numpy as np import os from datetime import datetime +import numpy as np + from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC diff --git a/rsciio/digitalmicrograph/__init__.py b/rsciio/digitalmicrograph/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/digitalmicrograph/__init__.py +++ b/rsciio/digitalmicrograph/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/digitalmicrograph/_api.py b/rsciio/digitalmicrograph/_api.py index 1d6777c9e..daf670db1 100644 --- a/rsciio/digitalmicrograph/_api.py +++ b/rsciio/digitalmicrograph/_api.py @@ -21,18 +21,18 @@ # Plugin to read the Gatan Digital Micrograph(TM) file format -import os import logging -import dateutil.parser - -import numpy as np +import os from copy import deepcopy -from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC -import rsciio.utils.readfile as iou -from rsciio.utils.exceptions import DM3TagIDError, DM3DataTypeError, DM3TagTypeError +import dateutil.parser +import numpy as np from box import Box +import rsciio.utils.readfile as iou +from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC +from rsciio.utils.exceptions import DM3DataTypeError, DM3TagIDError, DM3TagTypeError +from rsciio.utils.tools import ensure_unicode _logger = logging.getLogger(__name__) @@ -272,11 +272,13 @@ def parse_struct_definition(self): struct encoded dtype. """ - length = self.read_l_or_q(self.f, "big") + # expected to be a length + _ = self.read_l_or_q(self.f, "big") nfields = self.read_l_or_q(self.f, "big") definition = () for ifield in range(nfields): - length2 = self.read_l_or_q(self.f, "big") + # expected to be a length + _ = self.read_l_or_q(self.f, "big") definition += (self.read_l_or_q(self.f, "big"),) return definition @@ -292,7 +294,7 @@ def read_simple_data(self, etype): """ data = self.get_data_reader(etype)[0](self.f, self.endian) if isinstance(data, str): - data = hyperspy.misc.utils.ensure_unicode(data) + data = ensure_unicode(data) return data def read_string(self, length, skip=False): @@ -302,6 +304,7 @@ def read_string(self, length, skip=False): If it's a tag name, each char is 1-Byte; if it's a tag data, each char is 2-Bytes Unicode, """ + size_bytes = 0 if skip is True: offset = self.f.tell() self.f.seek(length, 1) @@ -438,7 +441,7 @@ def get_image_dictionaries(self): images = [ image for key, image in self.tags_dict["ImageList"].items() - if not int(key.replace("TagGroup", "")) in thumbnail_idx + if int(key.replace("TagGroup", "")) not in thumbnail_idx ] return images @@ -1294,8 +1297,8 @@ def file_reader(filename, lazy=False, order=None, optimize=True): post_process.append(lambda s: s.squeeze()) if lazy: image.filename = filename - from dask.array import from_delayed import dask.delayed as dd + from dask.array import from_delayed val = dd(image.get_data, pure=True)() data = from_delayed(val, shape=image.shape, dtype=image.dtype) diff --git a/rsciio/digitalsurf/__init__.py b/rsciio/digitalsurf/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/digitalsurf/__init__.py +++ b/rsciio/digitalsurf/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 364a1dd18..f2acb1556 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -24,32 +24,27 @@ # original_metadata or other import logging - -# Dateutil allows to parse date but I don't think it's useful here -# import dateutil.parser - -import numpy as np +import os +import struct +import sys +import warnings +import zlib # Commented for now because I don't know what purpose it serves # import traits.api as t - from copy import deepcopy -import struct -import sys -import zlib -import os -import warnings + +# Dateutil allows to parse date but I don't think it's useful here +# import dateutil.parser +import numpy as np # Maybe later we can implement reading the class with the io utils tools instead # of re-defining read functions in the class # import rsciio.utils.readfile as iou - # This module will prove useful when we write the export function # import rsciio.utils.tools - # DictionaryTreeBrowser class handles the fancy metadata dictionnaries # from hyperspy.misc.utils import DictionaryTreeBrowser - from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC from rsciio.utils.exceptions import MountainsMapFileError diff --git a/rsciio/edax/__init__.py b/rsciio/edax/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/edax/__init__.py +++ b/rsciio/edax/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/edax/_api.py b/rsciio/edax/_api.py index 9a5ca6d80..7fa3b472f 100644 --- a/rsciio/edax/_api.py +++ b/rsciio/edax/_api.py @@ -20,19 +20,19 @@ # https://www.biochem.mpg.de/doc_tom/TOM_Release_2008/IOfun/tom_mrcread.html # and https://ami.scripps.edu/software/mrctools/mrc_specification.php -import os import logging +import os + import numpy as np from rsciio._docstrings import ( + ENDIANESS_DOC, FILENAME_DOC, LAZY_DOC, - ENDIANESS_DOC, RETURNS_DOC, ) -from rsciio.utils.tools import sarray2dict from rsciio.utils.elements import atomic_number2name - +from rsciio.utils.tools import sarray2dict _logger = logging.getLogger(__name__) diff --git a/rsciio/emd/__init__.py b/rsciio/emd/__init__.py index 5699aa972..a671da393 100644 --- a/rsciio/emd/__init__.py +++ b/rsciio/emd/__init__.py @@ -3,7 +3,6 @@ file_writer, ) - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/emd/_api.py b/rsciio/emd/_api.py index 96d102acf..e749dc59d 100644 --- a/rsciio/emd/_api.py +++ b/rsciio/emd/_api.py @@ -35,8 +35,8 @@ RETURNS_DOC, SIGNAL_DOC, ) -from ._emd_ncem import read_emd_version +from ._emd_ncem import read_emd_version _logger = logging.getLogger(__name__) diff --git a/rsciio/emd/_emd_ncem.py b/rsciio/emd/_emd_ncem.py index 9664e2f9e..dea1b1751 100644 --- a/rsciio/emd/_emd_ncem.py +++ b/rsciio/emd/_emd_ncem.py @@ -24,18 +24,17 @@ # Writing file is only supported for EMD Berkeley file. -import re -import os -import math import logging +import math +import os +import re +import dask.array as da import h5py import numpy as np -import dask.array as da -from rsciio.utils.tools import _UREG, DTBox from rsciio._hierarchical import get_signal_chunks - +from rsciio.utils.tools import _UREG, DTBox EMD_VERSION = "0.2" @@ -170,9 +169,8 @@ def print_dataset_only(item_name, item, dataset_only): if cls._get_emd_group_type(grp): dataset_path.append(item_name) - f = lambda item_name, item: print_dataset_only( - item_name, item, supported_dataset - ) + def f(item_name, item): + return print_dataset_only(item_name, item, supported_dataset) dataset_path = [] file.visititems(f) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index cb32347b0..d68667755 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -25,24 +25,22 @@ import json +import logging import os -from datetime import datetime import time -import logging +from datetime import datetime -import numpy as np import dask.array as da +import numpy as np from dateutil import tz +from rsciio.utils.elements import atomic_number2name from rsciio.utils.hdf5 import ( _get_keys_from_group, _parse_metadata, _parse_sub_data_group_metadata, ) -from rsciio.utils.tools import _UREG -from rsciio.utils.tools import convert_units -from rsciio.utils.elements import atomic_number2name - +from rsciio.utils.tools import _UREG, convert_units _logger = logging.getLogger(__name__) diff --git a/rsciio/empad/__init__.py b/rsciio/empad/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/empad/__init__.py +++ b/rsciio/empad/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/empad/_api.py b/rsciio/empad/_api.py index 4a490fdb2..a96961f7f 100644 --- a/rsciio/empad/_api.py +++ b/rsciio/empad/_api.py @@ -16,16 +16,15 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import os import ast +import logging +import os import xml.etree.ElementTree as ET + import numpy as np -import logging from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC -from rsciio.utils.tools import _UREG -from rsciio.utils.tools import convert_xml_to_dict - +from rsciio.utils.tools import _UREG, convert_xml_to_dict _logger = logging.getLogger(__name__) @@ -151,7 +150,7 @@ def file_reader(filename, lazy=False): sizes = [info[name] for name in names] - if not "series_count" in info.keys(): + if "series_count" not in info.keys(): try: fov = ast.literal_eval( om.root.iom_measurements.optics.get_full_scan_field_of_view diff --git a/rsciio/hamamatsu/__init__.py b/rsciio/hamamatsu/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/hamamatsu/__init__.py +++ b/rsciio/hamamatsu/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/hamamatsu/_api.py b/rsciio/hamamatsu/_api.py index 5e2deaf08..f47bafa54 100644 --- a/rsciio/hamamatsu/_api.py +++ b/rsciio/hamamatsu/_api.py @@ -16,11 +16,11 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import logging import importlib.util -from pathlib import Path +import logging from copy import deepcopy -from enum import IntEnum, EnumMeta +from enum import EnumMeta, IntEnum +from pathlib import Path import numpy as np from numpy.polynomial.polynomial import polyfit diff --git a/rsciio/hspy/__init__.py b/rsciio/hspy/__init__.py index 5699aa972..a671da393 100644 --- a/rsciio/hspy/__init__.py +++ b/rsciio/hspy/__init__.py @@ -3,7 +3,6 @@ file_writer, ) - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/hspy/_api.py b/rsciio/hspy/_api.py index d17c6e978..cf6d987ad 100644 --- a/rsciio/hspy/_api.py +++ b/rsciio/hspy/_api.py @@ -17,12 +17,12 @@ # along with RosettaSciIO. If not, see . import logging -from packaging.version import Version from pathlib import Path import dask.array as da -from dask.diagnostics import ProgressBar import h5py +from dask.diagnostics import ProgressBar +from packaging.version import Version from rsciio._docstrings import ( CHUNKS_DOC, @@ -30,13 +30,12 @@ COMPRESSION_HDF5_NOTES_DOC, FILENAME_DOC, LAZY_DOC, - SHOW_PROGRESSBAR_DOC, RETURNS_DOC, + SHOW_PROGRESSBAR_DOC, SIGNAL_DOC, ) -from rsciio._hierarchical import HierarchicalWriter, HierarchicalReader, version -from rsciio.utils.tools import get_file_handle, dummy_context_manager - +from rsciio._hierarchical import HierarchicalReader, HierarchicalWriter, version +from rsciio.utils.tools import dummy_context_manager, get_file_handle _logger = logging.getLogger(__name__) @@ -145,7 +144,8 @@ def file_reader(filename, lazy=False, **kwds): """ try: # in case blosc compression is used - import hdf5plugin + # module needs to be imported to register plugin + import hdf5plugin # noqa: F401 except ImportError: pass mode = kwds.pop("mode", "r") diff --git a/rsciio/image/__init__.py b/rsciio/image/__init__.py index 5699aa972..a671da393 100644 --- a/rsciio/image/__init__.py +++ b/rsciio/image/__init__.py @@ -3,7 +3,6 @@ file_writer, ) - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/image/_api.py b/rsciio/image/_api.py index df851092a..f01915028 100644 --- a/rsciio/image/_api.py +++ b/rsciio/image/_api.py @@ -16,13 +16,13 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -from collections.abc import Iterable -import os import logging +import os +from collections.abc import Iterable import imageio.v3 as iio -from PIL import Image import numpy as np +from PIL import Image from rsciio._docstrings import ( FILENAME_DOC, @@ -33,7 +33,6 @@ from rsciio.utils.image import _parse_axes_from_metadata, _parse_exif_tags from rsciio.utils.tools import _UREG - _logger = logging.getLogger(__name__) @@ -226,11 +225,11 @@ def file_reader(filename, lazy=False, **kwds): if lazy: # load the image fully to check the dtype and shape, should be cheap. # Then store this info for later re-loading when required - from dask.array import from_delayed from dask import delayed + from dask.array import from_delayed val = delayed(_read_data, pure=True)(filename, **kwds) - dc = from_delayed(val, shape=dc.shape, dtype=dc.dtype) + dc = from_delayed(val, shape=val.shape, dtype=val.dtype) else: dc = _read_data(filename, **kwds) diff --git a/rsciio/impulse/__init__.py b/rsciio/impulse/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/impulse/__init__.py +++ b/rsciio/impulse/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/impulse/_api.py b/rsciio/impulse/_api.py index 743d6e2a3..f81bc719d 100644 --- a/rsciio/impulse/_api.py +++ b/rsciio/impulse/_api.py @@ -1,10 +1,10 @@ -import numpy as np -import os import csv import logging +import os -from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC +import numpy as np +from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC _logger = logging.getLogger(__name__) diff --git a/rsciio/jeol/__init__.py b/rsciio/jeol/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/jeol/__init__.py +++ b/rsciio/jeol/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/jeol/_api.py b/rsciio/jeol/_api.py index 316638010..9cfe21118 100644 --- a/rsciio/jeol/_api.py +++ b/rsciio/jeol/_api.py @@ -16,18 +16,17 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import importlib +import logging import os from collections.abc import Iterable from datetime import datetime, timedelta -import logging -import importlib import numpy as np from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC from rsciio.utils.tools import jit_ifnumba - _logger = logging.getLogger(__name__) diff --git a/rsciio/jobinyvon/__init__.py b/rsciio/jobinyvon/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/jobinyvon/__init__.py +++ b/rsciio/jobinyvon/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/jobinyvon/_api.py b/rsciio/jobinyvon/_api.py index f1c4ec4bb..d1c320018 100644 --- a/rsciio/jobinyvon/_api.py +++ b/rsciio/jobinyvon/_api.py @@ -20,17 +20,16 @@ # https://www.biochem.mpg.de/doc_tom/TOM_Release_2008/IOfun/tom_mrcread.html # and https://ami.scripps.edu/software/mrctools/mrc_specification.php -import logging import importlib.util +import logging import xml.etree.ElementTree as ET -from pathlib import Path from copy import deepcopy +from pathlib import Path import numpy as np from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC - _logger = logging.getLogger(__name__) diff --git a/rsciio/mrc/__init__.py b/rsciio/mrc/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/mrc/__init__.py +++ b/rsciio/mrc/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/mrc/_api.py b/rsciio/mrc/_api.py index ba89bc2a5..a19f38e2b 100644 --- a/rsciio/mrc/_api.py +++ b/rsciio/mrc/_api.py @@ -20,26 +20,24 @@ # https://www.biochem.mpg.de/doc_tom/TOM_Release_2008/IOfun/tom_mrcread.html # and https://ami.scripps.edu/software/mrctools/mrc_specification.php -import os import logging +import os -import numpy as np import dask.array as da +import numpy as np from rsciio._docstrings import ( + CHUNKS_DOC, + DISTRIBUTED_DOC, ENDIANESS_DOC, FILENAME_DOC, LAZY_DOC, MMAP_DOC, NAVIGATION_SHAPE, RETURNS_DOC, - CHUNKS_DOC, - DISTRIBUTED_DOC, ) - -from rsciio.utils.tools import sarray2dict from rsciio.utils.distributed import memmap_distributed - +from rsciio.utils.tools import sarray2dict _logger = logging.getLogger(__name__) diff --git a/rsciio/mrcz/__init__.py b/rsciio/mrcz/__init__.py index 5699aa972..a671da393 100644 --- a/rsciio/mrcz/__init__.py +++ b/rsciio/mrcz/__init__.py @@ -3,7 +3,6 @@ file_writer, ) - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/mrcz/_api.py b/rsciio/mrcz/_api.py index f09b46f87..cfba3dfbb 100644 --- a/rsciio/mrcz/_api.py +++ b/rsciio/mrcz/_api.py @@ -16,21 +16,21 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -from packaging.version import Version -import mrcz as _mrcz import logging +import mrcz as _mrcz +from packaging.version import Version + from rsciio._docstrings import ( + ENDIANESS_DOC, FILENAME_DOC, LAZY_DOC, - ENDIANESS_DOC, MMAP_DOC, RETURNS_DOC, SIGNAL_DOC, ) from rsciio.utils.tools import DTBox - _logger = logging.getLogger(__name__) @@ -211,7 +211,7 @@ def file_writer( # Get pixelsize and pixelunits from the axes pixelunits = signal["axes"][-1]["units"] - pixelsize = [signal["axes"][I]["scale"] for I in _WRITE_ORDER] + pixelsize = [signal["axes"][I_]["scale"] for I_ in _WRITE_ORDER] # Strip out voltage from meta-data voltage = md.get("Acquisition_instrument.TEM.beam_energy") diff --git a/rsciio/msa/__init__.py b/rsciio/msa/__init__.py index 669c57b30..cf6eaf12a 100644 --- a/rsciio/msa/__init__.py +++ b/rsciio/msa/__init__.py @@ -4,7 +4,6 @@ parse_msa_string, ) - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/msa/_api.py b/rsciio/msa/_api.py index 30b9d1788..c70c810a8 100644 --- a/rsciio/msa/_api.py +++ b/rsciio/msa/_api.py @@ -16,18 +16,18 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -from datetime import datetime as dt import codecs -import os import logging +import os import warnings +from datetime import datetime as dt import numpy as np from rsciio._docstrings import ( + ENCODING_DOC, FILENAME_DOC, LAZY_UNSUPPORTED_DOC, - ENCODING_DOC, RETURNS_DOC, SIGNAL_DOC, ) @@ -447,7 +447,7 @@ def file_writer(filename, signal, format="Y", separator=", ", encoding="latin-1" # 'YLABEL' : '', "XUNITS": signal["axes"][0]["units"], # 'YUNITS' : '', - f"COMMENT": "File created by RosettaSciIO version {__version__}", + "COMMENT": "File created by RosettaSciIO version {__version__}", # Microscope # 'BEAMKV' : , # 'EMISSION' : , diff --git a/rsciio/netcdf/__init__.py b/rsciio/netcdf/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/netcdf/__init__.py +++ b/rsciio/netcdf/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/netcdf/_api.py b/rsciio/netcdf/_api.py index d32bb899c..58d087604 100644 --- a/rsciio/netcdf/_api.py +++ b/rsciio/netcdf/_api.py @@ -16,8 +16,8 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import os import logging +import os import numpy as np diff --git a/rsciio/nexus/__init__.py b/rsciio/nexus/__init__.py index 5699aa972..a671da393 100644 --- a/rsciio/nexus/__init__.py +++ b/rsciio/nexus/__init__.py @@ -3,7 +3,6 @@ file_writer, ) - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/nexus/_api.py b/rsciio/nexus/_api.py index 3eec4f480..38d8995ca 100644 --- a/rsciio/nexus/_api.py +++ b/rsciio/nexus/_api.py @@ -37,7 +37,6 @@ from rsciio.hspy._api import overwrite_dataset from rsciio.utils.tools import DTBox - _logger = logging.getLogger(__name__) @@ -696,9 +695,9 @@ def _is_int(s): def _check_search_keys(search_keys): - if type(search_keys) is str: + if isinstance(search_keys, str): return [search_keys] - elif type(search_keys) is list: + elif isinstance(search_keys, list): if all(isinstance(key, str) for key in search_keys): return search_keys else: @@ -792,7 +791,7 @@ def find_data_in_tree(group, rootname): else: return all_nx_datasets, all_hdf_datasets - elif type(search_keys) is list or type(absolute_path) is list: + elif isinstance(search_keys, list) or isinstance(absolute_path, list): if hardlinks_only: # return only the stored data, no linked data nx_datasets = unique_nx_datasets @@ -865,7 +864,7 @@ def find_meta_in_tree(group, rootname, lazy=False, skip_array_metadata=False): else: rootkey = "/" + key new_key = _fix_exclusion_keys(key) - if type(item) is h5py.Dataset: + if isinstance(item, h5py.Dataset): if item.attrs: if new_key not in tree.keys(): tree[new_key] = {} @@ -889,7 +888,7 @@ def find_meta_in_tree(group, rootname, lazy=False, skip_array_metadata=False): else: tree[new_key] = _parse_from_file(item, lazy=lazy) - elif type(item) is h5py.Group: + elif isinstance(item, h5py.Group): if "NX_class" in item.attrs: if item.attrs["NX_class"] not in [b"NXdata", "NXdata"]: tree[new_key] = find_meta_in_tree( @@ -970,7 +969,7 @@ def find_searchkeys_in_tree(myDict, rootname): rootkey = rootname + "/" + key else: rootkey = key - if type(search_keys) is list and any([s1 in rootkey for s1 in search_keys]): + if isinstance(search_keys, list) and any([s1 in rootkey for s1 in search_keys]): mod_keys = _text_split(rootkey, (".", "/")) # create the key, values in the dict p = metadata_dict diff --git a/rsciio/pantarhei/__init__.py b/rsciio/pantarhei/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/pantarhei/__init__.py +++ b/rsciio/pantarhei/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/pantarhei/_api.py b/rsciio/pantarhei/_api.py index 8c3da7237..d982b7b5d 100644 --- a/rsciio/pantarhei/_api.py +++ b/rsciio/pantarhei/_api.py @@ -18,17 +18,17 @@ # along with RosettaSciIO. If not, see . -from datetime import datetime as dt import logging import os +from datetime import datetime as dt import numpy as np from rsciio._docstrings import ( FILENAME_DOC, + LAZY_UNSUPPORTED_DOC, RETURNS_DOC, SIGNAL_DOC, - LAZY_UNSUPPORTED_DOC, ) from rsciio.utils.tools import DTBox @@ -301,7 +301,7 @@ def _metadata_converter_in(meta_data, axes, filename): signal_dimensions = 0 for ax in axes: - if ax["navigate"] == False: + if ax["navigate"] is False: signal_dimensions += 1 microscope_base_voltage = meta_data.get("electron_gun.voltage") diff --git a/rsciio/phenom/__init__.py b/rsciio/phenom/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/phenom/__init__.py +++ b/rsciio/phenom/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/phenom/_api.py b/rsciio/phenom/_api.py index c75f0221a..9b73b10a7 100644 --- a/rsciio/phenom/_api.py +++ b/rsciio/phenom/_api.py @@ -34,18 +34,19 @@ # file format. import bz2 -import math -import numpy as np import copy +import io +import math import os import struct -import io +import xml.etree.ElementTree as ET from datetime import datetime -from dateutil import tz + +import numpy as np import tifffile -import xml.etree.ElementTree as ET +from dateutil import tz -from rsciio._docstrings import FILENAME_DOC, RETURNS_DOC, LAZY_UNSUPPORTED_DOC +from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC def element_symbol(z): @@ -788,16 +789,19 @@ def _read_LineScanAnalysis(self, label, am): def _read_MapAnalysis(self, label, am): (om, sum_spectrum) = self._read_CommonAnalysis(am) - left = self._read_float64() - top = self._read_float64() - right = self._read_float64() - bottom = self._read_float64() - color_intensities = self._read_float64s() + # These metadata are currently not used but we still need to + # read these to advance the position in the file + # use placeholder for readability + _ = self._read_float64() # left + _ = self._read_float64() # top + _ = self._read_float64() # right + _ = self._read_float64() # bottom + _ = self._read_float64s() # color_intensities width = self._read_uint32() height = self._read_uint32() bins = self._read_uint32() - offset = self._read_float64() - dispersion = self._read_float64() + _ = self._read_float64() # offset + _ = self._read_float64() # dispersion original_metadata = copy.deepcopy(am) eds_metadata = self._read_eds_metadata(am) eds_metadata["live_time"] = om["acquisition"]["scan"]["detectors"]["EDS"][ @@ -897,8 +901,11 @@ def _read_ConstructiveAnalysisSources(self): def _read_ConstructiveAnalysis(self, label, am): self._read_CommonAnalysis(am) - description = self._read_string() - sources = self._read_ConstructiveAnalysisSources() + # These metadata are currently not used but we still need to + # read these to advance the position in the file + # use placeholder for readability + _ = self._read_string() # description + _ = self._read_ConstructiveAnalysisSources() # sources def _read_ConstructiveAnalyses(self): return self._read_Analyses("", {}) diff --git a/rsciio/protochips/__init__.py b/rsciio/protochips/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/protochips/__init__.py +++ b/rsciio/protochips/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/protochips/_api.py b/rsciio/protochips/_api.py index 9c1bafdd3..4fb10504c 100644 --- a/rsciio/protochips/_api.py +++ b/rsciio/protochips/_api.py @@ -17,14 +17,14 @@ # along with RosettaSciIO. If not, see . -import numpy as np +import logging import os -from datetime import datetime as dt import warnings -import logging +from datetime import datetime as dt -from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC +import numpy as np +from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC _logger = logging.getLogger(__name__) diff --git a/rsciio/quantumdetector/__init__.py b/rsciio/quantumdetector/__init__.py index 33d9c3e19..6c6e15970 100644 --- a/rsciio/quantumdetector/__init__.py +++ b/rsciio/quantumdetector/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, load_mib_data, parse_exposures, parse_timestamps - __all__ = [ "file_reader", "load_mib_data", diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index d198cc244..4f51c8708 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -21,8 +21,8 @@ import logging import os -from pathlib import Path import warnings +from pathlib import Path import dask.array as da import numpy as np @@ -36,7 +36,6 @@ RETURNS_DOC, ) - _logger = logging.getLogger(__name__) diff --git a/rsciio/renishaw/__init__.py b/rsciio/renishaw/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/renishaw/__init__.py +++ b/rsciio/renishaw/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index a1ef35fcb..6da969300 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -62,11 +62,11 @@ import datetime import importlib.util import logging +import os from copy import deepcopy -from enum import IntEnum, Enum, EnumMeta +from enum import Enum, EnumMeta, IntEnum from io import BytesIO from pathlib import Path -import os import numpy as np from numpy.polynomial.polynomial import polyfit @@ -74,7 +74,6 @@ from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC from rsciio.utils import rgb_tools - _logger = logging.getLogger(__name__) @@ -774,7 +773,7 @@ def _parse_WDF1(self): header["uuid"] = f"{self.__read_numeric('uint32', convert=False)}" for _ in range(3): header["uuid"] += f"-{self.__read_numeric('uint32', convert=False)}" - unused1 = self.__read_numeric("uint32", size=3) + _ = self.__read_numeric("uint32", size=3) header["ntracks"] = self.__read_numeric("uint32") header["file_status_error_code"] = self.__read_numeric("uint32") result["points_per_spectrum"] = self.__read_numeric("uint32") @@ -802,7 +801,7 @@ def _parse_WDF1(self): header["time_end"] = convert_windowstime_to_datetime(time_end_wt) header["quantity_unit"] = UnitType(self.__read_numeric("uint32")).name header["laser_wavenumber"] = self.__read_numeric("float") - unused2 = self.__read_numeric("uint64", size=6) + _ = self.__read_numeric("uint64", size=6) header["username"] = self.__read_utf8(32) header["title"] = self.__read_utf8(160) @@ -977,7 +976,7 @@ def _parse_WMAP(self): ) flag = MapType(self.__read_numeric("uint32")).name - unused = self.__read_numeric("uint32") + _ = self.__read_numeric("uint32") offset_xyz = [self.__read_numeric("float") for _ in range(3)] scale_xyz = [self.__read_numeric("float") for _ in range(3)] size_xyz = [self.__read_numeric("uint32") for _ in range(3)] diff --git a/rsciio/ripple/__init__.py b/rsciio/ripple/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/ripple/__init__.py +++ b/rsciio/ripple/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/ripple/_api.py b/rsciio/ripple/_api.py index 9c7c0d9b1..e08be7c1a 100644 --- a/rsciio/ripple/_api.py +++ b/rsciio/ripple/_api.py @@ -22,21 +22,21 @@ # https://www.nist.gov/services-resources/software/lispixdoc/image-file-formats/raw-file-format.htm import codecs +import logging import os.path from io import StringIO -import logging import numpy as np +from rsciio import __version__ from rsciio._docstrings import ( + ENCODING_DOC, FILENAME_DOC, LAZY_DOC, - ENCODING_DOC, MMAP_DOC, RETURNS_DOC, SIGNAL_DOC, ) -from rsciio import __version__ from rsciio.utils.tools import DTBox _logger = logging.getLogger(__name__) diff --git a/rsciio/semper/__init__.py b/rsciio/semper/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/semper/__init__.py +++ b/rsciio/semper/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/semper/_api.py b/rsciio/semper/_api.py index 7249b2b63..a18ada13b 100644 --- a/rsciio/semper/_api.py +++ b/rsciio/semper/_api.py @@ -72,12 +72,12 @@ # 57-99 all free/zero except for use by DATA cmd # 101-256 title (ic chars) -from collections import OrderedDict -import struct -from functools import partial import logging +import struct import warnings +from collections import OrderedDict from datetime import datetime +from functools import partial import numpy as np @@ -87,8 +87,7 @@ RETURNS_DOC, SIGNAL_DOC, ) -from rsciio.utils.tools import sarray2dict, DTBox - +from rsciio.utils.tools import DTBox, sarray2dict _logger = logging.getLogger(__name__) @@ -215,7 +214,7 @@ def _read_label(cls, unf_file): ) # Unpacking function for 4 byte floats! rec_length = np.fromfile(unf_file, dtype=". +import logging +import os import struct import warnings -from glob import glob -import os -from dateutil import parser -import logging import xml.etree.ElementTree as ET from collections import OrderedDict +from glob import glob import numpy as np +from dateutil import parser from rsciio._docstrings import FILENAME_DOC, LAZY_DOC, RETURNS_DOC -from rsciio.utils.tools import sarray2dict -from rsciio.utils.tools import DTBox - +from rsciio.utils.tools import DTBox, sarray2dict _logger = logging.getLogger(__name__) diff --git a/rsciio/tiff/__init__.py b/rsciio/tiff/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/tiff/__init__.py +++ b/rsciio/tiff/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/tiff/_api.py b/rsciio/tiff/_api.py index f63f1d209..dfa0218f0 100644 --- a/rsciio/tiff/_api.py +++ b/rsciio/tiff/_api.py @@ -17,17 +17,15 @@ # along with RosettaSciIO. If not, see . import csv -from datetime import datetime, timedelta -from dateutil import parser import logging import os -from packaging.version import Version import re import warnings +from datetime import datetime, timedelta import numpy as np -from tifffile import imwrite, TiffFile, TiffPage, TIFF -from tifffile import __version__ as tiffversion +from dateutil import parser +from tifffile import TIFF, TiffFile, TiffPage, imwrite from rsciio._docstrings import ( FILENAME_DOC, @@ -35,9 +33,8 @@ RETURNS_DOC, SIGNAL_DOC, ) -from rsciio.utils.tools import DTBox, _UREG from rsciio.utils.date_time_tools import get_date_time_from_metadata - +from rsciio.utils.tools import _UREG, DTBox _logger = logging.getLogger(__name__) @@ -574,9 +571,11 @@ def _is_jeol_sightx(op) -> bool: def _axes_jeol_sightx(tiff, op, shape, names): # convert xml text to dictionary of tiff op['ImageDescription'] import xml.etree.ElementTree as ET - from rsciio.utils.tools import XmlToDict + from box import Box + from rsciio.utils.tools import XmlToDict + scales, offsets, units = _axes_defaults() jeol_xml = "".join( [line.strip(" \r\n\t\x01\x00") for line in op["ImageDescription"].split("\n")] diff --git a/rsciio/trivista/__init__.py b/rsciio/trivista/__init__.py index d4de92f67..40459e88b 100644 --- a/rsciio/trivista/__init__.py +++ b/rsciio/trivista/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader - __all__ = [ "file_reader", ] diff --git a/rsciio/trivista/_api.py b/rsciio/trivista/_api.py index 81ed7e21d..12f44f0b4 100644 --- a/rsciio/trivista/_api.py +++ b/rsciio/trivista/_api.py @@ -16,12 +16,12 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import xml.etree.ElementTree as ET -import logging import importlib.util -from pathlib import Path -from copy import deepcopy +import logging +import xml.etree.ElementTree as ET from collections import defaultdict +from copy import deepcopy +from pathlib import Path import numpy as np from numpy.polynomial.polynomial import polyfit @@ -420,7 +420,7 @@ def _map_laser_md(original_metadata, laser_wavelength): "Objective" ]["Magnification"] ) - if not laser_wavelength is None: + if laser_wavelength is not None: laser["wavelength"] = laser_wavelength return laser diff --git a/rsciio/tvips/__init__.py b/rsciio/tvips/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/tvips/__init__.py +++ b/rsciio/tvips/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/tvips/_api.py b/rsciio/tvips/_api.py index a602faf8e..5760febfd 100644 --- a/rsciio/tvips/_api.py +++ b/rsciio/tvips/_api.py @@ -16,35 +16,34 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import logging import os import re -import logging import warnings -from dateutil.parser import parse as dtparse from datetime import datetime, timezone -import numpy as np -import dask.array as da import dask -from dask.diagnostics import ProgressBar +import dask.array as da +import numpy as np import pint +from dask.diagnostics import ProgressBar +from dateutil.parser import parse as dtparse from rsciio._docstrings import ( FILENAME_DOC, LAZY_DOC, RETURNS_DOC, - SIGNAL_DOC, SHOW_PROGRESSBAR_DOC, + SIGNAL_DOC, ) from rsciio.utils.tools import ( _UREG, - dummy_context_manager, DTBox, + dummy_context_manager, jit_ifnumba, sarray2dict, ) - _logger = logging.getLogger(__name__) diff --git a/rsciio/usid/__init__.py b/rsciio/usid/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/usid/__init__.py +++ b/rsciio/usid/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/usid/_api.py b/rsciio/usid/_api.py index ec7087948..8acfee18e 100644 --- a/rsciio/usid/_api.py +++ b/rsciio/usid/_api.py @@ -16,11 +16,12 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import os import logging -from warnings import warn -from functools import partial +import os from collections.abc import MutableMapping +from functools import partial +from warnings import warn + import h5py import numpy as np import pyUSID as usid @@ -33,7 +34,6 @@ SIGNAL_DOC, ) - _logger = logging.getLogger(__name__) diff --git a/rsciio/utils/array.py b/rsciio/utils/array.py index 574865727..30eea2495 100644 --- a/rsciio/utils/array.py +++ b/rsciio/utils/array.py @@ -16,9 +16,8 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -from packaging.version import Version - import numpy as np +from packaging.version import Version def get_numpy_kwargs(array): diff --git a/rsciio/utils/date_time_tools.py b/rsciio/utils/date_time_tools.py index 51b164a04..3f9ce4022 100644 --- a/rsciio/utils/date_time_tools.py +++ b/rsciio/utils/date_time_tools.py @@ -17,10 +17,10 @@ # along with RosettaSciIO. If not, see . import datetime -from dateutil import tz, parser import logging import numpy as np +from dateutil import parser, tz _logger = logging.getLogger(__name__) diff --git a/rsciio/utils/distributed.py b/rsciio/utils/distributed.py index 49617a600..e5be58bd5 100644 --- a/rsciio/utils/distributed.py +++ b/rsciio/utils/distributed.py @@ -17,8 +17,8 @@ # along with RosettaSciIO. If not, see . -import numpy as np import dask.array as da +import numpy as np def get_chunk_slice( diff --git a/rsciio/utils/fei_stream_readers.py b/rsciio/utils/fei_stream_readers.py index 85b7a071f..05bf54881 100644 --- a/rsciio/utils/fei_stream_readers.py +++ b/rsciio/utils/fei_stream_readers.py @@ -16,8 +16,8 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . -import numpy as np import dask.array as da +import numpy as np import sparse from rsciio.utils.tools import jit_ifnumba diff --git a/rsciio/utils/hdf5.py b/rsciio/utils/hdf5.py index c41ae6718..446e7d29a 100644 --- a/rsciio/utils/hdf5.py +++ b/rsciio/utils/hdf5.py @@ -19,16 +19,17 @@ # along with RosettaSciIO. If not, see . # -import h5py import json import pprint +import h5py + from rsciio._docstrings import FILENAME_DOC, LAZY_DOC from rsciio.nexus._api import ( _check_search_keys, - _load_metadata, - _find_search_keys_in_dict, _find_data, + _find_search_keys_in_dict, + _load_metadata, ) diff --git a/rsciio/utils/image.py b/rsciio/utils/image.py index 0d58b1316..eccd392b4 100644 --- a/rsciio/utils/image.py +++ b/rsciio/utils/image.py @@ -18,7 +18,6 @@ from PIL.ExifTags import TAGS - CustomTAGS = { **TAGS, # Customized EXIF TAGS from Renishaw diff --git a/rsciio/utils/readfile.py b/rsciio/utils/readfile.py index 46b60dcad..4019970ed 100644 --- a/rsciio/utils/readfile.py +++ b/rsciio/utils/readfile.py @@ -21,8 +21,8 @@ # general functions for reading data from files -import struct import logging +import struct from rsciio.utils.exceptions import ByteOrderError diff --git a/rsciio/utils/rgb_tools.py b/rsciio/utils/rgb_tools.py index e99d52ee0..a56de868c 100644 --- a/rsciio/utils/rgb_tools.py +++ b/rsciio/utils/rgb_tools.py @@ -24,7 +24,6 @@ from rsciio.utils.array import get_numpy_kwargs from rsciio.utils.tools import dummy_context_manager - rgba8 = np.dtype({"names": ["R", "G", "B", "A"], "formats": ["u1", "u1", "u1", "u1"]}) rgb8 = np.dtype({"names": ["R", "G", "B"], "formats": ["u1", "u1", "u1"]}) diff --git a/rsciio/utils/skimage_exposure.py b/rsciio/utils/skimage_exposure.py index b11955b08..ea99b11f8 100644 --- a/rsciio/utils/skimage_exposure.py +++ b/rsciio/utils/skimage_exposure.py @@ -1,12 +1,12 @@ """skimage's `rescale_intensity` that takes and returns dask arrays. """ -from packaging.version import Version import warnings import numpy as np import skimage -from skimage.exposure.exposure import intensity_range, _output_dtype +from packaging.version import Version +from skimage.exposure.exposure import _output_dtype, intensity_range def rescale_intensity(image, in_range="image", out_range="dtype"): diff --git a/rsciio/utils/tests.py b/rsciio/utils/tests.py index de07fb1cd..36ac68f70 100644 --- a/rsciio/utils/tests.py +++ b/rsciio/utils/tests.py @@ -16,6 +16,8 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import importlib + import numpy as np @@ -25,12 +27,10 @@ def expected_is_binned(): some signal will be assigned to EDS or EELS class instead of Signal1D class and the binned attribute will change accordingly. """ - try: - import exspy - - binned = True - except ImportError: + if importlib.util.find_spec("exspy") is None: binned = False + else: + binned = True return binned diff --git a/rsciio/utils/tools.py b/rsciio/utils/tools.py index 7c0e52e37..57b957c34 100644 --- a/rsciio/utils/tools.py +++ b/rsciio/utils/tools.py @@ -17,15 +17,15 @@ # along with RosettaSciIO. If not, see . +import importlib import logging -import xml.etree.ElementTree as ET -from pathlib import Path import os +import re +import xml.etree.ElementTree as ET from ast import literal_eval from collections import OrderedDict, defaultdict from contextlib import contextmanager -import importlib -import re +from pathlib import Path import numpy as np from box import Box diff --git a/rsciio/zspy/__init__.py b/rsciio/zspy/__init__.py index 61acf603f..0b6797e5e 100644 --- a/rsciio/zspy/__init__.py +++ b/rsciio/zspy/__init__.py @@ -1,6 +1,5 @@ from ._api import file_reader, file_writer - __all__ = [ "file_reader", "file_writer", diff --git a/rsciio/zspy/_api.py b/rsciio/zspy/_api.py index 07caae9f9..c03ae0387 100644 --- a/rsciio/zspy/_api.py +++ b/rsciio/zspy/_api.py @@ -20,23 +20,22 @@ from collections.abc import MutableMapping import dask.array as da -from dask.diagnostics import ProgressBar import numcodecs import numpy as np import zarr +from dask.diagnostics import ProgressBar from rsciio._docstrings import ( CHUNKS_DOC, FILENAME_DOC, LAZY_DOC, - SHOW_PROGRESSBAR_DOC, RETURNS_DOC, + SHOW_PROGRESSBAR_DOC, SIGNAL_DOC, ) -from rsciio._hierarchical import HierarchicalWriter, HierarchicalReader, version +from rsciio._hierarchical import HierarchicalReader, HierarchicalWriter, version from rsciio.utils.tools import dummy_context_manager - _logger = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index 9e81720f3..bb6659f10 100644 --- a/setup.py +++ b/setup.py @@ -6,10 +6,11 @@ """ # Always prefer setuptools over distutils -from setuptools import setup, Extension, Command import os import warnings +from setuptools import Command, Extension, setup + setup_path = os.path.abspath(os.path.dirname(__file__)) From 702760cd9e88e7df638c1b2e211441493886995b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 6 Apr 2024 12:51:22 +0100 Subject: [PATCH 086/174] fix formatting using `ruff format .` in root folder --- rsciio/bruker/_api.py | 6 +++++- rsciio/digitalsurf/_api.py | 4 +--- rsciio/jobinyvon/_api.py | 18 +++++++++--------- rsciio/msa/_api.py | 12 ++++++------ rsciio/nexus/_api.py | 4 +++- rsciio/semper/_api.py | 16 ++++++++++++---- rsciio/trivista/_api.py | 18 +++++++++--------- rsciio/usid/_api.py | 5 +++-- rsciio/utils/skimage_exposure.py | 3 +-- 9 files changed, 49 insertions(+), 37 deletions(-) diff --git a/rsciio/bruker/_api.py b/rsciio/bruker/_api.py index ecbacc92f..44333f186 100644 --- a/rsciio/bruker/_api.py +++ b/rsciio/bruker/_api.py @@ -1410,7 +1410,11 @@ def bcf_images(obj_bcf): def bcf_hyperspectra( - obj_bcf, index=None, downsample=None, cutoff_at_kV=None, lazy=False # noqa + obj_bcf, + index=None, + downsample=None, + cutoff_at_kV=None, + lazy=False, # noqa ): """Returns list of dict with eds hyperspectra and metadata.""" global warn_once diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index f2acb1556..e81695cb4 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -1366,9 +1366,7 @@ def _unpack_data(self, file, encoding="latin-1"): "_23_Z_Spacing" ) * self._get_work_dict_key_value( "_35_Z_Unit_Ratio" - ) + self._get_work_dict_key_value( - "_55_Z_Offset" - ) + ) + self._get_work_dict_key_value("_55_Z_Offset") _points[nm] = np.nan # Return the points, rescaled diff --git a/rsciio/jobinyvon/_api.py b/rsciio/jobinyvon/_api.py index d1c320018..cc793f035 100644 --- a/rsciio/jobinyvon/_api.py +++ b/rsciio/jobinyvon/_api.py @@ -294,9 +294,9 @@ def get_original_metadata(self): self._get_metadata_values(metadata, "experimental_setup") self._get_metadata_values(file_specs, "file_information") try: - self.original_metadata["experimental_setup"][ - "measurement_type" - ] = self._measurement_type + self.original_metadata["experimental_setup"]["measurement_type"] = ( + self._measurement_type + ) except AttributeError: # pragma: no cover pass # pragma: no cover try: @@ -304,9 +304,9 @@ def get_original_metadata(self): except AttributeError: # pragma: no cover pass # pragma: no cover try: - self.original_metadata["experimental_setup"][ - "rotation angle (rad)" - ] = self._angle + self.original_metadata["experimental_setup"]["rotation angle (rad)"] = ( + self._angle + ) except AttributeError: pass self._clean_up_metadata() @@ -327,9 +327,9 @@ def _set_signal_type(self, xml_element): if id == "0x6D707974": self.original_metadata["experimental_setup"]["signal type"] = child.text if id == "0x7C696E75": - self.original_metadata["experimental_setup"][ - "signal units" - ] = child.text + self.original_metadata["experimental_setup"]["signal units"] = ( + child.text + ) def _set_nav_axis(self, xml_element, tag): """Helper method for setting navigation axes. diff --git a/rsciio/msa/_api.py b/rsciio/msa/_api.py index c70c810a8..153f30b7d 100644 --- a/rsciio/msa/_api.py +++ b/rsciio/msa/_api.py @@ -490,12 +490,12 @@ def file_writer(filename, signal, format="Y", separator=", ", encoding="latin-1" if key in loc_kwds: del loc_kwds[key] - f.write("#%-12s: %s\u000D\u000A" % ("FORMAT", loc_kwds.pop("FORMAT"))) - f.write("#%-12s: %s\u000D\u000A" % ("VERSION", loc_kwds.pop("VERSION"))) + f.write("#%-12s: %s\u000d\u000a" % ("FORMAT", loc_kwds.pop("FORMAT"))) + f.write("#%-12s: %s\u000d\u000a" % ("VERSION", loc_kwds.pop("VERSION"))) for keyword, value in loc_kwds.items(): - f.write("#%-12s: %s\u000D\u000A" % (keyword, value)) + f.write("#%-12s: %s\u000d\u000a" % (keyword, value)) - f.write("#%-12s: Spectral Data Starts Here\u000D\u000A" % "SPECTRUM") + f.write("#%-12s: Spectral Data Starts Here\u000d\u000a" % "SPECTRUM") if format == "XY": axis_dict = signal["axes"][0] @@ -504,11 +504,11 @@ def file_writer(filename, signal, format="Y", separator=", ", encoding="latin-1" ) for x, y in zip(axis, signal["data"]): f.write("%g%s%g" % (x, separator, y)) - f.write("\u000D\u000A") + f.write("\u000d\u000a") elif format == "Y": for y in signal["data"]: f.write("%f%s" % (y, separator)) - f.write("\u000D\u000A") + f.write("\u000d\u000a") else: raise ValueError("format must be one of: None, 'XY' or 'Y'") diff --git a/rsciio/nexus/_api.py b/rsciio/nexus/_api.py index 38d8995ca..9be0ad39f 100644 --- a/rsciio/nexus/_api.py +++ b/rsciio/nexus/_api.py @@ -969,7 +969,9 @@ def find_searchkeys_in_tree(myDict, rootname): rootkey = rootname + "/" + key else: rootkey = key - if isinstance(search_keys, list) and any([s1 in rootkey for s1 in search_keys]): + if isinstance(search_keys, list) and any( + [s1 in rootkey for s1 in search_keys] + ): mod_keys = _text_split(rootkey, (".", "/")) # create the key, values in the dict p = metadata_dict diff --git a/rsciio/semper/_api.py b/rsciio/semper/_api.py index a18ada13b..540ebfa80 100644 --- a/rsciio/semper/_api.py +++ b/rsciio/semper/_api.py @@ -250,12 +250,20 @@ def _read_label(cls, unf_file): label["DATAV6"] = data_v6 label["DATAV7"] = data_v7 # Process title: - title = "".join([str(chr(label_)) for label_ in label["TITLE"][: label["NTITLE"]]]) + title = "".join( + [str(chr(label_)) for label_ in label["TITLE"][: label["NTITLE"]]] + ) label["TITLE"] = title # Process units: - label["XUNIT"] = "".join([chr(label_) for label_ in label["XUNIT"]]).replace("\x00", "") - label["YUNIT"] = "".join([chr(label_) for label_ in label["YUNIT"]]).replace("\x00", "") - label["ZUNIT"] = "".join([chr(label_) for label_ in label["ZUNIT"]]).replace("\x00", "") + label["XUNIT"] = "".join([chr(label_) for label_ in label["XUNIT"]]).replace( + "\x00", "" + ) + label["YUNIT"] = "".join([chr(label_) for label_ in label["YUNIT"]]).replace( + "\x00", "" + ) + label["ZUNIT"] = "".join([chr(label_) for label_ in label["ZUNIT"]]).replace( + "\x00", "" + ) # Sanity check: assert np.fromfile(unf_file, dtype=" Date: Sat, 6 Apr 2024 12:59:19 +0100 Subject: [PATCH 087/174] Add changelog entry --- upcoming_changes/250.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/250.maintenance.rst diff --git a/upcoming_changes/250.maintenance.rst b/upcoming_changes/250.maintenance.rst new file mode 100644 index 000000000..ce2033044 --- /dev/null +++ b/upcoming_changes/250.maintenance.rst @@ -0,0 +1 @@ +Use ``ruff`` for code formating and linting. \ No newline at end of file From 3206c1bc66d7f0b4ad3141835ed9c413ee1db934 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 15 Apr 2024 19:01:08 +0100 Subject: [PATCH 088/174] Fix typo and improve error message in ripple writer --- rsciio/ripple/_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rsciio/ripple/_api.py b/rsciio/ripple/_api.py index e08be7c1a..8fd92c715 100644 --- a/rsciio/ripple/_api.py +++ b/rsciio/ripple/_api.py @@ -477,8 +477,10 @@ def file_writer(filename, signal, encoding="latin-1"): md = DTBox(signal["metadata"], box_dots=True) dtype_name = dc.dtype.name if dtype_name not in dtype2keys.keys(): + supported_dtype = ", ".join(dtype2keys.keys()) raise IOError( - "The ripple format does not support writting data of {dtype_name} type" + f"The ripple format does not support writting data of {dtype_name} type. " + f"Supported data type are: {supported_dtype}." ) # Check if the dimensions are supported dimension = len(dc.shape) From c0c4569a62514ea3f6bcf9d562e34749f02ea648 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 15 Apr 2024 20:18:15 +0100 Subject: [PATCH 089/174] Add changelog entry --- upcoming_changes/251.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/251.bugfix.rst diff --git a/upcoming_changes/251.bugfix.rst b/upcoming_changes/251.bugfix.rst new file mode 100644 index 000000000..79bc7a72d --- /dev/null +++ b/upcoming_changes/251.bugfix.rst @@ -0,0 +1 @@ +:ref:`ripple-format`: Fix typo and improve error message in writer. \ No newline at end of file From 09ce2acdbd30a34a286f76dd75a36e3413769fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20L=C3=A4hnemann?= Date: Mon, 15 Apr 2024 22:05:35 +0200 Subject: [PATCH 090/174] Apply suggestions from code review --- rsciio/ripple/_api.py | 4 ++-- upcoming_changes/251.bugfix.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rsciio/ripple/_api.py b/rsciio/ripple/_api.py index 8fd92c715..1f0352811 100644 --- a/rsciio/ripple/_api.py +++ b/rsciio/ripple/_api.py @@ -479,8 +479,8 @@ def file_writer(filename, signal, encoding="latin-1"): if dtype_name not in dtype2keys.keys(): supported_dtype = ", ".join(dtype2keys.keys()) raise IOError( - f"The ripple format does not support writting data of {dtype_name} type. " - f"Supported data type are: {supported_dtype}." + f"The ripple format does not support writing data of {dtype_name} type. " + f"Supported data types are: {supported_dtype}." ) # Check if the dimensions are supported dimension = len(dc.shape) diff --git a/upcoming_changes/251.bugfix.rst b/upcoming_changes/251.bugfix.rst index 79bc7a72d..3ebaa6643 100644 --- a/upcoming_changes/251.bugfix.rst +++ b/upcoming_changes/251.bugfix.rst @@ -1 +1 @@ -:ref:`ripple-format`: Fix typo and improve error message in writer. \ No newline at end of file +:ref:`ripple-format`: Fix typo and improve error message for unsupported ``dtype`` in writer. \ No newline at end of file From f9b0c9c7ec1810c965ac55d77ca78f447f7afd80 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Apr 2024 21:42:31 +0000 Subject: [PATCH 091/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.4.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.4.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f195d2309..0b97c5234 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.3.5 + rev: v0.4.1 hooks: # Run the linter. - id: ruff From 77ea9e8501b87e7417c0bfe84e7396076dc0c96b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 13:08:20 +0000 Subject: [PATCH 092/174] Bump softprops/action-gh-release from 2.0.4 to 2.0.5 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.4 to 2.0.5. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/9d7c94cfd0a1f3ed45544c887983e9fa900f0564...69320dbe05506a9a39fc8ae11030b214ec2d1f87) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab50ff5b6..19a549959 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,4 +143,4 @@ jobs: uses: actions/checkout@v4 - name: Create Release id: create_release - uses: softprops/action-gh-release@9d7c94cfd0a1f3ed45544c887983e9fa900f0564 + uses: softprops/action-gh-release@69320dbe05506a9a39fc8ae11030b214ec2d1f87 From ff5c4a17b4d3d6395c85045b1a010d607438bd48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 13:08:27 +0000 Subject: [PATCH 093/174] Bump pypa/cibuildwheel from 2.17.0 to 2.18.0 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 2.17.0 to 2.18.0. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v2.17.0...v2.18.0) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab50ff5b6..2dd7ab512 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels for CPython - uses: pypa/cibuildwheel@v2.17.0 + uses: pypa/cibuildwheel@v2.18.0 env: CIBW_ARCHS: ${{ matrix.CIBW_ARCHS }} From 5c286468fdd493f20f489631f90aefa28624b85b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 21:53:32 +0000 Subject: [PATCH 094/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.1 → v0.4.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.1...v0.4.4) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b97c5234..f73a38177 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.1 + rev: v0.4.4 hooks: # Run the linter. - id: ruff From 6045bed95cfe8c4c8979c02c604fe1900f4f2d7e Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 25 May 2024 07:38:00 +0100 Subject: [PATCH 095/174] Fix error and deprecation in tifffile --- .github/workflows/tests.yml | 2 +- pyproject.toml | 2 +- rsciio/tiff/_api.py | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7667b9e9f..322807660 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: PYTHON_VERSION: '3.8' # Set pillow and scikit-image version to be compatible with imageio and scipy # matplotlib needs 3.5 to support markers in hyperspy 2.0 (requires `collection.set_offset_transform`) - DEPENDENCIES: matplotlib==3.5 numpy==1.20.0 imagecodecs==2020.1.31 tifffile==2020.2.16 dask[array]==2021.3.1 numba==0.52 imageio==2.16 pillow==8.3.2 scikit-image==0.18.0 + DEPENDENCIES: matplotlib==3.5 numpy==1.20.0 tifffile==2022.7.28 dask[array]==2021.3.1 numba==0.52 imageio==2.16 pillow==8.3.2 scikit-image==0.18.0 LABEL: '-oldest' # test minimum requirement - os: ubuntu diff --git a/pyproject.toml b/pyproject.toml index a98d4b0bf..59e21b8ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,7 @@ image = ["imageio>=2.16"] mrcz = ["blosc>=1.5", "mrcz>=0.3.6"] scalebar_export = ["matplotlib-scalebar", "matplotlib>=3.5"] speed = ["numba>=0.52"] -tiff = ["tifffile>=2020.2.16", "imagecodecs>=2020.1.31"] +tiff = ["tifffile>=2022.7.28", "imagecodecs"] # Add sidpy dependency and pinning as workaround to fix pyUSID import # Remove sidpy dependency once https://github.com/pycroscopy/pyUSID/issues/85 is fixed. usid = ["pyUSID", "sidpy<=0.12.0"] diff --git a/rsciio/tiff/_api.py b/rsciio/tiff/_api.py index dfa0218f0..79d18d82f 100644 --- a/rsciio/tiff/_api.py +++ b/rsciio/tiff/_api.py @@ -24,8 +24,9 @@ from datetime import datetime, timedelta import numpy as np +import tifffile from dateutil import parser -from tifffile import TIFF, TiffFile, TiffPage, imwrite +from tifffile import TiffFile, TiffPage, imwrite from rsciio._docstrings import ( FILENAME_DOC, @@ -284,7 +285,7 @@ def _read_tiff( shape = handle.shape dtype = handle.dtype - is_rgb = page.photometric == TIFF.PHOTOMETRIC.RGB and RGB_as_structured_array + is_rgb = page.photometric == tifffile.PHOTOMETRIC.RGB and RGB_as_structured_array _logger.debug("Is RGB: %s" % is_rgb) if is_rgb: axes = axes[:-1] @@ -420,15 +421,15 @@ def _is_force_readable(op, force_read_resolution) -> bool: def _axes_force_read(op, shape, names): scales, offsets, units = _axes_defaults() res_unit_tag = op["ResolutionUnit"] - if res_unit_tag != TIFF.RESUNIT.NONE: + if res_unit_tag != tifffile.RESUNIT.NONE: _logger.debug("Resolution unit: %s" % res_unit_tag) scales["x"], scales["y"] = _get_scales_from_x_y_resolution(op) # conversion to µm: - if res_unit_tag == TIFF.RESUNIT.INCH: + if res_unit_tag == tifffile.RESUNIT.INCH: for key in ["x", "y"]: units[key] = "µm" scales[key] = scales[key] * 25400 - elif res_unit_tag == TIFF.RESUNIT.CENTIMETER: + elif res_unit_tag == tifffile.RESUNIT.CENTIMETER: for key in ["x", "y"]: units[key] = "µm" scales[key] = scales[key] * 10000 @@ -601,7 +602,7 @@ def _axes_jeol_sightx(tiff, op, shape, names): op["SightX_Notes"] = ", ".join(mode_strs) res_unit_tag = op["ResolutionUnit"] - if res_unit_tag == TIFF.RESUNIT.INCH: + if res_unit_tag == tifffile.RESUNIT.INCH: scale = 0.0254 # inch/m else: scale = 0.01 # tiff scaling, cm/m From e0379d1991d2a5710afed93127bdb9ecb5c33d1a Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 25 May 2024 09:38:06 +0100 Subject: [PATCH 096/174] Fetch tags in fork on azure pipelines --- azure-pipelines.yml | 10 +++++++--- conda_environment.yml | 9 +++------ conda_environment_dev.yml | 10 ++++++++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 4f842f0a2..d7a2f2722 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -57,8 +57,13 @@ pool: steps: - checkout: self - fetchDepth: 0 # Fetch all commits for setuptools_scm - fetchTags: true # tags necessary for setuptools_scm + fetchDepth: '0' # Fetch all commits for setuptools_scm + fetchTags: 'true' # tags necessary for setuptools_scm +- bash: | + git remote add upstream https://github.com/hyperspy/rosettasciio.git + git fetch upstream --tags + condition: ne(variables['Build.Repository.Name'], 'hyperspy/rosettasciio') + displayName: Fetch tags from hyperspy/rosettasciio - template: azure_pipelines/clone_ci-scripts_repo.yml@templates - template: azure_pipelines/install_mambaforge.yml@templates - template: azure_pipelines/activate_conda.yml@templates @@ -66,7 +71,6 @@ steps: - bash: | source activate $ENV_NAME - pip install "hyperspy>=2.0rc0" pip install --no-deps -e . conda list displayName: Install package diff --git a/conda_environment.yml b/conda_environment.yml index 04715cce0..127b723f3 100644 --- a/conda_environment.yml +++ b/conda_environment.yml @@ -2,12 +2,9 @@ name: test_env channels: - conda-forge dependencies: -- dask-core >=2.11 -- h5py -- imageio -- numba >=0.52 -- numpy -- pint +- dask-core >=2021.3.1 +- numpy >=1.20.0 +- pint >=0.8 - python-box >=6.0,<7.0 - python-dateutil - pyyaml diff --git a/conda_environment_dev.yml b/conda_environment_dev.yml index 42e4bd323..fe2678175 100644 --- a/conda_environment_dev.yml +++ b/conda_environment_dev.yml @@ -3,10 +3,16 @@ channels: - conda-forge dependencies: - cython +- filelock +- h5py >=2.3 +- imageio >=2.16 +- numba >=0.52 - pooch - pytest - pytest-xdist - pytest-rerunfailures -- hyperspy-base +- hyperspy-base >=2.0 - setuptools-scm -- filelock +- sparse +- tifffile>=2022.7.28 +- zarr From 244cca7e6d77a33676667e97f7158c7da2e584ce Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 25 May 2024 10:42:17 +0100 Subject: [PATCH 097/174] Add changelog entry --- upcoming_changes/262.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/262.maintenance.rst diff --git a/upcoming_changes/262.maintenance.rst b/upcoming_changes/262.maintenance.rst new file mode 100644 index 000000000..16f3df00b --- /dev/null +++ b/upcoming_changes/262.maintenance.rst @@ -0,0 +1 @@ +Fix ``tifffile`` deprecation. \ No newline at end of file From 6b2d673d93917832367192ef35f67dd73513b4ea Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 26 May 2024 11:37:57 +0100 Subject: [PATCH 098/174] Fix using `DTBox` with key containing dots following changes in python-box 7. Unpin python-box --- .github/workflows/tests.yml | 9 ++++- conda_environment.yml | 2 +- pyproject.toml | 2 +- rsciio/pantarhei/_api.py | 66 ++++++++++++++++++---------------- rsciio/tests/test_pantarhei.py | 1 + rsciio/utils/tools.py | 9 +++++ 6 files changed, 55 insertions(+), 34 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 322807660..0a4ec19e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: PYTHON_VERSION: '3.8' # Set pillow and scikit-image version to be compatible with imageio and scipy # matplotlib needs 3.5 to support markers in hyperspy 2.0 (requires `collection.set_offset_transform`) - DEPENDENCIES: matplotlib==3.5 numpy==1.20.0 tifffile==2022.7.28 dask[array]==2021.3.1 numba==0.52 imageio==2.16 pillow==8.3.2 scikit-image==0.18.0 + DEPENDENCIES: matplotlib==3.5 numpy==1.20.0 tifffile==2022.7.28 dask[array]==2021.3.1 numba==0.52 imageio==2.16 pillow==8.3.2 scikit-image==0.18.0 python-box==6.0.0 LABEL: '-oldest' # test minimum requirement - os: ubuntu @@ -118,6 +118,13 @@ jobs: pip install git+https://github.com/hyperspy/hyperspy.git pip install git+https://github.com/hyperspy/exspy.git + - name: Install latest python-box + # When installing hyperspy, python-box 6 is installed (rosettasciio pinning) + # Remove when rosettasciio >0.4.0 is released + if: ${{ ! contains(matrix.LABEL, 'oldest') }} + run: | + pip install --upgrade python-box + - name: Install shell: bash run: | diff --git a/conda_environment.yml b/conda_environment.yml index 127b723f3..b6549c6d3 100644 --- a/conda_environment.yml +++ b/conda_environment.yml @@ -5,7 +5,7 @@ dependencies: - dask-core >=2021.3.1 - numpy >=1.20.0 - pint >=0.8 -- python-box >=6.0,<7.0 +- python-box >=6.0 - python-dateutil - pyyaml - scipy diff --git a/pyproject.toml b/pyproject.toml index 59e21b8ec..a1fa64864 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "python-dateutil", "numpy>=1.20.0", "pint>=0.8", - "python-box>=6,<7", + "python-box>=6", "pyyaml", ] dynamic = ["version"] diff --git a/rsciio/pantarhei/_api.py b/rsciio/pantarhei/_api.py index d982b7b5d..160206308 100644 --- a/rsciio/pantarhei/_api.py +++ b/rsciio/pantarhei/_api.py @@ -291,8 +291,8 @@ def export_pr(signal): ref_size = meta_data["ref_size"][::-1] # switch to numpy order pixel_factors = [ref_size[i] / data.shape[i] for i in range(data.ndim)] axes_meta_data = get_metadata_from_axes_info(axes_info, pixel_factors=pixel_factors) - for k in axes_meta_data: - meta_data[k] = axes_meta_data[k] + meta_data.update(axes_meta_data) + return data, meta_data @@ -331,17 +331,19 @@ def _metadata_converter_in(meta_data, axes, filename): if meta_data.get("filter.mode") == "EELS" and signal_dimensions == 1: mapped.set_item("Signal.signal_type", "EELS") - name = meta_data.get("repo_id").split(".")[0] - mapped.set_item("General.title", name) + name = meta_data.get("repo_id") + if name is not None: + mapped.set_item("General.title", name.split(".")[0]) if filename is not None: mapped.set_item("General.original_filename", os.path.split(filename)[1]) + timestamp = None if "acquisition.time" in meta_data: timestamp = meta_data["acquisition.time"] elif "camera.time" in meta_data: timestamp = meta_data["camera.time"] - if "timestamp" in locals(): + if timestamp is not None: timestamp = dt.fromisoformat(timestamp) mapped.set_item("General.date", timestamp.date().isoformat()) mapped.set_item("General.time", timestamp.time().isoformat()) @@ -384,9 +386,11 @@ def _metadata_converter_in(meta_data, axes, filename): def _metadata_converter_out(metadata, original_metadata=None): - metadata = DTBox(metadata, box_dots=True) - original_metadata = DTBox(original_metadata, box_dots=True) - original_fname = metadata.get("General.original_filename", "") + # Don't use `box_dots=True` to be able to use key containing period + # When a entry doesn't exist a empty DTBox is returned + metadata = DTBox(metadata, box_dots=False, default_box=True) + original_metadata = DTBox(original_metadata, box_dots=False, default_box=True) + original_fname = metadata.General.original_filename or "" original_extension = os.path.splitext(original_fname)[1] if original_metadata.get("ref_size"): PR_metadata_present = True @@ -394,7 +398,7 @@ def _metadata_converter_out(metadata, original_metadata=None): PR_metadata_present = False if original_extension == ".prz" and PR_metadata_present: - meta_data = original_metadata + meta_data = original_metadata.to_dict() meta_data["ref_size"] = meta_data["ref_size"][::-1] for key in ["content.types", "user.calib", "inherited.calib", "device.calib"]: if key in meta_data: @@ -407,43 +411,43 @@ def _metadata_converter_out(metadata, original_metadata=None): else: meta_data = {} - if metadata.get("Signal.signal_type") == "EELS": + if metadata.Signal.signal_type == "EELS": meta_data["filter.mode"] = "EELS" - name = metadata.get("General.title") - if name is not None: + name = metadata.General.title + if name: meta_data["repo_id"] = name + ".0" - date = metadata.get("General.date") - time = metadata.get("General.time") - if date is not None and time is not None: + date = metadata.General.date + time = metadata.General.time + if date and time: timestamp = date + "T" + time meta_data["acquisition.time"] = timestamp - md_TEM = metadata.get("Acquisition_instrument.TEM") - if md_TEM is not None: - beam_energy = md_TEM.get("beam_energy") - convergence_angle = md_TEM.get("convergence_angle") - collection_angle = md_TEM.get("Detector.EELS.collection_angle") - aperture = md_TEM.get("Detector.EELS.aperture") - acquisition_mode = md_TEM.get("acquisition_mode") - magnification = md_TEM.get("magnification") - camera_length = md_TEM.get("camera_length") - - if aperture is not None: + md_TEM = metadata.Acquisition_instrument.TEM + if md_TEM: + beam_energy = md_TEM.beam_energy + convergence_angle = md_TEM.convergence_angle + collection_angle = md_TEM.Detector.EELS.collection_angle + aperture = md_TEM.Detector.EELS.aperture + acquisition_mode = md_TEM.acquisition_mode + magnification = md_TEM.magnification + camera_length = md_TEM.camera_length + + if aperture: if isinstance(aperture, (float, int)): aperture = str(aperture) + " mm" meta_data["filter.aperture"] = aperture - if beam_energy is not None: + if beam_energy: beam_energy_ev = beam_energy * 1e3 meta_data["electron_gun.voltage"] = beam_energy_ev - if convergence_angle is not None: + if convergence_angle: convergence_angle_rad = convergence_angle / 1e3 meta_data["condenser.convergence_semi_angle"] = convergence_angle_rad - if collection_angle is not None: + if collection_angle: collection_angle_rad = collection_angle / 1e3 meta_data["filter.collection_semi_angle"] = collection_angle_rad - if camera_length is not None: + if camera_length: meta_data["projector.camera_length"] = camera_length if acquisition_mode == "STEM": key = "scan_driver" @@ -451,7 +455,7 @@ def _metadata_converter_out(metadata, original_metadata=None): else: key = "projector" meta_data["source.type"] = "camera" - if magnification is not None: + if magnification: meta_data[f"{key}.magnification"] = magnification return meta_data diff --git a/rsciio/tests/test_pantarhei.py b/rsciio/tests/test_pantarhei.py index 1a4d845d9..e065079dd 100644 --- a/rsciio/tests/test_pantarhei.py +++ b/rsciio/tests/test_pantarhei.py @@ -90,6 +90,7 @@ def test_save_load_cycle(tmp_path): s2 = hs.load(fname) np.testing.assert_allclose(s2.data, s.data) + assert s2.metadata.Signal.signal_type == s.metadata.Signal.signal_type def test_save_load_cycle_new_signal_1D_nav1(tmp_path): diff --git a/rsciio/utils/tools.py b/rsciio/utils/tools.py index 57b957c34..42870ab04 100644 --- a/rsciio/utils/tools.py +++ b/rsciio/utils/tools.py @@ -374,6 +374,15 @@ def xml2dtb(et, dictree): class DTBox(Box): + """ + Subclass of Box to help migration from hyperspy `DictionaryTreeBrowser` + to `Box` when splitting IO code from hyperspy to rosettasciio. + + When using `box_dots=True`, by default, period will be removed from keys. + To support period containing keys, use `box_dots=False, default_box=True`. + https://github.com/cdgriffith/Box/wiki/Types-of-Boxes#default-box + """ + def add_node(self, path): keys = path.split(".") for key in keys: From a38ce74dc68411d23dc7e53ef0b678a5c9eed3b5 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 26 May 2024 12:12:50 +0100 Subject: [PATCH 099/174] Add changelog entry --- upcoming_changes/263.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/263.maintenance.rst diff --git a/upcoming_changes/263.maintenance.rst b/upcoming_changes/263.maintenance.rst new file mode 100644 index 000000000..bddd8ab68 --- /dev/null +++ b/upcoming_changes/263.maintenance.rst @@ -0,0 +1 @@ +Add support for ``python-box`` 7. \ No newline at end of file From aff946f0cdd682bde8c5d15fa821a9e4e9474727 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 27 May 2024 10:24:41 +0100 Subject: [PATCH 100/174] Update GitHub CI matrix following changes to macos runner: latest is now macos-14 with arm64 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 322807660..4203e5d62 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,7 +49,7 @@ jobs: os_version: latest PYTHON_VERSION: '3.11' - os: macos - os_version: '14' + os_version: '13' PYTHON_VERSION: '3.11' steps: From d124752cb21552ea09cfe5b8c6f318b98aa0ecc2 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 29 May 2024 21:56:46 +0100 Subject: [PATCH 101/174] Add support for dask distributed scheduler in quantum detector reader --- .../supported_formats/supported_formats.rst | 2 +- rsciio/quantumdetector/_api.py | 34 +++++++-- rsciio/tests/test_quantumdetector.py | 18 +++++ rsciio/tests/utils/test_utils.py | 2 +- rsciio/utils/distributed.py | 69 ++++++++++++++----- 5 files changed, 97 insertions(+), 28 deletions(-) diff --git a/doc/user_guide/supported_formats/supported_formats.rst b/doc/user_guide/supported_formats/supported_formats.rst index 4e39b26b9..b7dff940e 100644 --- a/doc/user_guide/supported_formats/supported_formats.rst +++ b/doc/user_guide/supported_formats/supported_formats.rst @@ -56,7 +56,7 @@ +---------------------------------------------------------------------+-------------------------+--------+--------+--------+-------------+ | :ref:`Protochips logfile ` | csv & log | Yes | No | No | No | +---------------------------------------------------------------------+-------------------------+--------+--------+--------+-------------+ - | :ref:`Quantum Detector ` | mib | Yes | No | Yes | No | + | :ref:`Quantum Detector ` | mib | Yes | No | Yes | Yes | +---------------------------------------------------------------------+-------------------------+--------+--------+--------+-------------+ | :ref:`Renishaw ` | wdf | Yes | No | No | No | +---------------------------------------------------------------------+-------------------------+--------+--------+--------+-------------+ diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index 4f51c8708..1f3c10973 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -29,12 +29,14 @@ from rsciio._docstrings import ( CHUNKS_READ_DOC, + DISTRIBUTED_DOC, FILENAME_DOC, LAZY_DOC, MMAP_DOC, NAVIGATION_SHAPE, RETURNS_DOC, ) +from rsciio.utils.distributed import memmap_distributed _logger = logging.getLogger(__name__) @@ -194,6 +196,7 @@ def load_mib_data( navigation_shape=None, first_frame=None, last_frame=None, + distributed=False, mib_prop=None, return_headers=False, print_info=False, @@ -210,6 +213,7 @@ def load_mib_data( %s %s %s + %s mib_prop : ``MIBProperties``, default=None The ``MIBProperties`` instance of the file. If None, it will be parsed from the file. @@ -302,15 +306,21 @@ def load_mib_data( # if it is read from TCPIP interface it needs to drop first 15 bytes which # describe the stream size. Also watch for the coma in front of the stream. if isinstance(mib_prop.path, str): - data = np.memmap( - mib_prop.path, - dtype=merlin_frame_dtype, + memmap_kwargs = dict( + filename=mib_prop.path, # take into account first_frame offset=mib_prop.offset + merlin_frame_dtype.itemsize * first_frame, # need to use np.prod(navigation_shape) to crop number line shape=np.prod(navigation_shape), - mode=mmap_mode, + dtype=merlin_frame_dtype, ) + if distributed: + data = memmap_distributed(chunks=chunks, key="data", **memmap_kwargs) + if not lazy: + data = data.compute() + # get_file_handle(data).close() + else: + data = np.memmap(mode=mmap_mode, **memmap_kwargs) elif isinstance(path, bytes): data = np.frombuffer( path, @@ -322,10 +332,11 @@ def load_mib_data( else: # pragma: no cover raise TypeError("`path` must be a str or a buffer.") - headers = data["header"] - data = data["data"] + if not distributed: + headers = data["header"] + data = data["data"] if not return_mmap: - if lazy: + if not distributed and lazy: if isinstance(chunks, tuple) and len(chunks) > 2: # Since the data is reshaped later on, we set only the # signal dimension chunks here @@ -344,6 +355,10 @@ def load_mib_data( data = data.rechunk(chunks) if return_headers: + if distributed: + raise ValueError( + "Retuning headers is not supported with `distributed=True`." + ) return data, headers else: return data @@ -356,6 +371,7 @@ def load_mib_data( MMAP_DOC, NAVIGATION_SHAPE, _FIRST_LAST_FRAME, + DISTRIBUTED_DOC, ) @@ -489,6 +505,7 @@ def file_reader( navigation_shape=None, first_frame=None, last_frame=None, + distributed=False, print_info=False, ): """ @@ -505,6 +522,7 @@ def file_reader( %s %s %s + %s print_info : bool Display information about the mib file. @@ -589,6 +607,7 @@ def file_reader( navigation_shape=navigation_shape, first_frame=first_frame, last_frame=last_frame, + distributed=distributed, mib_prop=mib_prop, print_info=print_info, return_mmap=False, @@ -653,5 +672,6 @@ def file_reader( MMAP_DOC, NAVIGATION_SHAPE, _FIRST_LAST_FRAME, + DISTRIBUTED_DOC, RETURNS_DOC, ) diff --git a/rsciio/tests/test_quantumdetector.py b/rsciio/tests/test_quantumdetector.py index c7bd18fa1..4907bce71 100644 --- a/rsciio/tests/test_quantumdetector.py +++ b/rsciio/tests/test_quantumdetector.py @@ -394,3 +394,21 @@ def test_frames_in_acquisition_zero(): s = hs.load(f"{fname}.mib") assert s.axes_manager.navigation_shape == () + + +@pytest.mark.parametrize("lazy", (True, False)) +def test_distributed(lazy): + s = hs.load( + TEST_DATA_DIR_UNZIPPED / "001_4x2_6bit.mib", + distributed=False, + lazy=lazy, + ) + s2 = hs.load( + TEST_DATA_DIR_UNZIPPED / "001_4x2_6bit.mib", + distributed=True, + lazy=lazy, + ) + if lazy: + s.compute() + s2.compute() + np.testing.assert_array_equal(s.data, s2.data) diff --git a/rsciio/tests/utils/test_utils.py b/rsciio/tests/utils/test_utils.py index 97364db5a..1c1cb9046 100644 --- a/rsciio/tests/utils/test_utils.py +++ b/rsciio/tests/utils/test_utils.py @@ -384,7 +384,7 @@ def test_get_date_time_from_metadata(): @pytest.mark.parametrize( "shape", - ((10, 20, 30, 512, 512),(20, 30, 512, 512), (10, 512, 512), (512, 512)) + ((10, 20, 30, 512, 512), (20, 30, 512, 512), (10, 512, 512), (512, 512)) ) def test_get_chunk_slice(shape): chunk_arr, chunk = get_chunk_slice(shape=shape, chunks=-1) # 1 chunk diff --git a/rsciio/utils/distributed.py b/rsciio/utils/distributed.py index e5be58bd5..f880a9faa 100644 --- a/rsciio/utils/distributed.py +++ b/rsciio/utils/distributed.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with RosettaSciIO. If not, see . +import os import dask.array as da import numpy as np @@ -60,11 +61,7 @@ def get_chunk_slice( ) chunks_shape = tuple([len(c) for c in chunks]) slices = np.empty( - shape=chunks_shape - + ( - len(chunks_shape), - 2, - ), + shape=chunks_shape + (len(chunks_shape), 2), dtype=int, ) for ind in np.ndindex(chunks_shape): @@ -72,10 +69,11 @@ def get_chunk_slice( starts = [int(np.sum(chunk[:i])) for i, chunk in zip(ind, chunks)] stops = [s + c for s, c in zip(starts, current_chunk)] slices[ind] = [[start, stop] for start, stop in zip(starts, stops)] + return da.from_array(slices, chunks=(1,) * len(shape) + slices.shape[-2:]), chunks -def slice_memmap(slices, file, dtypes, shape, **kwargs): +def slice_memmap(slices, file, dtypes, shape, key=None, **kwargs): """ Slice a memory mapped file using a tuple of slices. @@ -96,6 +94,8 @@ def slice_memmap(slices, file, dtypes, shape, **kwargs): Data type of the data for :class:`numpy.memmap` function. shape : tuple Shape of the entire dataset. Passed to the :class:`numpy.memmap` function. + key : None, str + For structured dtype only. Specify the key of the structured dtype to use. **kwargs : dict Additional keyword arguments to pass to the :class:`numpy.memmap` function. @@ -104,31 +104,36 @@ def slice_memmap(slices, file, dtypes, shape, **kwargs): numpy.ndarray Array of the data from the memory mapped file sliced using the provided slice. """ - sl = np.squeeze(slices)[()] + slices_ = np.squeeze(slices)[()] data = np.memmap(file, dtypes, shape=shape, **kwargs) - slics = tuple([slice(s[0], s[1]) for s in sl]) - return data[slics] + if key is not None: + data = data[key] + slices_ = tuple([slice(s[0], s[1]) for s in slices_]) + return data[slices_] def memmap_distributed( - file, + filename, dtype, offset=0, shape=None, order="C", chunks="auto", block_size_limit=None, + key=None, ): """ - Drop in replacement for py:func:`numpy.memmap` allowing for distributed loading of data. + Drop in replacement for py:func:`numpy.memmap` allowing for distributed + loading of data. - This always loads the data using dask which can be beneficial in many cases, but - may not be ideal in others. The ``chunks`` and ``block_size_limit`` are for describing an ideal chunk shape and size - as defined using the :py:func:`dask.array.core.normalize_chunks` function. + This always loads the data using dask which can be beneficial in many + cases, but may not be ideal in others. The ``chunks`` and ``block_size_limit`` + are for describing an ideal chunk shape and size as defined using the + :func:`dask.array.core.normalize_chunks` function. Parameters ---------- - file : str + filename : str Path to the file. dtype : numpy.dtype Data type of the data for memmap function. @@ -142,25 +147,50 @@ def memmap_distributed( Chunk shape. The default is "auto". block_size_limit : int, optional Maximum size of a block in bytes. The default is None. + key : None, str + For structured dtype only. Specify the key of the structured dtype to use. Returns ------- dask.array.Array Dask array of the data from the memmaped file and with the specified chunks. + + Notes + ----- + Currently :func:`dask.array.map_blocks` does not allow for multiple outputs. + As a result, in case of structured dtype, the key of the structured dtype need + to be specified. + For example: with dtype = (("data", int, (128, 128)), ("sec", " Date: Wed, 29 May 2024 22:19:34 +0100 Subject: [PATCH 102/174] Add changelog entry --- upcoming_changes/267.enhancements.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/267.enhancements.rst diff --git a/upcoming_changes/267.enhancements.rst b/upcoming_changes/267.enhancements.rst new file mode 100644 index 000000000..aeaae1ff8 --- /dev/null +++ b/upcoming_changes/267.enhancements.rst @@ -0,0 +1 @@ +:ref:`quantumdetector-format`: Add support for dask distributed scheduler. \ No newline at end of file From c07dea61c827bb958bdc26f4656fc6d3d75f0899 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:04:04 +0000 Subject: [PATCH 103/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.4 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.4...v0.4.7) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f73a38177..ac91ab870 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.4 + rev: v0.4.7 hooks: # Run the linter. - id: ruff From 82b63ba6ff109e2b9c1e9ad8a85d1967ec138789 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 12 Jun 2024 17:37:21 +0100 Subject: [PATCH 104/174] Fix parsing elements from EDS data from velox emd file v11 --- rsciio/emd/_emd_velox.py | 14 ++++++-------- rsciio/tests/test_emd_velox.py | 1 + 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index d68667755..48632e843 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -121,6 +121,7 @@ def __init__( def read_file(self, f): self.filename = f.filename self.version = _parse_json(f["Version"][0])["version"] + _logger.info(f"EMD file version: {self.version}") self.d_grp = f.get("Data") self._check_im_type() for key in ["Displays", "Operations", "SharedProperties", "Features"]: @@ -841,14 +842,11 @@ def _get_mapping( # Add selected element if map_selected_element: - mapping.update( - { - "Operations.ImageQuantificationOperation": ( - "Sample.elements", - self._convert_element_list, - ), - } - ) + if int(self.version) >= 11: + key = "SharedProperties.EDSSpectrumQuantificationSettings" + else: + key = "Operations.ImageQuantificationOperation" + mapping[key] = ("Sample.elements", self._convert_element_list) return mapping diff --git a/rsciio/tests/test_emd_velox.py b/rsciio/tests/test_emd_velox.py index ec51ca602..68ad2950c 100644 --- a/rsciio/tests/test_emd_velox.py +++ b/rsciio/tests/test_emd_velox.py @@ -524,6 +524,7 @@ def teardown_class(cls): @pytest.mark.parametrize("lazy", (True, False)) def test_spectrum_images(self, lazy): s = hs.load(self.fei_files_path / "Test SI 16x16 215 kx.emd", lazy=lazy) + assert s[-1].metadata.Sample.elements == ["C", "O", "Ca", "Cu"] assert len(s) == 10 for i, v in enumerate(["C", "Ca", "O", "Cu", "HAADF", "EDS"]): assert s[i + 4].metadata.General.title == v From 055283eb48623a5aa90dca1ac112777fb57cae3f Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 12 Jun 2024 17:50:19 +0100 Subject: [PATCH 105/174] Add changelog entry --- upcoming_changes/274.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/274.bugfix.rst diff --git a/upcoming_changes/274.bugfix.rst b/upcoming_changes/274.bugfix.rst new file mode 100644 index 000000000..ac0d389ff --- /dev/null +++ b/upcoming_changes/274.bugfix.rst @@ -0,0 +1 @@ +:ref:`emd_fei-format`: Fix parsing elements from EDS data from velox emd file v11. \ No newline at end of file From 47f993a7c34dca53ba57835a5f0cc2f393f1233d Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 15 Jun 2024 19:43:45 +0100 Subject: [PATCH 106/174] Prepare 0.5 release --- CHANGES.rst | 26 ++++++++++++++++++++++++++ pyproject.toml | 2 +- upcoming_changes/243.bugfix.rst | 1 - upcoming_changes/243.enhancements.rst | 1 - upcoming_changes/250.maintenance.rst | 1 - upcoming_changes/251.bugfix.rst | 1 - upcoming_changes/262.maintenance.rst | 1 - upcoming_changes/263.maintenance.rst | 1 - upcoming_changes/267.enhancements.rst | 1 - upcoming_changes/274.bugfix.rst | 1 - 10 files changed, 27 insertions(+), 9 deletions(-) delete mode 100644 upcoming_changes/243.bugfix.rst delete mode 100644 upcoming_changes/243.enhancements.rst delete mode 100644 upcoming_changes/250.maintenance.rst delete mode 100644 upcoming_changes/251.bugfix.rst delete mode 100644 upcoming_changes/262.maintenance.rst delete mode 100644 upcoming_changes/263.maintenance.rst delete mode 100644 upcoming_changes/267.enhancements.rst delete mode 100644 upcoming_changes/274.bugfix.rst diff --git a/CHANGES.rst b/CHANGES.rst index e8d73b84f..56e880d86 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,32 @@ https://rosettasciio.readthedocs.io/en/latest/changes.html .. towncrier release notes start +0.5 (2024-06-15) +================ + +Enhancements +------------ + +- :ref:`emd_fei-format`: Enforce setting identical units for the ``x`` and ``y`` axes, as convenience to use the scalebar in HyperSpy. (`#243 `_) +- :ref:`quantumdetector-format`: Add support for dask distributed scheduler. (`#267 `_) + + +Bug Fixes +--------- + +- :ref:`emd_fei-format`: Fix conversion of offset units which can sometimes mismatch the scale units. (`#243 `_) +- :ref:`ripple-format`: Fix typo and improve error message for unsupported ``dtype`` in writer. (`#251 `_) +- :ref:`emd_fei-format`: Fix parsing elements from EDS data from velox emd file v11. (`#274 `_) + + +Maintenance +----------- + +- Use ``ruff`` for code formating and linting. (`#250 `_) +- Fix ``tifffile`` deprecation. (`#262 `_) +- Add support for ``python-box`` 7. (`#263 `_) + + 0.4 (2024-04-02) ================ diff --git a/pyproject.toml b/pyproject.toml index a1fa64864..d0c384864 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ include = ["rsciio*"] [tool.setuptools_scm] # Presence enables setuptools_scm, the version will be determine at build time from git # The version will be updated by the `prepare_release.py` script -fallback_version = "0.5.dev0" +fallback_version = "0.6.dev0" [tool.towncrier] directory = "upcoming_changes/" diff --git a/upcoming_changes/243.bugfix.rst b/upcoming_changes/243.bugfix.rst deleted file mode 100644 index 1f18e0350..000000000 --- a/upcoming_changes/243.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`emd_fei-format`: Fix conversion of offset units which can sometimes mismatch the scale units. \ No newline at end of file diff --git a/upcoming_changes/243.enhancements.rst b/upcoming_changes/243.enhancements.rst deleted file mode 100644 index 24809d507..000000000 --- a/upcoming_changes/243.enhancements.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`emd_fei-format`: Enforce setting identical units for the ``x`` and ``y`` axes, as convenience to use the scalebar in HyperSpy. \ No newline at end of file diff --git a/upcoming_changes/250.maintenance.rst b/upcoming_changes/250.maintenance.rst deleted file mode 100644 index ce2033044..000000000 --- a/upcoming_changes/250.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Use ``ruff`` for code formating and linting. \ No newline at end of file diff --git a/upcoming_changes/251.bugfix.rst b/upcoming_changes/251.bugfix.rst deleted file mode 100644 index 3ebaa6643..000000000 --- a/upcoming_changes/251.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`ripple-format`: Fix typo and improve error message for unsupported ``dtype`` in writer. \ No newline at end of file diff --git a/upcoming_changes/262.maintenance.rst b/upcoming_changes/262.maintenance.rst deleted file mode 100644 index 16f3df00b..000000000 --- a/upcoming_changes/262.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Fix ``tifffile`` deprecation. \ No newline at end of file diff --git a/upcoming_changes/263.maintenance.rst b/upcoming_changes/263.maintenance.rst deleted file mode 100644 index bddd8ab68..000000000 --- a/upcoming_changes/263.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Add support for ``python-box`` 7. \ No newline at end of file diff --git a/upcoming_changes/267.enhancements.rst b/upcoming_changes/267.enhancements.rst deleted file mode 100644 index aeaae1ff8..000000000 --- a/upcoming_changes/267.enhancements.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`quantumdetector-format`: Add support for dask distributed scheduler. \ No newline at end of file diff --git a/upcoming_changes/274.bugfix.rst b/upcoming_changes/274.bugfix.rst deleted file mode 100644 index ac0d389ff..000000000 --- a/upcoming_changes/274.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`emd_fei-format`: Fix parsing elements from EDS data from velox emd file v11. \ No newline at end of file From 1bb6728eb274b881f778daf682a655d928f3879b Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Thu, 20 Jun 2024 18:03:06 +0200 Subject: [PATCH 107/174] support for .sur .pro export, bugfixes --- .../supported_formats/digitalsurf.rst | 32 +- rsciio/digitalsurf/__init__.py | 3 +- rsciio/digitalsurf/_api.py | 1051 +++++++++++++++-- rsciio/digitalsurf/specifications.yaml | 4 +- rsciio/emd/_emd_velox.py | 14 +- rsciio/tests/test_digitalsurf.py | 153 +++ rsciio/tests/test_emd_velox.py | 1 + upcoming_changes/274.bugfix.rst | 1 + 8 files changed, 1163 insertions(+), 96 deletions(-) create mode 100644 upcoming_changes/274.bugfix.rst diff --git a/doc/user_guide/supported_formats/digitalsurf.rst b/doc/user_guide/supported_formats/digitalsurf.rst index 0f6610ccd..48608a28d 100644 --- a/doc/user_guide/supported_formats/digitalsurf.rst +++ b/doc/user_guide/supported_formats/digitalsurf.rst @@ -3,16 +3,30 @@ DigitalSurf format (SUR & PRO) ------------------------------ -The ``.sur`` and ``.pro`` files are a format developed by the digitalsurf company to handle various types of -scientific measurements data such as profilometer, SEM, AFM, RGB(A) images, multilayer -surfaces and profiles. Even though it is essentially a surfaces format, 1D signals -are supported for spectra and spectral maps. Specifically, this file format is used -by Attolight SA for its scanning electron microscope cathodoluminescence -(SEM-CL) hyperspectral maps. Metadata parsing is supported, including user-specific -metadata, as well as the loading of files containing multiple objects packed together. +The ``.sur`` and ``.pro`` files are a format developed by the digitalsurf company to handle +various types of scientific data with their MountainsMap software, such as profilometer, SEM, +AFM, RGB(A) images, multilayer surfaces and profiles. Even though it is essentially a surfaces +format, 1D signals are supported for spectra and spectral maps. Specifically, this file format +is used by Attolight SA for its scanning electron microscope cathodoluminescence (SEM-CL) +hyperspectral maps. The plugin was developed based on the MountainsMap software documentation, +which contains a description of the binary format. -The plugin was developed based on the MountainsMap software documentation, which -contains a description of the binary format. +Support for ``.sur`` and ``.pro`` datasets loading is complete, including parsing of user/customer +-specific metadata, and opening of files containing multiple objects. Some rare specific objects +(e.g. force curves) are not supported, due to no example data being available. Those can be added +upon request and providing of example datasets. Heterogeneous data can be represented in ``.sur`` +and ``.pro`` objects, for instance floating-point/topography and rgb data can coexist along the same +navigation dimension. Those are casted to a homogeneous floating-point representation upon loading. + +Support for data saving is partial as ``.sur`` and ``.pro`` can be fundamentally incompatible with +hyperspy signals. First, they have limited dimensionality. Up to 3d data arrays with +either 1d (series of images) or 2d (hyperspectral studiable) navigation space can be saved. Also, +``.sur`` and ``.pro`` do not support non-uniform axes and saving of models. Finally, ``.sur`` / ``.pro`` +linearize intensities along a uniform axis to enforce an integer-representation of the data (with scaling and +offset). This means that export from float-type hyperspy signals is inherently lossy. + +Within these limitations, all features from the fileformat are supported at export, notably data +compression and setting of custom metadata. API functions ^^^^^^^^^^^^^ diff --git a/rsciio/digitalsurf/__init__.py b/rsciio/digitalsurf/__init__.py index 40459e88b..7db9455d9 100644 --- a/rsciio/digitalsurf/__init__.py +++ b/rsciio/digitalsurf/__init__.py @@ -1,7 +1,8 @@ -from ._api import file_reader +from ._api import file_reader, file_writer __all__ = [ "file_reader", + "file_writer" ] diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index e81695cb4..cbf999ff1 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -23,17 +23,19 @@ # comments can be systematically parsed into metadata and write a support for # original_metadata or other +import datetime +from copy import deepcopy import logging import os import struct import sys +import re import warnings import zlib +import ast # Commented for now because I don't know what purpose it serves # import traits.api as t -from copy import deepcopy - # Dateutil allows to parse date but I don't think it's useful here # import dateutil.parser import numpy as np @@ -45,12 +47,13 @@ # import rsciio.utils.tools # DictionaryTreeBrowser class handles the fancy metadata dictionnaries # from hyperspy.misc.utils import DictionaryTreeBrowser -from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC +from rsciio._docstrings import FILENAME_DOC, SIGNAL_DOC from rsciio.utils.exceptions import MountainsMapFileError +from rsciio.utils.rgb_tools import is_rgb, is_rgba +from rsciio.utils.date_time_tools import get_date_time_from_metadata _logger = logging.getLogger(__name__) - class DigitalSurfHandler(object): """Class to read Digital Surf MountainsMap files. @@ -81,26 +84,28 @@ class DigitalSurfHandler(object): 6: "_MERIDIANDISC", 7: "_MULTILAYERPROFILE", 8: "_MULTILAYERSURFACE", - 9: "_PARALLELDISC", + 9: "_PARALLELDISC", #not implemented 10: "_INTENSITYIMAGE", 11: "_INTENSITYSURFACE", 12: "_RGBIMAGE", - 13: "_RGBSURFACE", - 14: "_FORCECURVE", - 15: "_SERIEOFFORCECURVE", - 16: "_RGBINTENSITYSURFACE", + 13: "_RGBSURFACE", #Deprecated + 14: "_FORCECURVE", #Deprecated + 15: "_SERIEOFFORCECURVE", #Deprecated + 16: "_RGBINTENSITYSURFACE", #Surface + Image + 17: "_CONTOURPROFILE", + 18: "_SERIESOFRGBIMAGES", 20: "_SPECTRUM", 21: "_HYPCARD", } - def __init__(self, filename=None): + def __init__(self, filename : str|None = None): # We do not need to check for file existence here because # io module implements it in the load function self.filename = filename # The signal_dict dictionnary has to be returned by the - # file_reader function. Apparently original_metadata needs - # to be set + # file_reader function. By default, we return the minimal + # mandatory fields self.signal_dict = { "data": np.empty((0, 0, 0)), "axes": [], @@ -115,12 +120,12 @@ def __init__(self, filename=None): # _work_dict['Field']['b_pack_fn'](f,v): pack value v in file f self._work_dict = { "_01_Signature": { - "value": "DSCOMPRESSED", + "value": "DSCOMPRESSED", #Uncompressed key is DIGITAL SURF "b_unpack_fn": lambda f: self._get_str(f, 12, "DSCOMPRESSED"), "b_pack_fn": lambda f, v: self._set_str(f, v, 12), }, "_02_Format": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int16, "b_pack_fn": self._set_int16, }, @@ -145,7 +150,7 @@ def __init__(self, filename=None): "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_07_Operator_Name": { - "value": "", + "value": "ROSETTA", "b_unpack_fn": lambda f: self._get_str(f, 30, ""), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, @@ -200,17 +205,17 @@ def __init__(self, filename=None): "b_pack_fn": self._set_int32, }, "_18_Number_of_Points": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int32, "b_pack_fn": self._set_int32, }, "_19_Number_of_Lines": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int32, "b_pack_fn": self._set_int32, }, "_20_Total_Nb_of_Pts": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int32, "b_pack_fn": self._set_int32, }, @@ -305,7 +310,7 @@ def __init__(self, filename=None): "b_pack_fn": self._set_int16, }, "_39_Obsolete": { - "value": 0, + "value": b'0', "b_unpack_fn": lambda f: self._get_bytes(f, 12), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 12), }, @@ -355,7 +360,7 @@ def __init__(self, filename=None): "b_pack_fn": self._set_uint32, }, "_49_Obsolete": { - "value": 0, + "value": b'0', "b_unpack_fn": lambda f: self._get_bytes(f, 6), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 6), }, @@ -370,7 +375,7 @@ def __init__(self, filename=None): "b_pack_fn": self._set_int16, }, "_52_Client_zone": { - "value": 0, + "value": b'0', "b_unpack_fn": lambda f: self._get_bytes(f, 128), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 128), }, @@ -422,7 +427,7 @@ def __init__(self, filename=None): "_62_points": { "value": 0, "b_unpack_fn": self._unpack_data, - "b_pack_fn": lambda f, v: 0, # Not implemented + "b_pack_fn": self._pack_data, }, } @@ -442,6 +447,732 @@ def __init__(self, filename=None): self._N_data_object = 1 self._N_data_channels = 1 + # Attributes useful for save and export + + # Number of nav / sig axes + self._n_ax_nav: int = 0 + self._n_ax_sig: int = 0 + + # All as a rsciio-convention axis dict or empty + self.Xaxis: dict = {} + self.Yaxis: dict = {} + self.Zaxis: dict = {} + self.Taxis: dict = {} + + # These must be set in the split functions + self.data_split = [] + self.objtype_split = [] + # Packaging methods for writing files + + def _build_sur_file_contents(self, + set_comments:str='auto', + is_special:bool=False, + compressed:bool=True, + comments: dict = {}, + operator_name: str = '', + private_zone: bytes = b'', + client_zone: bytes = b'' + ): + + self._list_sur_file_content = [] + + #Compute number of navigation / signal axes + self._n_ax_nav, self._n_ax_sig = DigitalSurfHandler._get_n_axes(self.signal_dict) + + # Choose object type based on number of navigation and signal axes + # Populate self.Xaxis, self.Yaxis, self.Taxis (if not empty) + # Populate self.data_split and self.objtype_split (always) + self._split_signal_dict() + + # This initialize the Comment string saved with the studiable. + comment_dict = self._get_comment_dict(self.signal_dict['original_metadata'], + method=set_comments, + custom=comments) + comment_str = self._stringify_dict(comment_dict) + + #Now we build a workdict for every data object + for data,objtype in zip(self.data_split,self.objtype_split): + self._build_workdict(data, + objtype, + self.signal_dict['metadata'], + comment=comment_str, + is_special=is_special, + compressed=compressed, + operator_name=operator_name, + private_zone=private_zone, + client_zone=client_zone) + # if more than one object, we erase comment after first object. + if comment_str: + comment_str = '' + + # Finally we push it all to the content list. + self._append_work_dict_to_content() + + def _write_sur_file(self): + """Write self._list_sur_file_content to a """ + + with open(self.filename, "wb") as f: + for dic in self._list_sur_file_content: + # Extremely important! self._work_dict must access + # other fields to properly encode and decode data, + # comments etc. etc. + self._move_values_to_workdict(dic) + # Then inner consistency is trivial + for key in self._work_dict: + self._work_dict[key]['b_pack_fn'](f,self._work_dict[key]['value']) + + @staticmethod + def _get_n_axes(sig_dict: dict) -> tuple[int,int]: + """Return number of navigation and signal axes in the signal dict (in that order). + + Args: + sig_dict (dict): signal dictionary. Contains keys 'data', 'axes', 'metadata', 'original_metadata' + + Returns: + Tuple[int,int]: nax_nav,nax_sig. Number of navigation and signal axes + """ + nax_nav = 0 + nax_sig = 0 + for ax in sig_dict['axes']: + if ax['navigate']: + nax_nav += 1 + else: + nax_sig += 1 + return nax_nav, nax_sig + + @staticmethod + def _get_nobjects(omd: dict) -> int: + maxobj = 0 + for k in omd: + objnum = k.split('_')[1] + objnum = int(objnum) + if objnum > maxobj: + maxobj = objnum + return maxobj + + def _is_spectrum(self) -> bool: + """Determine if a signal is a spectrum based on axes naming""" + + spectrumlike_axnames = ['Wavelength', 'Energy', 'Energy Loss', 'E'] + is_spec = False + + for ax in self.signal_dict['axes']: + if ax['name'] in spectrumlike_axnames: + is_spec = True + + return is_spec + + def _is_surface(self) -> bool: + """Determine if a 2d-data-like signal_dict should be of surface type, ie the dataset + is a 2d surface of the 3d plane. """ + is_surface = False + surfacelike_quantnames = ['Height', 'Altitude', 'Elevation', 'Depth', 'Z'] + quant: str = self.signal_dict['metadata']['Signal']['quantity'] + for name in surfacelike_quantnames: + if quant.startswith(name): + is_surface = True + + return is_surface + + def _is_binary(self) -> bool: + return self.signal_dict['data'].dtype == bool + + def _get_num_chans(self) -> int: + """Get number of channels (aka point size) + + Args: + obj_type (int): Object type numeric code + + Returns: + int: Number of channels (point size). + """ + obj_type = self._get_object_type() + + if obj_type == 11: + return 2 #Intensity + surface (deprecated type) + elif obj_type in [12,18]: + return 3 #RGB types + elif obj_type == 13: + return 4 #RGB surface + elif obj_type in [14, 15, 35, 36]: + return 2 #Force curves + elif obj_type in [16]: + return 5 #Surface, Intensity, R, G, B (but hardly applicable to hyperspy) + else: + return 1 + + def _get_wsize(self, nax_sig: int) -> int: + if nax_sig != 1: + raise MountainsMapFileError(f"Attempted parsing W-axis size from signal with navigation dimension {nax_sig}!= 1.") + for ax in self.signal_dict['axes']: + if not ax['navigate']: + return ax['size'] + + def _get_num_objs(self,) -> int: + """Get number of objects based on object type and number of navigation axes in the signal. + + Raises: + ValueError: Several digital surf save formats will need a navigation dimension of 1 + + Returns: + int: _description_ + """ + obj_type = self._get_object_type() + nax_nav, _ = self._get_n_axes() + + if obj_type in [1,2,3,6,9,10,11,12,13,14,15,16,17,20,21,35,36,37]: + return 1 + elif obj_type in [4,5,7,8,18]: + if nax_nav != 1: + raise MountainsMapFileError(f"Attempted to save signal with number type {obj_type} and navigation dimension {nax_nav}.") + for ax in enumerate(self.signal_dict['axes']): + if ax['navigate']: + return ax['size'] + + def _get_object_type(self) -> int: + """Select the suitable _mountains_object_types """ + + nax_nav, nax_sig = self._get_n_axes(self.signal_dict) + + obj_type = None + if nax_nav == 0: + if nax_sig == 0: + raise MountainsMapFileError(msg=f"Object with empty navigation and signal axes not supported for .sur export") + elif nax_sig == 1: + if self._is_spectrum(): + obj_type = 20 # '_SPECTRUM' + else: + obj_type = 1 # '_PROFILE' + elif nax_sig == 2: + if self._is_binary(): + obj_type = 3 # "_BINARYIMAGE" + elif is_rgb(self.signal_dict['data']): + obj_type = 12 #"_RGBIMAGE" + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + obj_type = 12 #"_RGBIMAGE" + elif self._is_surface(): + obj_type = 2 #'_SURFACE' + else: + obj_type = 10 #_INTENSITYSURFACE + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} > 2 not supported for .sur export") + elif nax_nav == 1: + if nax_sig == 0: + warnings.warn(f"Exporting surface signal dimension {nax_sig} and navigation dimension {nax_nav} falls back on surface type but is not good practice.") + obj_type = 1 # '_PROFILE' + elif nax_sig == 1: + if self._is_spectrum(): + obj_type = 20 # '_SPECTRUM' + else: + obj_type = 1 # '_PROFILE' + elif nax_sig ==2: + #Also warn + if is_rgb(self.signal_dict['data']): + obj_type = 18 #"_SERIESOFRGBIMAGE" + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + obj_type = 18 #"_SERIESOFRGBIMAGE" + else: + obj_type = 5 #"_SURFACESERIE" + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} > 2 not supported for .sur export") + elif nax_nav == 2: + if nax_sig == 0: + warnings.warn(f"Signal dimension {nax_sig} and navigation dimension {nax_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") + if self._is_surface(): + obj_type = 2 #'_SURFACE' + else: + obj_type = 10 #_INTENSITYSURFACE + elif nax_sig == 1: + obj_type = 21 #'_HYPCARD' + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} and navigation dimension {nax_nav} not supported for .sur export") + else: + #Also raise + raise MountainsMapFileError(msg=f"Object with navigation dimension {nax_nav} > 2 not supported for .sur export") + + return obj_type + + def _split_spectrum(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + #When splitting spectrum, remember that instead of the series axis (T/W), + #X axis is the spectral dimension and Y the series dimension (if series). + # Xaxis = {} + # Yaxis = {} + nax_nav = self._n_ax_nav + nax_sig = self._n_ax_sig + + if (nax_nav,nax_sig)==(0,1) or (nax_nav,nax_sig)==(1,0): + self.Xaxis = self.signal_dict['axes'][0] + elif (nax_nav,nax_sig)==(1,1): + self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Yaxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + else: + raise MountainsMapFileError(f"Dimensions ({nax_nav})|{nax_sig}) invalid for export as spectrum type") + + self.data_split = [self.signal_dict['data']] + self.objtype_split = [20] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_profile(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + + if (self._n_ax_nav,self._n_ax_sig) in [(0,1),(1,0)]: + self.Xaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for a profile type") + + self.data_split = [self.signal_dict['data']] + self.objtype_split = [1] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_profileserie(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 4 # '_PROFILESERIE' + + if (self._n_ax_nav,self._n_ax_sig)==(1,1): + self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + + self.data_split = self._split_data_alongaxis(self.Taxis) + self.objtype_split = [obj_type] + [1]*(len(self.data_split)-1) + self._N_data_object = len(self.objtype_split) + self._N_data_channels = 1 + + def _split_binary_img(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 3 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_rgb(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 12 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + + self.data_split = [np.int32(self.signal_dict['data']['R']), + np.int32(self.signal_dict['data']['G']), + np.int32(self.signal_dict['data']['B']) + ] + self.objtype_split = [obj_type] + [10,10] + self._N_data_object = 1 + self._N_data_channels = 3 + + def _split_surface(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 2 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_intensitysurface(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 10 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_rgbserie(self): + obj_type = 18 #"_SERIESOFRGBIMAGE" + + sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Yaxis = next(sigaxes_iter) + self.Xaxis = next(sigaxes_iter) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + tmp_data_split = self._split_data_alongaxis(self.Taxis) + + self.data_split = [] + self.objtype_split = [] + for d in tmp_data_split: + self.data_split += [d['R'].astype(np.int32), d['G'].astype(np.int32), d['B'].astype(np.int32)] + self.objtype_split += [12,10,10] + self.objtype_split[0] = obj_type + + self._N_data_object = self.Taxis['size'] + self._N_data_channels = 3 + + def _split_surfaceserie(self): + obj_type = 5 + sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Yaxis = next(sigaxes_iter) + self.Xaxis = next(sigaxes_iter) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + self.data_split = self._split_data_alongaxis(self.Taxis) + self.objtype_split = [2]*len(self.data_split) + self.objtype_split[0] = obj_type + self._N_data_object = len(self.data_split) + self._N_data_channels = 1 + + def _split_hyperspectral(self): + obj_type = 21 + sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if ax['navigate']) + self.Yaxis = next(sigaxes_iter) + self.Xaxis = next(sigaxes_iter) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_data_alongaxis(self, axis: dict) -> list[np.ndarray]: + idx = self.signal_dict['axes'].index(axis) + # return idx + datasplit = [] + for dslice in np.rollaxis(self.signal_dict['data'],idx): + datasplit.append(dslice) + return datasplit + + def _split_signal_dict(self): + """Select the suitable _mountains_object_types """ + + n_nav = self._n_ax_nav + n_sig = self._n_ax_sig + + #Here, I manually unfold the nested conditions for legibility. + #Since there are a fixed number of dimensions supported by + # digitalsurf .sur/.pro files, I think this is the best way to + # proceed. + if (n_nav,n_sig) == (0,1): + if self._is_spectrum(): + self._split_spectrum() + else: + self._split_profile() + elif (n_nav,n_sig) == (0,2): + if self._is_binary(): + self._split_binary_img() + elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + self._split_rgb() + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"A channel discarded upon saving \ + RGBA signal in .sur format") + self._split_rgb() + elif self._is_surface(): #'_SURFACE' + self._split_surface() + else: # _INTENSITYSURFACE + self._split_intensitysurface() + elif (n_nav,n_sig) == (1,0): + warnings.warn(f"Exporting surface signal dimension {n_sig} and navigation dimension \ + {n_nav} falls back on profile type but is not good practice. Consider \ + transposing before saving to avoid unexpected behaviour.") + self._split_profile() + elif (n_nav,n_sig) == (1,1): + if self._is_spectrum(): + self._split_spectrum() + else: + self._split_profileserie() + elif (n_nav,n_sig) == (1,2): + if is_rgb(self.signal_dict['data']): + self._split_rgbserie() + if is_rgba(self.signal_dict['data']): + warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + obj_type = 18 #"_SERIESOFRGBIMAGE" + self._split_rgbserie() + else: + self._split_surfaceserie() + elif (n_nav,n_sig) == (2,0): + warnings.warn(f"Signal dimension {n_sig} and navigation dimension {n_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") + if self._is_binary(): + self._split_binary_img() + elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + self._split_rgb() + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"A channel discarded upon saving \ + RGBA signal in .sur format") + self._split_rgb() + if self._is_surface(): + self._split_surface() + else: + self._split_intensitysurface() + elif (n_nav,n_sig) == (2,1): + self._split_hyperspectral() + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {n_sig} and navigation dimension {n_nav} not supported for .sur export") + + def _norm_data(self, data: np.ndarray, is_special: bool, apply_sat_lo: bool = False, apply_sat_hi: bool = False): + """Normalize input data to 16-bits or 32-bits ints and initialize an axis on which the data is normalized. + + Args: + data (np.ndarray): dataset + is_special (bool): whether NaNs get sent to N.M points in the sur format. + apply_sat_lo (bool, optional): Signal low-value saturation in output datafile. Defaults to False. + apply_sat_hi (bool, optional): Signal high-value saturation in output datafile. Defaults to False. + + Raises: + MountainsMapFileError: raised if input is of complex type + MountainsMapFileError: raised if input is of unsigned int type + MountainsMapFileError: raised if input is of int > 32 bits type + + Returns: + tuple[int,int,int,float,float,np.ndarray[int]]: pointsize, Zmin, Zmax, Zscale, Zoffset, data_int + """ + data_type = data.dtype + + if np.issubdtype(data_type,np.complexfloating): + raise MountainsMapFileError(f"digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export") + elif data_type==np.uint8: + warnings.warn("np.uint8 datatype exported as 16bits") + pointsize = 16 #Pointsize has to be 16 or 32 in surf format + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int16), pointsize, is_special) + data_int = data.astype(np.int16) + elif data_type==np.uint16: + warnings.warn("np.uint16 datatype exported as 32bits") + pointsize = 32 #Pointsize has to be 16 or 32 in surf format + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int32), pointsize, is_special) + data_int = data.astype(np.int32) + elif np.issubdtype(data_type,np.unsignedinteger): + raise MountainsMapFileError(f"digitalsurf file formats do not support unsigned data >16bits. Convert data to signed integers before export.") + elif data_type==np.int8: + pointsize = 16 #Pointsize has to be 16 or 32 in surf format + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, 8, is_special) + data_int = data + elif data_type==np.int16: + pointsize = 16 + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, pointsize, is_special) + data_int = data + elif data_type==np.int32: + pointsize = 32 + data_int = data + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, pointsize, is_special) + elif np.issubdtype(data_type,np.integer): + raise MountainsMapFileError(f"digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting") + elif np.issubdtype(data_type,np.floating): + if self.signal_dict['data'].itemsize*8 > 32: + warnings.warn(f"Lossy conversion of {data_type} to 32-bits-ints representation will occur.") + pointsize = 32 + Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_float(data, is_special) + + return pointsize, Zmin, Zmax, Zscale, Zoffset, data_int + + def _norm_signed_int(self, data:np.ndarray, intsize: int, is_special: bool = False): + # There are no NaN values for integers. Special points means considering high/low saturation of integer scale. + + data_int_min = - 2**(intsize-1) + data_int_max = 2**(intsize -1) + + is_satlo = (data==data_int_min).sum() >= 1 and is_special + is_sathi = (data==data_int_max).sum() >= 1 and is_special + + Zmin = data_int_min + 1 if is_satlo else data.min() + Zmax = data_int_max - 1 if is_sathi else data.max() + Zscale = 1.0 + Zoffset = 0.0 + + return Zmin, Zmax, Zscale, Zoffset + + def _norm_float(self, data : np.ndarray, is_special: bool = False,): + """Normalize float data on a 32 bits int scale.""" + + Zoffset_f = np.nanmin(data) + Zmax_f = np.nanmax(data) + is_nan = np.any(np.isnan(data)) + + if is_special and is_nan: + Zmin = - 2**(32-1) + 2 + Zmax = 2**32 + Zmin - 3 + else: + Zmin = - 2**(32-1) + Zmax = 2**32 + Zmin - 1 + + Zscale = (Zmax_f - Zoffset_f)/(Zmax - Zmin) + data_int = (data - Zoffset_f)/Zscale + Zmin + + if is_special and is_nan: + data_int[np.isnan(data)] = Zmin - 2 + + data_int = data_int.astype(np.int32) + + return Zmin, Zmax, Zscale, Zoffset_f, data_int + + def _get_Zname_Zunit(self, metadata: dict) -> tuple[str,str]: + """Attempt reading Z-axis name and Unit from metadata.Signal.Quantity field. + Return empty str if do not exist. + + Returns: + tuple[str,str]: Zname,Zunit + """ + quantitystr: str = metadata.get('Signal',{}).get('quantity','') + quantitystr = quantitystr.strip() + quantity = quantitystr.split(' ') + if len(quantity)>1: + Zunit = quantity.pop() + Zunit = Zunit.strip('()') + Zname = ' '.join(quantity) + elif len(quantity)==1: + Zname = quantity.pop() + Zunit = '' + else: + Zname = '' + Zunit = '' + + return Zname,Zunit + + def _get_datetime_info(self,) -> tuple[int,int,int,int,int,int]: + date = self.signal_dict['metadata']['General'].get('date','') + time = self.signal_dict['metadata']['General'].get('time','') + + try: + [yyyy,mm,dd] = date.strip().split('-') + except ValueError: + [yyyy,mm,dd] = [0,0,0] + + try: + [hh,minmin,ss] = time.strip().strip('Z').slit(':') + except ValueError: + [hh,minmin,ss] = [0,0,0] + + return yyyy,mm,dd,hh,minmin,ss + + def _build_workdict(self, + data: np.ndarray, + obj_type: int, + metadata: dict = {}, + comment: str = "", + is_special: bool = True, + compressed: bool = True, + operator_name: str = '', + private_zone: bytes = b'', + client_zone: bytes = b'' + ): + + if not compressed: + self._work_dict['_01_Signature']['value'] = 'DIGITAL SURF' # DSCOMPRESSED by default + else: + self._work_dict['_01_Signature']['value'] = 'DSCOMPRESSED' # DSCOMPRESSED by default + + # self._work_dict['_02_Format']['value'] = 0 # Dft. other possible value is 257 for MacintoshII computers with Motorola CPUs. Obv not supported... + self._work_dict['_03_Number_of_Objects']['value'] = self._N_data_object + # self._work_dict['_04_Version']['value'] = 1 # Version number. Always default. + self._work_dict['_05_Object_Type']['value'] = obj_type + # self._work_dict['_06_Object_Name']['value'] = '' Obsolete, DOS-version only (Not supported) + self._work_dict['_07_Operator_Name']['value'] = operator_name #Should be settable from kwargs + self._work_dict['_08_P_Size']['value'] = self._N_data_channels + + # self._work_dict['_09_Acquisition_Type']['value'] = 0 # AFM data only, could be inferred + # self._work_dict['_10_Range_Type']['value'] = 0 #Only 1 for high-range (z-stage scanning), AFM data only, could be inferred + + self._work_dict['_11_Special_Points']['value'] = int(is_special) + + # self._work_dict['_12_Absolute']['value'] = 0 #Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred + # self._work_dict['_13_Gauge_Resolution']['value'] = 0.0 #Probably irrelevant. Only for profilometers (maybe AFM), can be inferred + + # T-axis acts as W-axis for spectrum / hyperspectrum surfaces. + if obj_type in [21]: + ws = self.Taxis.get('size',0) + else: + ws = 0 + self._work_dict['_14_W_Size']['value'] = ws + + bsize, Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_data(data,is_special,apply_sat_lo=True,apply_sat_hi=True) + Zname, Zunit = self._get_Zname_Zunit(metadata) + + #Axes element set regardless of object size + self._work_dict['_15_Size_of_Points']['value'] = bsize + self._work_dict['_16_Zmin']['value'] = Zmin + self._work_dict['_17_Zmax']['value'] = Zmax + self._work_dict['_18_Number_of_Points']['value']= self.Xaxis.get('size',1) + self._work_dict['_19_Number_of_Lines']['value'] = self.Yaxis.get('size',1) + self._work_dict['_20_Total_Nb_of_Pts']['value'] = data.size + self._work_dict['_21_X_Spacing']['value'] = self.Xaxis.get('scale',0.0) + self._work_dict['_22_Y_Spacing']['value'] = self.Yaxis.get('scale',0.0) + self._work_dict['_23_Z_Spacing']['value'] = Zscale + self._work_dict['_24_Name_of_X_Axis']['value'] = self.Xaxis.get('name','') + self._work_dict['_25_Name_of_Y_Axis']['value'] = self.Yaxis.get('name','') + self._work_dict['_26_Name_of_Z_Axis']['value'] = Zname + self._work_dict['_27_X_Step_Unit']['value'] = self.Xaxis.get('units','') + self._work_dict['_28_Y_Step_Unit']['value'] = self.Yaxis.get('units','') + self._work_dict['_29_Z_Step_Unit']['value'] = Zunit + self._work_dict['_30_X_Length_Unit']['value'] = self.Xaxis.get('units','') + self._work_dict['_31_Y_Length_Unit']['value'] = self.Yaxis.get('units','') + self._work_dict['_32_Z_Length_Unit']['value'] = Zunit + self._work_dict['_33_X_Unit_Ratio']['value'] = 1 + self._work_dict['_34_Y_Unit_Ratio']['value'] = 1 + self._work_dict['_35_Z_Unit_Ratio']['value'] = 1 + + # _36_Imprint -> Obsolete + # _37_Inverted -> Always No + # _38_Levelled -> Always No + # _39_Obsolete -> Obsolete + + dt: datetime.datetime = get_date_time_from_metadata(metadata,formatting='datetime') + if dt is not None: + self._work_dict['_40_Seconds']['value'] = dt.second + self._work_dict['_41_Minutes']['value'] = dt.minute + self._work_dict['_42_Hours']['value'] = dt.hour + self._work_dict['_43_Day']['value'] = dt.day + self._work_dict['_44_Month']['value'] = dt.month + self._work_dict['_45_Year']['value'] = dt.year + self._work_dict['_46_Day_of_week']['value'] = dt.weekday() + + # _47_Measurement_duration -> Nonsaved and non-metadata, but float in seconds + + if compressed: + data_bin = self._compress_data(data_int,nstreams=1) #nstreams hard-set to 1. Could be unlocked in the future + else: + fmt = " 2**15: + warnings.warn(f"Comment exceeding max length of 32.0 kB and will be cropped") + comment_len = np.int16(2**15) + + self._work_dict['_50_Comment_size']['value'] = comment_len + + privatesize = len(private_zone) + if privatesize > 2**15: + warnings.warn(f"Private size exceeding max length of 32.0 kB and will be cropped") + privatesize = np.int16(2**15) + + self._work_dict['_51_Private_size']['value'] = privatesize + + self._work_dict['_52_Client_zone']['value'] = client_zone + + self._work_dict['_53_X_Offset']['value'] = self.Xaxis.get('offset',0.0) + self._work_dict['_54_Y_Offset']['value'] = self.Yaxis.get('offset',0.0) + self._work_dict['_55_Z_Offset']['value'] = Zoffset + self._work_dict['_56_T_Spacing']['value'] = self.Taxis.get('scale',0.0) + self._work_dict['_57_T_Offset']['value'] = self.Taxis.get('offset',0.0) + self._work_dict['_58_T_Axis_Name']['value'] = self.Taxis.get('name','') + self._work_dict['_59_T_Step_Unit']['value'] = self.Taxis.get('units','') + + self._work_dict['_60_Comment']['value'] = comment + + self._work_dict['_61_Private_zone']['value'] = private_zone + self._work_dict['_62_points']['value'] = data_bin + # Read methods def _read_sur_file(self): """Read the binary, possibly compressed, content of the surface @@ -485,12 +1216,17 @@ def _read_sur_file(self): def _read_single_sur_object(self, file): for key, val in self._work_dict.items(): self._work_dict[key]["value"] = val["b_unpack_fn"](file) + # print(f"{key}: {self._work_dict[key]['value']}") def _append_work_dict_to_content(self): """Save the values stored in the work dict in the surface file list""" datadict = deepcopy({key: val["value"] for key, val in self._work_dict.items()}) self._list_sur_file_content.append(datadict) + def _move_values_to_workdict(self,dic:dict): + for key in self._work_dict: + self._work_dict[key]['value'] = deepcopy(dic[key]) + def _get_work_dict_key_value(self, key): return self._work_dict[key]["value"] @@ -499,9 +1235,7 @@ def _build_sur_dict(self): """Create a signal dict with an unpacked object""" # If the signal is of the type spectrum or hypercard - if self._Object_type in [ - "_HYPCARD", - ]: + if self._Object_type in ["_HYPCARD"]: self._build_hyperspectral_map() elif self._Object_type in ["_SPECTRUM"]: self._build_spectrum() @@ -509,7 +1243,7 @@ def _build_sur_dict(self): self._build_general_1D_data() elif self._Object_type in ["_PROFILESERIE"]: self._build_1D_series() - elif self._Object_type in ["_SURFACE"]: + elif self._Object_type in ["_SURFACE","_INTENSITYIMAGE","_BINARYIMAGE"]: self._build_surface() elif self._Object_type in ["_SURFACESERIE"]: self._build_surface_series() @@ -521,12 +1255,12 @@ def _build_sur_dict(self): self._build_RGB_image() elif self._Object_type in ["_RGBINTENSITYSURFACE"]: self._build_RGB_surface() - elif self._Object_type in ["_BINARYIMAGE"]: - self._build_surface() + # elif self._Object_type in ["_BINARYIMAGE"]: + # self._build_surface() else: raise MountainsMapFileError( - self._Object_type + "is not a supported mountain object." - ) + f"{self._Object_type} is not a supported mountain object." + ) return self.signal_dict @@ -700,9 +1434,7 @@ def _build_1D_series( self.signal_dict["data"] = np.stack(data) - def _build_surface( - self, - ): + def _build_surface(self,): """Build a surface""" # Check that the object contained only one object. @@ -723,9 +1455,7 @@ def _build_surface( self._set_metadata_and_original_metadata(hypdic) - def _build_surface_series( - self, - ): + def _build_surface_series(self,): """Build a series of surfaces. The T axis is navigation and set from the first object""" @@ -784,9 +1514,7 @@ def _build_RGB_surface( # Pushing data into the dictionary self.signal_dict["data"] = np.stack(data) - def _build_RGB_image( - self, - ): + def _build_RGB_image(self,): """Build an RGB image. The T axis is navigation and set from P Size""" @@ -893,16 +1621,14 @@ def _build_generic_metadata(self, unpacked_dict): return metadict - def _build_original_metadata( - self, - ): + def _build_original_metadata(self,): """Builds a metadata dictionary from the header""" original_metadata_dict = {} # Iteration over Number of data objects for i in range(self._N_data_object): # Iteration over the Number of Data channels - for j in range(self._N_data_channels): + for j in range(max(self._N_data_channels,1)): # Creating a dictionary key for each object k = (i + 1) * (j + 1) key = "Object_{:d}_Channel_{:d}".format(i, j) @@ -930,9 +1656,7 @@ def _build_original_metadata( return original_metadata_dict - def _build_signal_specific_metadata( - self, - ) -> dict: + def _build_signal_specific_metadata(self,) -> dict: """Build additional metadata specific to signal type. return a dictionary for update in the metadata.""" if self.signal_dict["metadata"]["Signal"]["signal_type"] == "CL": @@ -1161,31 +1885,126 @@ def _MS_parse(str_ms, prefix, delimiter): li_value = str_value.split(" ") try: if key == "Grating": - dict_ms[key_main][key] = li_value[ - 0 - ] # we don't want to eval this one + dict_ms[key_main][key] = li_value[0] # we don't want to eval this one else: - dict_ms[key_main][key] = eval(li_value[0]) + dict_ms[key_main][key] = ast.literal_eval(li_value[0]) except Exception: dict_ms[key_main][key] = li_value[0] if len(li_value) > 1: dict_ms[key_main][key + "_units"] = li_value[1] return dict_ms + @staticmethod + def _get_comment_dict(original_metadata: dict, method: str = 'auto', custom: dict = {}) -> dict: + """Return the dictionary used to set the dataset comments (akA custom parameters) while exporting a file. + + By default (method='auto'), tries to identify if the object was originally imported by rosettasciio + from a digitalsurf .sur/.pro file with a comment field parsed as original_metadata (i.e. + Object_0_Channel_0.Parsed). In that case, digitalsurf ignores non-parsed original metadata + (ie .sur/.pro file headers). If the original metadata contains multiple objects with + non-empty parsed content (Object_0_Channel_0.Parsed, Object_0_Channel_1.Parsed etc...), only + the first non-empty X.Parsed sub-dictionary is returned. This falls back on returning the + raw 'original_metadata' + + Optionally the raw 'original_metadata' dictionary can be exported (method='raw'), + a custom dictionary provided by the user (method='custom'), or no comment at all (method='off') + + Args: + method (str, optional): method to export. Defaults to 'auto'. + custom (dict, optional): custom dictionary. Ignored unless method is set to 'custom', Defaults to {}. + + Raises: + MountainsMapFileError: if an invalid key is entered + + Returns: + dict: dictionary to be exported as a .sur object + """ + if method == 'raw': + return original_metadata + elif method == 'custom': + return custom + elif method == 'off': + return {} + elif method == 'auto': + pattern = re.compile("Object_\d*_Channel_\d*") + omd = original_metadata + #filter original metadata content of dict type and matching pattern. + validfields = [omd[key] for key in omd if pattern.match(key) and isinstance(omd[key],dict)] + #In case none match, give up filtering and return raw + if not validfields: + return omd + #In case some match, return first non-empty "Parsed" sub-dict + for field in validfields: + #Return none for non-existing "Parsed" key + candidate = field.get('Parsed') + #For non-none, non-empty dict-type candidate + if candidate and isinstance(candidate,dict): + return candidate + #dict casting for non-none but non-dict candidate + elif candidate is not None: + return {'Parsed': candidate} + #else none candidate, or empty dict -> do nothing + #Finally, if valid fields are present but no candidate + #did a non-empty return, it is safe to return empty + return {} + else: + raise MountainsMapFileError(f"Non-valid method for setting mountainsmap file comment. Choose one of: 'auto','raw','custom','off' ") + + @staticmethod + def _stringify_dict(omd: dict): + """Pack nested dictionary metadata into a string. Pack dictionary-type elements + into digitalsurf "Section title" metadata type ('$_ preceding section title). Pack + other elements into equal-sign separated key-value pairs. + + Supports the key-units logic {'key': value, 'key_units': 'un'} used in hyperspy. + """ + + #Separate dict into list of keys and list of values to authorize index-based pop/insert + keys_queue = list(omd.keys()) + vals_queue = list(omd.values()) + #commentstring to be returned + cmtstr: str = "" + #Loop until queues are empty + while keys_queue: + #pop first object + k = keys_queue.pop(0) + v = vals_queue.pop(0) + #if object is header + if isinstance(v,dict): + cmtstr += f"$_{k}\n" + keys_queue = list(v.keys()) + keys_queue + vals_queue = list(v.values()) + vals_queue + else: + try: + ku_idx = keys_queue.index(k + '_units') + has_units = True + except ValueError: + ku_idx = None + has_units = False + + if has_units: + _ = keys_queue.pop(ku_idx) + vu = vals_queue.pop(ku_idx) + cmtstr += f"${k} = {v.__repr__()} {vu}\n" + else: + cmtstr += f"${k} = {v.__repr__()}\n" + + return cmtstr + # Post processing @staticmethod def post_process_RGB(signal): signal = signal.transpose() - max_data = np.nanmax(signal.data) - if max_data <= 256: + max_data = np.max(signal.data) + if max_data <= 255: signal.change_dtype("uint8") signal.change_dtype("rgb8") elif max_data <= 65536: - signal.change_dtype("uint8") - signal.change_dtype("rgb8") + signal.change_dtype("uint16") + signal.change_dtype("rgb16") else: warnings.warn( - """RGB-announced data could not be converted to + """RGB-announced data could not be converted to uint8 or uint16 datatype""" ) @@ -1224,7 +2043,7 @@ def _set_str(file, val, size, encoding="latin-1"): file.write( struct.pack( "<{:d}s".format(size), - "{{:<{:d}s}}".format(size).format(val).encode(encoding), + f"{val}".ljust(size).encode(encoding), ) ) @@ -1299,12 +2118,21 @@ def _pack_private(self, file, val, encoding="latin-1"): privatesize = self._get_work_dict_key_value("_51_Private_size") self._set_str(file, val, privatesize) + def _is_data_int(self,): + if self._Object_type in ['_BINARYIMAGE', + '_RGBIMAGE', + '_RGBSURFACE', + '_SERIESOFRGBIMAGES']: + return True + else: + return False + def _unpack_data(self, file, encoding="latin-1"): - """This needs to be special because it reads until the end of - file. This causes an error in the series of data""" # Size of datapoints in bytes. Always int16 (==2) or 32 (==4) psize = int(self._get_work_dict_key_value("_15_Size_of_Points") / 8) + Zmin = self._get_work_dict_key_value("_16_Zmin") + dtype = np.int16 if psize == 2 else np.int32 if self._get_work_dict_key_value("_01_Signature") != "DSCOMPRESSED": @@ -1322,12 +2150,9 @@ def _unpack_data(self, file, encoding="latin-1"): readsize = Npts_tot * psize if Wsize != 0: readsize *= Wsize - # if Npts_channel is not 0: - # readsize*=Npts_channel # Read the exact size of the data _points = np.frombuffer(file.read(readsize), dtype=dtype) - # _points = np.fromstring(file.read(readsize),dtype=dtype) else: # If the points are compressed do the uncompress magic. There @@ -1352,36 +2177,74 @@ def _unpack_data(self, file, encoding="latin-1"): # Finally numpy converts it to a numeric object _points = np.frombuffer(rawData, dtype=dtype) - # _points = np.fromstring(rawData, dtype=dtype) # rescale data # We set non measured points to nan according to .sur ways nm = [] if self._get_work_dict_key_value("_11_Special_Points") == 1: - # has unmeasured points + # has non-measured points nm = _points == self._get_work_dict_key_value("_16_Zmin") - 2 - # We set the point in the numeric scale - _points = _points.astype(float) * self._get_work_dict_key_value( - "_23_Z_Spacing" - ) * self._get_work_dict_key_value( - "_35_Z_Unit_Ratio" - ) + self._get_work_dict_key_value("_55_Z_Offset") + _points = (_points.astype(float) - Zmin) * self._get_work_dict_key_value("_23_Z_Spacing") * self._get_work_dict_key_value("_35_Z_Unit_Ratio") + self._get_work_dict_key_value("_55_Z_Offset") - _points[nm] = np.nan + # We set the point in the numeric scale + if self._is_data_int(): + _points = np.round(_points).astype(int) + else: + _points[nm] = np.nan + # Return the points, rescaled return _points def _pack_data(self, file, val, encoding="latin-1"): - """This needs to be special because it writes until the end of - file.""" - datasize = self._get_work_dict_key_value("_62_points") - self._set_str(file, val, datasize) + """This needs to be special because it writes until the end of file.""" + #Also valid for uncompressed + datasize = self._get_work_dict_key_value('_48_Compressed_data_size') + self._set_bytes(file,val,datasize) + + @staticmethod + def _compress_data(data_int, nstreams: int = 1) -> bytes: + """Pack the input data using the digitalsurf zip approach and return the result as a + binary string ready to be written onto a file. """ + if nstreams <= 0 or nstreams >8 : + raise MountainsMapFileError(f"Number of compression streams must be >= 1, <= 8") + + bstr = b'' + bstr += struct.pack("= 11: + key = "SharedProperties.EDSSpectrumQuantificationSettings" + else: + key = "Operations.ImageQuantificationOperation" + mapping[key] = ("Sample.elements", self._convert_element_list) return mapping diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index a49c5dc9f..9121d90f8 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -496,3 +496,156 @@ def test_metadata_mapping(): ] == 7000 ) + + +def test_get_n_obj_chn(): + + omd = {"Object_0_Channel_0":{}, + "Object_1_Channel_0":{}, + "Object_2_Channel_0":{}, + "Object_2_Channel_1":{}, + "Object_2_Channel_2":{}, + "Object_3_Channel_0":{},} + + assert DigitalSurfHandler._get_nobjects(omd)==3 + + +def test_compressdata(): + + testdat = np.arange(120, dtype=np.int32) + + #Refuse too many / neg streams + with pytest.raises(MountainsMapFileError): + DigitalSurfHandler._compress_data(testdat,nstreams=9) + with pytest.raises(MountainsMapFileError): + DigitalSurfHandler._compress_data(testdat,nstreams=-1) + + # Accept 1 (dft) or several streams + bcomp = DigitalSurfHandler._compress_data(testdat) + assert bcomp.startswith(b'\x01\x00\x00\x00\xe0\x01\x00\x00') + bcomp = DigitalSurfHandler._compress_data(testdat,nstreams=2) + assert bcomp.startswith(b'\x02\x00\x00\x00\xf0\x00\x00\x00_\x00\x00\x00') + + # Accept 16-bits int as well as 32 + testdat = np.arange(120, dtype=np.int16) + bcomp = DigitalSurfHandler._compress_data(testdat) + assert bcomp.startswith(b'\x01\x00\x00\x00\xf0\x00\x00\x00') + + + # Also streams non-perfectly divided data + testdat = np.arange(120, dtype=np.int16) + bcomp = DigitalSurfHandler._compress_data(testdat) + assert bcomp.startswith(b'\x01\x00\x00\x00\xf0\x00\x00\x00') + + testdat = np.arange(127, dtype=np.int16) + bcomp = DigitalSurfHandler._compress_data(testdat,nstreams=3) + assert bcomp.startswith(b'\x03\x00\x00\x00V\x00\x00\x00C\x00\x00\x00'+ + b'V\x00\x00\x00F\x00\x00\x00'+ + b'R\x00\x00\x00B\x00\x00\x00') + + +def test_get_comment_dict(): + tdh = DigitalSurfHandler() + tdh.signal_dict={'original_metadata':{ + 'Object_0_Channel_0':{ + 'Parsed':{ + 'key_1': 1, + 'key_2':'2' + } + } + }} + + assert tdh._get_comment_dict('auto')=={'key_1': 1,'key_2':'2'} + assert tdh._get_comment_dict('off')=={} + assert tdh._get_comment_dict('raw')=={'Object_0_Channel_0':{'Parsed':{'key_1': 1,'key_2':'2'}}} + assert tdh._get_comment_dict('custom',custom={'a':0}) == {'a':0} + + #Goes to second dict if only this one's valid + tdh.signal_dict={'original_metadata':{ + 'Object_0_Channel_0':{'Header':{}}, + 'Object_0_Channel_1':{'Header':'ObjHead','Parsed':{'key_1': '0'}}, + }} + assert tdh._get_comment_dict('auto') == {'key_1': '0'} + + #Return empty if none valid + tdh.signal_dict={'original_metadata':{ + 'Object_0_Channel_0':{'Header':{}}, + 'Object_0_Channel_1':{'Header':'ObjHead'}, + }} + assert tdh._get_comment_dict('auto') == {} + + #Return dict-cast if a single field is named 'Parsed' (weird case) + tdh.signal_dict={'original_metadata':{ + 'Object_0_Channel_0':{'Header':{}}, + 'Object_0_Channel_1':{'Header':'ObjHead','Parsed':'SomeContent'}, + }} + assert tdh._get_comment_dict('auto') == {'Parsed':'SomeContent'} + +@pytest.mark.parametrize("test_object", ["test_profile.pro", "test_spectra.pro", "test_spectral_map.sur", "test_spectral_map_compressed.sur", "test_spectrum.pro", "test_spectrum_compressed.pro", "test_surface.sur"]) +def test_writetestobjects(tmp_path,test_object): + """Test data integrity of load/save functions. Starting from externally-generated data (i.e. not from hyperspy)""" + + df = TEST_DATA_PATH.joinpath(test_object) + + d = hs.load(df) + fn = tmp_path.joinpath(test_object) + d.save(fn,is_special=False) + d2 = hs.load(fn) + d2.save(fn,is_special=False) + d3 = hs.load(fn) + + assert np.allclose(d2.data,d.data) + assert np.allclose(d2.data,d3.data) + + a = d.axes_manager.navigation_axes + b = d2.axes_manager.navigation_axes + c = d3.axes_manager.navigation_axes + + for ax,ax2,ax3 in zip(a,b,c): + assert np.allclose(ax.axis,ax2.axis) + assert np.allclose(ax.axis,ax3.axis) + + a = d.axes_manager.signal_axes + b = d2.axes_manager.signal_axes + c = d3.axes_manager.signal_axes + + for ax,ax2,ax3 in zip(a,b,c): + assert np.allclose(ax.axis,ax2.axis) + assert np.allclose(ax.axis,ax3.axis) + +def test_writeRGB(tmp_path): + + df = TEST_DATA_PATH.joinpath("test_RGB.sur") + d = hs.load(df) + fn = tmp_path.joinpath("test_RGB.sur") + d.save(fn,is_special=False) + d2 = hs.load(fn) + d2.save(fn,is_special=False) + d3 = hs.load(fn) + + for k in ['R','G','B']: + assert np.allclose(d2.data[k],d.data[k]) + assert np.allclose(d3.data[k],d.data[k]) + + a = d.axes_manager.navigation_axes + b = d2.axes_manager.navigation_axes + c = d3.axes_manager.navigation_axes + + for ax,ax2,ax3 in zip(a,b,c): + assert np.allclose(ax.axis,ax2.axis) + assert np.allclose(ax.axis,ax3.axis) + + a = d.axes_manager.signal_axes + b = d2.axes_manager.signal_axes + c = d3.axes_manager.signal_axes + + for ax,ax2,ax3 in zip(a,b,c): + assert np.allclose(ax.axis,ax2.axis) + assert np.allclose(ax.axis,ax3.axis) + +@pytest.mark.parametrize("dtype", [np.int16, np.int32, np.float64, np.uint8, np.uint16]) +def test_writegeneric_validtypes(tmp_path,dtype): + + gen = hs.signals.Signal1D(np.arange(24,dtype=dtype))+25 + fgen = tmp_path.joinpath('test.pro') + gen.save(fgen,overwrite=True) \ No newline at end of file diff --git a/rsciio/tests/test_emd_velox.py b/rsciio/tests/test_emd_velox.py index ec51ca602..68ad2950c 100644 --- a/rsciio/tests/test_emd_velox.py +++ b/rsciio/tests/test_emd_velox.py @@ -524,6 +524,7 @@ def teardown_class(cls): @pytest.mark.parametrize("lazy", (True, False)) def test_spectrum_images(self, lazy): s = hs.load(self.fei_files_path / "Test SI 16x16 215 kx.emd", lazy=lazy) + assert s[-1].metadata.Sample.elements == ["C", "O", "Ca", "Cu"] assert len(s) == 10 for i, v in enumerate(["C", "Ca", "O", "Cu", "HAADF", "EDS"]): assert s[i + 4].metadata.General.title == v diff --git a/upcoming_changes/274.bugfix.rst b/upcoming_changes/274.bugfix.rst new file mode 100644 index 000000000..ac0d389ff --- /dev/null +++ b/upcoming_changes/274.bugfix.rst @@ -0,0 +1 @@ +:ref:`emd_fei-format`: Fix parsing elements from EDS data from velox emd file v11. \ No newline at end of file From 72aa59c995707fc55bd2709f7b1b5f13a308fde2 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Thu, 20 Jun 2024 18:03:06 +0200 Subject: [PATCH 108/174] support for .sur .pro export, bugfixes --- .../supported_formats/digitalsurf.rst | 32 +- rsciio/digitalsurf/__init__.py | 3 +- rsciio/digitalsurf/_api.py | 1051 +++++++++++++++-- rsciio/digitalsurf/specifications.yaml | 4 +- rsciio/tests/test_digitalsurf.py | 153 +++ 5 files changed, 1155 insertions(+), 88 deletions(-) diff --git a/doc/user_guide/supported_formats/digitalsurf.rst b/doc/user_guide/supported_formats/digitalsurf.rst index 0f6610ccd..48608a28d 100644 --- a/doc/user_guide/supported_formats/digitalsurf.rst +++ b/doc/user_guide/supported_formats/digitalsurf.rst @@ -3,16 +3,30 @@ DigitalSurf format (SUR & PRO) ------------------------------ -The ``.sur`` and ``.pro`` files are a format developed by the digitalsurf company to handle various types of -scientific measurements data such as profilometer, SEM, AFM, RGB(A) images, multilayer -surfaces and profiles. Even though it is essentially a surfaces format, 1D signals -are supported for spectra and spectral maps. Specifically, this file format is used -by Attolight SA for its scanning electron microscope cathodoluminescence -(SEM-CL) hyperspectral maps. Metadata parsing is supported, including user-specific -metadata, as well as the loading of files containing multiple objects packed together. +The ``.sur`` and ``.pro`` files are a format developed by the digitalsurf company to handle +various types of scientific data with their MountainsMap software, such as profilometer, SEM, +AFM, RGB(A) images, multilayer surfaces and profiles. Even though it is essentially a surfaces +format, 1D signals are supported for spectra and spectral maps. Specifically, this file format +is used by Attolight SA for its scanning electron microscope cathodoluminescence (SEM-CL) +hyperspectral maps. The plugin was developed based on the MountainsMap software documentation, +which contains a description of the binary format. -The plugin was developed based on the MountainsMap software documentation, which -contains a description of the binary format. +Support for ``.sur`` and ``.pro`` datasets loading is complete, including parsing of user/customer +-specific metadata, and opening of files containing multiple objects. Some rare specific objects +(e.g. force curves) are not supported, due to no example data being available. Those can be added +upon request and providing of example datasets. Heterogeneous data can be represented in ``.sur`` +and ``.pro`` objects, for instance floating-point/topography and rgb data can coexist along the same +navigation dimension. Those are casted to a homogeneous floating-point representation upon loading. + +Support for data saving is partial as ``.sur`` and ``.pro`` can be fundamentally incompatible with +hyperspy signals. First, they have limited dimensionality. Up to 3d data arrays with +either 1d (series of images) or 2d (hyperspectral studiable) navigation space can be saved. Also, +``.sur`` and ``.pro`` do not support non-uniform axes and saving of models. Finally, ``.sur`` / ``.pro`` +linearize intensities along a uniform axis to enforce an integer-representation of the data (with scaling and +offset). This means that export from float-type hyperspy signals is inherently lossy. + +Within these limitations, all features from the fileformat are supported at export, notably data +compression and setting of custom metadata. API functions ^^^^^^^^^^^^^ diff --git a/rsciio/digitalsurf/__init__.py b/rsciio/digitalsurf/__init__.py index 40459e88b..7db9455d9 100644 --- a/rsciio/digitalsurf/__init__.py +++ b/rsciio/digitalsurf/__init__.py @@ -1,7 +1,8 @@ -from ._api import file_reader +from ._api import file_reader, file_writer __all__ = [ "file_reader", + "file_writer" ] diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index e81695cb4..cbf999ff1 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -23,17 +23,19 @@ # comments can be systematically parsed into metadata and write a support for # original_metadata or other +import datetime +from copy import deepcopy import logging import os import struct import sys +import re import warnings import zlib +import ast # Commented for now because I don't know what purpose it serves # import traits.api as t -from copy import deepcopy - # Dateutil allows to parse date but I don't think it's useful here # import dateutil.parser import numpy as np @@ -45,12 +47,13 @@ # import rsciio.utils.tools # DictionaryTreeBrowser class handles the fancy metadata dictionnaries # from hyperspy.misc.utils import DictionaryTreeBrowser -from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC +from rsciio._docstrings import FILENAME_DOC, SIGNAL_DOC from rsciio.utils.exceptions import MountainsMapFileError +from rsciio.utils.rgb_tools import is_rgb, is_rgba +from rsciio.utils.date_time_tools import get_date_time_from_metadata _logger = logging.getLogger(__name__) - class DigitalSurfHandler(object): """Class to read Digital Surf MountainsMap files. @@ -81,26 +84,28 @@ class DigitalSurfHandler(object): 6: "_MERIDIANDISC", 7: "_MULTILAYERPROFILE", 8: "_MULTILAYERSURFACE", - 9: "_PARALLELDISC", + 9: "_PARALLELDISC", #not implemented 10: "_INTENSITYIMAGE", 11: "_INTENSITYSURFACE", 12: "_RGBIMAGE", - 13: "_RGBSURFACE", - 14: "_FORCECURVE", - 15: "_SERIEOFFORCECURVE", - 16: "_RGBINTENSITYSURFACE", + 13: "_RGBSURFACE", #Deprecated + 14: "_FORCECURVE", #Deprecated + 15: "_SERIEOFFORCECURVE", #Deprecated + 16: "_RGBINTENSITYSURFACE", #Surface + Image + 17: "_CONTOURPROFILE", + 18: "_SERIESOFRGBIMAGES", 20: "_SPECTRUM", 21: "_HYPCARD", } - def __init__(self, filename=None): + def __init__(self, filename : str|None = None): # We do not need to check for file existence here because # io module implements it in the load function self.filename = filename # The signal_dict dictionnary has to be returned by the - # file_reader function. Apparently original_metadata needs - # to be set + # file_reader function. By default, we return the minimal + # mandatory fields self.signal_dict = { "data": np.empty((0, 0, 0)), "axes": [], @@ -115,12 +120,12 @@ def __init__(self, filename=None): # _work_dict['Field']['b_pack_fn'](f,v): pack value v in file f self._work_dict = { "_01_Signature": { - "value": "DSCOMPRESSED", + "value": "DSCOMPRESSED", #Uncompressed key is DIGITAL SURF "b_unpack_fn": lambda f: self._get_str(f, 12, "DSCOMPRESSED"), "b_pack_fn": lambda f, v: self._set_str(f, v, 12), }, "_02_Format": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int16, "b_pack_fn": self._set_int16, }, @@ -145,7 +150,7 @@ def __init__(self, filename=None): "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_07_Operator_Name": { - "value": "", + "value": "ROSETTA", "b_unpack_fn": lambda f: self._get_str(f, 30, ""), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, @@ -200,17 +205,17 @@ def __init__(self, filename=None): "b_pack_fn": self._set_int32, }, "_18_Number_of_Points": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int32, "b_pack_fn": self._set_int32, }, "_19_Number_of_Lines": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int32, "b_pack_fn": self._set_int32, }, "_20_Total_Nb_of_Pts": { - "value": 0, + "value": 1, "b_unpack_fn": self._get_int32, "b_pack_fn": self._set_int32, }, @@ -305,7 +310,7 @@ def __init__(self, filename=None): "b_pack_fn": self._set_int16, }, "_39_Obsolete": { - "value": 0, + "value": b'0', "b_unpack_fn": lambda f: self._get_bytes(f, 12), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 12), }, @@ -355,7 +360,7 @@ def __init__(self, filename=None): "b_pack_fn": self._set_uint32, }, "_49_Obsolete": { - "value": 0, + "value": b'0', "b_unpack_fn": lambda f: self._get_bytes(f, 6), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 6), }, @@ -370,7 +375,7 @@ def __init__(self, filename=None): "b_pack_fn": self._set_int16, }, "_52_Client_zone": { - "value": 0, + "value": b'0', "b_unpack_fn": lambda f: self._get_bytes(f, 128), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 128), }, @@ -422,7 +427,7 @@ def __init__(self, filename=None): "_62_points": { "value": 0, "b_unpack_fn": self._unpack_data, - "b_pack_fn": lambda f, v: 0, # Not implemented + "b_pack_fn": self._pack_data, }, } @@ -442,6 +447,732 @@ def __init__(self, filename=None): self._N_data_object = 1 self._N_data_channels = 1 + # Attributes useful for save and export + + # Number of nav / sig axes + self._n_ax_nav: int = 0 + self._n_ax_sig: int = 0 + + # All as a rsciio-convention axis dict or empty + self.Xaxis: dict = {} + self.Yaxis: dict = {} + self.Zaxis: dict = {} + self.Taxis: dict = {} + + # These must be set in the split functions + self.data_split = [] + self.objtype_split = [] + # Packaging methods for writing files + + def _build_sur_file_contents(self, + set_comments:str='auto', + is_special:bool=False, + compressed:bool=True, + comments: dict = {}, + operator_name: str = '', + private_zone: bytes = b'', + client_zone: bytes = b'' + ): + + self._list_sur_file_content = [] + + #Compute number of navigation / signal axes + self._n_ax_nav, self._n_ax_sig = DigitalSurfHandler._get_n_axes(self.signal_dict) + + # Choose object type based on number of navigation and signal axes + # Populate self.Xaxis, self.Yaxis, self.Taxis (if not empty) + # Populate self.data_split and self.objtype_split (always) + self._split_signal_dict() + + # This initialize the Comment string saved with the studiable. + comment_dict = self._get_comment_dict(self.signal_dict['original_metadata'], + method=set_comments, + custom=comments) + comment_str = self._stringify_dict(comment_dict) + + #Now we build a workdict for every data object + for data,objtype in zip(self.data_split,self.objtype_split): + self._build_workdict(data, + objtype, + self.signal_dict['metadata'], + comment=comment_str, + is_special=is_special, + compressed=compressed, + operator_name=operator_name, + private_zone=private_zone, + client_zone=client_zone) + # if more than one object, we erase comment after first object. + if comment_str: + comment_str = '' + + # Finally we push it all to the content list. + self._append_work_dict_to_content() + + def _write_sur_file(self): + """Write self._list_sur_file_content to a """ + + with open(self.filename, "wb") as f: + for dic in self._list_sur_file_content: + # Extremely important! self._work_dict must access + # other fields to properly encode and decode data, + # comments etc. etc. + self._move_values_to_workdict(dic) + # Then inner consistency is trivial + for key in self._work_dict: + self._work_dict[key]['b_pack_fn'](f,self._work_dict[key]['value']) + + @staticmethod + def _get_n_axes(sig_dict: dict) -> tuple[int,int]: + """Return number of navigation and signal axes in the signal dict (in that order). + + Args: + sig_dict (dict): signal dictionary. Contains keys 'data', 'axes', 'metadata', 'original_metadata' + + Returns: + Tuple[int,int]: nax_nav,nax_sig. Number of navigation and signal axes + """ + nax_nav = 0 + nax_sig = 0 + for ax in sig_dict['axes']: + if ax['navigate']: + nax_nav += 1 + else: + nax_sig += 1 + return nax_nav, nax_sig + + @staticmethod + def _get_nobjects(omd: dict) -> int: + maxobj = 0 + for k in omd: + objnum = k.split('_')[1] + objnum = int(objnum) + if objnum > maxobj: + maxobj = objnum + return maxobj + + def _is_spectrum(self) -> bool: + """Determine if a signal is a spectrum based on axes naming""" + + spectrumlike_axnames = ['Wavelength', 'Energy', 'Energy Loss', 'E'] + is_spec = False + + for ax in self.signal_dict['axes']: + if ax['name'] in spectrumlike_axnames: + is_spec = True + + return is_spec + + def _is_surface(self) -> bool: + """Determine if a 2d-data-like signal_dict should be of surface type, ie the dataset + is a 2d surface of the 3d plane. """ + is_surface = False + surfacelike_quantnames = ['Height', 'Altitude', 'Elevation', 'Depth', 'Z'] + quant: str = self.signal_dict['metadata']['Signal']['quantity'] + for name in surfacelike_quantnames: + if quant.startswith(name): + is_surface = True + + return is_surface + + def _is_binary(self) -> bool: + return self.signal_dict['data'].dtype == bool + + def _get_num_chans(self) -> int: + """Get number of channels (aka point size) + + Args: + obj_type (int): Object type numeric code + + Returns: + int: Number of channels (point size). + """ + obj_type = self._get_object_type() + + if obj_type == 11: + return 2 #Intensity + surface (deprecated type) + elif obj_type in [12,18]: + return 3 #RGB types + elif obj_type == 13: + return 4 #RGB surface + elif obj_type in [14, 15, 35, 36]: + return 2 #Force curves + elif obj_type in [16]: + return 5 #Surface, Intensity, R, G, B (but hardly applicable to hyperspy) + else: + return 1 + + def _get_wsize(self, nax_sig: int) -> int: + if nax_sig != 1: + raise MountainsMapFileError(f"Attempted parsing W-axis size from signal with navigation dimension {nax_sig}!= 1.") + for ax in self.signal_dict['axes']: + if not ax['navigate']: + return ax['size'] + + def _get_num_objs(self,) -> int: + """Get number of objects based on object type and number of navigation axes in the signal. + + Raises: + ValueError: Several digital surf save formats will need a navigation dimension of 1 + + Returns: + int: _description_ + """ + obj_type = self._get_object_type() + nax_nav, _ = self._get_n_axes() + + if obj_type in [1,2,3,6,9,10,11,12,13,14,15,16,17,20,21,35,36,37]: + return 1 + elif obj_type in [4,5,7,8,18]: + if nax_nav != 1: + raise MountainsMapFileError(f"Attempted to save signal with number type {obj_type} and navigation dimension {nax_nav}.") + for ax in enumerate(self.signal_dict['axes']): + if ax['navigate']: + return ax['size'] + + def _get_object_type(self) -> int: + """Select the suitable _mountains_object_types """ + + nax_nav, nax_sig = self._get_n_axes(self.signal_dict) + + obj_type = None + if nax_nav == 0: + if nax_sig == 0: + raise MountainsMapFileError(msg=f"Object with empty navigation and signal axes not supported for .sur export") + elif nax_sig == 1: + if self._is_spectrum(): + obj_type = 20 # '_SPECTRUM' + else: + obj_type = 1 # '_PROFILE' + elif nax_sig == 2: + if self._is_binary(): + obj_type = 3 # "_BINARYIMAGE" + elif is_rgb(self.signal_dict['data']): + obj_type = 12 #"_RGBIMAGE" + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + obj_type = 12 #"_RGBIMAGE" + elif self._is_surface(): + obj_type = 2 #'_SURFACE' + else: + obj_type = 10 #_INTENSITYSURFACE + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} > 2 not supported for .sur export") + elif nax_nav == 1: + if nax_sig == 0: + warnings.warn(f"Exporting surface signal dimension {nax_sig} and navigation dimension {nax_nav} falls back on surface type but is not good practice.") + obj_type = 1 # '_PROFILE' + elif nax_sig == 1: + if self._is_spectrum(): + obj_type = 20 # '_SPECTRUM' + else: + obj_type = 1 # '_PROFILE' + elif nax_sig ==2: + #Also warn + if is_rgb(self.signal_dict['data']): + obj_type = 18 #"_SERIESOFRGBIMAGE" + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + obj_type = 18 #"_SERIESOFRGBIMAGE" + else: + obj_type = 5 #"_SURFACESERIE" + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} > 2 not supported for .sur export") + elif nax_nav == 2: + if nax_sig == 0: + warnings.warn(f"Signal dimension {nax_sig} and navigation dimension {nax_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") + if self._is_surface(): + obj_type = 2 #'_SURFACE' + else: + obj_type = 10 #_INTENSITYSURFACE + elif nax_sig == 1: + obj_type = 21 #'_HYPCARD' + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} and navigation dimension {nax_nav} not supported for .sur export") + else: + #Also raise + raise MountainsMapFileError(msg=f"Object with navigation dimension {nax_nav} > 2 not supported for .sur export") + + return obj_type + + def _split_spectrum(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + #When splitting spectrum, remember that instead of the series axis (T/W), + #X axis is the spectral dimension and Y the series dimension (if series). + # Xaxis = {} + # Yaxis = {} + nax_nav = self._n_ax_nav + nax_sig = self._n_ax_sig + + if (nax_nav,nax_sig)==(0,1) or (nax_nav,nax_sig)==(1,0): + self.Xaxis = self.signal_dict['axes'][0] + elif (nax_nav,nax_sig)==(1,1): + self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Yaxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + else: + raise MountainsMapFileError(f"Dimensions ({nax_nav})|{nax_sig}) invalid for export as spectrum type") + + self.data_split = [self.signal_dict['data']] + self.objtype_split = [20] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_profile(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + + if (self._n_ax_nav,self._n_ax_sig) in [(0,1),(1,0)]: + self.Xaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for a profile type") + + self.data_split = [self.signal_dict['data']] + self.objtype_split = [1] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_profileserie(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 4 # '_PROFILESERIE' + + if (self._n_ax_nav,self._n_ax_sig)==(1,1): + self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + + self.data_split = self._split_data_alongaxis(self.Taxis) + self.objtype_split = [obj_type] + [1]*(len(self.data_split)-1) + self._N_data_object = len(self.objtype_split) + self._N_data_channels = 1 + + def _split_binary_img(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 3 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_rgb(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 12 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + + self.data_split = [np.int32(self.signal_dict['data']['R']), + np.int32(self.signal_dict['data']['G']), + np.int32(self.signal_dict['data']['B']) + ] + self.objtype_split = [obj_type] + [10,10] + self._N_data_object = 1 + self._N_data_channels = 3 + + def _split_surface(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 2 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_intensitysurface(self,): + """Must set axes except Z, data_split & objtype_split attributes""" + obj_type = 10 + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] + else: + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_rgbserie(self): + obj_type = 18 #"_SERIESOFRGBIMAGE" + + sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Yaxis = next(sigaxes_iter) + self.Xaxis = next(sigaxes_iter) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + tmp_data_split = self._split_data_alongaxis(self.Taxis) + + self.data_split = [] + self.objtype_split = [] + for d in tmp_data_split: + self.data_split += [d['R'].astype(np.int32), d['G'].astype(np.int32), d['B'].astype(np.int32)] + self.objtype_split += [12,10,10] + self.objtype_split[0] = obj_type + + self._N_data_object = self.Taxis['size'] + self._N_data_channels = 3 + + def _split_surfaceserie(self): + obj_type = 5 + sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Yaxis = next(sigaxes_iter) + self.Xaxis = next(sigaxes_iter) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + self.data_split = self._split_data_alongaxis(self.Taxis) + self.objtype_split = [2]*len(self.data_split) + self.objtype_split[0] = obj_type + self._N_data_object = len(self.data_split) + self._N_data_channels = 1 + + def _split_hyperspectral(self): + obj_type = 21 + sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if ax['navigate']) + self.Yaxis = next(sigaxes_iter) + self.Xaxis = next(sigaxes_iter) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.data_split = [self.signal_dict['data']] + self.objtype_split = [obj_type] + self._N_data_object = 1 + self._N_data_channels = 1 + + def _split_data_alongaxis(self, axis: dict) -> list[np.ndarray]: + idx = self.signal_dict['axes'].index(axis) + # return idx + datasplit = [] + for dslice in np.rollaxis(self.signal_dict['data'],idx): + datasplit.append(dslice) + return datasplit + + def _split_signal_dict(self): + """Select the suitable _mountains_object_types """ + + n_nav = self._n_ax_nav + n_sig = self._n_ax_sig + + #Here, I manually unfold the nested conditions for legibility. + #Since there are a fixed number of dimensions supported by + # digitalsurf .sur/.pro files, I think this is the best way to + # proceed. + if (n_nav,n_sig) == (0,1): + if self._is_spectrum(): + self._split_spectrum() + else: + self._split_profile() + elif (n_nav,n_sig) == (0,2): + if self._is_binary(): + self._split_binary_img() + elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + self._split_rgb() + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"A channel discarded upon saving \ + RGBA signal in .sur format") + self._split_rgb() + elif self._is_surface(): #'_SURFACE' + self._split_surface() + else: # _INTENSITYSURFACE + self._split_intensitysurface() + elif (n_nav,n_sig) == (1,0): + warnings.warn(f"Exporting surface signal dimension {n_sig} and navigation dimension \ + {n_nav} falls back on profile type but is not good practice. Consider \ + transposing before saving to avoid unexpected behaviour.") + self._split_profile() + elif (n_nav,n_sig) == (1,1): + if self._is_spectrum(): + self._split_spectrum() + else: + self._split_profileserie() + elif (n_nav,n_sig) == (1,2): + if is_rgb(self.signal_dict['data']): + self._split_rgbserie() + if is_rgba(self.signal_dict['data']): + warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + obj_type = 18 #"_SERIESOFRGBIMAGE" + self._split_rgbserie() + else: + self._split_surfaceserie() + elif (n_nav,n_sig) == (2,0): + warnings.warn(f"Signal dimension {n_sig} and navigation dimension {n_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") + if self._is_binary(): + self._split_binary_img() + elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + self._split_rgb() + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"A channel discarded upon saving \ + RGBA signal in .sur format") + self._split_rgb() + if self._is_surface(): + self._split_surface() + else: + self._split_intensitysurface() + elif (n_nav,n_sig) == (2,1): + self._split_hyperspectral() + else: + raise MountainsMapFileError(msg=f"Object with signal dimension {n_sig} and navigation dimension {n_nav} not supported for .sur export") + + def _norm_data(self, data: np.ndarray, is_special: bool, apply_sat_lo: bool = False, apply_sat_hi: bool = False): + """Normalize input data to 16-bits or 32-bits ints and initialize an axis on which the data is normalized. + + Args: + data (np.ndarray): dataset + is_special (bool): whether NaNs get sent to N.M points in the sur format. + apply_sat_lo (bool, optional): Signal low-value saturation in output datafile. Defaults to False. + apply_sat_hi (bool, optional): Signal high-value saturation in output datafile. Defaults to False. + + Raises: + MountainsMapFileError: raised if input is of complex type + MountainsMapFileError: raised if input is of unsigned int type + MountainsMapFileError: raised if input is of int > 32 bits type + + Returns: + tuple[int,int,int,float,float,np.ndarray[int]]: pointsize, Zmin, Zmax, Zscale, Zoffset, data_int + """ + data_type = data.dtype + + if np.issubdtype(data_type,np.complexfloating): + raise MountainsMapFileError(f"digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export") + elif data_type==np.uint8: + warnings.warn("np.uint8 datatype exported as 16bits") + pointsize = 16 #Pointsize has to be 16 or 32 in surf format + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int16), pointsize, is_special) + data_int = data.astype(np.int16) + elif data_type==np.uint16: + warnings.warn("np.uint16 datatype exported as 32bits") + pointsize = 32 #Pointsize has to be 16 or 32 in surf format + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int32), pointsize, is_special) + data_int = data.astype(np.int32) + elif np.issubdtype(data_type,np.unsignedinteger): + raise MountainsMapFileError(f"digitalsurf file formats do not support unsigned data >16bits. Convert data to signed integers before export.") + elif data_type==np.int8: + pointsize = 16 #Pointsize has to be 16 or 32 in surf format + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, 8, is_special) + data_int = data + elif data_type==np.int16: + pointsize = 16 + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, pointsize, is_special) + data_int = data + elif data_type==np.int32: + pointsize = 32 + data_int = data + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, pointsize, is_special) + elif np.issubdtype(data_type,np.integer): + raise MountainsMapFileError(f"digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting") + elif np.issubdtype(data_type,np.floating): + if self.signal_dict['data'].itemsize*8 > 32: + warnings.warn(f"Lossy conversion of {data_type} to 32-bits-ints representation will occur.") + pointsize = 32 + Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_float(data, is_special) + + return pointsize, Zmin, Zmax, Zscale, Zoffset, data_int + + def _norm_signed_int(self, data:np.ndarray, intsize: int, is_special: bool = False): + # There are no NaN values for integers. Special points means considering high/low saturation of integer scale. + + data_int_min = - 2**(intsize-1) + data_int_max = 2**(intsize -1) + + is_satlo = (data==data_int_min).sum() >= 1 and is_special + is_sathi = (data==data_int_max).sum() >= 1 and is_special + + Zmin = data_int_min + 1 if is_satlo else data.min() + Zmax = data_int_max - 1 if is_sathi else data.max() + Zscale = 1.0 + Zoffset = 0.0 + + return Zmin, Zmax, Zscale, Zoffset + + def _norm_float(self, data : np.ndarray, is_special: bool = False,): + """Normalize float data on a 32 bits int scale.""" + + Zoffset_f = np.nanmin(data) + Zmax_f = np.nanmax(data) + is_nan = np.any(np.isnan(data)) + + if is_special and is_nan: + Zmin = - 2**(32-1) + 2 + Zmax = 2**32 + Zmin - 3 + else: + Zmin = - 2**(32-1) + Zmax = 2**32 + Zmin - 1 + + Zscale = (Zmax_f - Zoffset_f)/(Zmax - Zmin) + data_int = (data - Zoffset_f)/Zscale + Zmin + + if is_special and is_nan: + data_int[np.isnan(data)] = Zmin - 2 + + data_int = data_int.astype(np.int32) + + return Zmin, Zmax, Zscale, Zoffset_f, data_int + + def _get_Zname_Zunit(self, metadata: dict) -> tuple[str,str]: + """Attempt reading Z-axis name and Unit from metadata.Signal.Quantity field. + Return empty str if do not exist. + + Returns: + tuple[str,str]: Zname,Zunit + """ + quantitystr: str = metadata.get('Signal',{}).get('quantity','') + quantitystr = quantitystr.strip() + quantity = quantitystr.split(' ') + if len(quantity)>1: + Zunit = quantity.pop() + Zunit = Zunit.strip('()') + Zname = ' '.join(quantity) + elif len(quantity)==1: + Zname = quantity.pop() + Zunit = '' + else: + Zname = '' + Zunit = '' + + return Zname,Zunit + + def _get_datetime_info(self,) -> tuple[int,int,int,int,int,int]: + date = self.signal_dict['metadata']['General'].get('date','') + time = self.signal_dict['metadata']['General'].get('time','') + + try: + [yyyy,mm,dd] = date.strip().split('-') + except ValueError: + [yyyy,mm,dd] = [0,0,0] + + try: + [hh,minmin,ss] = time.strip().strip('Z').slit(':') + except ValueError: + [hh,minmin,ss] = [0,0,0] + + return yyyy,mm,dd,hh,minmin,ss + + def _build_workdict(self, + data: np.ndarray, + obj_type: int, + metadata: dict = {}, + comment: str = "", + is_special: bool = True, + compressed: bool = True, + operator_name: str = '', + private_zone: bytes = b'', + client_zone: bytes = b'' + ): + + if not compressed: + self._work_dict['_01_Signature']['value'] = 'DIGITAL SURF' # DSCOMPRESSED by default + else: + self._work_dict['_01_Signature']['value'] = 'DSCOMPRESSED' # DSCOMPRESSED by default + + # self._work_dict['_02_Format']['value'] = 0 # Dft. other possible value is 257 for MacintoshII computers with Motorola CPUs. Obv not supported... + self._work_dict['_03_Number_of_Objects']['value'] = self._N_data_object + # self._work_dict['_04_Version']['value'] = 1 # Version number. Always default. + self._work_dict['_05_Object_Type']['value'] = obj_type + # self._work_dict['_06_Object_Name']['value'] = '' Obsolete, DOS-version only (Not supported) + self._work_dict['_07_Operator_Name']['value'] = operator_name #Should be settable from kwargs + self._work_dict['_08_P_Size']['value'] = self._N_data_channels + + # self._work_dict['_09_Acquisition_Type']['value'] = 0 # AFM data only, could be inferred + # self._work_dict['_10_Range_Type']['value'] = 0 #Only 1 for high-range (z-stage scanning), AFM data only, could be inferred + + self._work_dict['_11_Special_Points']['value'] = int(is_special) + + # self._work_dict['_12_Absolute']['value'] = 0 #Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred + # self._work_dict['_13_Gauge_Resolution']['value'] = 0.0 #Probably irrelevant. Only for profilometers (maybe AFM), can be inferred + + # T-axis acts as W-axis for spectrum / hyperspectrum surfaces. + if obj_type in [21]: + ws = self.Taxis.get('size',0) + else: + ws = 0 + self._work_dict['_14_W_Size']['value'] = ws + + bsize, Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_data(data,is_special,apply_sat_lo=True,apply_sat_hi=True) + Zname, Zunit = self._get_Zname_Zunit(metadata) + + #Axes element set regardless of object size + self._work_dict['_15_Size_of_Points']['value'] = bsize + self._work_dict['_16_Zmin']['value'] = Zmin + self._work_dict['_17_Zmax']['value'] = Zmax + self._work_dict['_18_Number_of_Points']['value']= self.Xaxis.get('size',1) + self._work_dict['_19_Number_of_Lines']['value'] = self.Yaxis.get('size',1) + self._work_dict['_20_Total_Nb_of_Pts']['value'] = data.size + self._work_dict['_21_X_Spacing']['value'] = self.Xaxis.get('scale',0.0) + self._work_dict['_22_Y_Spacing']['value'] = self.Yaxis.get('scale',0.0) + self._work_dict['_23_Z_Spacing']['value'] = Zscale + self._work_dict['_24_Name_of_X_Axis']['value'] = self.Xaxis.get('name','') + self._work_dict['_25_Name_of_Y_Axis']['value'] = self.Yaxis.get('name','') + self._work_dict['_26_Name_of_Z_Axis']['value'] = Zname + self._work_dict['_27_X_Step_Unit']['value'] = self.Xaxis.get('units','') + self._work_dict['_28_Y_Step_Unit']['value'] = self.Yaxis.get('units','') + self._work_dict['_29_Z_Step_Unit']['value'] = Zunit + self._work_dict['_30_X_Length_Unit']['value'] = self.Xaxis.get('units','') + self._work_dict['_31_Y_Length_Unit']['value'] = self.Yaxis.get('units','') + self._work_dict['_32_Z_Length_Unit']['value'] = Zunit + self._work_dict['_33_X_Unit_Ratio']['value'] = 1 + self._work_dict['_34_Y_Unit_Ratio']['value'] = 1 + self._work_dict['_35_Z_Unit_Ratio']['value'] = 1 + + # _36_Imprint -> Obsolete + # _37_Inverted -> Always No + # _38_Levelled -> Always No + # _39_Obsolete -> Obsolete + + dt: datetime.datetime = get_date_time_from_metadata(metadata,formatting='datetime') + if dt is not None: + self._work_dict['_40_Seconds']['value'] = dt.second + self._work_dict['_41_Minutes']['value'] = dt.minute + self._work_dict['_42_Hours']['value'] = dt.hour + self._work_dict['_43_Day']['value'] = dt.day + self._work_dict['_44_Month']['value'] = dt.month + self._work_dict['_45_Year']['value'] = dt.year + self._work_dict['_46_Day_of_week']['value'] = dt.weekday() + + # _47_Measurement_duration -> Nonsaved and non-metadata, but float in seconds + + if compressed: + data_bin = self._compress_data(data_int,nstreams=1) #nstreams hard-set to 1. Could be unlocked in the future + else: + fmt = " 2**15: + warnings.warn(f"Comment exceeding max length of 32.0 kB and will be cropped") + comment_len = np.int16(2**15) + + self._work_dict['_50_Comment_size']['value'] = comment_len + + privatesize = len(private_zone) + if privatesize > 2**15: + warnings.warn(f"Private size exceeding max length of 32.0 kB and will be cropped") + privatesize = np.int16(2**15) + + self._work_dict['_51_Private_size']['value'] = privatesize + + self._work_dict['_52_Client_zone']['value'] = client_zone + + self._work_dict['_53_X_Offset']['value'] = self.Xaxis.get('offset',0.0) + self._work_dict['_54_Y_Offset']['value'] = self.Yaxis.get('offset',0.0) + self._work_dict['_55_Z_Offset']['value'] = Zoffset + self._work_dict['_56_T_Spacing']['value'] = self.Taxis.get('scale',0.0) + self._work_dict['_57_T_Offset']['value'] = self.Taxis.get('offset',0.0) + self._work_dict['_58_T_Axis_Name']['value'] = self.Taxis.get('name','') + self._work_dict['_59_T_Step_Unit']['value'] = self.Taxis.get('units','') + + self._work_dict['_60_Comment']['value'] = comment + + self._work_dict['_61_Private_zone']['value'] = private_zone + self._work_dict['_62_points']['value'] = data_bin + # Read methods def _read_sur_file(self): """Read the binary, possibly compressed, content of the surface @@ -485,12 +1216,17 @@ def _read_sur_file(self): def _read_single_sur_object(self, file): for key, val in self._work_dict.items(): self._work_dict[key]["value"] = val["b_unpack_fn"](file) + # print(f"{key}: {self._work_dict[key]['value']}") def _append_work_dict_to_content(self): """Save the values stored in the work dict in the surface file list""" datadict = deepcopy({key: val["value"] for key, val in self._work_dict.items()}) self._list_sur_file_content.append(datadict) + def _move_values_to_workdict(self,dic:dict): + for key in self._work_dict: + self._work_dict[key]['value'] = deepcopy(dic[key]) + def _get_work_dict_key_value(self, key): return self._work_dict[key]["value"] @@ -499,9 +1235,7 @@ def _build_sur_dict(self): """Create a signal dict with an unpacked object""" # If the signal is of the type spectrum or hypercard - if self._Object_type in [ - "_HYPCARD", - ]: + if self._Object_type in ["_HYPCARD"]: self._build_hyperspectral_map() elif self._Object_type in ["_SPECTRUM"]: self._build_spectrum() @@ -509,7 +1243,7 @@ def _build_sur_dict(self): self._build_general_1D_data() elif self._Object_type in ["_PROFILESERIE"]: self._build_1D_series() - elif self._Object_type in ["_SURFACE"]: + elif self._Object_type in ["_SURFACE","_INTENSITYIMAGE","_BINARYIMAGE"]: self._build_surface() elif self._Object_type in ["_SURFACESERIE"]: self._build_surface_series() @@ -521,12 +1255,12 @@ def _build_sur_dict(self): self._build_RGB_image() elif self._Object_type in ["_RGBINTENSITYSURFACE"]: self._build_RGB_surface() - elif self._Object_type in ["_BINARYIMAGE"]: - self._build_surface() + # elif self._Object_type in ["_BINARYIMAGE"]: + # self._build_surface() else: raise MountainsMapFileError( - self._Object_type + "is not a supported mountain object." - ) + f"{self._Object_type} is not a supported mountain object." + ) return self.signal_dict @@ -700,9 +1434,7 @@ def _build_1D_series( self.signal_dict["data"] = np.stack(data) - def _build_surface( - self, - ): + def _build_surface(self,): """Build a surface""" # Check that the object contained only one object. @@ -723,9 +1455,7 @@ def _build_surface( self._set_metadata_and_original_metadata(hypdic) - def _build_surface_series( - self, - ): + def _build_surface_series(self,): """Build a series of surfaces. The T axis is navigation and set from the first object""" @@ -784,9 +1514,7 @@ def _build_RGB_surface( # Pushing data into the dictionary self.signal_dict["data"] = np.stack(data) - def _build_RGB_image( - self, - ): + def _build_RGB_image(self,): """Build an RGB image. The T axis is navigation and set from P Size""" @@ -893,16 +1621,14 @@ def _build_generic_metadata(self, unpacked_dict): return metadict - def _build_original_metadata( - self, - ): + def _build_original_metadata(self,): """Builds a metadata dictionary from the header""" original_metadata_dict = {} # Iteration over Number of data objects for i in range(self._N_data_object): # Iteration over the Number of Data channels - for j in range(self._N_data_channels): + for j in range(max(self._N_data_channels,1)): # Creating a dictionary key for each object k = (i + 1) * (j + 1) key = "Object_{:d}_Channel_{:d}".format(i, j) @@ -930,9 +1656,7 @@ def _build_original_metadata( return original_metadata_dict - def _build_signal_specific_metadata( - self, - ) -> dict: + def _build_signal_specific_metadata(self,) -> dict: """Build additional metadata specific to signal type. return a dictionary for update in the metadata.""" if self.signal_dict["metadata"]["Signal"]["signal_type"] == "CL": @@ -1161,31 +1885,126 @@ def _MS_parse(str_ms, prefix, delimiter): li_value = str_value.split(" ") try: if key == "Grating": - dict_ms[key_main][key] = li_value[ - 0 - ] # we don't want to eval this one + dict_ms[key_main][key] = li_value[0] # we don't want to eval this one else: - dict_ms[key_main][key] = eval(li_value[0]) + dict_ms[key_main][key] = ast.literal_eval(li_value[0]) except Exception: dict_ms[key_main][key] = li_value[0] if len(li_value) > 1: dict_ms[key_main][key + "_units"] = li_value[1] return dict_ms + @staticmethod + def _get_comment_dict(original_metadata: dict, method: str = 'auto', custom: dict = {}) -> dict: + """Return the dictionary used to set the dataset comments (akA custom parameters) while exporting a file. + + By default (method='auto'), tries to identify if the object was originally imported by rosettasciio + from a digitalsurf .sur/.pro file with a comment field parsed as original_metadata (i.e. + Object_0_Channel_0.Parsed). In that case, digitalsurf ignores non-parsed original metadata + (ie .sur/.pro file headers). If the original metadata contains multiple objects with + non-empty parsed content (Object_0_Channel_0.Parsed, Object_0_Channel_1.Parsed etc...), only + the first non-empty X.Parsed sub-dictionary is returned. This falls back on returning the + raw 'original_metadata' + + Optionally the raw 'original_metadata' dictionary can be exported (method='raw'), + a custom dictionary provided by the user (method='custom'), or no comment at all (method='off') + + Args: + method (str, optional): method to export. Defaults to 'auto'. + custom (dict, optional): custom dictionary. Ignored unless method is set to 'custom', Defaults to {}. + + Raises: + MountainsMapFileError: if an invalid key is entered + + Returns: + dict: dictionary to be exported as a .sur object + """ + if method == 'raw': + return original_metadata + elif method == 'custom': + return custom + elif method == 'off': + return {} + elif method == 'auto': + pattern = re.compile("Object_\d*_Channel_\d*") + omd = original_metadata + #filter original metadata content of dict type and matching pattern. + validfields = [omd[key] for key in omd if pattern.match(key) and isinstance(omd[key],dict)] + #In case none match, give up filtering and return raw + if not validfields: + return omd + #In case some match, return first non-empty "Parsed" sub-dict + for field in validfields: + #Return none for non-existing "Parsed" key + candidate = field.get('Parsed') + #For non-none, non-empty dict-type candidate + if candidate and isinstance(candidate,dict): + return candidate + #dict casting for non-none but non-dict candidate + elif candidate is not None: + return {'Parsed': candidate} + #else none candidate, or empty dict -> do nothing + #Finally, if valid fields are present but no candidate + #did a non-empty return, it is safe to return empty + return {} + else: + raise MountainsMapFileError(f"Non-valid method for setting mountainsmap file comment. Choose one of: 'auto','raw','custom','off' ") + + @staticmethod + def _stringify_dict(omd: dict): + """Pack nested dictionary metadata into a string. Pack dictionary-type elements + into digitalsurf "Section title" metadata type ('$_ preceding section title). Pack + other elements into equal-sign separated key-value pairs. + + Supports the key-units logic {'key': value, 'key_units': 'un'} used in hyperspy. + """ + + #Separate dict into list of keys and list of values to authorize index-based pop/insert + keys_queue = list(omd.keys()) + vals_queue = list(omd.values()) + #commentstring to be returned + cmtstr: str = "" + #Loop until queues are empty + while keys_queue: + #pop first object + k = keys_queue.pop(0) + v = vals_queue.pop(0) + #if object is header + if isinstance(v,dict): + cmtstr += f"$_{k}\n" + keys_queue = list(v.keys()) + keys_queue + vals_queue = list(v.values()) + vals_queue + else: + try: + ku_idx = keys_queue.index(k + '_units') + has_units = True + except ValueError: + ku_idx = None + has_units = False + + if has_units: + _ = keys_queue.pop(ku_idx) + vu = vals_queue.pop(ku_idx) + cmtstr += f"${k} = {v.__repr__()} {vu}\n" + else: + cmtstr += f"${k} = {v.__repr__()}\n" + + return cmtstr + # Post processing @staticmethod def post_process_RGB(signal): signal = signal.transpose() - max_data = np.nanmax(signal.data) - if max_data <= 256: + max_data = np.max(signal.data) + if max_data <= 255: signal.change_dtype("uint8") signal.change_dtype("rgb8") elif max_data <= 65536: - signal.change_dtype("uint8") - signal.change_dtype("rgb8") + signal.change_dtype("uint16") + signal.change_dtype("rgb16") else: warnings.warn( - """RGB-announced data could not be converted to + """RGB-announced data could not be converted to uint8 or uint16 datatype""" ) @@ -1224,7 +2043,7 @@ def _set_str(file, val, size, encoding="latin-1"): file.write( struct.pack( "<{:d}s".format(size), - "{{:<{:d}s}}".format(size).format(val).encode(encoding), + f"{val}".ljust(size).encode(encoding), ) ) @@ -1299,12 +2118,21 @@ def _pack_private(self, file, val, encoding="latin-1"): privatesize = self._get_work_dict_key_value("_51_Private_size") self._set_str(file, val, privatesize) + def _is_data_int(self,): + if self._Object_type in ['_BINARYIMAGE', + '_RGBIMAGE', + '_RGBSURFACE', + '_SERIESOFRGBIMAGES']: + return True + else: + return False + def _unpack_data(self, file, encoding="latin-1"): - """This needs to be special because it reads until the end of - file. This causes an error in the series of data""" # Size of datapoints in bytes. Always int16 (==2) or 32 (==4) psize = int(self._get_work_dict_key_value("_15_Size_of_Points") / 8) + Zmin = self._get_work_dict_key_value("_16_Zmin") + dtype = np.int16 if psize == 2 else np.int32 if self._get_work_dict_key_value("_01_Signature") != "DSCOMPRESSED": @@ -1322,12 +2150,9 @@ def _unpack_data(self, file, encoding="latin-1"): readsize = Npts_tot * psize if Wsize != 0: readsize *= Wsize - # if Npts_channel is not 0: - # readsize*=Npts_channel # Read the exact size of the data _points = np.frombuffer(file.read(readsize), dtype=dtype) - # _points = np.fromstring(file.read(readsize),dtype=dtype) else: # If the points are compressed do the uncompress magic. There @@ -1352,36 +2177,74 @@ def _unpack_data(self, file, encoding="latin-1"): # Finally numpy converts it to a numeric object _points = np.frombuffer(rawData, dtype=dtype) - # _points = np.fromstring(rawData, dtype=dtype) # rescale data # We set non measured points to nan according to .sur ways nm = [] if self._get_work_dict_key_value("_11_Special_Points") == 1: - # has unmeasured points + # has non-measured points nm = _points == self._get_work_dict_key_value("_16_Zmin") - 2 - # We set the point in the numeric scale - _points = _points.astype(float) * self._get_work_dict_key_value( - "_23_Z_Spacing" - ) * self._get_work_dict_key_value( - "_35_Z_Unit_Ratio" - ) + self._get_work_dict_key_value("_55_Z_Offset") + _points = (_points.astype(float) - Zmin) * self._get_work_dict_key_value("_23_Z_Spacing") * self._get_work_dict_key_value("_35_Z_Unit_Ratio") + self._get_work_dict_key_value("_55_Z_Offset") - _points[nm] = np.nan + # We set the point in the numeric scale + if self._is_data_int(): + _points = np.round(_points).astype(int) + else: + _points[nm] = np.nan + # Return the points, rescaled return _points def _pack_data(self, file, val, encoding="latin-1"): - """This needs to be special because it writes until the end of - file.""" - datasize = self._get_work_dict_key_value("_62_points") - self._set_str(file, val, datasize) + """This needs to be special because it writes until the end of file.""" + #Also valid for uncompressed + datasize = self._get_work_dict_key_value('_48_Compressed_data_size') + self._set_bytes(file,val,datasize) + + @staticmethod + def _compress_data(data_int, nstreams: int = 1) -> bytes: + """Pack the input data using the digitalsurf zip approach and return the result as a + binary string ready to be written onto a file. """ + if nstreams <= 0 or nstreams >8 : + raise MountainsMapFileError(f"Number of compression streams must be >= 1, <= 8") + + bstr = b'' + bstr += struct.pack(" Date: Fri, 21 Jun 2024 08:23:57 +0100 Subject: [PATCH 109/174] Pin numpy <2.0.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d0c384864..f987a2784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ dependencies = [ "dask[array]>=2021.3.1", "python-dateutil", - "numpy>=1.20.0", + "numpy>=1.20.0,<2.0.0", "pint>=0.8", "python-box>=6", "pyyaml", From de825c2b351a9d1c8aed0ea28f105456ce7c22bd Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 21 Jun 2024 09:26:43 +0100 Subject: [PATCH 110/174] Set python-box pinning >=6,<8, since API changes in python-box can break rosettasciio fairly easily --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f987a2784..2fdb10fc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,9 @@ dependencies = [ "python-dateutil", "numpy>=1.20.0,<2.0.0", "pint>=0.8", - "python-box>=6", + # python-box API changed on major release + # and compatibility needs to be checked + "python-box>=6,<8", "pyyaml", ] dynamic = ["version"] From e6afe84cf7b9dbf4279f3f5411ee522af523da36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:07:25 +0000 Subject: [PATCH 111/174] Bump pypa/cibuildwheel from 2.18.0 to 2.19.1 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 2.18.0 to 2.19.1. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v2.18.0...v2.19.1) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f1e81304..1096ff9f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels for CPython - uses: pypa/cibuildwheel@v2.18.0 + uses: pypa/cibuildwheel@v2.19.1 env: CIBW_ARCHS: ${{ matrix.CIBW_ARCHS }} From 5c9b4ff7d12c9c50434696149d5acbd23876b3cb Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Fri, 21 Jun 2024 19:21:25 +0200 Subject: [PATCH 112/174] Fixing bugs, making doc --- .../supported_formats/digitalsurf.rst | 74 ++- rsciio/digitalsurf/_api.py | 584 +++++++++--------- .../tests/data/digitalsurf/test_isurface.sur | Bin 0 -> 56141 bytes rsciio/tests/test_digitalsurf.py | 94 +-- 4 files changed, 383 insertions(+), 369 deletions(-) create mode 100644 rsciio/tests/data/digitalsurf/test_isurface.sur diff --git a/doc/user_guide/supported_formats/digitalsurf.rst b/doc/user_guide/supported_formats/digitalsurf.rst index 48608a28d..8b5807abd 100644 --- a/doc/user_guide/supported_formats/digitalsurf.rst +++ b/doc/user_guide/supported_formats/digitalsurf.rst @@ -3,30 +3,56 @@ DigitalSurf format (SUR & PRO) ------------------------------ -The ``.sur`` and ``.pro`` files are a format developed by the digitalsurf company to handle -various types of scientific data with their MountainsMap software, such as profilometer, SEM, -AFM, RGB(A) images, multilayer surfaces and profiles. Even though it is essentially a surfaces -format, 1D signals are supported for spectra and spectral maps. Specifically, this file format -is used by Attolight SA for its scanning electron microscope cathodoluminescence (SEM-CL) -hyperspectral maps. The plugin was developed based on the MountainsMap software documentation, -which contains a description of the binary format. - -Support for ``.sur`` and ``.pro`` datasets loading is complete, including parsing of user/customer --specific metadata, and opening of files containing multiple objects. Some rare specific objects -(e.g. force curves) are not supported, due to no example data being available. Those can be added -upon request and providing of example datasets. Heterogeneous data can be represented in ``.sur`` -and ``.pro`` objects, for instance floating-point/topography and rgb data can coexist along the same -navigation dimension. Those are casted to a homogeneous floating-point representation upon loading. - -Support for data saving is partial as ``.sur`` and ``.pro`` can be fundamentally incompatible with -hyperspy signals. First, they have limited dimensionality. Up to 3d data arrays with -either 1d (series of images) or 2d (hyperspectral studiable) navigation space can be saved. Also, -``.sur`` and ``.pro`` do not support non-uniform axes and saving of models. Finally, ``.sur`` / ``.pro`` -linearize intensities along a uniform axis to enforce an integer-representation of the data (with scaling and -offset). This means that export from float-type hyperspy signals is inherently lossy. - -Within these limitations, all features from the fileformat are supported at export, notably data -compression and setting of custom metadata. +``.sur`` and ``.pro`` is format developed by digitalsurf to import/export data in their MountainsMap scientific +analysis software. Target datasets originally result from (micro)-topography and imaging instruments: SEM, AFM, +profilometer. RGB(A) images, multilayer surfaces and profiles are also supported. Even though it is essentially +a surfaces format, 1D signals are supported for spectra and spectral maps. Specifically, this is the fileformat +used by Attolight SA for its scanning electron microscope cathodoluminescence (SEM-CL) hyperspectral maps. This +plugin was developed based on the MountainsMap software documentation. + +Support for loading ``.sur`` and ``.pro`` datasets is complete, including parsing of user/customer-specific +metadata, and opening of files containing multiple objects. Some rare specific objects (e.g. force curves) +are not supported, due to no example data being available. Those can be added upon request and providing of +example datasets. Heterogeneous data can be represented in ``.sur`` and ``.pro`` objects, for instance +floating-point/topography and rgb data can coexist along the same navigation dimension. Those are casted to +a homogeneous floating-point representation upon loading. + +Support for data saving is partial as ``.sur`` and ``.pro`` do not support all features of hyperspy signals. +First, they have limited dimensionality. Up to 3d data arrays with either 1d (series of images) or 2d +(hyperspectral studiable) navigation space can be saved. Also, ``.sur`` and ``.pro`` do not support non-uniform +axes and saving of models. Finally, ``.sur`` / ``.pro`` linearize intensities along a uniform axis to enforce +an integer-representation of the data (with scaling and offset). This means that export from float-type hyperspy +signals is inherently lossy. + +Within these limitations, all features from ``.sur`` and ``.pro`` fileformats are supported, notably data +compression and setting of custom metadata. The file writer splits a signal into the suitable digitalsurf +dataobject primarily by inspecting its dimensions and its datatype, ultimately how various axes and signal +quantity are named. The criteria are listed here below: + ++-----------------+---------------+------------------------------------------------------------------------------+ +| Nav. dimension | Sig dimension | Extension and MountainsMap subclass | ++=================+===============+==============================================================================+ +| 0 | 1 | ``.pro``: Spectrum (based on axes name), Profile (default) | ++-----------------+---------------+------------------------------------------------------------------------------+ +| 0 | 2 | ``.sur``: BinaryImage (based on dtype), RGBImage (based on dtype), | +| | | Surface (default), | ++-----------------+---------------+------------------------------------------------------------------------------+ +| 1 | 0 | ``.pro``: same as (1,0) | ++-----------------+---------------+------------------------------------------------------------------------------+ +| 1 | 1 | ``.pro``: Spectrum Serie (based on axes name), Profile Serie (default) | ++-----------------+---------------+------------------------------------------------------------------------------+ +| 1 | 2 | ``.sur``: RGBImage Serie (based on dtype), Surface Series (default) | ++-----------------+---------------+------------------------------------------------------------------------------+ +| 2 | 0 | ``.sur``: same as (0,2) | ++-----------------+---------------+------------------------------------------------------------------------------+ +| 2 | 1 | ``.sur``: hyperspectralMap (default) | ++-----------------+---------------+------------------------------------------------------------------------------+ + +Axes named one of ``Wavelength``, ``Energy``, ``Energy Loss``, ``E``, are considered spectral, and quantities +named one of ``Height``, ``Altitude``, ``Elevation``, ``Depth``, ``Z`` are considered surface. The difference +between Surface and IntensitySurface stems from the AFM / profilometry origin of MountainsMap. "Surface" has +the proper meaning of an open boundary of 3d space, whereas "IntensitySurface" is a mere 2D mapping of an arbitrary +quantity. API functions ^^^^^^^^^^^^^ diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index cbf999ff1..2685fc622 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -47,7 +47,7 @@ # import rsciio.utils.tools # DictionaryTreeBrowser class handles the fancy metadata dictionnaries # from hyperspy.misc.utils import DictionaryTreeBrowser -from rsciio._docstrings import FILENAME_DOC, SIGNAL_DOC +from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC, SIGNAL_DOC from rsciio.utils.exceptions import MountainsMapFileError from rsciio.utils.rgb_tools import is_rgb, is_rgba from rsciio.utils.date_time_tools import get_date_time_from_metadata @@ -125,14 +125,14 @@ def __init__(self, filename : str|None = None): "b_pack_fn": lambda f, v: self._set_str(f, v, 12), }, "_02_Format": { - "value": 1, + "value": 0, "b_unpack_fn": self._get_int16, "b_pack_fn": self._set_int16, }, "_03_Number_of_Objects": { "value": 1, - "b_unpack_fn": self._get_int16, - "b_pack_fn": self._set_int16, + "b_unpack_fn": self._get_uint16, + "b_pack_fn": self._set_uint16, }, "_04_Version": { "value": 1, @@ -146,12 +146,12 @@ def __init__(self, filename : str|None = None): }, "_06_Object_Name": { "value": "", - "b_unpack_fn": lambda f: self._get_str(f, 30, "DOSONLY"), + "b_unpack_fn": lambda f: self._get_str(f, 30, ''), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_07_Operator_Name": { "value": "ROSETTA", - "b_unpack_fn": lambda f: self._get_str(f, 30, ""), + "b_unpack_fn": lambda f: self._get_str(f, 30, ''), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_08_P_Size": { @@ -186,8 +186,8 @@ def __init__(self, filename : str|None = None): }, "_14_W_Size": { "value": 0, - "b_unpack_fn": self._get_int32, - "b_pack_fn": self._set_int32, + "b_unpack_fn": self._get_uint32, + "b_pack_fn": self._set_uint32, }, "_15_Size_of_Points": { "value": 16, @@ -310,7 +310,7 @@ def __init__(self, filename : str|None = None): "b_pack_fn": self._set_int16, }, "_39_Obsolete": { - "value": b'0', + "value": b'', "b_unpack_fn": lambda f: self._get_bytes(f, 12), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 12), }, @@ -360,7 +360,7 @@ def __init__(self, filename : str|None = None): "b_pack_fn": self._set_uint32, }, "_49_Obsolete": { - "value": b'0', + "value": b'', "b_unpack_fn": lambda f: self._get_bytes(f, 6), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 6), }, @@ -375,7 +375,7 @@ def __init__(self, filename : str|None = None): "b_pack_fn": self._set_int16, }, "_52_Client_zone": { - "value": b'0', + "value": b'', "b_unpack_fn": lambda f: self._get_bytes(f, 128), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 128), }, @@ -420,7 +420,7 @@ def __init__(self, filename : str|None = None): "b_pack_fn": self._pack_comment, }, "_61_Private_zone": { - "value": 0, + "value": b'', "b_unpack_fn": self._unpack_private, "b_pack_fn": self._pack_private, }, @@ -444,7 +444,7 @@ def __init__(self, filename : str|None = None): self._Object_type = "_UNKNOWN" # Number of data objects in the file. - self._N_data_object = 1 + self._N_data_objects = 1 self._N_data_channels = 1 # Attributes useful for save and export @@ -462,35 +462,95 @@ def __init__(self, filename : str|None = None): # These must be set in the split functions self.data_split = [] self.objtype_split = [] - # Packaging methods for writing files + + # File Writer Inner methods + + def _write_sur_file(self): + """Write self._list_sur_file_content to a file. This method is + start-and-forget. The brainwork is performed in the construction + of sur_file_content list of dictionaries.""" + + with open(self.filename, "wb") as f: + for dic in self._list_sur_file_content: + # Extremely important! self._work_dict must access + # other fields to properly encode and decode data, + # comments etc. etc. + self._move_values_to_workdict(dic) + # Then inner consistency is trivial + for key in self._work_dict: + self._work_dict[key]['b_pack_fn'](f,self._work_dict[key]['value']) + + def _validate_filename(self): + + sur_only = ['_SURFACE', + '_BINARYIMAGE', + '_SURFACESERIE', + '_MULTILAYERSURFACE', + '_INTENSITYIMAGE', + '_INTENSITYSURFACE', + '_RGBIMAGE', + '_RGBSURFACE', + '_RGBINTENSITYSURFACE', + '_SERIESOFRGBIMAGES', + '_HYPCARD'] + + pro_only = ['_PROFILE', + '_PROFILESERIE', + '_MULTILAYERPROFILE', + '_FORCECURVE', + '_SERIEOFFORCECURVE', + '_CONTOURPROFILE', + '_SPECTRUM', + ] + + if self._Object_type in sur_only and not self.filename.lower().endswith('sur'): + raise MountainsMapFileError(f"Attempting save of DigitalSurf {self._Object_type} with\ + .{self.filename.split('.')[-1]} extension, which only supports .sur") + + if self._Object_type in pro_only and not self.filename.lower().endswith('pro'): + raise MountainsMapFileError(f"Attempting save of DigitalSurf {self._Object_type} with\ + .{self.filename.split('.')[-1]} extension, which only supports .pro") def _build_sur_file_contents(self, set_comments:str='auto', is_special:bool=False, compressed:bool=True, comments: dict = {}, + object_name: str = '', operator_name: str = '', + absolute: int = 0, private_zone: bytes = b'', client_zone: bytes = b'' ): - + """Build the _sur_file_content list necessary to write a signal dictionary to + a ``.sur`` or ``.pro`` file. The signal dictionary's inner consistency is the + responsibility of hyperspy, and the this function's responsibility is to make + a consistent list of _sur_file_content.""" + self._list_sur_file_content = [] #Compute number of navigation / signal axes self._n_ax_nav, self._n_ax_sig = DigitalSurfHandler._get_n_axes(self.signal_dict) # Choose object type based on number of navigation and signal axes + # Populate self._Object_type # Populate self.Xaxis, self.Yaxis, self.Taxis (if not empty) # Populate self.data_split and self.objtype_split (always) self._split_signal_dict() - # This initialize the Comment string saved with the studiable. + #Raise error if wrong extension + # self._validate_filename() + + #Get a dictionary to be saved in the comment fielt of exported file comment_dict = self._get_comment_dict(self.signal_dict['original_metadata'], method=set_comments, custom=comments) + #Convert the dictionary to a string of suitable format. comment_str = self._stringify_dict(comment_dict) - #Now we build a workdict for every data object + # A _work_dict is created for each of the data arrays and object + # that have splitted from the main object. In most cases, only a + # single object is present in the split. for data,objtype in zip(self.data_split,self.objtype_split): self._build_workdict(data, objtype, @@ -498,35 +558,27 @@ def _build_sur_file_contents(self, comment=comment_str, is_special=is_special, compressed=compressed, + object_name=object_name, operator_name=operator_name, + absolute=absolute, private_zone=private_zone, client_zone=client_zone) - # if more than one object, we erase comment after first object. + # if the objects are multiple, comment is erased after the first + # object. This is not mandatory, but makes marginally smaller files. if comment_str: comment_str = '' # Finally we push it all to the content list. self._append_work_dict_to_content() - - def _write_sur_file(self): - """Write self._list_sur_file_content to a """ - - with open(self.filename, "wb") as f: - for dic in self._list_sur_file_content: - # Extremely important! self._work_dict must access - # other fields to properly encode and decode data, - # comments etc. etc. - self._move_values_to_workdict(dic) - # Then inner consistency is trivial - for key in self._work_dict: - self._work_dict[key]['b_pack_fn'](f,self._work_dict[key]['value']) - + + #Signal dictionary analysis methods @staticmethod def _get_n_axes(sig_dict: dict) -> tuple[int,int]: """Return number of navigation and signal axes in the signal dict (in that order). + Could be moved away from the .sur api as other functions probably use this as well Args: - sig_dict (dict): signal dictionary. Contains keys 'data', 'axes', 'metadata', 'original_metadata' + sig_dict (dict): signal dict, has to contain keys: 'data', 'axes', 'metadata' Returns: Tuple[int,int]: nax_nav,nax_sig. Number of navigation and signal axes @@ -540,18 +592,11 @@ def _get_n_axes(sig_dict: dict) -> tuple[int,int]: nax_sig += 1 return nax_nav, nax_sig - @staticmethod - def _get_nobjects(omd: dict) -> int: - maxobj = 0 - for k in omd: - objnum = k.split('_')[1] - objnum = int(objnum) - if objnum > maxobj: - maxobj = objnum - return maxobj - def _is_spectrum(self) -> bool: - """Determine if a signal is a spectrum based on axes naming""" + """Determine if a signal is a spectrum type based on axes naming + for export of sur_files. Could be cross-checked with other criteria + such as hyperspy subclass etc... For now we keep it simple. If it has + an ax named like a spectral axis, then probably its a spectrum. """ spectrumlike_axnames = ['Wavelength', 'Energy', 'Energy Loss', 'E'] is_spec = False @@ -564,7 +609,7 @@ def _is_spectrum(self) -> bool: def _is_surface(self) -> bool: """Determine if a 2d-data-like signal_dict should be of surface type, ie the dataset - is a 2d surface of the 3d plane. """ + is a 2d surface of the 3d space. """ is_surface = False surfacelike_quantnames = ['Height', 'Altitude', 'Elevation', 'Depth', 'Z'] quant: str = self.signal_dict['metadata']['Signal']['quantity'] @@ -577,129 +622,79 @@ def _is_surface(self) -> bool: def _is_binary(self) -> bool: return self.signal_dict['data'].dtype == bool - def _get_num_chans(self) -> int: - """Get number of channels (aka point size) - - Args: - obj_type (int): Object type numeric code - - Returns: - int: Number of channels (point size). - """ - obj_type = self._get_object_type() - - if obj_type == 11: - return 2 #Intensity + surface (deprecated type) - elif obj_type in [12,18]: - return 3 #RGB types - elif obj_type == 13: - return 4 #RGB surface - elif obj_type in [14, 15, 35, 36]: - return 2 #Force curves - elif obj_type in [16]: - return 5 #Surface, Intensity, R, G, B (but hardly applicable to hyperspy) - else: - return 1 - - def _get_wsize(self, nax_sig: int) -> int: - if nax_sig != 1: - raise MountainsMapFileError(f"Attempted parsing W-axis size from signal with navigation dimension {nax_sig}!= 1.") - for ax in self.signal_dict['axes']: - if not ax['navigate']: - return ax['size'] - - def _get_num_objs(self,) -> int: - """Get number of objects based on object type and number of navigation axes in the signal. - - Raises: - ValueError: Several digital surf save formats will need a navigation dimension of 1 - - Returns: - int: _description_ - """ - obj_type = self._get_object_type() - nax_nav, _ = self._get_n_axes() - - if obj_type in [1,2,3,6,9,10,11,12,13,14,15,16,17,20,21,35,36,37]: - return 1 - elif obj_type in [4,5,7,8,18]: - if nax_nav != 1: - raise MountainsMapFileError(f"Attempted to save signal with number type {obj_type} and navigation dimension {nax_nav}.") - for ax in enumerate(self.signal_dict['axes']): - if ax['navigate']: - return ax['size'] - - def _get_object_type(self) -> int: + #Splitting /subclassing methods + def _split_signal_dict(self): """Select the suitable _mountains_object_types """ - nax_nav, nax_sig = self._get_n_axes(self.signal_dict) - - obj_type = None - if nax_nav == 0: - if nax_sig == 0: - raise MountainsMapFileError(msg=f"Object with empty navigation and signal axes not supported for .sur export") - elif nax_sig == 1: - if self._is_spectrum(): - obj_type = 20 # '_SPECTRUM' - else: - obj_type = 1 # '_PROFILE' - elif nax_sig == 2: - if self._is_binary(): - obj_type = 3 # "_BINARYIMAGE" - elif is_rgb(self.signal_dict['data']): - obj_type = 12 #"_RGBIMAGE" - elif is_rgba(self.signal_dict['data']): - warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") - obj_type = 12 #"_RGBIMAGE" - elif self._is_surface(): - obj_type = 2 #'_SURFACE' - else: - obj_type = 10 #_INTENSITYSURFACE + n_nav = self._n_ax_nav + n_sig = self._n_ax_sig + + #Here, I manually unfold the nested conditions for legibility. + #Since there are a fixed number of dimensions supported by + # digitalsurf .sur/.pro files, I think this is the best way to + # proceed. + if (n_nav,n_sig) == (0,1): + if self._is_spectrum(): + self._split_spectrum() else: - raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} > 2 not supported for .sur export") - elif nax_nav == 1: - if nax_sig == 0: - warnings.warn(f"Exporting surface signal dimension {nax_sig} and navigation dimension {nax_nav} falls back on surface type but is not good practice.") - obj_type = 1 # '_PROFILE' - elif nax_sig == 1: - if self._is_spectrum(): - obj_type = 20 # '_SPECTRUM' - else: - obj_type = 1 # '_PROFILE' - elif nax_sig ==2: - #Also warn - if is_rgb(self.signal_dict['data']): - obj_type = 18 #"_SERIESOFRGBIMAGE" - elif is_rgba(self.signal_dict['data']): - warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") - obj_type = 18 #"_SERIESOFRGBIMAGE" - else: - obj_type = 5 #"_SURFACESERIE" - else: - raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} > 2 not supported for .sur export") - elif nax_nav == 2: - if nax_sig == 0: - warnings.warn(f"Signal dimension {nax_sig} and navigation dimension {nax_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") - if self._is_surface(): - obj_type = 2 #'_SURFACE' - else: - obj_type = 10 #_INTENSITYSURFACE - elif nax_sig == 1: - obj_type = 21 #'_HYPCARD' + self._split_profile() + elif (n_nav,n_sig) == (0,2): + if self._is_binary(): + self._split_binary_img() + elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + self._split_rgb() + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"A channel discarded upon saving \ + RGBA signal in .sur format") + self._split_rgb() + # elif self._is_surface(): #'_SURFACE' + # self._split_surface() + else: # _INTENSITYSURFACE + self._split_surface() + elif (n_nav,n_sig) == (1,0): + warnings.warn(f"Exporting surface signal dimension {n_sig} and navigation dimension \ + {n_nav} falls back on profile type but is not good practice. Consider \ + transposing before saving to avoid unexpected behaviour.") + self._split_profile() + elif (n_nav,n_sig) == (1,1): + if self._is_spectrum(): + self._split_spectrum() + else: + self._split_profileserie() + elif (n_nav,n_sig) == (1,2): + if is_rgb(self.signal_dict['data']): + self._split_rgbserie() + if is_rgba(self.signal_dict['data']): + warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + self._split_rgbserie() else: - raise MountainsMapFileError(msg=f"Object with signal dimension {nax_sig} and navigation dimension {nax_nav} not supported for .sur export") + self._split_surfaceserie() + elif (n_nav,n_sig) == (2,0): + warnings.warn(f"Signal dimension {n_sig} and navigation dimension {n_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") + if self._is_binary(): + self._split_binary_img() + elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + self._split_rgb() + elif is_rgba(self.signal_dict['data']): + warnings.warn(f"A channel discarded upon saving \ + RGBA signal in .sur format") + self._split_rgb() + if self._is_surface(): + self._split_surface() + else: + self._split_intensitysurface() + elif (n_nav,n_sig) == (2,1): + self._split_hyperspectral() else: - #Also raise - raise MountainsMapFileError(msg=f"Object with navigation dimension {nax_nav} > 2 not supported for .sur export") - - return obj_type + raise MountainsMapFileError(msg=f"Object with signal dimension {n_sig} and navigation dimension {n_nav} not supported for .sur export") def _split_spectrum(self,): - """Must set axes except Z, data_split & objtype_split attributes""" - #When splitting spectrum, remember that instead of the series axis (T/W), + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" + #When splitting spectrum, no series axis (T/W), #X axis is the spectral dimension and Y the series dimension (if series). - # Xaxis = {} - # Yaxis = {} + obj_type = 20 + self._Object_type = self._mountains_object_types[obj_type] + nax_nav = self._n_ax_nav nax_sig = self._n_ax_sig @@ -712,12 +707,15 @@ def _split_spectrum(self,): raise MountainsMapFileError(f"Dimensions ({nax_nav})|{nax_sig}) invalid for export as spectrum type") self.data_split = [self.signal_dict['data']] - self.objtype_split = [20] - self._N_data_object = 1 + self.objtype_split = [obj_type] + self._N_data_objects = 1 self._N_data_channels = 1 def _split_profile(self,): - """Must set axes except Z, data_split & objtype_split attributes""" + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" + + obj_type = 1 + self._Object_type = self._mountains_object_types[obj_type] if (self._n_ax_nav,self._n_ax_sig) in [(0,1),(1,0)]: self.Xaxis = self.signal_dict['axes'][0] @@ -725,28 +723,31 @@ def _split_profile(self,): raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for a profile type") self.data_split = [self.signal_dict['data']] - self.objtype_split = [1] - self._N_data_object = 1 + self.objtype_split = [obj_type] + self._N_data_objects = 1 self._N_data_channels = 1 def _split_profileserie(self,): - """Must set axes except Z, data_split & objtype_split attributes""" + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 4 # '_PROFILESERIE' + self._Object_type = self._mountains_object_types[obj_type] if (self._n_ax_nav,self._n_ax_sig)==(1,1): self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) else: - raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._Object_type} type") self.data_split = self._split_data_alongaxis(self.Taxis) self.objtype_split = [obj_type] + [1]*(len(self.data_split)-1) - self._N_data_object = len(self.objtype_split) + self._N_data_objects = len(self.objtype_split) self._N_data_channels = 1 def _split_binary_img(self,): - """Must set axes except Z, data_split & objtype_split attributes""" + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 3 + self._Object_type = self._mountains_object_types[obj_type] + if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: self.Xaxis = self.signal_dict['axes'][1] self.Yaxis = self.signal_dict['axes'][0] @@ -755,12 +756,13 @@ def _split_binary_img(self,): self.data_split = [self.signal_dict['data']] self.objtype_split = [obj_type] - self._N_data_object = 1 + self._N_data_objects = 1 self._N_data_channels = 1 def _split_rgb(self,): - """Must set axes except Z, data_split & objtype_split attributes""" + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 12 + self._Object_type = self._mountains_object_types[obj_type] if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: self.Xaxis = self.signal_dict['axes'][1] self.Yaxis = self.signal_dict['axes'][0] @@ -772,12 +774,13 @@ def _split_rgb(self,): np.int32(self.signal_dict['data']['B']) ] self.objtype_split = [obj_type] + [10,10] - self._N_data_object = 1 + self._N_data_objects = 1 self._N_data_channels = 3 def _split_surface(self,): - """Must set axes except Z, data_split & objtype_split attributes""" + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 2 + self._Object_type = self._mountains_object_types[obj_type] if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: self.Xaxis = self.signal_dict['axes'][1] self.Yaxis = self.signal_dict['axes'][0] @@ -785,12 +788,13 @@ def _split_surface(self,): raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") self.data_split = [self.signal_dict['data']] self.objtype_split = [obj_type] - self._N_data_object = 1 + self._N_data_objects = 1 self._N_data_channels = 1 def _split_intensitysurface(self,): - """Must set axes except Z, data_split & objtype_split attributes""" + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 10 + self._Object_type = self._mountains_object_types[obj_type] if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: self.Xaxis = self.signal_dict['axes'][1] self.Yaxis = self.signal_dict['axes'][0] @@ -798,12 +802,14 @@ def _split_intensitysurface(self,): raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") self.data_split = [self.signal_dict['data']] self.objtype_split = [obj_type] - self._N_data_object = 1 + self._N_data_objects = 1 self._N_data_channels = 1 def _split_rgbserie(self): + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 18 #"_SERIESOFRGBIMAGE" - + self._Object_type = self._mountains_object_types[obj_type] + sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) self.Yaxis = next(sigaxes_iter) self.Xaxis = next(sigaxes_iter) @@ -817,11 +823,13 @@ def _split_rgbserie(self): self.objtype_split += [12,10,10] self.objtype_split[0] = obj_type - self._N_data_object = self.Taxis['size'] + self._N_data_objects = self.Taxis['size'] self._N_data_channels = 3 def _split_surfaceserie(self): + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 5 + self._Object_type = self._mountains_object_types[obj_type] sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) self.Yaxis = next(sigaxes_iter) self.Xaxis = next(sigaxes_iter) @@ -829,21 +837,25 @@ def _split_surfaceserie(self): self.data_split = self._split_data_alongaxis(self.Taxis) self.objtype_split = [2]*len(self.data_split) self.objtype_split[0] = obj_type - self._N_data_object = len(self.data_split) + self._N_data_objects = len(self.data_split) self._N_data_channels = 1 def _split_hyperspectral(self): + """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 21 + self._Object_type = self._mountains_object_types[obj_type] sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if ax['navigate']) self.Yaxis = next(sigaxes_iter) self.Xaxis = next(sigaxes_iter) self.Taxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) self.data_split = [self.signal_dict['data']] self.objtype_split = [obj_type] - self._N_data_object = 1 + self._N_data_objects = 1 self._N_data_channels = 1 def _split_data_alongaxis(self, axis: dict) -> list[np.ndarray]: + """Split the data in a series of lower-dim datasets that can be exported to + a surface / profile file""" idx = self.signal_dict['axes'].index(axis) # return idx datasplit = [] @@ -851,80 +863,12 @@ def _split_data_alongaxis(self, axis: dict) -> list[np.ndarray]: datasplit.append(dslice) return datasplit - def _split_signal_dict(self): - """Select the suitable _mountains_object_types """ - - n_nav = self._n_ax_nav - n_sig = self._n_ax_sig - - #Here, I manually unfold the nested conditions for legibility. - #Since there are a fixed number of dimensions supported by - # digitalsurf .sur/.pro files, I think this is the best way to - # proceed. - if (n_nav,n_sig) == (0,1): - if self._is_spectrum(): - self._split_spectrum() - else: - self._split_profile() - elif (n_nav,n_sig) == (0,2): - if self._is_binary(): - self._split_binary_img() - elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" - self._split_rgb() - elif is_rgba(self.signal_dict['data']): - warnings.warn(f"A channel discarded upon saving \ - RGBA signal in .sur format") - self._split_rgb() - elif self._is_surface(): #'_SURFACE' - self._split_surface() - else: # _INTENSITYSURFACE - self._split_intensitysurface() - elif (n_nav,n_sig) == (1,0): - warnings.warn(f"Exporting surface signal dimension {n_sig} and navigation dimension \ - {n_nav} falls back on profile type but is not good practice. Consider \ - transposing before saving to avoid unexpected behaviour.") - self._split_profile() - elif (n_nav,n_sig) == (1,1): - if self._is_spectrum(): - self._split_spectrum() - else: - self._split_profileserie() - elif (n_nav,n_sig) == (1,2): - if is_rgb(self.signal_dict['data']): - self._split_rgbserie() - if is_rgba(self.signal_dict['data']): - warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") - obj_type = 18 #"_SERIESOFRGBIMAGE" - self._split_rgbserie() - else: - self._split_surfaceserie() - elif (n_nav,n_sig) == (2,0): - warnings.warn(f"Signal dimension {n_sig} and navigation dimension {n_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") - if self._is_binary(): - self._split_binary_img() - elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" - self._split_rgb() - elif is_rgba(self.signal_dict['data']): - warnings.warn(f"A channel discarded upon saving \ - RGBA signal in .sur format") - self._split_rgb() - if self._is_surface(): - self._split_surface() - else: - self._split_intensitysurface() - elif (n_nav,n_sig) == (2,1): - self._split_hyperspectral() - else: - raise MountainsMapFileError(msg=f"Object with signal dimension {n_sig} and navigation dimension {n_nav} not supported for .sur export") - - def _norm_data(self, data: np.ndarray, is_special: bool, apply_sat_lo: bool = False, apply_sat_hi: bool = False): + def _norm_data(self, data: np.ndarray, is_special: bool): """Normalize input data to 16-bits or 32-bits ints and initialize an axis on which the data is normalized. Args: data (np.ndarray): dataset - is_special (bool): whether NaNs get sent to N.M points in the sur format. - apply_sat_lo (bool, optional): Signal low-value saturation in output datafile. Defaults to False. - apply_sat_hi (bool, optional): Signal high-value saturation in output datafile. Defaults to False. + is_special (bool): whether NaNs get sent to N.M points in the sur format and apply saturation Raises: MountainsMapFileError: raised if input is of complex type @@ -939,17 +883,17 @@ def _norm_data(self, data: np.ndarray, is_special: bool, apply_sat_lo: bool = Fa if np.issubdtype(data_type,np.complexfloating): raise MountainsMapFileError(f"digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export") elif data_type==np.uint8: - warnings.warn("np.uint8 datatype exported as 16bits") - pointsize = 16 #Pointsize has to be 16 or 32 in surf format + warnings.warn("np.uint8 datatype exported as np.int16.") + pointsize = 16 Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int16), pointsize, is_special) data_int = data.astype(np.int16) elif data_type==np.uint16: - warnings.warn("np.uint16 datatype exported as 32bits") + warnings.warn("np.uint16 datatype exported as np.int32") pointsize = 32 #Pointsize has to be 16 or 32 in surf format Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int32), pointsize, is_special) data_int = data.astype(np.int32) elif np.issubdtype(data_type,np.unsignedinteger): - raise MountainsMapFileError(f"digitalsurf file formats do not support unsigned data >16bits. Convert data to signed integers before export.") + raise MountainsMapFileError(f"digitalsurf file formats do not support unsigned int >16bits. Convert data to signed integers before export.") elif data_type==np.int8: pointsize = 16 #Pointsize has to be 16 or 32 in surf format Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, 8, is_special) @@ -965,16 +909,15 @@ def _norm_data(self, data: np.ndarray, is_special: bool, apply_sat_lo: bool = Fa elif np.issubdtype(data_type,np.integer): raise MountainsMapFileError(f"digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting") elif np.issubdtype(data_type,np.floating): - if self.signal_dict['data'].itemsize*8 > 32: - warnings.warn(f"Lossy conversion of {data_type} to 32-bits-ints representation will occur.") pointsize = 32 Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_float(data, is_special) return pointsize, Zmin, Zmax, Zscale, Zoffset, data_int def _norm_signed_int(self, data:np.ndarray, intsize: int, is_special: bool = False): + """Normalized data of integer type. No normalization per se, but the Zmin and Zmax threshold are set + if saturation needs to be flagged""" # There are no NaN values for integers. Special points means considering high/low saturation of integer scale. - data_int_min = - 2**(intsize-1) data_int_max = 2**(intsize -1) @@ -984,12 +927,13 @@ def _norm_signed_int(self, data:np.ndarray, intsize: int, is_special: bool = Fal Zmin = data_int_min + 1 if is_satlo else data.min() Zmax = data_int_max - 1 if is_sathi else data.max() Zscale = 1.0 - Zoffset = 0.0 + Zoffset = Zmin return Zmin, Zmax, Zscale, Zoffset def _norm_float(self, data : np.ndarray, is_special: bool = False,): - """Normalize float data on a 32 bits int scale.""" + """Normalize float data on a 32 bits int scale. Inherently lossy + but that's how things are with mountainsmap files. """ Zoffset_f = np.nanmin(data) Zmax_f = np.nanmax(data) @@ -1035,22 +979,6 @@ def _get_Zname_Zunit(self, metadata: dict) -> tuple[str,str]: return Zname,Zunit - def _get_datetime_info(self,) -> tuple[int,int,int,int,int,int]: - date = self.signal_dict['metadata']['General'].get('date','') - time = self.signal_dict['metadata']['General'].get('time','') - - try: - [yyyy,mm,dd] = date.strip().split('-') - except ValueError: - [yyyy,mm,dd] = [0,0,0] - - try: - [hh,minmin,ss] = time.strip().strip('Z').slit(':') - except ValueError: - [hh,minmin,ss] = [0,0,0] - - return yyyy,mm,dd,hh,minmin,ss - def _build_workdict(self, data: np.ndarray, obj_type: int, @@ -1058,10 +986,13 @@ def _build_workdict(self, comment: str = "", is_special: bool = True, compressed: bool = True, + object_name: str = '', operator_name: str = '', + absolute: int = 0, private_zone: bytes = b'', client_zone: bytes = b'' ): + """Populate _work_dict with the """ if not compressed: self._work_dict['_01_Signature']['value'] = 'DIGITAL SURF' # DSCOMPRESSED by default @@ -1069,20 +1000,20 @@ def _build_workdict(self, self._work_dict['_01_Signature']['value'] = 'DSCOMPRESSED' # DSCOMPRESSED by default # self._work_dict['_02_Format']['value'] = 0 # Dft. other possible value is 257 for MacintoshII computers with Motorola CPUs. Obv not supported... - self._work_dict['_03_Number_of_Objects']['value'] = self._N_data_object + self._work_dict['_03_Number_of_Objects']['value'] = self._N_data_objects # self._work_dict['_04_Version']['value'] = 1 # Version number. Always default. self._work_dict['_05_Object_Type']['value'] = obj_type - # self._work_dict['_06_Object_Name']['value'] = '' Obsolete, DOS-version only (Not supported) + self._work_dict['_06_Object_Name']['value'] = object_name #Obsolete, DOS-version only (Not supported) self._work_dict['_07_Operator_Name']['value'] = operator_name #Should be settable from kwargs self._work_dict['_08_P_Size']['value'] = self._N_data_channels - # self._work_dict['_09_Acquisition_Type']['value'] = 0 # AFM data only, could be inferred - # self._work_dict['_10_Range_Type']['value'] = 0 #Only 1 for high-range (z-stage scanning), AFM data only, could be inferred + self._work_dict['_09_Acquisition_Type']['value'] = 0 # AFM data only, could be inferred + self._work_dict['_10_Range_Type']['value'] = 0 #Only 1 for high-range (z-stage scanning), AFM data only, could be inferred self._work_dict['_11_Special_Points']['value'] = int(is_special) - # self._work_dict['_12_Absolute']['value'] = 0 #Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred - # self._work_dict['_13_Gauge_Resolution']['value'] = 0.0 #Probably irrelevant. Only for profilometers (maybe AFM), can be inferred + self._work_dict['_12_Absolute']['value'] = absolute #Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred + self._work_dict['_13_Gauge_Resolution']['value'] = 0.0 #Probably irrelevant. Only for profilometers (maybe AFM), can be inferred # T-axis acts as W-axis for spectrum / hyperspectrum surfaces. if obj_type in [21]: @@ -1091,7 +1022,7 @@ def _build_workdict(self, ws = 0 self._work_dict['_14_W_Size']['value'] = ws - bsize, Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_data(data,is_special,apply_sat_lo=True,apply_sat_hi=True) + bsize, Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_data(data,is_special) Zname, Zunit = self._get_Zname_Zunit(metadata) #Axes element set regardless of object size @@ -1100,7 +1031,9 @@ def _build_workdict(self, self._work_dict['_17_Zmax']['value'] = Zmax self._work_dict['_18_Number_of_Points']['value']= self.Xaxis.get('size',1) self._work_dict['_19_Number_of_Lines']['value'] = self.Yaxis.get('size',1) - self._work_dict['_20_Total_Nb_of_Pts']['value'] = data.size + #This needs to be this way due to the way we export our hyp maps + self._work_dict['_20_Total_Nb_of_Pts']['value'] = self.Xaxis.get('size',1)*self.Yaxis.get('size',1) + self._work_dict['_21_X_Spacing']['value'] = self.Xaxis.get('scale',0.0) self._work_dict['_22_Y_Spacing']['value'] = self.Yaxis.get('scale',0.0) self._work_dict['_23_Z_Spacing']['value'] = Zscale @@ -1136,11 +1069,13 @@ def _build_workdict(self, if compressed: data_bin = self._compress_data(data_int,nstreams=1) #nstreams hard-set to 1. Could be unlocked in the future + compressed_size = len(data_bin) else: - fmt = " 0 and self._N_data_object > 0: - n_objects_to_read = self._N_data_channels * self._N_data_object + if self._N_data_channels > 0 and self._N_data_objects > 0: + n_objects_to_read = self._N_data_channels * self._N_data_objects elif self._N_data_channels > 0: n_objects_to_read = self._N_data_channels - elif self._N_data_object > 0: - n_objects_to_read = self._N_data_object + elif self._N_data_objects > 0: + n_objects_to_read = self._N_data_objects else: n_objects_to_read = 1 @@ -1626,7 +1561,7 @@ def _build_original_metadata(self,): original_metadata_dict = {} # Iteration over Number of data objects - for i in range(self._N_data_object): + for i in range(self._N_data_objects): # Iteration over the Number of Data channels for j in range(max(self._N_data_channels,1)): # Creating a dictionary key for each object @@ -2009,8 +1944,21 @@ def post_process_RGB(signal): ) return signal - # pack/unpack binary quantities + + @staticmethod + def _get_uint16(file, default=None): + """Read a 16-bits int with a user-definable default value if + no file is given""" + if file is None: + return default + b = file.read(2) + return struct.unpack("h", b)[0] - else: - return struct.unpack(" int: + """Return size of uncompressed data in bytes""" + psize = int(self._get_work_dict_key_value("_15_Size_of_Points") / 8) + # Datapoints in X and Y dimensions + Npts_tot = self._get_work_dict_key_value("_20_Total_Nb_of_Pts") + # Datasize in WL. max between value and 1 as often W_Size saved as 0 + Wsize = max(self._get_work_dict_key_value("_14_W_Size"),1) + # Wsize = 1 + + datasize = Npts_tot * Wsize * psize + + return datasize + def _unpack_data(self, file, encoding="latin-1"): # Size of datapoints in bytes. Always int16 (==2) or 32 (==4) psize = int(self._get_work_dict_key_value("_15_Size_of_Points") / 8) - Zmin = self._get_work_dict_key_value("_16_Zmin") - dtype = np.int16 if psize == 2 else np.int32 if self._get_work_dict_key_value("_01_Signature") != "DSCOMPRESSED": @@ -2142,17 +2098,17 @@ def _unpack_data(self, file, encoding="latin-1"): # Datapoints in X and Y dimensions Npts_tot = self._get_work_dict_key_value("_20_Total_Nb_of_Pts") # Datasize in WL - Wsize = self._get_work_dict_key_value("_14_W_Size") + Wsize = max(self._get_work_dict_key_value("_14_W_Size"),1) # We need to take into account the fact that Wsize is often # set to 0 instead of 1 in non-spectral data to compute the # space occupied by data in the file - readsize = Npts_tot * psize - if Wsize != 0: - readsize *= Wsize - + readsize = Npts_tot * psize * Wsize + # if Wsize != 0: + # readsize *= Wsize + buf = file.read(readsize) # Read the exact size of the data - _points = np.frombuffer(file.read(readsize), dtype=dtype) + _points = np.frombuffer(buf, dtype=dtype) else: # If the points are compressed do the uncompress magic. There @@ -2184,8 +2140,9 @@ def _unpack_data(self, file, encoding="latin-1"): if self._get_work_dict_key_value("_11_Special_Points") == 1: # has non-measured points nm = _points == self._get_work_dict_key_value("_16_Zmin") - 2 - - _points = (_points.astype(float) - Zmin) * self._get_work_dict_key_value("_23_Z_Spacing") * self._get_work_dict_key_value("_35_Z_Unit_Ratio") + self._get_work_dict_key_value("_55_Z_Offset") + + Zmin = self._get_work_dict_key_value("_16_Zmin") + _points = (_points.astype(float) - Zmin)*self._get_work_dict_key_value("_23_Z_Spacing") * self._get_work_dict_key_value("_35_Z_Unit_Ratio") + self._get_work_dict_key_value("_55_Z_Offset") # We set the point in the numeric scale if self._is_data_int(): @@ -2199,7 +2156,10 @@ def _unpack_data(self, file, encoding="latin-1"): def _pack_data(self, file, val, encoding="latin-1"): """This needs to be special because it writes until the end of file.""" #Also valid for uncompressed - datasize = self._get_work_dict_key_value('_48_Compressed_data_size') + if self._get_work_dict_key_value("_01_Signature") != "DSCOMPRESSED": + datasize = self._get_uncompressed_datasize() + else: + datasize = self._get_work_dict_key_value('_48_Compressed_data_size') self._set_bytes(file,val,datasize) @staticmethod @@ -2286,8 +2246,14 @@ def file_writer(filename, signal: dict, **kwds): comments: dict, default = {} Set a custom dictionnary in the comments field of the exported file. Ignored if set_comments is not set to 'custom'. + object_name: str, default = '' + Set the object name field in the output file operator_name: str, default = '' Set the operator name field in the exported file. + absolute: int, default = 0, + Unsigned int capable of flagging whether surface heights are relative (0) or + absolute (1). Higher unsigned int values can be used to distinguish several + data series sharing internal reference private_zone: bytes, default = b'', Set arbitrary byte-content in the private_zone field of exported file metadata. Maximum size is 32.0 kB and content will be cropped if this size is exceeded @@ -2301,5 +2267,5 @@ def file_writer(filename, signal: dict, **kwds): ds._build_sur_file_contents(**kwds) ds._write_sur_file() - -file_reader.__doc__ %= (FILENAME_DOC,SIGNAL_DOC) +file_reader.__doc__ %= (FILENAME_DOC,LAZY_UNSUPPORTED_DOC,RETURNS_DOC) +file_writer.__doc__ %= (FILENAME_DOC,SIGNAL_DOC) diff --git a/rsciio/tests/data/digitalsurf/test_isurface.sur b/rsciio/tests/data/digitalsurf/test_isurface.sur new file mode 100644 index 0000000000000000000000000000000000000000..2719726e8197eef500378a04998f2d7935ebf709 GIT binary patch literal 56141 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

{n-rzvR`z$>6l`Z#MDD|48m+PY~-Ux&GnBz;O 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 Stb+VAo_J;GgZ}||Cx8{~Jac*g literal 0 HcmV?d00001 diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 9121d90f8..2a51b663f 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -409,7 +409,7 @@ def test_load_spectrum(): def test_load_surface(): - fname = TEST_DATA_PATH / "test_surface.sur" + fname = TEST_DATA_PATH / "test_isurface.sur" s = hs.load(fname) md = s.metadata assert md.Signal.quantity == "CL Intensity (a.u.)" @@ -495,19 +495,7 @@ def test_metadata_mapping(): "exit_slit_width" ] == 7000 - ) - - -def test_get_n_obj_chn(): - - omd = {"Object_0_Channel_0":{}, - "Object_1_Channel_0":{}, - "Object_2_Channel_0":{}, - "Object_2_Channel_1":{}, - "Object_2_Channel_2":{}, - "Object_3_Channel_0":{},} - - assert DigitalSurfHandler._get_nobjects(omd)==3 + ) def test_compressdata(): @@ -545,43 +533,48 @@ def test_compressdata(): def test_get_comment_dict(): - tdh = DigitalSurfHandler() - tdh.signal_dict={'original_metadata':{ - 'Object_0_Channel_0':{ + omd={'Object_0_Channel_0':{ 'Parsed':{ 'key_1': 1, 'key_2':'2' } } - }} + } - assert tdh._get_comment_dict('auto')=={'key_1': 1,'key_2':'2'} - assert tdh._get_comment_dict('off')=={} - assert tdh._get_comment_dict('raw')=={'Object_0_Channel_0':{'Parsed':{'key_1': 1,'key_2':'2'}}} - assert tdh._get_comment_dict('custom',custom={'a':0}) == {'a':0} + assert DigitalSurfHandler._get_comment_dict(omd,'auto')=={'key_1': 1,'key_2':'2'} + assert DigitalSurfHandler._get_comment_dict(omd,'off')=={} + assert DigitalSurfHandler._get_comment_dict(omd,'raw')=={'Object_0_Channel_0':{'Parsed':{'key_1': 1,'key_2':'2'}}} + assert DigitalSurfHandler._get_comment_dict(omd,'custom',custom={'a':0}) == {'a':0} #Goes to second dict if only this one's valid - tdh.signal_dict={'original_metadata':{ + omd={ 'Object_0_Channel_0':{'Header':{}}, 'Object_0_Channel_1':{'Header':'ObjHead','Parsed':{'key_1': '0'}}, - }} - assert tdh._get_comment_dict('auto') == {'key_1': '0'} + } + assert DigitalSurfHandler._get_comment_dict(omd, 'auto') == {'key_1': '0'} #Return empty if none valid - tdh.signal_dict={'original_metadata':{ + omd={ 'Object_0_Channel_0':{'Header':{}}, 'Object_0_Channel_1':{'Header':'ObjHead'}, - }} - assert tdh._get_comment_dict('auto') == {} + } + assert DigitalSurfHandler._get_comment_dict(omd,'auto') == {} #Return dict-cast if a single field is named 'Parsed' (weird case) - tdh.signal_dict={'original_metadata':{ + omd={ 'Object_0_Channel_0':{'Header':{}}, 'Object_0_Channel_1':{'Header':'ObjHead','Parsed':'SomeContent'}, - }} - assert tdh._get_comment_dict('auto') == {'Parsed':'SomeContent'} + } + assert DigitalSurfHandler._get_comment_dict(omd,'auto') == {'Parsed':'SomeContent'} + -@pytest.mark.parametrize("test_object", ["test_profile.pro", "test_spectra.pro", "test_spectral_map.sur", "test_spectral_map_compressed.sur", "test_spectrum.pro", "test_spectrum_compressed.pro", "test_surface.sur"]) +@pytest.mark.parametrize("test_object", ["test_profile.pro", + "test_spectra.pro", + "test_spectral_map.sur", + "test_spectral_map_compressed.sur", + "test_spectrum.pro", + "test_spectrum_compressed.pro", + "test_isurface.sur"]) def test_writetestobjects(tmp_path,test_object): """Test data integrity of load/save functions. Starting from externally-generated data (i.e. not from hyperspy)""" @@ -613,8 +606,33 @@ def test_writetestobjects(tmp_path,test_object): assert np.allclose(ax.axis,ax2.axis) assert np.allclose(ax.axis,ax3.axis) +@pytest.mark.parametrize("test_tuple ", [("test_profile.pro",'_PROFILE'), + ("test_spectra.pro",'_SPECTRUM'), + ("test_spectral_map.sur",'_HYPCARD'), + ("test_spectral_map_compressed.sur",'_HYPCARD'), + ("test_spectrum.pro",'_SPECTRUM'), + ("test_spectrum_compressed.pro",'_SPECTRUM'), + ("test_surface.sur",'_SURFACE'), + ('test_RGB.sur','_RGBIMAGE')]) +def test_split(test_tuple): + """Test for expected object type in the reference dataset""" + obj = test_tuple[0] + res = test_tuple[1] + + df = TEST_DATA_PATH.joinpath(obj) + dh= DigitalSurfHandler(obj) + + d = hs.load(df) + dh.signal_dict = d._to_dictionary() + dh._n_ax_nav, dh._n_ax_sig = dh._get_n_axes(dh.signal_dict) + dh._split_signal_dict() + + assert dh._Object_type == res + def test_writeRGB(tmp_path): - + # This is just a different test function because the + # comparison of rgb data must be done differently + # (due to hyperspy underlying structure) df = TEST_DATA_PATH.joinpath("test_RGB.sur") d = hs.load(df) fn = tmp_path.joinpath("test_RGB.sur") @@ -644,8 +662,12 @@ def test_writeRGB(tmp_path): assert np.allclose(ax.axis,ax3.axis) @pytest.mark.parametrize("dtype", [np.int16, np.int32, np.float64, np.uint8, np.uint16]) -def test_writegeneric_validtypes(tmp_path,dtype): - +@pytest.mark.parametrize('compressed',[True,False]) +def test_writegeneric_validtypes(tmp_path,dtype,compressed): + """This test establish""" gen = hs.signals.Signal1D(np.arange(24,dtype=dtype))+25 fgen = tmp_path.joinpath('test.pro') - gen.save(fgen,overwrite=True) \ No newline at end of file + gen.save(fgen,compressed = compressed, overwrite=True) + + gen2 = hs.load(fgen) + assert np.allclose(gen2.data,gen.data) From d7379505d00c0a038ff380d004d5188182f258ee Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 21 Jun 2024 18:39:57 +0100 Subject: [PATCH 113/174] Explicitly Skip testing wheels with cp38 on arm64 --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1096ff9f6..8d9ca9d45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,8 @@ jobs: CIBW_ENVIRONMENT: POOCH_BASE_URL=https://github.com/${{ github.repository }}/raw/${{ github.ref_name }}/rsciio/tests/data/ CIBW_TEST_COMMAND: "pytest --pyargs rsciio" CIBW_TEST_EXTRAS: "tests" + # Skip testing arm64 builds with python 3.8 + CIBW_TEST_SKIP: "cp38-macosx_arm64" # No need to build wheels for pypy because the pure python wheels can be used # PyPy documentation recommends no to build the C extension CIBW_SKIP: "{pp*,*-musllinux*,*win32,*-manylinux_i686}" From 0f0b25d278f23c869feb9e7148d64c87328c1b59 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Thu, 20 Jun 2024 23:15:55 +0100 Subject: [PATCH 114/174] Fix reading some numeric with numpy 2.0 --- pyproject.toml | 2 +- rsciio/renishaw/_api.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2fdb10fc0..bf36e9c3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ dependencies = [ "dask[array]>=2021.3.1", "python-dateutil", - "numpy>=1.20.0,<2.0.0", + "numpy>=1.20.0", "pint>=0.8", # python-box API changed on major release # and compatibility needs to be checked diff --git a/rsciio/renishaw/_api.py b/rsciio/renishaw/_api.py index 6da969300..f59cdafc7 100644 --- a/rsciio/renishaw/_api.py +++ b/rsciio/renishaw/_api.py @@ -922,9 +922,7 @@ def _parse_ORGN(self, header_orgn_count): for _ in range(origin_count): ax_tmp_dict = {} ## ignore first bit of dtype read (sometimes 0, sometimes 1 in testfiles) - dtype = DataType( - self.__read_numeric("uint32", convert=False) & ~(0b1 << 31) - ).name + dtype = DataType(self.__read_numeric("uint32") & ~(0b1 << 31)).name ax_tmp_dict["units"] = str(UnitType(self.__read_numeric("uint32"))) ax_tmp_dict["annotation"] = self.__read_utf8(0x10) ax_tmp_dict["data"] = self._set_data_for_ORGN(dtype) From 85c11d9026faf746c352617896655df6c79d5316 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 23 Jun 2024 10:32:38 +0100 Subject: [PATCH 115/174] Update CI to test with numpy 2 --- .github/workflows/tests.yml | 23 ++++++++++++++++++----- pyproject.toml | 12 +++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bea9f2dad..6afa18ba7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -118,18 +118,31 @@ jobs: pip install git+https://github.com/hyperspy/hyperspy.git pip install git+https://github.com/hyperspy/exspy.git - - name: Install latest python-box - # When installing hyperspy, python-box 6 is installed (rosettasciio pinning) - # Remove when rosettasciio >0.4.0 is released - if: ${{ ! contains(matrix.LABEL, 'oldest') }} + - name: Install pint and python-mrcz dev + # for numpy 2.0 support for python >= 3.9 + # https://github.com/em-MRCZ/python-mrcz/pull/15 + # https://github.com/hgrecco/pint/issues/1974 + if: ${{ ! contains(matrix.LABEL, 'oldest') && matrix.PYTHON_VERSION != '3.8' }} run: | - pip install --upgrade python-box + pip install git+https://github.com/ericpre/python-mrcz.git@numpy2.0_and_deprecation_fixes + pip install git+https://github.com/hgrecco/pint - name: Install shell: bash run: | pip install --upgrade -e .'${{ env.PIP_SELECTOR }}' + - name: Uninstall pyUSID + # remove when pyUSID supports numpy 2 + if: ${{ ! contains(matrix.LABEL, 'oldest') && matrix.PYTHON_VERSION != '3.8' }} + run: | + pip uninstall -y pyUSID + + - name: Install numpy 2.0 + if: ${{ ! contains(matrix.LABEL, 'oldest') && matrix.PYTHON_VERSION != '3.8' }} + run: | + pip install numpy==2 + - name: Pip list run: | pip list diff --git a/pyproject.toml b/pyproject.toml index bf36e9c3e..aa3f7da43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,13 +23,13 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] dependencies = [ - "dask[array]>=2021.3.1", + "dask[array] >=2021.3.1", "python-dateutil", - "numpy>=1.20.0", - "pint>=0.8", + "numpy >=1.20", + "pint >=0.8", # python-box API changed on major release # and compatibility needs to be checked - "python-box>=6,<8", + "python-box >=6,<8", "pyyaml", ] dynamic = ["version"] @@ -97,9 +97,7 @@ mrcz = ["blosc>=1.5", "mrcz>=0.3.6"] scalebar_export = ["matplotlib-scalebar", "matplotlib>=3.5"] speed = ["numba>=0.52"] tiff = ["tifffile>=2022.7.28", "imagecodecs"] -# Add sidpy dependency and pinning as workaround to fix pyUSID import -# Remove sidpy dependency once https://github.com/pycroscopy/pyUSID/issues/85 is fixed. -usid = ["pyUSID", "sidpy<=0.12.0"] +usid = ["pyUSID>=0.0.11"] zspy = ["zarr", "msgpack"] tests = [ "filelock", From 529e2c5df5d73142c2c22e2e8f4b184a7ca22114 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 23 Jun 2024 10:55:46 +0100 Subject: [PATCH 116/174] Add support for numpy 2.0 in dens reader --- rsciio/dens/_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rsciio/dens/_api.py b/rsciio/dens/_api.py index 94ab766eb..136b70e6e 100644 --- a/rsciio/dens/_api.py +++ b/rsciio/dens/_api.py @@ -27,7 +27,10 @@ def _cnv_time(timestr): try: - t = datetime.strptime(timestr.decode(), "%H:%M:%S.%f") + if not isinstance(timestr, str): + # for numpy < 2.0 + timestr = timestr.decode() + t = datetime.strptime(timestr, "%H:%M:%S.%f") dt = t - datetime(t.year, t.month, t.day) r = float(dt.seconds) + float(dt.microseconds) * 1e-6 except ValueError: From 7d9d2b2bc86667fdee27f64261737c21eccce302 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 23 Jun 2024 11:27:35 +0100 Subject: [PATCH 117/174] Add support for numpy 2 in semper reader --- rsciio/semper/_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rsciio/semper/_api.py b/rsciio/semper/_api.py index 540ebfa80..af64cd9a1 100644 --- a/rsciio/semper/_api.py +++ b/rsciio/semper/_api.py @@ -218,7 +218,11 @@ def _read_label(cls, unf_file): assert label["SEMPER"] == "Semper" # Process dimensions: for key in ["NCOL", "NROW", "NLAY", "ICCOLN", "ICROWN", "ICLAYN"]: - value = 256**2 * label.pop(key + "H") + 256 * label[key][0] + label[key][1] + value = ( + 256**2 * np.int32(label.pop(key + "H")) + + 256 * label[key][0] + + label[key][1] + ) label[key] = value # Process date: date = "{}-{}-{} {}:{}:{}".format(label["DATE"][0] + 1900, *label["DATE"][1:]) From 6d2c9dbe61987fccb91d12f272f90bf7529b0d57 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 23 Jun 2024 12:22:37 +0100 Subject: [PATCH 118/174] Add changelog entry --- upcoming_changes/281.maintenance.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/281.maintenance.rst diff --git a/upcoming_changes/281.maintenance.rst b/upcoming_changes/281.maintenance.rst new file mode 100644 index 000000000..2e0389835 --- /dev/null +++ b/upcoming_changes/281.maintenance.rst @@ -0,0 +1 @@ +Add support for numpy 2 in Renishaw, Semper and Dens reader. From f8187be91f5e6116e4482d17882aa4b54fefd6ed Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 23 Jun 2024 12:58:52 +0100 Subject: [PATCH 119/174] Fix failure in empad test when pyxem >=0.19 is installed --- rsciio/tests/test_empad.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rsciio/tests/test_empad.py b/rsciio/tests/test_empad.py index 5594d7641..3473aeb2a 100644 --- a/rsciio/tests/test_empad.py +++ b/rsciio/tests/test_empad.py @@ -17,10 +17,13 @@ # along with RosettaSciIO. If not, see . import gc +import importlib +from importlib.metadata import version from pathlib import Path import numpy as np import pytest +from packaging.version import Version from rsciio.empad._api import _parse_xml @@ -66,7 +69,13 @@ def test_read_stack(lazy): assert signal_axes[0].name == "width" assert signal_axes[1].name == "height" for axis in signal_axes: - assert axis.units == t.Undefined + if importlib.util.find_spec("pyxem") and Version(version("pyxem")) >= Version( + "0.19" + ): + units = "px" + else: + units = t.Undefined + assert axis.units == units assert axis.scale == 1.0 assert axis.offset == -64 navigation_axes = s.axes_manager.navigation_axes From 80ccb0012233039e0569750a2f7a613e41f1cbce Mon Sep 17 00:00:00 2001 From: wieczoth Date: Mon, 24 Jun 2024 10:27:50 +0200 Subject: [PATCH 120/174] fix for no exif_tags JPG files. --- rsciio/tests/data/image/jpg_no_exif_tags.jpg | Bin 0 -> 13773 bytes rsciio/utils/image.py | 17 +++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 rsciio/tests/data/image/jpg_no_exif_tags.jpg diff --git a/rsciio/tests/data/image/jpg_no_exif_tags.jpg b/rsciio/tests/data/image/jpg_no_exif_tags.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1edf4466b799dc88b16f84fff51a42e500cf3b90 GIT binary patch literal 13773 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 W2y{6~s{wnixSNXYxLfq~=l=mjjzG%* literal 0 HcmV?d00001 diff --git a/rsciio/utils/image.py b/rsciio/utils/image.py index eccd392b4..a85a77896 100644 --- a/rsciio/utils/image.py +++ b/rsciio/utils/image.py @@ -41,13 +41,18 @@ def _parse_axes_from_metadata(exif_tags, sizes): if exif_tags is None: - return [] - offsets = exif_tags.get("FocalPlaneXYOrigins", [0, 0]) - # jpg files made with Renishaw have this tag - scales = exif_tags.get("FieldOfViewXY", [1, 1]) + # if no exif_tags exist, axes are set to scale of 1 pixel/pixel + # return of axes must not be empty, or dimensions are lost + offsets = [0,0] + scales = [sizes[1],sizes[0]] + unit = "pixel" + else: + offsets = exif_tags.get("FocalPlaneXYOrigins", [0, 0]) + # jpg files made with Renishaw have this tag + scales = exif_tags.get("FieldOfViewXY", [1, 1]) - unit = FocalPlaneResolutionUnit_mapping[ - exif_tags.get("FocalPlaneResolutionUnit", "") + unit = FocalPlaneResolutionUnit_mapping[ + exif_tags.get("FocalPlaneResolutionUnit", "") ] axes = [ From 7377e088c0846b360fd9d4fc343877b79493ea5e Mon Sep 17 00:00:00 2001 From: wieczoth Date: Mon, 24 Jun 2024 10:57:21 +0200 Subject: [PATCH 121/174] upcoming_changes --- upcoming_changes/283.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/283.bugfix.rst diff --git a/upcoming_changes/283.bugfix.rst b/upcoming_changes/283.bugfix.rst new file mode 100644 index 000000000..b50f0c993 --- /dev/null +++ b/upcoming_changes/283.bugfix.rst @@ -0,0 +1 @@ +Fixes axes for JPG with no exif_tags. Return of axes while loading isn't emty anymore. \ No newline at end of file From 8749119ed526bf6062b3a53a12083c1924d33014 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:28:21 +0000 Subject: [PATCH 122/174] Bump softprops/action-gh-release from 2.0.5 to 2.0.6 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.5 to 2.0.6. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/69320dbe05506a9a39fc8ae11030b214ec2d1f87...a74c6b72af54cfa997e81df42d94703d6313a2d0) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d9ca9d45..28dd2a866 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,4 +145,4 @@ jobs: uses: actions/checkout@v4 - name: Create Release id: create_release - uses: softprops/action-gh-release@69320dbe05506a9a39fc8ae11030b214ec2d1f87 + uses: softprops/action-gh-release@a74c6b72af54cfa997e81df42d94703d6313a2d0 From cc52a66471daa3c6127072b394e8458896a57f92 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Mon, 24 Jun 2024 18:26:54 +0200 Subject: [PATCH 123/174] Fix RGBImageSeries, enhance tests, improve doc --- rsciio/digitalsurf/Untitled-1.ipynb | 673 ++++++++++++++++++++++++++++ rsciio/digitalsurf/_api.py | 198 ++++---- rsciio/tests/test_digitalsurf.py | 96 +++- 3 files changed, 890 insertions(+), 77 deletions(-) create mode 100644 rsciio/digitalsurf/Untitled-1.ipynb diff --git a/rsciio/digitalsurf/Untitled-1.ipynb b/rsciio/digitalsurf/Untitled-1.ipynb new file mode 100644 index 000000000..c35673f23 --- /dev/null +++ b/rsciio/digitalsurf/Untitled-1.ipynb @@ -0,0 +1,673 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from rsciio.digitalsurf._api import DigitalSurfHandler\n", + "import hyperspy.api as hs\n", + "import numpy as np\n", + "import pathlib\n", + "import matplotlib.pyplot as plt\n", + "%matplotlib qt\n", + "\n", + "savedir = pathlib.Path().home().joinpath(\"OneDrive - Attolight/Desktop/\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "ddd = np.loadtxt(r\"C:\\Users\\NicolasTappy\\Attolight Dropbox\\ATT_RnD\\INJECT\\BEAMFOUR\\BeamFour-end-users_Windows\\histo2dim_500mmoffset.txt\",delimiter=',')" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "255" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "np.iinfo(np.uint8).max" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dt = np.uint8\n", + "maxint = np.iinfo(dt).max\n", + "np.random.randint(low=0,high=maxint,size=(17,38,3),dtype=dt)\n", + "size = (5,17,38,3)\n", + "maxint = np.iinfo(dt).max\n", + "\n", + "gen = hs.signals.Signal1D(np.random.randint(low=0,high=maxint,size=size,dtype=dt))\n", + "gen\n", + "# gen.change_dtype('rgb8')" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "52c591c4ee4f44d6a582c5958ecffd12", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(HBox(children=(Label(value='Unnamed 0th axis', layout=Layout(width='15%')), IntSlider(value=0, …" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "gen.change_dtype('rgb8')\n", + "gen.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Text(0, 0.5, 'Y (um)')" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "plt.matshow(ddd)\n", + "plt.xlabel('X (um)')\n", + "plt.ylabel('Y (um)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def diffdic(a:dict,b:dict):\n", + " set1 = set(a.items())\n", + " set2 = set(b.items())\n", + " return set1^set2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import struct\n", + "def _pack_str(val, size, encoding=\"latin-1\"):\n", + " \"\"\"Write a str of defined size in bytes to a file. struct.pack\n", + " will automatically trim the string if it is too long\"\"\"\n", + " return struct.pack(\"<{:d}s\".format(size), f\"{val}\".ljust(size).encode(encoding))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "testdir = pathlib.Path(r'C:\\Program Files\\Attolight\\AttoMap Advanced 7.4\\Example Data')\n", + "testfiles = list(testdir.glob('*.sur'))+list(testdir.glob('*pro'))\n", + "list(testfiles)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "testdir = pathlib.Path(r'C:\\Program Files\\Attolight\\AttoMap Advanced 7.4\\Example Data')\n", + "testfiles = list(testdir.glob('*.sur'))+list(testdir.glob('*pro'))\n", + "savedir = pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles')\n", + "for tf in testfiles:\n", + " d = hs.load(tf)\n", + " comp = d.original_metadata.Object_0_Channel_0.Header.H01_Signature == 'DSCOMPRESSED'\n", + " nam = d.original_metadata.Object_0_Channel_0.Header.H06_Object_Name\n", + " abso = d.original_metadata.Object_0_Channel_0.Header.H12_Absolute\n", + " # print(tf.name)\n", + " # if d.original_metadata.Object_0_Channel_0.Header.H05_Object_Type == 12:\n", + " # print(d.original_metadata.Object_0_Channel_0.Header.H23_Z_Spacing)\n", + " nn = savedir.joinpath(f\"EXPORTED_{tf.name}\")\n", + " print(f\"{nn.name}: {comp}, {abso}\")\n", + " d.save(nn,object_name=nam,compressed=comp,absolute=abso,overwrite=True)\n", + " tmp = hs.load(nn)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "a = d.axes_manager[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "a.get_axis_dictionary()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "exptf = list(pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles').glob('*.sur'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "i = 26\n", + "print(testfiles[i].name)\n", + "d = hs.load(testfiles[i])\n", + "print(exptf[i].name)\n", + "ed = hs.load(exptf[i])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "diffdic(d.original_metadata.Object_0_Channel_0.Header.as_dictionary(),\n", + " ed.original_metadata.Object_0_Channel_0.Header.as_dictionary())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d.plot(),ed.plot(),(d-ed).plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pathlib\n", + "d = pathlib.Path(r\"C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Documents\\GIT\\rosettasciio\\rsciio\\tests\\data\\digitalsurf\")\n", + "fl = list(d.iterdir())\n", + "fl" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "a = hs.load(r\"C:\\Users\\NicolasTappy\\Attolight Dropbox\\ATT_RnD\\INJECT\\hyperspectral tests\\HYP-TEST-NOLASER\\HYPCard.sur\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "savedir = pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles')\n", + "for tf in fl:\n", + " d = hs.load(tf)\n", + " try:\n", + " comp = d.original_metadata.Object_0_Channel_0.Header.H01_Signature == 'DSCOMPRESSED'\n", + " nam = d.original_metadata.Object_0_Channel_0.Header.H06_Object_Name\n", + " abso = d.original_metadata.Object_0_Channel_0.Header.H12_Absolute\n", + " except:\n", + " comp=False\n", + " nam= 'test'\n", + " abso = 0\n", + " # print(tf.name)\n", + " # if d.original_metadata.Object_0_Channel_0.Header.H05_Object_Type == 12:\n", + " # print(d.original_metadata.Object_0_Channel_0.Header.H23_Z_Spacing)\n", + " nn = savedir.joinpath(f\"EXPORTED_{tf.name}\")\n", + " print(f\"{nn.name}: {comp}, {abso}\")\n", + " d.save(nn,object_name=nam,compressed=comp,absolute=abso,overwrite=True)\n", + " tmp = hs.load(nn)\n", + "exptf = list(pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles').glob('*'))\n", + "exptf" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "t = hs.load(r\"C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles\\EXPORTED_test_spectral_map.sur\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b'\\x19\\x00\\x00\\x00\\x1a\\x00\\x00\\x00\\x1b\\x00\\x00\\x00\\x1c\\x00\\x00\\x00\\x1d\\x00\\x00\\x00\\x1e\\x00\\x00\\x00\\x1f\\x00\\x00\\x00 \\x00\\x00\\x00!\\x00\\x00\\x00\"\\x00\\x00\\x00#\\x00\\x00\\x00$\\x00\\x00\\x00'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "i = 4\n", + "print(fl[i].name)\n", + "# d = hs.load(fl[i])\n", + "print(exptf[i].name)\n", + "ed = hs.load(exptf[i])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "a = d.metadata\n", + "a.as_dictionary()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d = hs.load(r\"C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Pictures\\Untitled.jpg\")\n", + "n = savedir.joinpath(f\"EXPORTED_Untitled.sur\")\n", + "d.save(n)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "i = 1\n", + "d1 = hs.load(fl[i])\n", + "d1.save(savedir.joinpath(fl[i].name),overwrite=True)\n", + "d2 = hs.load(savedir.joinpath(fl[i].name))\n", + "for k in ['R','G','B']:\n", + " plt.figure()\n", + " plt.imshow(d1.data[k].astype(np.int16)-d2.data[k].astype(np.int16))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "aa[0].axis" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "k.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121.,\n", + " 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132.,\n", + " 133., 134.])" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import numpy as np\n", + "from rsciio.digitalsurf import file_writer,file_reader\n", + "md = { 'General': {},\n", + " 'Signal': {}}\n", + "\n", + "ax = {'name': 'X',\n", + " 'navigate': False,\n", + " }\n", + "\n", + "sd = {\"data\": np.arange(24)+111,\n", + " \"axes\": [ax],\n", + " \"metadata\": md,\n", + " \"original_metadata\": {}}\n", + "\n", + "file_writer(\"test.pro\",sd)\n", + "file_reader('test.pro')[0]['data']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for f in fl:\n", + " print(f.name)\n", + " d = hs.load(f)\n", + " # d.plot()\n", + " # d.save(savedir.joinpath(f.name),overwrite=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for f in fl:\n", + " print(f.name)\n", + " d = hs.load(savedir.joinpath(f.name))\n", + " # d.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "k = hs.load(savedir.joinpath('test_RGB.sur'))\n", + "k.original_metadata" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for f in testrgbfiles:\n", + " print(pathlib.Path(f).name)\n", + " d = hs.load(f)\n", + " d.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ds = DigitalSurfHandler(savedir.joinpath('test_spectra.pro'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d.save(savedir.joinpath('test_spectra.pro'),comment='off')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "gen = hs.signals.Signal1D(np.arange(24,dtype=np.float32))\n", + "fgen = savedir.joinpath('test.pro')\n", + "gen.save(fgen,overwrite=True,is_special=False)\n", + "gen.data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hs.load(fgen).original_metadata" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "11.5+11.5" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [], + "source": [ + "from rsciio.utils import rgb_tools" + ] + }, + { + "cell_type": "code", + "execution_count": 74, + "metadata": {}, + "outputs": [], + "source": [ + "# a = np.random.randint(0,65535,size=(8,3,12,14),dtype=np.uint16)\n", + "a = np.random.randint(0,65535,size=(24,12,14),dtype=np.uint16)\n", + "a = a.reshape(8,3,12,14)" + ] + }, + { + "cell_type": "code", + "execution_count": 78, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(8, 12, 14, 3)" + ] + }, + "execution_count": 78, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "np.rollaxis(a,1,4).shape" + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "metadata": {}, + "outputs": [], + "source": [ + "b = rgb_tools.regular_array2rgbx(a)" + ] + }, + { + "cell_type": "code", + "execution_count": 80, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,\n", + " 42, 43, 44, 45, 46, 47, 48], dtype=int8)" + ] + }, + "execution_count": 80, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "d=hs.signals.Signal1D(np.arange(24,dtype=np.int8))+25\n", + "d.data" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "metadata": {}, + "outputs": [], + "source": [ + "c = b[:8]" + ] + }, + { + "cell_type": "code", + "execution_count": 81, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "-128" + ] + }, + "execution_count": 81, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "- 2**(8-1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "hsdev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 2685fc622..2b1277552 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -49,7 +49,7 @@ # from hyperspy.misc.utils import DictionaryTreeBrowser from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC, SIGNAL_DOC from rsciio.utils.exceptions import MountainsMapFileError -from rsciio.utils.rgb_tools import is_rgb, is_rgba +from rsciio.utils.rgb_tools import is_rgb, is_rgba, rgbx2regular_array from rsciio.utils.date_time_tools import get_date_time_from_metadata _logger = logging.getLogger(__name__) @@ -98,7 +98,7 @@ class DigitalSurfHandler(object): 21: "_HYPCARD", } - def __init__(self, filename : str|None = None): + def __init__(self, filename : str = ''): # We do not need to check for file existence here because # io module implements it in the load function self.filename = filename @@ -607,18 +607,6 @@ def _is_spectrum(self) -> bool: return is_spec - def _is_surface(self) -> bool: - """Determine if a 2d-data-like signal_dict should be of surface type, ie the dataset - is a 2d surface of the 3d space. """ - is_surface = False - surfacelike_quantnames = ['Height', 'Altitude', 'Elevation', 'Depth', 'Z'] - quant: str = self.signal_dict['metadata']['Signal']['quantity'] - for name in surfacelike_quantnames: - if quant.startswith(name): - is_surface = True - - return is_surface - def _is_binary(self) -> bool: return self.signal_dict['data'].dtype == bool @@ -647,8 +635,6 @@ def _split_signal_dict(self): warnings.warn(f"A channel discarded upon saving \ RGBA signal in .sur format") self._split_rgb() - # elif self._is_surface(): #'_SURFACE' - # self._split_surface() else: # _INTENSITYSURFACE self._split_surface() elif (n_nav,n_sig) == (1,0): @@ -664,7 +650,7 @@ def _split_signal_dict(self): elif (n_nav,n_sig) == (1,2): if is_rgb(self.signal_dict['data']): self._split_rgbserie() - if is_rgba(self.signal_dict['data']): + elif is_rgba(self.signal_dict['data']): warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") self._split_rgbserie() else: @@ -679,10 +665,8 @@ def _split_signal_dict(self): warnings.warn(f"A channel discarded upon saving \ RGBA signal in .sur format") self._split_rgb() - if self._is_surface(): - self._split_surface() else: - self._split_intensitysurface() + self._split_surface() elif (n_nav,n_sig) == (2,1): self._split_hyperspectral() else: @@ -716,12 +700,7 @@ def _split_profile(self,): obj_type = 1 self._Object_type = self._mountains_object_types[obj_type] - - if (self._n_ax_nav,self._n_ax_sig) in [(0,1),(1,0)]: - self.Xaxis = self.signal_dict['axes'][0] - else: - raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for a profile type") - + self.Xaxis = self.signal_dict['axes'][0] self.data_split = [self.signal_dict['data']] self.objtype_split = [obj_type] self._N_data_objects = 1 @@ -763,12 +742,8 @@ def _split_rgb(self,): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 12 self._Object_type = self._mountains_object_types[obj_type] - if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: - self.Xaxis = self.signal_dict['axes'][1] - self.Yaxis = self.signal_dict['axes'][0] - else: - raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") - + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] self.data_split = [np.int32(self.signal_dict['data']['R']), np.int32(self.signal_dict['data']['G']), np.int32(self.signal_dict['data']['B']) @@ -781,25 +756,8 @@ def _split_surface(self,): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 2 self._Object_type = self._mountains_object_types[obj_type] - if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: - self.Xaxis = self.signal_dict['axes'][1] - self.Yaxis = self.signal_dict['axes'][0] - else: - raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") - self.data_split = [self.signal_dict['data']] - self.objtype_split = [obj_type] - self._N_data_objects = 1 - self._N_data_channels = 1 - - def _split_intensitysurface(self,): - """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" - obj_type = 10 - self._Object_type = self._mountains_object_types[obj_type] - if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: - self.Xaxis = self.signal_dict['axes'][1] - self.Yaxis = self.signal_dict['axes'][0] - else: - raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] self.data_split = [self.signal_dict['data']] self.objtype_split = [obj_type] self._N_data_objects = 1 @@ -816,13 +774,18 @@ def _split_rgbserie(self): self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) tmp_data_split = self._split_data_alongaxis(self.Taxis) - self.data_split = [] + # self.data_split = [] self.objtype_split = [] for d in tmp_data_split: - self.data_split += [d['R'].astype(np.int32), d['G'].astype(np.int32), d['B'].astype(np.int32)] - self.objtype_split += [12,10,10] + self.data_split += [d['R'].astype(np.int16).copy(), + d['G'].astype(np.int16).copy(), + d['B'].astype(np.int16).copy(), + ] + # self.objtype_split += [12,10,10] + self.objtype_split = [12,10,10]*self.Taxis['size'] self.objtype_split[0] = obj_type - + # self.data_split = rgbx2regular_array(self.signal_dict['data']) + self._N_data_objects = self.Taxis['size'] self._N_data_channels = 3 @@ -882,6 +845,13 @@ def _norm_data(self, data: np.ndarray, is_special: bool): if np.issubdtype(data_type,np.complexfloating): raise MountainsMapFileError(f"digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export") + elif data_type==bool: + pointsize = 16 + Zmin = 0 + Zmax = 1 + Zscale = 1 + Zoffset = 0 + data_int = data.astype(np.int16) elif data_type==np.uint8: warnings.warn("np.uint8 datatype exported as np.int16.") pointsize = 16 @@ -897,7 +867,7 @@ def _norm_data(self, data: np.ndarray, is_special: bool): elif data_type==np.int8: pointsize = 16 #Pointsize has to be 16 or 32 in surf format Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, 8, is_special) - data_int = data + data_int = data.astype(np.int16) elif data_type==np.int16: pointsize = 16 Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, pointsize, is_special) @@ -919,7 +889,7 @@ def _norm_signed_int(self, data:np.ndarray, intsize: int, is_special: bool = Fal if saturation needs to be flagged""" # There are no NaN values for integers. Special points means considering high/low saturation of integer scale. data_int_min = - 2**(intsize-1) - data_int_max = 2**(intsize -1) + data_int_max = 2**(intsize -1) - 1 is_satlo = (data==data_int_min).sum() >= 1 and is_special is_sathi = (data==data_int_max).sum() >= 1 and is_special @@ -1178,7 +1148,10 @@ def _build_sur_dict(self): self._build_general_1D_data() elif self._Object_type in ["_PROFILESERIE"]: self._build_1D_series() - elif self._Object_type in ["_SURFACE","_INTENSITYIMAGE","_BINARYIMAGE"]: + elif self._Object_type in ["_BINARYIMAGE"]: + self._build_surface() + self.signal_dict.update({"post_process": [self.post_process_binary]}) + elif self._Object_type in ["_SURFACE","_INTENSITYIMAGE"]: self._build_surface() elif self._Object_type in ["_SURFACESERIE"]: self._build_surface_series() @@ -1190,8 +1163,8 @@ def _build_sur_dict(self): self._build_RGB_image() elif self._Object_type in ["_RGBINTENSITYSURFACE"]: self._build_RGB_surface() - # elif self._Object_type in ["_BINARYIMAGE"]: - # self._build_surface() + elif self._Object_type in ['_SERIESOFRGBIMAGES']: + self._build_RGB_image_series() else: raise MountainsMapFileError( f"{self._Object_type} is not a supported mountain object." @@ -1480,6 +1453,54 @@ def _build_RGB_image(self,): self.signal_dict.update({"post_process": [self.post_process_RGB]}) + def _build_RGB_image_series(self,): + + # First object dictionary + hypdic = self._list_sur_file_content[0] + + # Metadata are set from first dictionary + self._set_metadata_and_original_metadata(hypdic) + + # We build the series-axis + self.signal_dict["axes"].append( + self._build_Tax(hypdic, "_03_Number_of_Objects", ind=0, nav=False) + ) + + # All objects must share the same signal axes + self.signal_dict["axes"].append(self._build_Yax(hypdic, ind=1, nav=False)) + self.signal_dict["axes"].append(self._build_Xax(hypdic, ind=2, nav=False)) + + # shape of the surfaces in the series + shape = (hypdic["_19_Number_of_Lines"], hypdic["_18_Number_of_Points"]) + nimg = hypdic["_03_Number_of_Objects"] + nchan = hypdic["_08_P_Size"] + # We put all the data together + data = np.empty(shape=(nimg,*shape,nchan)) + i = 0 + for imgidx in range(nimg): + for chanidx in range(nchan): + obj = self._list_sur_file_content[i] + data[imgidx,...,chanidx] = obj["_62_points"].reshape(shape) + i+=1 + + # for obj in self._list_sur_file_content: + # data.append(obj["_62_points"].reshape(shape)) + + # data = np.stack(data) + + # data = data.reshape(nimg,nchan,*shape) + # data = np.rollaxis(data,) + + # Pushing data into the dictionary + self.signal_dict["data"] = data + + # Add the color-axis to the signal dict so it can be consumed + self.signal_dict["axes"].append( + self._build_Tax(hypdic, "_08_P_Size", ind=3, nav=True) + ) + + self.signal_dict.update({"post_process": [self.post_process_RGB]}) + # Metadata utility methods @staticmethod @@ -1944,6 +1965,11 @@ def post_process_RGB(signal): ) return signal + + @staticmethod + def post_process_binary(signal): + signal.change_dtype('bool') + return signal # pack/unpack binary quantities @staticmethod @@ -2225,7 +2251,18 @@ def file_reader(filename, lazy=False): surdict, ] -def file_writer(filename, signal: dict, **kwds): +def file_writer(filename, + signal: dict, + set_comments: str = 'auto', + is_special: bool = False, + compressed: bool = True, + comments: dict = {}, + object_name: str = '', + operator_name: str = '', + absolute: int = 0, + private_zone: bytes = b'', + client_zone: bytes = b'' + ): """ Write a mountainsmap ``.sur`` or ``.pro`` file. @@ -2237,34 +2274,45 @@ def file_writer(filename, signal: dict, **kwds): Whether comments should be a simplified original_metadata ('auto'), exported as the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied by the user as an additional kwarg ('custom'). - is_special : bool, default = False + is_special : bool , default = False If True, NaN values in the dataset or integers reaching boundary values are flagged in the export as non-measured and saturating, respectively. If False, those values are kept as-is. - compressed: bool, default =True + compressed : bool, default =True If True, compress the data in the export file using zlib. - comments: dict, default = {} + comments : dict, default = {} Set a custom dictionnary in the comments field of the exported file. - Ignored if set_comments is not set to 'custom'. - object_name: str, default = '' - Set the object name field in the output file - operator_name: str, default = '' + Ignored if set_comments is not set to 'custom'. + object_name : str, default = '' + Set the object name field in the output file. + operator_name : str, default = '' Set the operator name field in the exported file. - absolute: int, default = 0, + absolute : int, default = 0, Unsigned int capable of flagging whether surface heights are relative (0) or absolute (1). Higher unsigned int values can be used to distinguish several - data series sharing internal reference - private_zone: bytes, default = b'', + data series sharing internal reference. + private_zone : bytes, default = b'', Set arbitrary byte-content in the private_zone field of exported file metadata. - Maximum size is 32.0 kB and content will be cropped if this size is exceeded - client_zone: bytes, default = b'' + Maximum size is 32.0 kB and content will be cropped if this size is exceeded. + client_zone : bytes, default = b'' Set arbitrary byte-content in the client_zone field of exported file metadata. - Maximum size is 128B and and content will be cropped if this size is exceeded + Maximum size is 128B and and content will be cropped if this size is exceeded. + **kwds : dict + Unpacked keywords arguments dictionary. Does not accept other arguments than + those specified above. """ ds = DigitalSurfHandler(filename=filename) ds.signal_dict = signal - ds._build_sur_file_contents(**kwds) + ds._build_sur_file_contents(set_comments, + is_special, + compressed, + comments, + object_name, + operator_name, + absolute, + private_zone, + client_zone) ds._write_sur_file() file_reader.__doc__ %= (FILENAME_DOC,LAZY_UNSUPPORTED_DOC,RETURNS_DOC) diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 2a51b663f..b9ce74d9b 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -606,6 +606,7 @@ def test_writetestobjects(tmp_path,test_object): assert np.allclose(ax.axis,ax2.axis) assert np.allclose(ax.axis,ax3.axis) + @pytest.mark.parametrize("test_tuple ", [("test_profile.pro",'_PROFILE'), ("test_spectra.pro",'_SPECTRUM'), ("test_spectral_map.sur",'_HYPCARD'), @@ -661,13 +662,104 @@ def test_writeRGB(tmp_path): assert np.allclose(ax.axis,ax2.axis) assert np.allclose(ax.axis,ax3.axis) -@pytest.mark.parametrize("dtype", [np.int16, np.int32, np.float64, np.uint8, np.uint16]) +@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.float64, np.uint8, np.uint16]) @pytest.mark.parametrize('compressed',[True,False]) def test_writegeneric_validtypes(tmp_path,dtype,compressed): - """This test establish""" + """This test establishes the capability of saving a generic hyperspy signals + generated from numpy array""" gen = hs.signals.Signal1D(np.arange(24,dtype=dtype))+25 fgen = tmp_path.joinpath('test.pro') gen.save(fgen,compressed = compressed, overwrite=True) gen2 = hs.load(fgen) assert np.allclose(gen2.data,gen.data) + +@pytest.mark.parametrize("dtype", [np.int64, np.complex64, np.uint64, ]) +def test_writegeneric_failingtypes(tmp_path,dtype): + gen = hs.signals.Signal1D(np.arange(24,dtype=dtype))+25 + fgen = tmp_path.joinpath('test.pro') + with pytest.raises(MountainsMapFileError): + gen.save(fgen,overwrite= True) + +@pytest.mark.parametrize("dtype", [(np.uint8,"rgba8"), (np.uint16,"rgba16")]) +@pytest.mark.parametrize('compressed',[True,False]) +@pytest.mark.parametrize('transpose',[True,False]) +def test_writegeneric_rgba(tmp_path,dtype,compressed,transpose): + """This test establishes the possibility of saving RGBA data while discarding + A channel and warning""" + size = (17,38,4) + maxint = np.iinfo(dtype[0]).max + + gen = hs.signals.Signal1D(np.random.randint(low=0,high=maxint,size=size,dtype=dtype[0])) + gen.change_dtype(dtype[1]) + + fgen = tmp_path.joinpath('test.sur') + + if transpose: + gen = gen.T + + with pytest.warns(): + gen.save(fgen,compressed = compressed, overwrite=True) + + gen2 = hs.load(fgen) + + for k in ['R','G','B']: + assert np.allclose(gen.data[k],gen2.data[k]) + assert np.allclose(gen.data[k],gen2.data[k]) + +@pytest.mark.parametrize('compressed',[True,False]) +@pytest.mark.parametrize('transpose',[True,False]) +def test_writegeneric_binaryimg(tmp_path,compressed,transpose): + + size = (76,3) + + gen = hs.signals.Signal2D(np.random.randint(low=0,high=1,size=size,dtype=bool)) + + fgen = tmp_path.joinpath('test.sur') + + if transpose: + gen = gen.T + with pytest.warns(): + gen.save(fgen,compressed = compressed, overwrite=True) + else: + gen.save(fgen,compressed = compressed, overwrite=True) + + gen2 = hs.load(fgen) + + assert np.allclose(gen.data,gen2.data) + +@pytest.mark.parametrize('compressed',[True,False]) +def test_writegeneric_profileseries(tmp_path,compressed): + + size = (9,655) + + gen = hs.signals.Signal1D(np.random.random(size=size)*1444+2550.) + fgen = tmp_path.joinpath('test.pro') + + gen.save(fgen,compressed = compressed, overwrite=True) + + gen2 = hs.load(fgen) + + assert np.allclose(gen.data,gen2.data) + + +@pytest.mark.parametrize("dtype", [(np.uint8,"rgb8"), (np.uint16,"rgb16")]) +@pytest.mark.parametrize('compressed',[True,False]) +def test_writegeneric_rgbseries(tmp_path,dtype,compressed): + """This test establishes the possibility of saving RGBA data while discarding + A channel and warning""" + size = (5,44,24,3) + maxint = np.iinfo(dtype[0]).max + + gen = hs.signals.Signal1D(np.random.randint(low=0,high=maxint,size=size,dtype=dtype[0])) + gen.change_dtype(dtype[1]) + + fgen = tmp_path.joinpath('test.sur') + + gen.save(fgen,compressed = compressed, overwrite=True) + + gen2 = hs.load(fgen) + + for k in ['R','G','B']: + assert np.allclose(gen.data[k],gen2.data[k]) + assert np.allclose(gen.data[k],gen2.data[k]) \ No newline at end of file From 5d3baa32f146a7cdde99914aab6f80554ca230ea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 22:07:54 +0000 Subject: [PATCH 124/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.4.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.4.10) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac91ab870..a6e9a168b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.7 + rev: v0.4.10 hooks: # Run the linter. - id: ruff From 1e6e4b910ae3dfa7aa189dbae5dca2b8155bda9f Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Tue, 25 Jun 2024 09:23:22 +0200 Subject: [PATCH 125/174] increase codecov, fix bugs --- .../supported_formats/digitalsurf.rst | 2 +- rsciio/digitalsurf/_api.py | 118 ++++++++---------- rsciio/tests/test_digitalsurf.py | 89 ++++++++++++- upcoming_changes/280.enhancements.rst | 1 + 4 files changed, 135 insertions(+), 75 deletions(-) create mode 100644 upcoming_changes/280.enhancements.rst diff --git a/doc/user_guide/supported_formats/digitalsurf.rst b/doc/user_guide/supported_formats/digitalsurf.rst index 8b5807abd..6b52cadc0 100644 --- a/doc/user_guide/supported_formats/digitalsurf.rst +++ b/doc/user_guide/supported_formats/digitalsurf.rst @@ -35,7 +35,7 @@ quantity are named. The criteria are listed here below: | 0 | 1 | ``.pro``: Spectrum (based on axes name), Profile (default) | +-----------------+---------------+------------------------------------------------------------------------------+ | 0 | 2 | ``.sur``: BinaryImage (based on dtype), RGBImage (based on dtype), | -| | | Surface (default), | +| | | Surface (default) | +-----------------+---------------+------------------------------------------------------------------------------+ | 1 | 0 | ``.pro``: same as (1,0) | +-----------------+---------------+------------------------------------------------------------------------------+ diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 2b1277552..32a1bd16b 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -49,7 +49,7 @@ # from hyperspy.misc.utils import DictionaryTreeBrowser from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC, SIGNAL_DOC from rsciio.utils.exceptions import MountainsMapFileError -from rsciio.utils.rgb_tools import is_rgb, is_rgba, rgbx2regular_array +from rsciio.utils.rgb_tools import is_rgb, is_rgba from rsciio.utils.date_time_tools import get_date_time_from_metadata _logger = logging.getLogger(__name__) @@ -480,37 +480,6 @@ def _write_sur_file(self): for key in self._work_dict: self._work_dict[key]['b_pack_fn'](f,self._work_dict[key]['value']) - def _validate_filename(self): - - sur_only = ['_SURFACE', - '_BINARYIMAGE', - '_SURFACESERIE', - '_MULTILAYERSURFACE', - '_INTENSITYIMAGE', - '_INTENSITYSURFACE', - '_RGBIMAGE', - '_RGBSURFACE', - '_RGBINTENSITYSURFACE', - '_SERIESOFRGBIMAGES', - '_HYPCARD'] - - pro_only = ['_PROFILE', - '_PROFILESERIE', - '_MULTILAYERPROFILE', - '_FORCECURVE', - '_SERIEOFFORCECURVE', - '_CONTOURPROFILE', - '_SPECTRUM', - ] - - if self._Object_type in sur_only and not self.filename.lower().endswith('sur'): - raise MountainsMapFileError(f"Attempting save of DigitalSurf {self._Object_type} with\ - .{self.filename.split('.')[-1]} extension, which only supports .sur") - - if self._Object_type in pro_only and not self.filename.lower().endswith('pro'): - raise MountainsMapFileError(f"Attempting save of DigitalSurf {self._Object_type} with\ - .{self.filename.split('.')[-1]} extension, which only supports .pro") - def _build_sur_file_contents(self, set_comments:str='auto', is_special:bool=False, @@ -573,7 +542,7 @@ def _build_sur_file_contents(self, #Signal dictionary analysis methods @staticmethod - def _get_n_axes(sig_dict: dict) -> tuple[int,int]: + def _get_n_axes(sig_dict: dict): """Return number of navigation and signal axes in the signal dict (in that order). Could be moved away from the .sur api as other functions probably use this as well @@ -711,11 +680,8 @@ def _split_profileserie(self,): obj_type = 4 # '_PROFILESERIE' self._Object_type = self._mountains_object_types[obj_type] - if (self._n_ax_nav,self._n_ax_sig)==(1,1): - self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) - self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) - else: - raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._Object_type} type") + self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) self.data_split = self._split_data_alongaxis(self.Taxis) self.objtype_split = [obj_type] + [1]*(len(self.data_split)-1) @@ -727,11 +693,8 @@ def _split_binary_img(self,): obj_type = 3 self._Object_type = self._mountains_object_types[obj_type] - if (self._n_ax_nav,self._n_ax_sig) in [(0,2),(2,0)]: - self.Xaxis = self.signal_dict['axes'][1] - self.Yaxis = self.signal_dict['axes'][0] - else: - raise MountainsMapFileError(f"Invalid ({self._n_ax_nav},{self._n_ax_sig}) for {self._mountains_object_types[obj_type]} type") + self.Xaxis = self.signal_dict['axes'][1] + self.Yaxis = self.signal_dict['axes'][0] self.data_split = [self.signal_dict['data']] self.objtype_split = [obj_type] @@ -816,7 +779,7 @@ def _split_hyperspectral(self): self._N_data_objects = 1 self._N_data_channels = 1 - def _split_data_alongaxis(self, axis: dict) -> list[np.ndarray]: + def _split_data_alongaxis(self, axis: dict): """Split the data in a series of lower-dim datasets that can be exported to a surface / profile file""" idx = self.signal_dict['axes'].index(axis) @@ -855,27 +818,27 @@ def _norm_data(self, data: np.ndarray, is_special: bool): elif data_type==np.uint8: warnings.warn("np.uint8 datatype exported as np.int16.") pointsize = 16 - Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int16), pointsize, is_special) + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data.astype(np.int16) elif data_type==np.uint16: warnings.warn("np.uint16 datatype exported as np.int32") pointsize = 32 #Pointsize has to be 16 or 32 in surf format - Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data.astype(np.int32), pointsize, is_special) + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data.astype(np.int32) elif np.issubdtype(data_type,np.unsignedinteger): raise MountainsMapFileError(f"digitalsurf file formats do not support unsigned int >16bits. Convert data to signed integers before export.") elif data_type==np.int8: pointsize = 16 #Pointsize has to be 16 or 32 in surf format - Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, 8, is_special) + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data.astype(np.int16) elif data_type==np.int16: pointsize = 16 - Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, pointsize, is_special) + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data elif data_type==np.int32: pointsize = 32 data_int = data - Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, pointsize, is_special) + Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) elif np.issubdtype(data_type,np.integer): raise MountainsMapFileError(f"digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting") elif np.issubdtype(data_type,np.floating): @@ -884,12 +847,12 @@ def _norm_data(self, data: np.ndarray, is_special: bool): return pointsize, Zmin, Zmax, Zscale, Zoffset, data_int - def _norm_signed_int(self, data:np.ndarray, intsize: int, is_special: bool = False): - """Normalized data of integer type. No normalization per se, but the Zmin and Zmax threshold are set - if saturation needs to be flagged""" - # There are no NaN values for integers. Special points means considering high/low saturation of integer scale. - data_int_min = - 2**(intsize-1) - data_int_max = 2**(intsize -1) - 1 + def _norm_signed_int(self, data:np.ndarray, is_special: bool = False): + """Normalized data of integer type. No normalization per se, but the Zmin and Zmax + threshold are set if saturation flagging is asked.""" + # There are no NaN values for integers. Special points means saturation of integer scale. + data_int_min = np.iinfo(data.dtype).min + data_int_max = np.iinfo(data.dtype).max is_satlo = (data==data_int_min).sum() >= 1 and is_special is_sathi = (data==data_int_max).sum() >= 1 and is_special @@ -926,7 +889,7 @@ def _norm_float(self, data : np.ndarray, is_special: bool = False,): return Zmin, Zmax, Zscale, Zoffset_f, data_int - def _get_Zname_Zunit(self, metadata: dict) -> tuple[str,str]: + def _get_Zname_Zunit(self, metadata: dict): """Attempt reading Z-axis name and Unit from metadata.Signal.Quantity field. Return empty str if do not exist. @@ -2090,14 +2053,31 @@ def _pack_private(self, file, val, encoding="latin-1"): self._set_str(file, val, privatesize) def _is_data_int(self,): - if self._Object_type in ['_BINARYIMAGE', - '_RGBIMAGE', - '_RGBSURFACE', - '_SERIESOFRGBIMAGES']: - return True + """Determine wether data consists of unscaled int values. + This is not the case for all objects. Surface and surface series can admit + this logic. In theory, hyperspectral studiables as well but it is more convenient + to use them as floats due to typical data treatment in hyperspy (scaling etc)""" + objtype = self._mountains_object_types[self._get_work_dict_key_value("_05_Object_Type")] + if objtype in ['_SURFACESERIE','_SURFACE']: + scale = self._get_work_dict_key_value("_23_Z_Spacing") / self._get_work_dict_key_value("_35_Z_Unit_Ratio") + offset = self._get_work_dict_key_value("_55_Z_Offset") + if float(scale).is_integer() and float(offset).is_integer(): + return True + else: + return False else: return False + def _is_data_scaleint(self,): + """Digitalsurf image formats are not stored as their raw int values, but instead are + scaled and a scale / offset is set so that the data scales down to uint. Why this is + done this way is not clear to me. """ + objtype = self._mountains_object_types[self._get_work_dict_key_value("_05_Object_Type")] + if objtype in ['_BINARYIMAGE', '_RGBIMAGE', + '_RGBSURFACE', '_SERIESOFRGBIMAGES', + '_INTENSITYIMAGE']: + return True + def _get_uncompressed_datasize(self) -> int: """Return size of uncompressed data in bytes""" psize = int(self._get_work_dict_key_value("_15_Size_of_Points") / 8) @@ -2168,14 +2148,19 @@ def _unpack_data(self, file, encoding="latin-1"): nm = _points == self._get_work_dict_key_value("_16_Zmin") - 2 Zmin = self._get_work_dict_key_value("_16_Zmin") - _points = (_points.astype(float) - Zmin)*self._get_work_dict_key_value("_23_Z_Spacing") * self._get_work_dict_key_value("_35_Z_Unit_Ratio") + self._get_work_dict_key_value("_55_Z_Offset") + scale = self._get_work_dict_key_value("_23_Z_Spacing") / self._get_work_dict_key_value("_35_Z_Unit_Ratio") + offset = self._get_work_dict_key_value("_55_Z_Offset") - # We set the point in the numeric scale + # Packing data into ints or float, with or without scaling. if self._is_data_int(): + _points = _points + elif self._is_data_scaleint(): + _points = (_points.astype(float) - Zmin)*scale + offset _points = np.round(_points).astype(int) else: - _points[nm] = np.nan - + _points = (_points.astype(float) - Zmin)*scale + offset + _points[nm] = np.nan #Ints have no nans + # Return the points, rescaled return _points @@ -2297,9 +2282,6 @@ def file_writer(filename, client_zone : bytes, default = b'' Set arbitrary byte-content in the client_zone field of exported file metadata. Maximum size is 128B and and content will be cropped if this size is exceeded. - **kwds : dict - Unpacked keywords arguments dictionary. Does not accept other arguments than - those specified above. """ ds = DigitalSurfHandler(filename=filename) ds.signal_dict = signal diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index b9ce74d9b..9caba8958 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -606,7 +606,6 @@ def test_writetestobjects(tmp_path,test_object): assert np.allclose(ax.axis,ax2.axis) assert np.allclose(ax.axis,ax3.axis) - @pytest.mark.parametrize("test_tuple ", [("test_profile.pro",'_PROFILE'), ("test_spectra.pro",'_SPECTRUM'), ("test_spectral_map.sur",'_HYPCARD'), @@ -630,6 +629,38 @@ def test_split(test_tuple): assert dh._Object_type == res +@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.uint8, np.uint16]) +@pytest.mark.parametrize('special',[True,False]) +@pytest.mark.parametrize('fullscale',[True,False]) +def test_norm_int_data(dtype,special,fullscale): + dh = DigitalSurfHandler() + + if fullscale: + minint = np.iinfo(dtype).min + maxint = np.iinfo(dtype).max + else: + minint = np.iinfo(dtype).min + 23 + maxint = np.iinfo(dtype).max - 9 + + dat = np.random.randint(low=minint,high=maxint,size=222,dtype=dtype) + #Ensure the maximum and minimum off the int scale is actually present in data + if fullscale: + dat[2] = minint + dat[11] = maxint + + pointsize, Zmin, Zmax, Zscale, Zoffset, data_int = dh._norm_data(dat,special) + + off = minint+1 if special and fullscale else dat.min() + maxval = maxint-1 if special and fullscale else dat.max() + + assert np.isclose(Zscale,1.0) + assert np.isclose(Zoffset,off) + assert np.allclose(data_int,dat) + assert Zmin==off + assert Zmax==maxval + + + def test_writeRGB(tmp_path): # This is just a different test function because the # comparison of rgb data must be done differently @@ -688,9 +719,10 @@ def test_writegeneric_rgba(tmp_path,dtype,compressed,transpose): """This test establishes the possibility of saving RGBA data while discarding A channel and warning""" size = (17,38,4) + minint = np.iinfo(dtype[0]).min maxint = np.iinfo(dtype[0]).max - gen = hs.signals.Signal1D(np.random.randint(low=0,high=maxint,size=size,dtype=dtype[0])) + gen = hs.signals.Signal1D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype[0])) gen.change_dtype(dtype[1]) fgen = tmp_path.joinpath('test.sur') @@ -746,12 +778,12 @@ def test_writegeneric_profileseries(tmp_path,compressed): @pytest.mark.parametrize("dtype", [(np.uint8,"rgb8"), (np.uint16,"rgb16")]) @pytest.mark.parametrize('compressed',[True,False]) def test_writegeneric_rgbseries(tmp_path,dtype,compressed): - """This test establishes the possibility of saving RGBA data while discarding - A channel and warning""" + """This test establishes the possibility of saving RGB surface series""" size = (5,44,24,3) + minint = np.iinfo(dtype[0]).min maxint = np.iinfo(dtype[0]).max - gen = hs.signals.Signal1D(np.random.randint(low=0,high=maxint,size=size,dtype=dtype[0])) + gen = hs.signals.Signal1D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype[0])) gen.change_dtype(dtype[1]) fgen = tmp_path.joinpath('test.sur') @@ -762,4 +794,49 @@ def test_writegeneric_rgbseries(tmp_path,dtype,compressed): for k in ['R','G','B']: assert np.allclose(gen.data[k],gen2.data[k]) - assert np.allclose(gen.data[k],gen2.data[k]) \ No newline at end of file + + +@pytest.mark.parametrize("dtype", [(np.uint8,"rgba8"), (np.uint16,"rgba16")]) +@pytest.mark.parametrize('compressed',[True,False]) +def test_writegeneric_rgbaseries(tmp_path,dtype,compressed): + """This test establishes the possibility of saving RGBA data while discarding + A channel and warning""" + size = (5,44,24,4) + minint = np.iinfo(dtype[0]).min + maxint = np.iinfo(dtype[0]).max + + gen = hs.signals.Signal1D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype[0])) + gen.change_dtype(dtype[1]) + + fgen = tmp_path.joinpath('test.sur') + + with pytest.warns(): + gen.save(fgen,compressed = compressed, overwrite=True) + + gen2 = hs.load(fgen) + + for k in ['R','G','B']: + assert np.allclose(gen.data[k],gen2.data[k]) + + +@pytest.mark.parametrize("dtype", [np.int16, np.int32, np.float64]) +@pytest.mark.parametrize("compressed",[True,False]) +def test_writegeneric_surfaceseries(tmp_path,dtype,compressed): + """This test establishes the possibility of saving RGBA surface series while discarding + A channel and warning""" + size = (9,44,58) + + if np.issubdtype(dtype,np.integer): + minint = np.iinfo(dtype).min + maxint = np.iinfo(dtype).max + gen = hs.signals.Signal2D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype)) + else: + gen = hs.signals.Signal2D(np.random.random(size=size).astype(dtype)*1e6) + + fgen = tmp_path.joinpath('test.sur') + + gen.save(fgen,compressed = compressed, overwrite=True) + + gen2 = hs.load(fgen) + + assert np.allclose(gen.data,gen2.data) \ No newline at end of file diff --git a/upcoming_changes/280.enhancements.rst b/upcoming_changes/280.enhancements.rst new file mode 100644 index 000000000..bd637c83b --- /dev/null +++ b/upcoming_changes/280.enhancements.rst @@ -0,0 +1 @@ +:ref:`DigitalSurf surfaces `: Add file_writer support, add series of RGB images / surfaces support. \ No newline at end of file From f968fe5d192b2a7fa7b78172ec5489576a3ff520 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Tue, 25 Jun 2024 09:40:06 +0200 Subject: [PATCH 126/174] Fix bug causing error from untitled metadata --- rsciio/digitalsurf/_api.py | 5 +++++ rsciio/tests/test_digitalsurf.py | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 32a1bd16b..d3558173c 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -1783,6 +1783,8 @@ def _MS_parse(str_ms, prefix, delimiter): # Title lines start with an underscore titlestart = "{:s}_".format(prefix) + keymain = None + for line in str_ms.splitlines(): # Here we ignore any empty line or line starting with @@ ignore = False @@ -1795,6 +1797,9 @@ def _MS_parse(str_ms, prefix, delimiter): key_main = line[len(titlestart) :].strip() dict_ms[key_main] = {} elif line.startswith(prefix): + if keymain is None: + keymain = 'UNTITLED' + dict_ms[key_main] = {} key, *li_value = line.split(delimiter) # Key is also stripped from beginning or end whitespace key = key[len(prefix) :].strip() diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 9caba8958..d08f27ba2 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -659,8 +659,6 @@ def test_norm_int_data(dtype,special,fullscale): assert Zmin==off assert Zmax==maxval - - def test_writeRGB(tmp_path): # This is just a different test function because the # comparison of rgb data must be done differently From c6587c92a661e417c87147e2a60e56ef45b41580 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Tue, 25 Jun 2024 09:42:04 +0200 Subject: [PATCH 127/174] Linting using black --- rsciio/digitalsurf/__init__.py | 5 +- rsciio/digitalsurf/_api.py | 953 ++++++++++++++++++------------- rsciio/tests/test_digitalsurf.py | 381 ++++++------ 3 files changed, 753 insertions(+), 586 deletions(-) diff --git a/rsciio/digitalsurf/__init__.py b/rsciio/digitalsurf/__init__.py index 7db9455d9..49230cbba 100644 --- a/rsciio/digitalsurf/__init__.py +++ b/rsciio/digitalsurf/__init__.py @@ -1,9 +1,6 @@ from ._api import file_reader, file_writer -__all__ = [ - "file_reader", - "file_writer" -] +__all__ = ["file_reader", "file_writer"] def __dir__(): diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index d3558173c..0930c9da0 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -47,13 +47,19 @@ # import rsciio.utils.tools # DictionaryTreeBrowser class handles the fancy metadata dictionnaries # from hyperspy.misc.utils import DictionaryTreeBrowser -from rsciio._docstrings import FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC, SIGNAL_DOC +from rsciio._docstrings import ( + FILENAME_DOC, + LAZY_UNSUPPORTED_DOC, + RETURNS_DOC, + SIGNAL_DOC, +) from rsciio.utils.exceptions import MountainsMapFileError from rsciio.utils.rgb_tools import is_rgb, is_rgba from rsciio.utils.date_time_tools import get_date_time_from_metadata _logger = logging.getLogger(__name__) + class DigitalSurfHandler(object): """Class to read Digital Surf MountainsMap files. @@ -84,21 +90,21 @@ class DigitalSurfHandler(object): 6: "_MERIDIANDISC", 7: "_MULTILAYERPROFILE", 8: "_MULTILAYERSURFACE", - 9: "_PARALLELDISC", #not implemented + 9: "_PARALLELDISC", # not implemented 10: "_INTENSITYIMAGE", 11: "_INTENSITYSURFACE", 12: "_RGBIMAGE", - 13: "_RGBSURFACE", #Deprecated - 14: "_FORCECURVE", #Deprecated - 15: "_SERIEOFFORCECURVE", #Deprecated - 16: "_RGBINTENSITYSURFACE", #Surface + Image + 13: "_RGBSURFACE", # Deprecated + 14: "_FORCECURVE", # Deprecated + 15: "_SERIEOFFORCECURVE", # Deprecated + 16: "_RGBINTENSITYSURFACE", # Surface + Image 17: "_CONTOURPROFILE", 18: "_SERIESOFRGBIMAGES", 20: "_SPECTRUM", 21: "_HYPCARD", } - def __init__(self, filename : str = ''): + def __init__(self, filename: str = ""): # We do not need to check for file existence here because # io module implements it in the load function self.filename = filename @@ -120,7 +126,7 @@ def __init__(self, filename : str = ''): # _work_dict['Field']['b_pack_fn'](f,v): pack value v in file f self._work_dict = { "_01_Signature": { - "value": "DSCOMPRESSED", #Uncompressed key is DIGITAL SURF + "value": "DSCOMPRESSED", # Uncompressed key is DIGITAL SURF "b_unpack_fn": lambda f: self._get_str(f, 12, "DSCOMPRESSED"), "b_pack_fn": lambda f, v: self._set_str(f, v, 12), }, @@ -146,12 +152,12 @@ def __init__(self, filename : str = ''): }, "_06_Object_Name": { "value": "", - "b_unpack_fn": lambda f: self._get_str(f, 30, ''), + "b_unpack_fn": lambda f: self._get_str(f, 30, ""), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_07_Operator_Name": { "value": "ROSETTA", - "b_unpack_fn": lambda f: self._get_str(f, 30, ''), + "b_unpack_fn": lambda f: self._get_str(f, 30, ""), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_08_P_Size": { @@ -310,7 +316,7 @@ def __init__(self, filename : str = ''): "b_pack_fn": self._set_int16, }, "_39_Obsolete": { - "value": b'', + "value": b"", "b_unpack_fn": lambda f: self._get_bytes(f, 12), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 12), }, @@ -360,7 +366,7 @@ def __init__(self, filename : str = ''): "b_pack_fn": self._set_uint32, }, "_49_Obsolete": { - "value": b'', + "value": b"", "b_unpack_fn": lambda f: self._get_bytes(f, 6), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 6), }, @@ -375,7 +381,7 @@ def __init__(self, filename : str = ''): "b_pack_fn": self._set_int16, }, "_52_Client_zone": { - "value": b'', + "value": b"", "b_unpack_fn": lambda f: self._get_bytes(f, 128), "b_pack_fn": lambda f, v: self._set_bytes(f, v, 128), }, @@ -420,7 +426,7 @@ def __init__(self, filename : str = ''): "b_pack_fn": self._pack_comment, }, "_61_Private_zone": { - "value": b'', + "value": b"", "b_unpack_fn": self._unpack_private, "b_pack_fn": self._pack_private, }, @@ -454,52 +460,55 @@ def __init__(self, filename : str = ''): self._n_ax_sig: int = 0 # All as a rsciio-convention axis dict or empty - self.Xaxis: dict = {} - self.Yaxis: dict = {} - self.Zaxis: dict = {} + self.Xaxis: dict = {} + self.Yaxis: dict = {} + self.Zaxis: dict = {} self.Taxis: dict = {} # These must be set in the split functions self.data_split = [] self.objtype_split = [] - + # File Writer Inner methods def _write_sur_file(self): - """Write self._list_sur_file_content to a file. This method is + """Write self._list_sur_file_content to a file. This method is start-and-forget. The brainwork is performed in the construction of sur_file_content list of dictionaries.""" with open(self.filename, "wb") as f: for dic in self._list_sur_file_content: - # Extremely important! self._work_dict must access - # other fields to properly encode and decode data, + # Extremely important! self._work_dict must access + # other fields to properly encode and decode data, # comments etc. etc. self._move_values_to_workdict(dic) # Then inner consistency is trivial for key in self._work_dict: - self._work_dict[key]['b_pack_fn'](f,self._work_dict[key]['value']) - - def _build_sur_file_contents(self, - set_comments:str='auto', - is_special:bool=False, - compressed:bool=True, - comments: dict = {}, - object_name: str = '', - operator_name: str = '', - absolute: int = 0, - private_zone: bytes = b'', - client_zone: bytes = b'' - ): - """Build the _sur_file_content list necessary to write a signal dictionary to - a ``.sur`` or ``.pro`` file. The signal dictionary's inner consistency is the + self._work_dict[key]["b_pack_fn"](f, self._work_dict[key]["value"]) + + def _build_sur_file_contents( + self, + set_comments: str = "auto", + is_special: bool = False, + compressed: bool = True, + comments: dict = {}, + object_name: str = "", + operator_name: str = "", + absolute: int = 0, + private_zone: bytes = b"", + client_zone: bytes = b"", + ): + """Build the _sur_file_content list necessary to write a signal dictionary to + a ``.sur`` or ``.pro`` file. The signal dictionary's inner consistency is the responsibility of hyperspy, and the this function's responsibility is to make a consistent list of _sur_file_content.""" self._list_sur_file_content = [] - #Compute number of navigation / signal axes - self._n_ax_nav, self._n_ax_sig = DigitalSurfHandler._get_n_axes(self.signal_dict) + # Compute number of navigation / signal axes + self._n_ax_nav, self._n_ax_sig = DigitalSurfHandler._get_n_axes( + self.signal_dict + ) # Choose object type based on number of navigation and signal axes # Populate self._Object_type @@ -507,40 +516,42 @@ def _build_sur_file_contents(self, # Populate self.data_split and self.objtype_split (always) self._split_signal_dict() - #Raise error if wrong extension + # Raise error if wrong extension # self._validate_filename() - #Get a dictionary to be saved in the comment fielt of exported file - comment_dict = self._get_comment_dict(self.signal_dict['original_metadata'], - method=set_comments, - custom=comments) - #Convert the dictionary to a string of suitable format. - comment_str = self._stringify_dict(comment_dict) + # Get a dictionary to be saved in the comment fielt of exported file + comment_dict = self._get_comment_dict( + self.signal_dict["original_metadata"], method=set_comments, custom=comments + ) + # Convert the dictionary to a string of suitable format. + comment_str = self._stringify_dict(comment_dict) # A _work_dict is created for each of the data arrays and object # that have splitted from the main object. In most cases, only a # single object is present in the split. - for data,objtype in zip(self.data_split,self.objtype_split): - self._build_workdict(data, - objtype, - self.signal_dict['metadata'], - comment=comment_str, - is_special=is_special, - compressed=compressed, - object_name=object_name, - operator_name=operator_name, - absolute=absolute, - private_zone=private_zone, - client_zone=client_zone) - # if the objects are multiple, comment is erased after the first + for data, objtype in zip(self.data_split, self.objtype_split): + self._build_workdict( + data, + objtype, + self.signal_dict["metadata"], + comment=comment_str, + is_special=is_special, + compressed=compressed, + object_name=object_name, + operator_name=operator_name, + absolute=absolute, + private_zone=private_zone, + client_zone=client_zone, + ) + # if the objects are multiple, comment is erased after the first # object. This is not mandatory, but makes marginally smaller files. if comment_str: - comment_str = '' + comment_str = "" # Finally we push it all to the content list. self._append_work_dict_to_content() - - #Signal dictionary analysis methods + + # Signal dictionary analysis methods @staticmethod def _get_n_axes(sig_dict: dict): """Return number of navigation and signal axes in the signal dict (in that order). @@ -554,214 +565,244 @@ def _get_n_axes(sig_dict: dict): """ nax_nav = 0 nax_sig = 0 - for ax in sig_dict['axes']: - if ax['navigate']: + for ax in sig_dict["axes"]: + if ax["navigate"]: nax_nav += 1 else: nax_sig += 1 return nax_nav, nax_sig - + def _is_spectrum(self) -> bool: """Determine if a signal is a spectrum type based on axes naming for export of sur_files. Could be cross-checked with other criteria such as hyperspy subclass etc... For now we keep it simple. If it has - an ax named like a spectral axis, then probably its a spectrum. """ + an ax named like a spectral axis, then probably its a spectrum.""" - spectrumlike_axnames = ['Wavelength', 'Energy', 'Energy Loss', 'E'] + spectrumlike_axnames = ["Wavelength", "Energy", "Energy Loss", "E"] is_spec = False - for ax in self.signal_dict['axes']: - if ax['name'] in spectrumlike_axnames: + for ax in self.signal_dict["axes"]: + if ax["name"] in spectrumlike_axnames: is_spec = True return is_spec def _is_binary(self) -> bool: - return self.signal_dict['data'].dtype == bool + return self.signal_dict["data"].dtype == bool - #Splitting /subclassing methods + # Splitting /subclassing methods def _split_signal_dict(self): - """Select the suitable _mountains_object_types """ - + """Select the suitable _mountains_object_types""" + n_nav = self._n_ax_nav n_sig = self._n_ax_sig - #Here, I manually unfold the nested conditions for legibility. - #Since there are a fixed number of dimensions supported by - # digitalsurf .sur/.pro files, I think this is the best way to + # Here, I manually unfold the nested conditions for legibility. + # Since there are a fixed number of dimensions supported by + # digitalsurf .sur/.pro files, I think this is the best way to # proceed. - if (n_nav,n_sig) == (0,1): + if (n_nav, n_sig) == (0, 1): if self._is_spectrum(): self._split_spectrum() else: self._split_profile() - elif (n_nav,n_sig) == (0,2): + elif (n_nav, n_sig) == (0, 2): if self._is_binary(): self._split_binary_img() - elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + elif is_rgb(self.signal_dict["data"]): # "_RGBIMAGE" self._split_rgb() - elif is_rgba(self.signal_dict['data']): - warnings.warn(f"A channel discarded upon saving \ - RGBA signal in .sur format") + elif is_rgba(self.signal_dict["data"]): + warnings.warn( + f"A channel discarded upon saving \ + RGBA signal in .sur format" + ) self._split_rgb() - else: # _INTENSITYSURFACE + else: # _INTENSITYSURFACE self._split_surface() - elif (n_nav,n_sig) == (1,0): - warnings.warn(f"Exporting surface signal dimension {n_sig} and navigation dimension \ + elif (n_nav, n_sig) == (1, 0): + warnings.warn( + f"Exporting surface signal dimension {n_sig} and navigation dimension \ {n_nav} falls back on profile type but is not good practice. Consider \ - transposing before saving to avoid unexpected behaviour.") + transposing before saving to avoid unexpected behaviour." + ) self._split_profile() - elif (n_nav,n_sig) == (1,1): + elif (n_nav, n_sig) == (1, 1): if self._is_spectrum(): self._split_spectrum() else: self._split_profileserie() - elif (n_nav,n_sig) == (1,2): - if is_rgb(self.signal_dict['data']): + elif (n_nav, n_sig) == (1, 2): + if is_rgb(self.signal_dict["data"]): self._split_rgbserie() - elif is_rgba(self.signal_dict['data']): - warnings.warn(f"Alpha channel discarded upon saving RGBA signal in .sur format") + elif is_rgba(self.signal_dict["data"]): + warnings.warn( + f"Alpha channel discarded upon saving RGBA signal in .sur format" + ) self._split_rgbserie() else: self._split_surfaceserie() - elif (n_nav,n_sig) == (2,0): - warnings.warn(f"Signal dimension {n_sig} and navigation dimension {n_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional.") + elif (n_nav, n_sig) == (2, 0): + warnings.warn( + f"Signal dimension {n_sig} and navigation dimension {n_nav} exported as surface type. Consider transposing signal object before exporting if this is intentional." + ) if self._is_binary(): self._split_binary_img() - elif is_rgb(self.signal_dict['data']): #"_RGBIMAGE" + elif is_rgb(self.signal_dict["data"]): # "_RGBIMAGE" self._split_rgb() - elif is_rgba(self.signal_dict['data']): - warnings.warn(f"A channel discarded upon saving \ - RGBA signal in .sur format") + elif is_rgba(self.signal_dict["data"]): + warnings.warn( + f"A channel discarded upon saving \ + RGBA signal in .sur format" + ) self._split_rgb() else: self._split_surface() - elif (n_nav,n_sig) == (2,1): + elif (n_nav, n_sig) == (2, 1): self._split_hyperspectral() else: - raise MountainsMapFileError(msg=f"Object with signal dimension {n_sig} and navigation dimension {n_nav} not supported for .sur export") + raise MountainsMapFileError( + msg=f"Object with signal dimension {n_sig} and navigation dimension {n_nav} not supported for .sur export" + ) - def _split_spectrum(self,): + def _split_spectrum( + self, + ): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" - #When splitting spectrum, no series axis (T/W), - #X axis is the spectral dimension and Y the series dimension (if series). + # When splitting spectrum, no series axis (T/W), + # X axis is the spectral dimension and Y the series dimension (if series). obj_type = 20 self._Object_type = self._mountains_object_types[obj_type] nax_nav = self._n_ax_nav nax_sig = self._n_ax_sig - if (nax_nav,nax_sig)==(0,1) or (nax_nav,nax_sig)==(1,0): - self.Xaxis = self.signal_dict['axes'][0] - elif (nax_nav,nax_sig)==(1,1): - self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) - self.Yaxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + if (nax_nav, nax_sig) == (0, 1) or (nax_nav, nax_sig) == (1, 0): + self.Xaxis = self.signal_dict["axes"][0] + elif (nax_nav, nax_sig) == (1, 1): + self.Xaxis = next( + ax for ax in self.signal_dict["axes"] if not ax["navigate"] + ) + self.Yaxis = next(ax for ax in self.signal_dict["axes"] if ax["navigate"]) else: - raise MountainsMapFileError(f"Dimensions ({nax_nav})|{nax_sig}) invalid for export as spectrum type") - - self.data_split = [self.signal_dict['data']] + raise MountainsMapFileError( + f"Dimensions ({nax_nav})|{nax_sig}) invalid for export as spectrum type" + ) + + self.data_split = [self.signal_dict["data"]] self.objtype_split = [obj_type] self._N_data_objects = 1 self._N_data_channels = 1 - - def _split_profile(self,): + + def _split_profile( + self, + ): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" - + obj_type = 1 self._Object_type = self._mountains_object_types[obj_type] - self.Xaxis = self.signal_dict['axes'][0] - self.data_split = [self.signal_dict['data']] + self.Xaxis = self.signal_dict["axes"][0] + self.data_split = [self.signal_dict["data"]] self.objtype_split = [obj_type] self._N_data_objects = 1 self._N_data_channels = 1 - def _split_profileserie(self,): + def _split_profileserie( + self, + ): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 4 # '_PROFILESERIE' self._Object_type = self._mountains_object_types[obj_type] - self.Xaxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) - self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) - + self.Xaxis = next(ax for ax in self.signal_dict["axes"] if not ax["navigate"]) + self.Taxis = next(ax for ax in self.signal_dict["axes"] if ax["navigate"]) + self.data_split = self._split_data_alongaxis(self.Taxis) - self.objtype_split = [obj_type] + [1]*(len(self.data_split)-1) + self.objtype_split = [obj_type] + [1] * (len(self.data_split) - 1) self._N_data_objects = len(self.objtype_split) self._N_data_channels = 1 - def _split_binary_img(self,): + def _split_binary_img( + self, + ): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 3 self._Object_type = self._mountains_object_types[obj_type] - self.Xaxis = self.signal_dict['axes'][1] - self.Yaxis = self.signal_dict['axes'][0] + self.Xaxis = self.signal_dict["axes"][1] + self.Yaxis = self.signal_dict["axes"][0] - self.data_split = [self.signal_dict['data']] + self.data_split = [self.signal_dict["data"]] self.objtype_split = [obj_type] self._N_data_objects = 1 self._N_data_channels = 1 - def _split_rgb(self,): + def _split_rgb( + self, + ): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 12 self._Object_type = self._mountains_object_types[obj_type] - self.Xaxis = self.signal_dict['axes'][1] - self.Yaxis = self.signal_dict['axes'][0] - self.data_split = [np.int32(self.signal_dict['data']['R']), - np.int32(self.signal_dict['data']['G']), - np.int32(self.signal_dict['data']['B']) - ] - self.objtype_split = [obj_type] + [10,10] + self.Xaxis = self.signal_dict["axes"][1] + self.Yaxis = self.signal_dict["axes"][0] + self.data_split = [ + np.int32(self.signal_dict["data"]["R"]), + np.int32(self.signal_dict["data"]["G"]), + np.int32(self.signal_dict["data"]["B"]), + ] + self.objtype_split = [obj_type] + [10, 10] self._N_data_objects = 1 self._N_data_channels = 3 - def _split_surface(self,): + def _split_surface( + self, + ): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 2 self._Object_type = self._mountains_object_types[obj_type] - self.Xaxis = self.signal_dict['axes'][1] - self.Yaxis = self.signal_dict['axes'][0] - self.data_split = [self.signal_dict['data']] + self.Xaxis = self.signal_dict["axes"][1] + self.Yaxis = self.signal_dict["axes"][0] + self.data_split = [self.signal_dict["data"]] self.objtype_split = [obj_type] self._N_data_objects = 1 self._N_data_channels = 1 def _split_rgbserie(self): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" - obj_type = 18 #"_SERIESOFRGBIMAGE" + obj_type = 18 # "_SERIESOFRGBIMAGE" self._Object_type = self._mountains_object_types[obj_type] - sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + sigaxes_iter = iter(ax for ax in self.signal_dict["axes"] if not ax["navigate"]) self.Yaxis = next(sigaxes_iter) self.Xaxis = next(sigaxes_iter) - self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + self.Taxis = next(ax for ax in self.signal_dict["axes"] if ax["navigate"]) tmp_data_split = self._split_data_alongaxis(self.Taxis) # self.data_split = [] self.objtype_split = [] for d in tmp_data_split: - self.data_split += [d['R'].astype(np.int16).copy(), - d['G'].astype(np.int16).copy(), - d['B'].astype(np.int16).copy(), - ] + self.data_split += [ + d["R"].astype(np.int16).copy(), + d["G"].astype(np.int16).copy(), + d["B"].astype(np.int16).copy(), + ] # self.objtype_split += [12,10,10] - self.objtype_split = [12,10,10]*self.Taxis['size'] + self.objtype_split = [12, 10, 10] * self.Taxis["size"] self.objtype_split[0] = obj_type # self.data_split = rgbx2regular_array(self.signal_dict['data']) - self._N_data_objects = self.Taxis['size'] + self._N_data_objects = self.Taxis["size"] self._N_data_channels = 3 def _split_surfaceserie(self): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 5 self._Object_type = self._mountains_object_types[obj_type] - sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if not ax['navigate']) + sigaxes_iter = iter(ax for ax in self.signal_dict["axes"] if not ax["navigate"]) self.Yaxis = next(sigaxes_iter) self.Xaxis = next(sigaxes_iter) - self.Taxis = next(ax for ax in self.signal_dict['axes'] if ax['navigate']) + self.Taxis = next(ax for ax in self.signal_dict["axes"] if ax["navigate"]) self.data_split = self._split_data_alongaxis(self.Taxis) - self.objtype_split = [2]*len(self.data_split) + self.objtype_split = [2] * len(self.data_split) self.objtype_split[0] = obj_type self._N_data_objects = len(self.data_split) self._N_data_channels = 1 @@ -770,22 +811,22 @@ def _split_hyperspectral(self): """Set _Object_type, axes except Z, data_split, objtype_split _N_data_objects, _N_data_channels""" obj_type = 21 self._Object_type = self._mountains_object_types[obj_type] - sigaxes_iter = iter(ax for ax in self.signal_dict['axes'] if ax['navigate']) + sigaxes_iter = iter(ax for ax in self.signal_dict["axes"] if ax["navigate"]) self.Yaxis = next(sigaxes_iter) self.Xaxis = next(sigaxes_iter) - self.Taxis = next(ax for ax in self.signal_dict['axes'] if not ax['navigate']) - self.data_split = [self.signal_dict['data']] + self.Taxis = next(ax for ax in self.signal_dict["axes"] if not ax["navigate"]) + self.data_split = [self.signal_dict["data"]] self.objtype_split = [obj_type] self._N_data_objects = 1 self._N_data_channels = 1 def _split_data_alongaxis(self, axis: dict): - """Split the data in a series of lower-dim datasets that can be exported to + """Split the data in a series of lower-dim datasets that can be exported to a surface / profile file""" - idx = self.signal_dict['axes'].index(axis) + idx = self.signal_dict["axes"].index(axis) # return idx datasplit = [] - for dslice in np.rollaxis(self.signal_dict['data'],idx): + for dslice in np.rollaxis(self.signal_dict["data"], idx): datasplit.append(dslice) return datasplit @@ -805,57 +846,63 @@ def _norm_data(self, data: np.ndarray, is_special: bool): tuple[int,int,int,float,float,np.ndarray[int]]: pointsize, Zmin, Zmax, Zscale, Zoffset, data_int """ data_type = data.dtype - - if np.issubdtype(data_type,np.complexfloating): - raise MountainsMapFileError(f"digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export") - elif data_type==bool: + + if np.issubdtype(data_type, np.complexfloating): + raise MountainsMapFileError( + f"digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export" + ) + elif data_type == bool: pointsize = 16 Zmin = 0 Zmax = 1 Zscale = 1 Zoffset = 0 data_int = data.astype(np.int16) - elif data_type==np.uint8: + elif data_type == np.uint8: warnings.warn("np.uint8 datatype exported as np.int16.") pointsize = 16 Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data.astype(np.int16) - elif data_type==np.uint16: + elif data_type == np.uint16: warnings.warn("np.uint16 datatype exported as np.int32") - pointsize = 32 #Pointsize has to be 16 or 32 in surf format + pointsize = 32 # Pointsize has to be 16 or 32 in surf format Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data.astype(np.int32) - elif np.issubdtype(data_type,np.unsignedinteger): - raise MountainsMapFileError(f"digitalsurf file formats do not support unsigned int >16bits. Convert data to signed integers before export.") - elif data_type==np.int8: - pointsize = 16 #Pointsize has to be 16 or 32 in surf format + elif np.issubdtype(data_type, np.unsignedinteger): + raise MountainsMapFileError( + f"digitalsurf file formats do not support unsigned int >16bits. Convert data to signed integers before export." + ) + elif data_type == np.int8: + pointsize = 16 # Pointsize has to be 16 or 32 in surf format Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data.astype(np.int16) - elif data_type==np.int16: + elif data_type == np.int16: pointsize = 16 Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) data_int = data - elif data_type==np.int32: + elif data_type == np.int32: pointsize = 32 data_int = data Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) - elif np.issubdtype(data_type,np.integer): - raise MountainsMapFileError(f"digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting") - elif np.issubdtype(data_type,np.floating): + elif np.issubdtype(data_type, np.integer): + raise MountainsMapFileError( + f"digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting" + ) + elif np.issubdtype(data_type, np.floating): pointsize = 32 Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_float(data, is_special) return pointsize, Zmin, Zmax, Zscale, Zoffset, data_int - def _norm_signed_int(self, data:np.ndarray, is_special: bool = False): - """Normalized data of integer type. No normalization per se, but the Zmin and Zmax + def _norm_signed_int(self, data: np.ndarray, is_special: bool = False): + """Normalized data of integer type. No normalization per se, but the Zmin and Zmax threshold are set if saturation flagging is asked.""" # There are no NaN values for integers. Special points means saturation of integer scale. data_int_min = np.iinfo(data.dtype).min data_int_max = np.iinfo(data.dtype).max - is_satlo = (data==data_int_min).sum() >= 1 and is_special - is_sathi = (data==data_int_max).sum() >= 1 and is_special + is_satlo = (data == data_int_min).sum() >= 1 and is_special + is_sathi = (data == data_int_max).sum() >= 1 and is_special Zmin = data_int_min + 1 if is_satlo else data.min() Zmax = data_int_max - 1 if is_sathi else data.max() @@ -864,24 +911,28 @@ def _norm_signed_int(self, data:np.ndarray, is_special: bool = False): return Zmin, Zmax, Zscale, Zoffset - def _norm_float(self, data : np.ndarray, is_special: bool = False,): + def _norm_float( + self, + data: np.ndarray, + is_special: bool = False, + ): """Normalize float data on a 32 bits int scale. Inherently lossy - but that's how things are with mountainsmap files. """ + but that's how things are with mountainsmap files.""" - Zoffset_f = np.nanmin(data) - Zmax_f = np.nanmax(data) - is_nan = np.any(np.isnan(data)) + Zoffset_f = np.nanmin(data) + Zmax_f = np.nanmax(data) + is_nan = np.any(np.isnan(data)) if is_special and is_nan: - Zmin = - 2**(32-1) + 2 - Zmax = 2**32 + Zmin - 3 + Zmin = -(2 ** (32 - 1)) + 2 + Zmax = 2**32 + Zmin - 3 else: - Zmin = - 2**(32-1) - Zmax = 2**32 + Zmin - 1 - - Zscale = (Zmax_f - Zoffset_f)/(Zmax - Zmin) - data_int = (data - Zoffset_f)/Zscale + Zmin - + Zmin = -(2 ** (32 - 1)) + Zmax = 2**32 + Zmin - 1 + + Zscale = (Zmax_f - Zoffset_f) / (Zmax - Zmin) + data_int = (data - Zoffset_f) / Zscale + Zmin + if is_special and is_nan: data_int[np.isnan(data)] = Zmin - 2 @@ -890,156 +941,187 @@ def _norm_float(self, data : np.ndarray, is_special: bool = False,): return Zmin, Zmax, Zscale, Zoffset_f, data_int def _get_Zname_Zunit(self, metadata: dict): - """Attempt reading Z-axis name and Unit from metadata.Signal.Quantity field. + """Attempt reading Z-axis name and Unit from metadata.Signal.Quantity field. Return empty str if do not exist. Returns: tuple[str,str]: Zname,Zunit """ - quantitystr: str = metadata.get('Signal',{}).get('quantity','') + quantitystr: str = metadata.get("Signal", {}).get("quantity", "") quantitystr = quantitystr.strip() - quantity = quantitystr.split(' ') - if len(quantity)>1: + quantity = quantitystr.split(" ") + if len(quantity) > 1: Zunit = quantity.pop() - Zunit = Zunit.strip('()') - Zname = ' '.join(quantity) - elif len(quantity)==1: + Zunit = Zunit.strip("()") + Zname = " ".join(quantity) + elif len(quantity) == 1: Zname = quantity.pop() - Zunit = '' + Zunit = "" else: - Zname = '' - Zunit = '' - - return Zname,Zunit - - def _build_workdict(self, - data: np.ndarray, - obj_type: int, - metadata: dict = {}, - comment: str = "", - is_special: bool = True, - compressed: bool = True, - object_name: str = '', - operator_name: str = '', - absolute: int = 0, - private_zone: bytes = b'', - client_zone: bytes = b'' - ): - """Populate _work_dict with the """ + Zname = "" + Zunit = "" + + return Zname, Zunit + + def _build_workdict( + self, + data: np.ndarray, + obj_type: int, + metadata: dict = {}, + comment: str = "", + is_special: bool = True, + compressed: bool = True, + object_name: str = "", + operator_name: str = "", + absolute: int = 0, + private_zone: bytes = b"", + client_zone: bytes = b"", + ): + """Populate _work_dict with the""" if not compressed: - self._work_dict['_01_Signature']['value'] = 'DIGITAL SURF' # DSCOMPRESSED by default + self._work_dict["_01_Signature"][ + "value" + ] = "DIGITAL SURF" # DSCOMPRESSED by default else: - self._work_dict['_01_Signature']['value'] = 'DSCOMPRESSED' # DSCOMPRESSED by default + self._work_dict["_01_Signature"][ + "value" + ] = "DSCOMPRESSED" # DSCOMPRESSED by default # self._work_dict['_02_Format']['value'] = 0 # Dft. other possible value is 257 for MacintoshII computers with Motorola CPUs. Obv not supported... - self._work_dict['_03_Number_of_Objects']['value'] = self._N_data_objects + self._work_dict["_03_Number_of_Objects"]["value"] = self._N_data_objects # self._work_dict['_04_Version']['value'] = 1 # Version number. Always default. - self._work_dict['_05_Object_Type']['value'] = obj_type - self._work_dict['_06_Object_Name']['value'] = object_name #Obsolete, DOS-version only (Not supported) - self._work_dict['_07_Operator_Name']['value'] = operator_name #Should be settable from kwargs - self._work_dict['_08_P_Size']['value'] = self._N_data_channels - - self._work_dict['_09_Acquisition_Type']['value'] = 0 # AFM data only, could be inferred - self._work_dict['_10_Range_Type']['value'] = 0 #Only 1 for high-range (z-stage scanning), AFM data only, could be inferred - - self._work_dict['_11_Special_Points']['value'] = int(is_special) - - self._work_dict['_12_Absolute']['value'] = absolute #Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred - self._work_dict['_13_Gauge_Resolution']['value'] = 0.0 #Probably irrelevant. Only for profilometers (maybe AFM), can be inferred + self._work_dict["_05_Object_Type"]["value"] = obj_type + self._work_dict["_06_Object_Name"][ + "value" + ] = object_name # Obsolete, DOS-version only (Not supported) + self._work_dict["_07_Operator_Name"][ + "value" + ] = operator_name # Should be settable from kwargs + self._work_dict["_08_P_Size"]["value"] = self._N_data_channels + + self._work_dict["_09_Acquisition_Type"][ + "value" + ] = 0 # AFM data only, could be inferred + self._work_dict["_10_Range_Type"][ + "value" + ] = 0 # Only 1 for high-range (z-stage scanning), AFM data only, could be inferred + + self._work_dict["_11_Special_Points"]["value"] = int(is_special) + + self._work_dict["_12_Absolute"][ + "value" + ] = absolute # Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred + self._work_dict["_13_Gauge_Resolution"][ + "value" + ] = 0.0 # Probably irrelevant. Only for profilometers (maybe AFM), can be inferred # T-axis acts as W-axis for spectrum / hyperspectrum surfaces. if obj_type in [21]: - ws = self.Taxis.get('size',0) + ws = self.Taxis.get("size", 0) else: ws = 0 - self._work_dict['_14_W_Size']['value'] = ws + self._work_dict["_14_W_Size"]["value"] = ws - bsize, Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_data(data,is_special) + bsize, Zmin, Zmax, Zscale, Zoffset, data_int = self._norm_data(data, is_special) Zname, Zunit = self._get_Zname_Zunit(metadata) - #Axes element set regardless of object size - self._work_dict['_15_Size_of_Points']['value'] = bsize - self._work_dict['_16_Zmin']['value'] = Zmin - self._work_dict['_17_Zmax']['value'] = Zmax - self._work_dict['_18_Number_of_Points']['value']= self.Xaxis.get('size',1) - self._work_dict['_19_Number_of_Lines']['value'] = self.Yaxis.get('size',1) - #This needs to be this way due to the way we export our hyp maps - self._work_dict['_20_Total_Nb_of_Pts']['value'] = self.Xaxis.get('size',1)*self.Yaxis.get('size',1) - - self._work_dict['_21_X_Spacing']['value'] = self.Xaxis.get('scale',0.0) - self._work_dict['_22_Y_Spacing']['value'] = self.Yaxis.get('scale',0.0) - self._work_dict['_23_Z_Spacing']['value'] = Zscale - self._work_dict['_24_Name_of_X_Axis']['value'] = self.Xaxis.get('name','') - self._work_dict['_25_Name_of_Y_Axis']['value'] = self.Yaxis.get('name','') - self._work_dict['_26_Name_of_Z_Axis']['value'] = Zname - self._work_dict['_27_X_Step_Unit']['value'] = self.Xaxis.get('units','') - self._work_dict['_28_Y_Step_Unit']['value'] = self.Yaxis.get('units','') - self._work_dict['_29_Z_Step_Unit']['value'] = Zunit - self._work_dict['_30_X_Length_Unit']['value'] = self.Xaxis.get('units','') - self._work_dict['_31_Y_Length_Unit']['value'] = self.Yaxis.get('units','') - self._work_dict['_32_Z_Length_Unit']['value'] = Zunit - self._work_dict['_33_X_Unit_Ratio']['value'] = 1 - self._work_dict['_34_Y_Unit_Ratio']['value'] = 1 - self._work_dict['_35_Z_Unit_Ratio']['value'] = 1 - + # Axes element set regardless of object size + self._work_dict["_15_Size_of_Points"]["value"] = bsize + self._work_dict["_16_Zmin"]["value"] = Zmin + self._work_dict["_17_Zmax"]["value"] = Zmax + self._work_dict["_18_Number_of_Points"]["value"] = self.Xaxis.get("size", 1) + self._work_dict["_19_Number_of_Lines"]["value"] = self.Yaxis.get("size", 1) + # This needs to be this way due to the way we export our hyp maps + self._work_dict["_20_Total_Nb_of_Pts"]["value"] = self.Xaxis.get( + "size", 1 + ) * self.Yaxis.get("size", 1) + + self._work_dict["_21_X_Spacing"]["value"] = self.Xaxis.get("scale", 0.0) + self._work_dict["_22_Y_Spacing"]["value"] = self.Yaxis.get("scale", 0.0) + self._work_dict["_23_Z_Spacing"]["value"] = Zscale + self._work_dict["_24_Name_of_X_Axis"]["value"] = self.Xaxis.get("name", "") + self._work_dict["_25_Name_of_Y_Axis"]["value"] = self.Yaxis.get("name", "") + self._work_dict["_26_Name_of_Z_Axis"]["value"] = Zname + self._work_dict["_27_X_Step_Unit"]["value"] = self.Xaxis.get("units", "") + self._work_dict["_28_Y_Step_Unit"]["value"] = self.Yaxis.get("units", "") + self._work_dict["_29_Z_Step_Unit"]["value"] = Zunit + self._work_dict["_30_X_Length_Unit"]["value"] = self.Xaxis.get("units", "") + self._work_dict["_31_Y_Length_Unit"]["value"] = self.Yaxis.get("units", "") + self._work_dict["_32_Z_Length_Unit"]["value"] = Zunit + self._work_dict["_33_X_Unit_Ratio"]["value"] = 1 + self._work_dict["_34_Y_Unit_Ratio"]["value"] = 1 + self._work_dict["_35_Z_Unit_Ratio"]["value"] = 1 + # _36_Imprint -> Obsolete # _37_Inverted -> Always No # _38_Levelled -> Always No # _39_Obsolete -> Obsolete - - dt: datetime.datetime = get_date_time_from_metadata(metadata,formatting='datetime') + + dt: datetime.datetime = get_date_time_from_metadata( + metadata, formatting="datetime" + ) if dt is not None: - self._work_dict['_40_Seconds']['value'] = dt.second - self._work_dict['_41_Minutes']['value'] = dt.minute - self._work_dict['_42_Hours']['value'] = dt.hour - self._work_dict['_43_Day']['value'] = dt.day - self._work_dict['_44_Month']['value'] = dt.month - self._work_dict['_45_Year']['value'] = dt.year - self._work_dict['_46_Day_of_week']['value'] = dt.weekday() + self._work_dict["_40_Seconds"]["value"] = dt.second + self._work_dict["_41_Minutes"]["value"] = dt.minute + self._work_dict["_42_Hours"]["value"] = dt.hour + self._work_dict["_43_Day"]["value"] = dt.day + self._work_dict["_44_Month"]["value"] = dt.month + self._work_dict["_45_Year"]["value"] = dt.year + self._work_dict["_46_Day_of_week"]["value"] = dt.weekday() # _47_Measurement_duration -> Nonsaved and non-metadata, but float in seconds - + if compressed: - data_bin = self._compress_data(data_int,nstreams=1) #nstreams hard-set to 1. Could be unlocked in the future + data_bin = self._compress_data( + data_int, nstreams=1 + ) # nstreams hard-set to 1. Could be unlocked in the future compressed_size = len(data_bin) else: - fmt = " 2**15: - warnings.warn(f"Comment exceeding max length of 32.0 kB and will be cropped") + warnings.warn( + f"Comment exceeding max length of 32.0 kB and will be cropped" + ) comment_len = np.int16(2**15) - self._work_dict['_50_Comment_size']['value'] = comment_len - + self._work_dict["_50_Comment_size"]["value"] = comment_len + privatesize = len(private_zone) if privatesize > 2**15: - warnings.warn(f"Private size exceeding max length of 32.0 kB and will be cropped") + warnings.warn( + f"Private size exceeding max length of 32.0 kB and will be cropped" + ) privatesize = np.int16(2**15) - - self._work_dict['_51_Private_size']['value'] = privatesize - - self._work_dict['_52_Client_zone']['value'] = client_zone - self._work_dict['_53_X_Offset']['value'] = self.Xaxis.get('offset',0.0) - self._work_dict['_54_Y_Offset']['value'] = self.Yaxis.get('offset',0.0) - self._work_dict['_55_Z_Offset']['value'] = Zoffset - self._work_dict['_56_T_Spacing']['value'] = self.Taxis.get('scale',0.0) - self._work_dict['_57_T_Offset']['value'] = self.Taxis.get('offset',0.0) - self._work_dict['_58_T_Axis_Name']['value'] = self.Taxis.get('name','') - self._work_dict['_59_T_Step_Unit']['value'] = self.Taxis.get('units','') + self._work_dict["_51_Private_size"]["value"] = privatesize + + self._work_dict["_52_Client_zone"]["value"] = client_zone + + self._work_dict["_53_X_Offset"]["value"] = self.Xaxis.get("offset", 0.0) + self._work_dict["_54_Y_Offset"]["value"] = self.Yaxis.get("offset", 0.0) + self._work_dict["_55_Z_Offset"]["value"] = Zoffset + self._work_dict["_56_T_Spacing"]["value"] = self.Taxis.get("scale", 0.0) + self._work_dict["_57_T_Offset"]["value"] = self.Taxis.get("offset", 0.0) + self._work_dict["_58_T_Axis_Name"]["value"] = self.Taxis.get("name", "") + self._work_dict["_59_T_Step_Unit"]["value"] = self.Taxis.get("units", "") - self._work_dict['_60_Comment']['value'] = comment + self._work_dict["_60_Comment"]["value"] = comment - self._work_dict['_61_Private_zone']['value'] = private_zone - self._work_dict['_62_points']['value'] = data_bin + self._work_dict["_61_Private_zone"]["value"] = private_zone + self._work_dict["_62_points"]["value"] = data_bin # Read methods def _read_sur_file(self): @@ -1054,7 +1136,9 @@ def _read_sur_file(self): # We append the first object to the content list self._append_work_dict_to_content() # Lookup how many objects are stored in the file and save - self._N_data_objects = self._get_work_dict_key_value("_03_Number_of_Objects") + self._N_data_objects = self._get_work_dict_key_value( + "_03_Number_of_Objects" + ) self._N_data_channels = self._get_work_dict_key_value("_08_P_Size") # Determine how many objects we need to read @@ -1091,9 +1175,9 @@ def _append_work_dict_to_content(self): datadict = deepcopy({key: val["value"] for key, val in self._work_dict.items()}) self._list_sur_file_content.append(datadict) - def _move_values_to_workdict(self,dic:dict): + def _move_values_to_workdict(self, dic: dict): for key in self._work_dict: - self._work_dict[key]['value'] = deepcopy(dic[key]) + self._work_dict[key]["value"] = deepcopy(dic[key]) def _get_work_dict_key_value(self, key): return self._work_dict[key]["value"] @@ -1114,7 +1198,7 @@ def _build_sur_dict(self): elif self._Object_type in ["_BINARYIMAGE"]: self._build_surface() self.signal_dict.update({"post_process": [self.post_process_binary]}) - elif self._Object_type in ["_SURFACE","_INTENSITYIMAGE"]: + elif self._Object_type in ["_SURFACE", "_INTENSITYIMAGE"]: self._build_surface() elif self._Object_type in ["_SURFACESERIE"]: self._build_surface_series() @@ -1126,12 +1210,12 @@ def _build_sur_dict(self): self._build_RGB_image() elif self._Object_type in ["_RGBINTENSITYSURFACE"]: self._build_RGB_surface() - elif self._Object_type in ['_SERIESOFRGBIMAGES']: + elif self._Object_type in ["_SERIESOFRGBIMAGES"]: self._build_RGB_image_series() else: raise MountainsMapFileError( f"{self._Object_type} is not a supported mountain object." - ) + ) return self.signal_dict @@ -1305,7 +1389,9 @@ def _build_1D_series( self.signal_dict["data"] = np.stack(data) - def _build_surface(self,): + def _build_surface( + self, + ): """Build a surface""" # Check that the object contained only one object. @@ -1326,7 +1412,9 @@ def _build_surface(self,): self._set_metadata_and_original_metadata(hypdic) - def _build_surface_series(self,): + def _build_surface_series( + self, + ): """Build a series of surfaces. The T axis is navigation and set from the first object""" @@ -1385,7 +1473,9 @@ def _build_RGB_surface( # Pushing data into the dictionary self.signal_dict["data"] = np.stack(data) - def _build_RGB_image(self,): + def _build_RGB_image( + self, + ): """Build an RGB image. The T axis is navigation and set from P Size""" @@ -1416,8 +1506,10 @@ def _build_RGB_image(self,): self.signal_dict.update({"post_process": [self.post_process_RGB]}) - def _build_RGB_image_series(self,): - + def _build_RGB_image_series( + self, + ): + # First object dictionary hypdic = self._list_sur_file_content[0] @@ -1438,17 +1530,17 @@ def _build_RGB_image_series(self,): nimg = hypdic["_03_Number_of_Objects"] nchan = hypdic["_08_P_Size"] # We put all the data together - data = np.empty(shape=(nimg,*shape,nchan)) + data = np.empty(shape=(nimg, *shape, nchan)) i = 0 for imgidx in range(nimg): for chanidx in range(nchan): obj = self._list_sur_file_content[i] - data[imgidx,...,chanidx] = obj["_62_points"].reshape(shape) - i+=1 + data[imgidx, ..., chanidx] = obj["_62_points"].reshape(shape) + i += 1 # for obj in self._list_sur_file_content: # data.append(obj["_62_points"].reshape(shape)) - + # data = np.stack(data) # data = data.reshape(nimg,nchan,*shape) @@ -1540,14 +1632,16 @@ def _build_generic_metadata(self, unpacked_dict): return metadict - def _build_original_metadata(self,): + def _build_original_metadata( + self, + ): """Builds a metadata dictionary from the header""" original_metadata_dict = {} # Iteration over Number of data objects for i in range(self._N_data_objects): # Iteration over the Number of Data channels - for j in range(max(self._N_data_channels,1)): + for j in range(max(self._N_data_channels, 1)): # Creating a dictionary key for each object k = (i + 1) * (j + 1) key = "Object_{:d}_Channel_{:d}".format(i, j) @@ -1575,7 +1669,9 @@ def _build_original_metadata(self,): return original_metadata_dict - def _build_signal_specific_metadata(self,) -> dict: + def _build_signal_specific_metadata( + self, + ) -> dict: """Build additional metadata specific to signal type. return a dictionary for update in the metadata.""" if self.signal_dict["metadata"]["Signal"]["signal_type"] == "CL": @@ -1798,7 +1894,7 @@ def _MS_parse(str_ms, prefix, delimiter): dict_ms[key_main] = {} elif line.startswith(prefix): if keymain is None: - keymain = 'UNTITLED' + keymain = "UNTITLED" dict_ms[key_main] = {} key, *li_value = line.split(delimiter) # Key is also stripped from beginning or end whitespace @@ -1809,7 +1905,9 @@ def _MS_parse(str_ms, prefix, delimiter): li_value = str_value.split(" ") try: if key == "Grating": - dict_ms[key_main][key] = li_value[0] # we don't want to eval this one + dict_ms[key_main][key] = li_value[ + 0 + ] # we don't want to eval this one else: dict_ms[key_main][key] = ast.literal_eval(li_value[0]) except Exception: @@ -1819,20 +1917,22 @@ def _MS_parse(str_ms, prefix, delimiter): return dict_ms @staticmethod - def _get_comment_dict(original_metadata: dict, method: str = 'auto', custom: dict = {}) -> dict: + def _get_comment_dict( + original_metadata: dict, method: str = "auto", custom: dict = {} + ) -> dict: """Return the dictionary used to set the dataset comments (akA custom parameters) while exporting a file. By default (method='auto'), tries to identify if the object was originally imported by rosettasciio - from a digitalsurf .sur/.pro file with a comment field parsed as original_metadata (i.e. - Object_0_Channel_0.Parsed). In that case, digitalsurf ignores non-parsed original metadata - (ie .sur/.pro file headers). If the original metadata contains multiple objects with + from a digitalsurf .sur/.pro file with a comment field parsed as original_metadata (i.e. + Object_0_Channel_0.Parsed). In that case, digitalsurf ignores non-parsed original metadata + (ie .sur/.pro file headers). If the original metadata contains multiple objects with non-empty parsed content (Object_0_Channel_0.Parsed, Object_0_Channel_1.Parsed etc...), only - the first non-empty X.Parsed sub-dictionary is returned. This falls back on returning the + the first non-empty X.Parsed sub-dictionary is returned. This falls back on returning the raw 'original_metadata' Optionally the raw 'original_metadata' dictionary can be exported (method='raw'), a custom dictionary provided by the user (method='custom'), or no comment at all (method='off') - + Args: method (str, optional): method to export. Defaults to 'auto'. custom (dict, optional): custom dictionary. Ignored unless method is set to 'custom', Defaults to {}. @@ -1842,77 +1942,83 @@ def _get_comment_dict(original_metadata: dict, method: str = 'auto', custom: dic Returns: dict: dictionary to be exported as a .sur object - """ - if method == 'raw': + """ + if method == "raw": return original_metadata - elif method == 'custom': + elif method == "custom": return custom - elif method == 'off': + elif method == "off": return {} - elif method == 'auto': + elif method == "auto": pattern = re.compile("Object_\d*_Channel_\d*") omd = original_metadata - #filter original metadata content of dict type and matching pattern. - validfields = [omd[key] for key in omd if pattern.match(key) and isinstance(omd[key],dict)] - #In case none match, give up filtering and return raw + # filter original metadata content of dict type and matching pattern. + validfields = [ + omd[key] + for key in omd + if pattern.match(key) and isinstance(omd[key], dict) + ] + # In case none match, give up filtering and return raw if not validfields: return omd - #In case some match, return first non-empty "Parsed" sub-dict + # In case some match, return first non-empty "Parsed" sub-dict for field in validfields: - #Return none for non-existing "Parsed" key - candidate = field.get('Parsed') - #For non-none, non-empty dict-type candidate - if candidate and isinstance(candidate,dict): + # Return none for non-existing "Parsed" key + candidate = field.get("Parsed") + # For non-none, non-empty dict-type candidate + if candidate and isinstance(candidate, dict): return candidate - #dict casting for non-none but non-dict candidate + # dict casting for non-none but non-dict candidate elif candidate is not None: - return {'Parsed': candidate} - #else none candidate, or empty dict -> do nothing - #Finally, if valid fields are present but no candidate - #did a non-empty return, it is safe to return empty + return {"Parsed": candidate} + # else none candidate, or empty dict -> do nothing + # Finally, if valid fields are present but no candidate + # did a non-empty return, it is safe to return empty return {} else: - raise MountainsMapFileError(f"Non-valid method for setting mountainsmap file comment. Choose one of: 'auto','raw','custom','off' ") - + raise MountainsMapFileError( + f"Non-valid method for setting mountainsmap file comment. Choose one of: 'auto','raw','custom','off' " + ) + @staticmethod def _stringify_dict(omd: dict): """Pack nested dictionary metadata into a string. Pack dictionary-type elements into digitalsurf "Section title" metadata type ('$_ preceding section title). Pack other elements into equal-sign separated key-value pairs. - + Supports the key-units logic {'key': value, 'key_units': 'un'} used in hyperspy. """ - #Separate dict into list of keys and list of values to authorize index-based pop/insert + # Separate dict into list of keys and list of values to authorize index-based pop/insert keys_queue = list(omd.keys()) vals_queue = list(omd.values()) - #commentstring to be returned + # commentstring to be returned cmtstr: str = "" - #Loop until queues are empty + # Loop until queues are empty while keys_queue: - #pop first object + # pop first object k = keys_queue.pop(0) v = vals_queue.pop(0) - #if object is header - if isinstance(v,dict): + # if object is header + if isinstance(v, dict): cmtstr += f"$_{k}\n" keys_queue = list(v.keys()) + keys_queue vals_queue = list(v.values()) + vals_queue else: try: - ku_idx = keys_queue.index(k + '_units') + ku_idx = keys_queue.index(k + "_units") has_units = True except ValueError: ku_idx = None has_units = False - + if has_units: _ = keys_queue.pop(ku_idx) vu = vals_queue.pop(ku_idx) cmtstr += f"${k} = {v.__repr__()} {vu}\n" else: cmtstr += f"${k} = {v.__repr__()}\n" - + return cmtstr # Post processing @@ -1928,16 +2034,17 @@ def post_process_RGB(signal): signal.change_dtype("rgb16") else: warnings.warn( - """RGB-announced data could not be converted to + """RGB-announced data could not be converted to uint8 or uint16 datatype""" ) return signal - + @staticmethod def post_process_binary(signal): - signal.change_dtype('bool') + signal.change_dtype("bool") return signal + # pack/unpack binary quantities @staticmethod @@ -2057,14 +2164,20 @@ def _pack_private(self, file, val, encoding="latin-1"): privatesize = self._get_work_dict_key_value("_51_Private_size") self._set_str(file, val, privatesize) - def _is_data_int(self,): + def _is_data_int( + self, + ): """Determine wether data consists of unscaled int values. - This is not the case for all objects. Surface and surface series can admit + This is not the case for all objects. Surface and surface series can admit this logic. In theory, hyperspectral studiables as well but it is more convenient to use them as floats due to typical data treatment in hyperspy (scaling etc)""" - objtype = self._mountains_object_types[self._get_work_dict_key_value("_05_Object_Type")] - if objtype in ['_SURFACESERIE','_SURFACE']: - scale = self._get_work_dict_key_value("_23_Z_Spacing") / self._get_work_dict_key_value("_35_Z_Unit_Ratio") + objtype = self._mountains_object_types[ + self._get_work_dict_key_value("_05_Object_Type") + ] + if objtype in ["_SURFACESERIE", "_SURFACE"]: + scale = self._get_work_dict_key_value( + "_23_Z_Spacing" + ) / self._get_work_dict_key_value("_35_Z_Unit_Ratio") offset = self._get_work_dict_key_value("_55_Z_Offset") if float(scale).is_integer() and float(offset).is_integer(): return True @@ -2073,14 +2186,22 @@ def _is_data_int(self,): else: return False - def _is_data_scaleint(self,): + def _is_data_scaleint( + self, + ): """Digitalsurf image formats are not stored as their raw int values, but instead are - scaled and a scale / offset is set so that the data scales down to uint. Why this is - done this way is not clear to me. """ - objtype = self._mountains_object_types[self._get_work_dict_key_value("_05_Object_Type")] - if objtype in ['_BINARYIMAGE', '_RGBIMAGE', - '_RGBSURFACE', '_SERIESOFRGBIMAGES', - '_INTENSITYIMAGE']: + scaled and a scale / offset is set so that the data scales down to uint. Why this is + done this way is not clear to me.""" + objtype = self._mountains_object_types[ + self._get_work_dict_key_value("_05_Object_Type") + ] + if objtype in [ + "_BINARYIMAGE", + "_RGBIMAGE", + "_RGBSURFACE", + "_SERIESOFRGBIMAGES", + "_INTENSITYIMAGE", + ]: return True def _get_uncompressed_datasize(self) -> int: @@ -2089,9 +2210,9 @@ def _get_uncompressed_datasize(self) -> int: # Datapoints in X and Y dimensions Npts_tot = self._get_work_dict_key_value("_20_Total_Nb_of_Pts") # Datasize in WL. max between value and 1 as often W_Size saved as 0 - Wsize = max(self._get_work_dict_key_value("_14_W_Size"),1) + Wsize = max(self._get_work_dict_key_value("_14_W_Size"), 1) # Wsize = 1 - + datasize = Npts_tot * Wsize * psize return datasize @@ -2109,7 +2230,7 @@ def _unpack_data(self, file, encoding="latin-1"): # Datapoints in X and Y dimensions Npts_tot = self._get_work_dict_key_value("_20_Total_Nb_of_Pts") # Datasize in WL - Wsize = max(self._get_work_dict_key_value("_14_W_Size"),1) + Wsize = max(self._get_work_dict_key_value("_14_W_Size"), 1) # We need to take into account the fact that Wsize is often # set to 0 instead of 1 in non-spectral data to compute the @@ -2151,64 +2272,68 @@ def _unpack_data(self, file, encoding="latin-1"): if self._get_work_dict_key_value("_11_Special_Points") == 1: # has non-measured points nm = _points == self._get_work_dict_key_value("_16_Zmin") - 2 - + Zmin = self._get_work_dict_key_value("_16_Zmin") - scale = self._get_work_dict_key_value("_23_Z_Spacing") / self._get_work_dict_key_value("_35_Z_Unit_Ratio") + scale = self._get_work_dict_key_value( + "_23_Z_Spacing" + ) / self._get_work_dict_key_value("_35_Z_Unit_Ratio") offset = self._get_work_dict_key_value("_55_Z_Offset") # Packing data into ints or float, with or without scaling. if self._is_data_int(): _points = _points elif self._is_data_scaleint(): - _points = (_points.astype(float) - Zmin)*scale + offset + _points = (_points.astype(float) - Zmin) * scale + offset _points = np.round(_points).astype(int) else: - _points = (_points.astype(float) - Zmin)*scale + offset - _points[nm] = np.nan #Ints have no nans + _points = (_points.astype(float) - Zmin) * scale + offset + _points[nm] = np.nan # Ints have no nans # Return the points, rescaled return _points def _pack_data(self, file, val, encoding="latin-1"): """This needs to be special because it writes until the end of file.""" - #Also valid for uncompressed + # Also valid for uncompressed if self._get_work_dict_key_value("_01_Signature") != "DSCOMPRESSED": datasize = self._get_uncompressed_datasize() else: - datasize = self._get_work_dict_key_value('_48_Compressed_data_size') - self._set_bytes(file,val,datasize) + datasize = self._get_work_dict_key_value("_48_Compressed_data_size") + self._set_bytes(file, val, datasize) @staticmethod def _compress_data(data_int, nstreams: int = 1) -> bytes: """Pack the input data using the digitalsurf zip approach and return the result as a - binary string ready to be written onto a file. """ + binary string ready to be written onto a file.""" - if nstreams <= 0 or nstreams >8 : - raise MountainsMapFileError(f"Number of compression streams must be >= 1, <= 8") - - bstr = b'' + if nstreams <= 0 or nstreams > 8: + raise MountainsMapFileError( + f"Number of compression streams must be >= 1, <= 8" + ) + + bstr = b"" bstr += struct.pack(" bytes: return bstr + def file_reader(filename, lazy=False): """ Read a mountainsmap ``.sur`` or ``.pro`` file. @@ -2241,18 +2367,20 @@ def file_reader(filename, lazy=False): surdict, ] -def file_writer(filename, - signal: dict, - set_comments: str = 'auto', - is_special: bool = False, - compressed: bool = True, - comments: dict = {}, - object_name: str = '', - operator_name: str = '', - absolute: int = 0, - private_zone: bytes = b'', - client_zone: bytes = b'' - ): + +def file_writer( + filename, + signal: dict, + set_comments: str = "auto", + is_special: bool = False, + compressed: bool = True, + comments: dict = {}, + object_name: str = "", + operator_name: str = "", + absolute: int = 0, + private_zone: bytes = b"", + client_zone: bytes = b"", +): """ Write a mountainsmap ``.sur`` or ``.pro`` file. @@ -2265,13 +2393,13 @@ def file_writer(filename, exported as the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied by the user as an additional kwarg ('custom'). is_special : bool , default = False - If True, NaN values in the dataset or integers reaching boundary values are + If True, NaN values in the dataset or integers reaching boundary values are flagged in the export as non-measured and saturating, respectively. If False, those values are kept as-is. compressed : bool, default =True If True, compress the data in the export file using zlib. comments : dict, default = {} - Set a custom dictionnary in the comments field of the exported file. + Set a custom dictionnary in the comments field of the exported file. Ignored if set_comments is not set to 'custom'. object_name : str, default = '' Set the object name field in the output file. @@ -2279,10 +2407,10 @@ def file_writer(filename, Set the operator name field in the exported file. absolute : int, default = 0, Unsigned int capable of flagging whether surface heights are relative (0) or - absolute (1). Higher unsigned int values can be used to distinguish several + absolute (1). Higher unsigned int values can be used to distinguish several data series sharing internal reference. private_zone : bytes, default = b'', - Set arbitrary byte-content in the private_zone field of exported file metadata. + Set arbitrary byte-content in the private_zone field of exported file metadata. Maximum size is 32.0 kB and content will be cropped if this size is exceeded. client_zone : bytes, default = b'' Set arbitrary byte-content in the client_zone field of exported file metadata. @@ -2291,16 +2419,19 @@ def file_writer(filename, ds = DigitalSurfHandler(filename=filename) ds.signal_dict = signal - ds._build_sur_file_contents(set_comments, - is_special, - compressed, - comments, - object_name, - operator_name, - absolute, - private_zone, - client_zone) + ds._build_sur_file_contents( + set_comments, + is_special, + compressed, + comments, + object_name, + operator_name, + absolute, + private_zone, + client_zone, + ) ds._write_sur_file() -file_reader.__doc__ %= (FILENAME_DOC,LAZY_UNSUPPORTED_DOC,RETURNS_DOC) -file_writer.__doc__ %= (FILENAME_DOC,SIGNAL_DOC) + +file_reader.__doc__ %= (FILENAME_DOC, LAZY_UNSUPPORTED_DOC, RETURNS_DOC) +file_writer.__doc__ %= (FILENAME_DOC, SIGNAL_DOC) diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index d08f27ba2..9fdafcdd2 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -495,132 +495,147 @@ def test_metadata_mapping(): "exit_slit_width" ] == 7000 - ) + ) def test_compressdata(): testdat = np.arange(120, dtype=np.int32) - #Refuse too many / neg streams + # Refuse too many / neg streams with pytest.raises(MountainsMapFileError): - DigitalSurfHandler._compress_data(testdat,nstreams=9) + DigitalSurfHandler._compress_data(testdat, nstreams=9) with pytest.raises(MountainsMapFileError): - DigitalSurfHandler._compress_data(testdat,nstreams=-1) - + DigitalSurfHandler._compress_data(testdat, nstreams=-1) + # Accept 1 (dft) or several streams bcomp = DigitalSurfHandler._compress_data(testdat) - assert bcomp.startswith(b'\x01\x00\x00\x00\xe0\x01\x00\x00') - bcomp = DigitalSurfHandler._compress_data(testdat,nstreams=2) - assert bcomp.startswith(b'\x02\x00\x00\x00\xf0\x00\x00\x00_\x00\x00\x00') + assert bcomp.startswith(b"\x01\x00\x00\x00\xe0\x01\x00\x00") + bcomp = DigitalSurfHandler._compress_data(testdat, nstreams=2) + assert bcomp.startswith(b"\x02\x00\x00\x00\xf0\x00\x00\x00_\x00\x00\x00") # Accept 16-bits int as well as 32 testdat = np.arange(120, dtype=np.int16) bcomp = DigitalSurfHandler._compress_data(testdat) - assert bcomp.startswith(b'\x01\x00\x00\x00\xf0\x00\x00\x00') - + assert bcomp.startswith(b"\x01\x00\x00\x00\xf0\x00\x00\x00") # Also streams non-perfectly divided data testdat = np.arange(120, dtype=np.int16) bcomp = DigitalSurfHandler._compress_data(testdat) - assert bcomp.startswith(b'\x01\x00\x00\x00\xf0\x00\x00\x00') + assert bcomp.startswith(b"\x01\x00\x00\x00\xf0\x00\x00\x00") testdat = np.arange(127, dtype=np.int16) - bcomp = DigitalSurfHandler._compress_data(testdat,nstreams=3) - assert bcomp.startswith(b'\x03\x00\x00\x00V\x00\x00\x00C\x00\x00\x00'+ - b'V\x00\x00\x00F\x00\x00\x00'+ - b'R\x00\x00\x00B\x00\x00\x00') + bcomp = DigitalSurfHandler._compress_data(testdat, nstreams=3) + assert bcomp.startswith( + b"\x03\x00\x00\x00V\x00\x00\x00C\x00\x00\x00" + + b"V\x00\x00\x00F\x00\x00\x00" + + b"R\x00\x00\x00B\x00\x00\x00" + ) def test_get_comment_dict(): - omd={'Object_0_Channel_0':{ - 'Parsed':{ - 'key_1': 1, - 'key_2':'2' - } - } - } + omd = {"Object_0_Channel_0": {"Parsed": {"key_1": 1, "key_2": "2"}}} - assert DigitalSurfHandler._get_comment_dict(omd,'auto')=={'key_1': 1,'key_2':'2'} - assert DigitalSurfHandler._get_comment_dict(omd,'off')=={} - assert DigitalSurfHandler._get_comment_dict(omd,'raw')=={'Object_0_Channel_0':{'Parsed':{'key_1': 1,'key_2':'2'}}} - assert DigitalSurfHandler._get_comment_dict(omd,'custom',custom={'a':0}) == {'a':0} + assert DigitalSurfHandler._get_comment_dict(omd, "auto") == { + "key_1": 1, + "key_2": "2", + } + assert DigitalSurfHandler._get_comment_dict(omd, "off") == {} + assert DigitalSurfHandler._get_comment_dict(omd, "raw") == { + "Object_0_Channel_0": {"Parsed": {"key_1": 1, "key_2": "2"}} + } + assert DigitalSurfHandler._get_comment_dict(omd, "custom", custom={"a": 0}) == { + "a": 0 + } - #Goes to second dict if only this one's valid - omd={ - 'Object_0_Channel_0':{'Header':{}}, - 'Object_0_Channel_1':{'Header':'ObjHead','Parsed':{'key_1': '0'}}, + # Goes to second dict if only this one's valid + omd = { + "Object_0_Channel_0": {"Header": {}}, + "Object_0_Channel_1": {"Header": "ObjHead", "Parsed": {"key_1": "0"}}, } - assert DigitalSurfHandler._get_comment_dict(omd, 'auto') == {'key_1': '0'} + assert DigitalSurfHandler._get_comment_dict(omd, "auto") == {"key_1": "0"} - #Return empty if none valid - omd={ - 'Object_0_Channel_0':{'Header':{}}, - 'Object_0_Channel_1':{'Header':'ObjHead'}, + # Return empty if none valid + omd = { + "Object_0_Channel_0": {"Header": {}}, + "Object_0_Channel_1": {"Header": "ObjHead"}, } - assert DigitalSurfHandler._get_comment_dict(omd,'auto') == {} + assert DigitalSurfHandler._get_comment_dict(omd, "auto") == {} - #Return dict-cast if a single field is named 'Parsed' (weird case) - omd={ - 'Object_0_Channel_0':{'Header':{}}, - 'Object_0_Channel_1':{'Header':'ObjHead','Parsed':'SomeContent'}, + # Return dict-cast if a single field is named 'Parsed' (weird case) + omd = { + "Object_0_Channel_0": {"Header": {}}, + "Object_0_Channel_1": {"Header": "ObjHead", "Parsed": "SomeContent"}, + } + assert DigitalSurfHandler._get_comment_dict(omd, "auto") == { + "Parsed": "SomeContent" } - assert DigitalSurfHandler._get_comment_dict(omd,'auto') == {'Parsed':'SomeContent'} -@pytest.mark.parametrize("test_object", ["test_profile.pro", - "test_spectra.pro", - "test_spectral_map.sur", - "test_spectral_map_compressed.sur", - "test_spectrum.pro", - "test_spectrum_compressed.pro", - "test_isurface.sur"]) -def test_writetestobjects(tmp_path,test_object): +@pytest.mark.parametrize( + "test_object", + [ + "test_profile.pro", + "test_spectra.pro", + "test_spectral_map.sur", + "test_spectral_map_compressed.sur", + "test_spectrum.pro", + "test_spectrum_compressed.pro", + "test_isurface.sur", + ], +) +def test_writetestobjects(tmp_path, test_object): """Test data integrity of load/save functions. Starting from externally-generated data (i.e. not from hyperspy)""" df = TEST_DATA_PATH.joinpath(test_object) d = hs.load(df) fn = tmp_path.joinpath(test_object) - d.save(fn,is_special=False) + d.save(fn, is_special=False) d2 = hs.load(fn) - d2.save(fn,is_special=False) + d2.save(fn, is_special=False) d3 = hs.load(fn) - assert np.allclose(d2.data,d.data) - assert np.allclose(d2.data,d3.data) - + assert np.allclose(d2.data, d.data) + assert np.allclose(d2.data, d3.data) + a = d.axes_manager.navigation_axes b = d2.axes_manager.navigation_axes c = d3.axes_manager.navigation_axes - for ax,ax2,ax3 in zip(a,b,c): - assert np.allclose(ax.axis,ax2.axis) - assert np.allclose(ax.axis,ax3.axis) + for ax, ax2, ax3 in zip(a, b, c): + assert np.allclose(ax.axis, ax2.axis) + assert np.allclose(ax.axis, ax3.axis) a = d.axes_manager.signal_axes b = d2.axes_manager.signal_axes c = d3.axes_manager.signal_axes - for ax,ax2,ax3 in zip(a,b,c): - assert np.allclose(ax.axis,ax2.axis) - assert np.allclose(ax.axis,ax3.axis) - -@pytest.mark.parametrize("test_tuple ", [("test_profile.pro",'_PROFILE'), - ("test_spectra.pro",'_SPECTRUM'), - ("test_spectral_map.sur",'_HYPCARD'), - ("test_spectral_map_compressed.sur",'_HYPCARD'), - ("test_spectrum.pro",'_SPECTRUM'), - ("test_spectrum_compressed.pro",'_SPECTRUM'), - ("test_surface.sur",'_SURFACE'), - ('test_RGB.sur','_RGBIMAGE')]) + for ax, ax2, ax3 in zip(a, b, c): + assert np.allclose(ax.axis, ax2.axis) + assert np.allclose(ax.axis, ax3.axis) + + +@pytest.mark.parametrize( + "test_tuple ", + [ + ("test_profile.pro", "_PROFILE"), + ("test_spectra.pro", "_SPECTRUM"), + ("test_spectral_map.sur", "_HYPCARD"), + ("test_spectral_map_compressed.sur", "_HYPCARD"), + ("test_spectrum.pro", "_SPECTRUM"), + ("test_spectrum_compressed.pro", "_SPECTRUM"), + ("test_surface.sur", "_SURFACE"), + ("test_RGB.sur", "_RGBIMAGE"), + ], +) def test_split(test_tuple): """Test for expected object type in the reference dataset""" obj = test_tuple[0] res = test_tuple[1] df = TEST_DATA_PATH.joinpath(obj) - dh= DigitalSurfHandler(obj) + dh = DigitalSurfHandler(obj) d = hs.load(df) dh.signal_dict = d._to_dictionary() @@ -629,12 +644,13 @@ def test_split(test_tuple): assert dh._Object_type == res + @pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.uint8, np.uint16]) -@pytest.mark.parametrize('special',[True,False]) -@pytest.mark.parametrize('fullscale',[True,False]) -def test_norm_int_data(dtype,special,fullscale): +@pytest.mark.parametrize("special", [True, False]) +@pytest.mark.parametrize("fullscale", [True, False]) +def test_norm_int_data(dtype, special, fullscale): dh = DigitalSurfHandler() - + if fullscale: minint = np.iinfo(dtype).min maxint = np.iinfo(dtype).max @@ -642,199 +658,222 @@ def test_norm_int_data(dtype,special,fullscale): minint = np.iinfo(dtype).min + 23 maxint = np.iinfo(dtype).max - 9 - dat = np.random.randint(low=minint,high=maxint,size=222,dtype=dtype) - #Ensure the maximum and minimum off the int scale is actually present in data + dat = np.random.randint(low=minint, high=maxint, size=222, dtype=dtype) + # Ensure the maximum and minimum off the int scale is actually present in data if fullscale: dat[2] = minint dat[11] = maxint - pointsize, Zmin, Zmax, Zscale, Zoffset, data_int = dh._norm_data(dat,special) + pointsize, Zmin, Zmax, Zscale, Zoffset, data_int = dh._norm_data(dat, special) + + off = minint + 1 if special and fullscale else dat.min() + maxval = maxint - 1 if special and fullscale else dat.max() - off = minint+1 if special and fullscale else dat.min() - maxval = maxint-1 if special and fullscale else dat.max() + assert np.isclose(Zscale, 1.0) + assert np.isclose(Zoffset, off) + assert np.allclose(data_int, dat) + assert Zmin == off + assert Zmax == maxval - assert np.isclose(Zscale,1.0) - assert np.isclose(Zoffset,off) - assert np.allclose(data_int,dat) - assert Zmin==off - assert Zmax==maxval def test_writeRGB(tmp_path): # This is just a different test function because the - # comparison of rgb data must be done differently + # comparison of rgb data must be done differently # (due to hyperspy underlying structure) df = TEST_DATA_PATH.joinpath("test_RGB.sur") d = hs.load(df) fn = tmp_path.joinpath("test_RGB.sur") - d.save(fn,is_special=False) + d.save(fn, is_special=False) d2 = hs.load(fn) - d2.save(fn,is_special=False) + d2.save(fn, is_special=False) d3 = hs.load(fn) - for k in ['R','G','B']: - assert np.allclose(d2.data[k],d.data[k]) - assert np.allclose(d3.data[k],d.data[k]) + for k in ["R", "G", "B"]: + assert np.allclose(d2.data[k], d.data[k]) + assert np.allclose(d3.data[k], d.data[k]) a = d.axes_manager.navigation_axes b = d2.axes_manager.navigation_axes c = d3.axes_manager.navigation_axes - for ax,ax2,ax3 in zip(a,b,c): - assert np.allclose(ax.axis,ax2.axis) - assert np.allclose(ax.axis,ax3.axis) + for ax, ax2, ax3 in zip(a, b, c): + assert np.allclose(ax.axis, ax2.axis) + assert np.allclose(ax.axis, ax3.axis) a = d.axes_manager.signal_axes b = d2.axes_manager.signal_axes c = d3.axes_manager.signal_axes - for ax,ax2,ax3 in zip(a,b,c): - assert np.allclose(ax.axis,ax2.axis) - assert np.allclose(ax.axis,ax3.axis) + for ax, ax2, ax3 in zip(a, b, c): + assert np.allclose(ax.axis, ax2.axis) + assert np.allclose(ax.axis, ax3.axis) -@pytest.mark.parametrize("dtype", [np.int8, np.int16, np.int32, np.float64, np.uint8, np.uint16]) -@pytest.mark.parametrize('compressed',[True,False]) -def test_writegeneric_validtypes(tmp_path,dtype,compressed): - """This test establishes the capability of saving a generic hyperspy signals + +@pytest.mark.parametrize( + "dtype", [np.int8, np.int16, np.int32, np.float64, np.uint8, np.uint16] +) +@pytest.mark.parametrize("compressed", [True, False]) +def test_writegeneric_validtypes(tmp_path, dtype, compressed): + """This test establishes the capability of saving a generic hyperspy signals generated from numpy array""" - gen = hs.signals.Signal1D(np.arange(24,dtype=dtype))+25 - fgen = tmp_path.joinpath('test.pro') - gen.save(fgen,compressed = compressed, overwrite=True) + gen = hs.signals.Signal1D(np.arange(24, dtype=dtype)) + 25 + fgen = tmp_path.joinpath("test.pro") + gen.save(fgen, compressed=compressed, overwrite=True) gen2 = hs.load(fgen) - assert np.allclose(gen2.data,gen.data) - -@pytest.mark.parametrize("dtype", [np.int64, np.complex64, np.uint64, ]) -def test_writegeneric_failingtypes(tmp_path,dtype): - gen = hs.signals.Signal1D(np.arange(24,dtype=dtype))+25 - fgen = tmp_path.joinpath('test.pro') + assert np.allclose(gen2.data, gen.data) + + +@pytest.mark.parametrize( + "dtype", + [ + np.int64, + np.complex64, + np.uint64, + ], +) +def test_writegeneric_failingtypes(tmp_path, dtype): + gen = hs.signals.Signal1D(np.arange(24, dtype=dtype)) + 25 + fgen = tmp_path.joinpath("test.pro") with pytest.raises(MountainsMapFileError): - gen.save(fgen,overwrite= True) + gen.save(fgen, overwrite=True) -@pytest.mark.parametrize("dtype", [(np.uint8,"rgba8"), (np.uint16,"rgba16")]) -@pytest.mark.parametrize('compressed',[True,False]) -@pytest.mark.parametrize('transpose',[True,False]) -def test_writegeneric_rgba(tmp_path,dtype,compressed,transpose): - """This test establishes the possibility of saving RGBA data while discarding + +@pytest.mark.parametrize("dtype", [(np.uint8, "rgba8"), (np.uint16, "rgba16")]) +@pytest.mark.parametrize("compressed", [True, False]) +@pytest.mark.parametrize("transpose", [True, False]) +def test_writegeneric_rgba(tmp_path, dtype, compressed, transpose): + """This test establishes the possibility of saving RGBA data while discarding A channel and warning""" - size = (17,38,4) + size = (17, 38, 4) minint = np.iinfo(dtype[0]).min maxint = np.iinfo(dtype[0]).max - gen = hs.signals.Signal1D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype[0])) + gen = hs.signals.Signal1D( + np.random.randint(low=minint, high=maxint, size=size, dtype=dtype[0]) + ) gen.change_dtype(dtype[1]) - fgen = tmp_path.joinpath('test.sur') - + fgen = tmp_path.joinpath("test.sur") + if transpose: gen = gen.T with pytest.warns(): - gen.save(fgen,compressed = compressed, overwrite=True) + gen.save(fgen, compressed=compressed, overwrite=True) gen2 = hs.load(fgen) - for k in ['R','G','B']: - assert np.allclose(gen.data[k],gen2.data[k]) - assert np.allclose(gen.data[k],gen2.data[k]) + for k in ["R", "G", "B"]: + assert np.allclose(gen.data[k], gen2.data[k]) + assert np.allclose(gen.data[k], gen2.data[k]) + + +@pytest.mark.parametrize("compressed", [True, False]) +@pytest.mark.parametrize("transpose", [True, False]) +def test_writegeneric_binaryimg(tmp_path, compressed, transpose): -@pytest.mark.parametrize('compressed',[True,False]) -@pytest.mark.parametrize('transpose',[True,False]) -def test_writegeneric_binaryimg(tmp_path,compressed,transpose): - - size = (76,3) + size = (76, 3) - gen = hs.signals.Signal2D(np.random.randint(low=0,high=1,size=size,dtype=bool)) + gen = hs.signals.Signal2D(np.random.randint(low=0, high=1, size=size, dtype=bool)) + + fgen = tmp_path.joinpath("test.sur") - fgen = tmp_path.joinpath('test.sur') - if transpose: gen = gen.T with pytest.warns(): - gen.save(fgen,compressed = compressed, overwrite=True) + gen.save(fgen, compressed=compressed, overwrite=True) else: - gen.save(fgen,compressed = compressed, overwrite=True) + gen.save(fgen, compressed=compressed, overwrite=True) gen2 = hs.load(fgen) - assert np.allclose(gen.data,gen2.data) + assert np.allclose(gen.data, gen2.data) + + +@pytest.mark.parametrize("compressed", [True, False]) +def test_writegeneric_profileseries(tmp_path, compressed): -@pytest.mark.parametrize('compressed',[True,False]) -def test_writegeneric_profileseries(tmp_path,compressed): + size = (9, 655) - size = (9,655) + gen = hs.signals.Signal1D(np.random.random(size=size) * 1444 + 2550.0) + fgen = tmp_path.joinpath("test.pro") - gen = hs.signals.Signal1D(np.random.random(size=size)*1444+2550.) - fgen = tmp_path.joinpath('test.pro') - - gen.save(fgen,compressed = compressed, overwrite=True) + gen.save(fgen, compressed=compressed, overwrite=True) gen2 = hs.load(fgen) - assert np.allclose(gen.data,gen2.data) + assert np.allclose(gen.data, gen2.data) -@pytest.mark.parametrize("dtype", [(np.uint8,"rgb8"), (np.uint16,"rgb16")]) -@pytest.mark.parametrize('compressed',[True,False]) -def test_writegeneric_rgbseries(tmp_path,dtype,compressed): +@pytest.mark.parametrize("dtype", [(np.uint8, "rgb8"), (np.uint16, "rgb16")]) +@pytest.mark.parametrize("compressed", [True, False]) +def test_writegeneric_rgbseries(tmp_path, dtype, compressed): """This test establishes the possibility of saving RGB surface series""" - size = (5,44,24,3) + size = (5, 44, 24, 3) minint = np.iinfo(dtype[0]).min maxint = np.iinfo(dtype[0]).max - gen = hs.signals.Signal1D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype[0])) + gen = hs.signals.Signal1D( + np.random.randint(low=minint, high=maxint, size=size, dtype=dtype[0]) + ) gen.change_dtype(dtype[1]) - fgen = tmp_path.joinpath('test.sur') + fgen = tmp_path.joinpath("test.sur") - gen.save(fgen,compressed = compressed, overwrite=True) + gen.save(fgen, compressed=compressed, overwrite=True) gen2 = hs.load(fgen) - for k in ['R','G','B']: - assert np.allclose(gen.data[k],gen2.data[k]) + for k in ["R", "G", "B"]: + assert np.allclose(gen.data[k], gen2.data[k]) -@pytest.mark.parametrize("dtype", [(np.uint8,"rgba8"), (np.uint16,"rgba16")]) -@pytest.mark.parametrize('compressed',[True,False]) -def test_writegeneric_rgbaseries(tmp_path,dtype,compressed): - """This test establishes the possibility of saving RGBA data while discarding +@pytest.mark.parametrize("dtype", [(np.uint8, "rgba8"), (np.uint16, "rgba16")]) +@pytest.mark.parametrize("compressed", [True, False]) +def test_writegeneric_rgbaseries(tmp_path, dtype, compressed): + """This test establishes the possibility of saving RGBA data while discarding A channel and warning""" - size = (5,44,24,4) + size = (5, 44, 24, 4) minint = np.iinfo(dtype[0]).min maxint = np.iinfo(dtype[0]).max - gen = hs.signals.Signal1D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype[0])) + gen = hs.signals.Signal1D( + np.random.randint(low=minint, high=maxint, size=size, dtype=dtype[0]) + ) gen.change_dtype(dtype[1]) - fgen = tmp_path.joinpath('test.sur') + fgen = tmp_path.joinpath("test.sur") with pytest.warns(): - gen.save(fgen,compressed = compressed, overwrite=True) + gen.save(fgen, compressed=compressed, overwrite=True) gen2 = hs.load(fgen) - for k in ['R','G','B']: - assert np.allclose(gen.data[k],gen2.data[k]) + for k in ["R", "G", "B"]: + assert np.allclose(gen.data[k], gen2.data[k]) @pytest.mark.parametrize("dtype", [np.int16, np.int32, np.float64]) -@pytest.mark.parametrize("compressed",[True,False]) -def test_writegeneric_surfaceseries(tmp_path,dtype,compressed): - """This test establishes the possibility of saving RGBA surface series while discarding +@pytest.mark.parametrize("compressed", [True, False]) +def test_writegeneric_surfaceseries(tmp_path, dtype, compressed): + """This test establishes the possibility of saving RGBA surface series while discarding A channel and warning""" - size = (9,44,58) + size = (9, 44, 58) - if np.issubdtype(dtype,np.integer): + if np.issubdtype(dtype, np.integer): minint = np.iinfo(dtype).min maxint = np.iinfo(dtype).max - gen = hs.signals.Signal2D(np.random.randint(low=minint,high=maxint,size=size,dtype=dtype)) + gen = hs.signals.Signal2D( + np.random.randint(low=minint, high=maxint, size=size, dtype=dtype) + ) else: - gen = hs.signals.Signal2D(np.random.random(size=size).astype(dtype)*1e6) + gen = hs.signals.Signal2D(np.random.random(size=size).astype(dtype) * 1e6) - fgen = tmp_path.joinpath('test.sur') + fgen = tmp_path.joinpath("test.sur") - gen.save(fgen,compressed = compressed, overwrite=True) + gen.save(fgen, compressed=compressed, overwrite=True) gen2 = hs.load(fgen) - assert np.allclose(gen.data,gen2.data) \ No newline at end of file + assert np.allclose(gen.data, gen2.data) From d914fda926ef0a7e1f7374b08d2d4a88b88d0827 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Tue, 25 Jun 2024 11:06:44 +0200 Subject: [PATCH 128/174] Increase codecov --- rsciio/digitalsurf/_api.py | 7 ++--- rsciio/tests/test_digitalsurf.py | 49 +++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 0930c9da0..718c5db96 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -676,6 +676,7 @@ def _split_spectrum( nax_nav = self._n_ax_nav nax_sig = self._n_ax_sig + # _split_signal_dict ensures that the correct dims are sent here. if (nax_nav, nax_sig) == (0, 1) or (nax_nav, nax_sig) == (1, 0): self.Xaxis = self.signal_dict["axes"][0] elif (nax_nav, nax_sig) == (1, 1): @@ -683,10 +684,6 @@ def _split_spectrum( ax for ax in self.signal_dict["axes"] if not ax["navigate"] ) self.Yaxis = next(ax for ax in self.signal_dict["axes"] if ax["navigate"]) - else: - raise MountainsMapFileError( - f"Dimensions ({nax_nav})|{nax_sig}) invalid for export as spectrum type" - ) self.data_split = [self.signal_dict["data"]] self.objtype_split = [obj_type] @@ -2281,7 +2278,7 @@ def _unpack_data(self, file, encoding="latin-1"): # Packing data into ints or float, with or without scaling. if self._is_data_int(): - _points = _points + pass #Case left here for future modification elif self._is_data_scaleint(): _points = (_points.astype(float) - Zmin) * scale + offset _points = np.round(_points).astype(int) diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 9fdafcdd2..43655966a 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -675,17 +675,24 @@ def test_norm_int_data(dtype, special, fullscale): assert Zmin == off assert Zmax == maxval - -def test_writeRGB(tmp_path): +@pytest.mark.parametrize("transpose", [True, False]) +def test_writetestobjects_rgb(tmp_path,transpose): # This is just a different test function because the # comparison of rgb data must be done differently # (due to hyperspy underlying structure) df = TEST_DATA_PATH.joinpath("test_RGB.sur") d = hs.load(df) fn = tmp_path.joinpath("test_RGB.sur") - d.save(fn, is_special=False) + + if transpose: + d = d.T + with pytest.warns(): + d.save(fn) + else: + d.save(fn) + d2 = hs.load(fn) - d2.save(fn, is_special=False) + d2.save(fn) d3 = hs.load(fn) for k in ["R", "G", "B"]: @@ -723,6 +730,35 @@ def test_writegeneric_validtypes(tmp_path, dtype, compressed): gen2 = hs.load(fgen) assert np.allclose(gen2.data, gen.data) +@pytest.mark.parametrize("compressed", [True, False]) +def test_writegeneric_nans(tmp_path, compressed): + """This test establishes the capability of saving a generic signal + generated from numpy array containing floats""" + gen = hs.signals.Signal1D(np.random.random(size=301)) + + gen.data[66] = np.nan + gen.data[111] = np.nan + + fgen = tmp_path.joinpath("test.pro") + + gen.save(fgen, compressed=compressed, is_special=True, overwrite=True) + + gen2 = hs.load(fgen) + assert np.allclose(gen2.data, gen.data, equal_nan=True) + +def test_writegeneric_transposedprofile(tmp_path): + """This test checks the expected behaviour that a transposed profile gets + correctly saved but a warning is raised.""" + gen = hs.signals.Signal1D(np.random.random(size=99)) + gen = gen.T + + fgen = tmp_path.joinpath("test.pro") + + with pytest.warns(): + gen.save(fgen, overwrite=True) + + gen2 = hs.load(fgen) + assert np.allclose(gen2.data, gen.data) @pytest.mark.parametrize( "dtype", @@ -738,6 +774,11 @@ def test_writegeneric_failingtypes(tmp_path, dtype): with pytest.raises(MountainsMapFileError): gen.save(fgen, overwrite=True) +def test_writegeneric_failingformat(tmp_path): + gen = hs.signals.Signal1D(np.zeros((3,4,5,6))) + fgen = tmp_path.joinpath("test.sur") + with pytest.raises(MountainsMapFileError): + gen.save(fgen, overwrite=True) @pytest.mark.parametrize("dtype", [(np.uint8, "rgba8"), (np.uint16, "rgba16")]) @pytest.mark.parametrize("compressed", [True, False]) From 94f0ae1002e0657fab0dc5dc99c19a0d1429e1db Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Tue, 25 Jun 2024 16:17:14 +0200 Subject: [PATCH 129/174] Suppress implicit return in _is_data_scaleint, also codestyle --- rsciio/digitalsurf/_api.py | 2 ++ rsciio/tests/test_digitalsurf.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 718c5db96..bf42e033c 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -2200,6 +2200,8 @@ def _is_data_scaleint( "_INTENSITYIMAGE", ]: return True + else: + return False def _get_uncompressed_datasize(self) -> int: """Return size of uncompressed data in bytes""" diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 43655966a..c5c2f9e86 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -664,6 +664,8 @@ def test_norm_int_data(dtype, special, fullscale): dat[2] = minint dat[11] = maxint + Zscale = 0.0 #to avoid CodeQL error: pot. non-initialized var + Zoffset = -np.inf #to avoid CodeQL error: pot. non-initialized var pointsize, Zmin, Zmax, Zscale, Zoffset, data_int = dh._norm_data(dat, special) off = minint + 1 if special and fullscale else dat.min() @@ -690,7 +692,7 @@ def test_writetestobjects_rgb(tmp_path,transpose): d.save(fn) else: d.save(fn) - + d2 = hs.load(fn) d2.save(fn) d3 = hs.load(fn) From acfb7f852f25636c70090cc38378c0238296a13b Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Thu, 27 Jun 2024 10:13:02 +0200 Subject: [PATCH 130/174] refactor n_objects_to_read, remove useless binary get / set defaults and fix endianness --- rsciio/digitalsurf/_api.py | 83 +++++++++++++------------------------- 1 file changed, 28 insertions(+), 55 deletions(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index bf42e033c..63252744b 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -127,7 +127,7 @@ def __init__(self, filename: str = ""): self._work_dict = { "_01_Signature": { "value": "DSCOMPRESSED", # Uncompressed key is DIGITAL SURF - "b_unpack_fn": lambda f: self._get_str(f, 12, "DSCOMPRESSED"), + "b_unpack_fn": lambda f: self._get_str(f, 12), "b_pack_fn": lambda f, v: self._set_str(f, v, 12), }, "_02_Format": { @@ -152,12 +152,12 @@ def __init__(self, filename: str = ""): }, "_06_Object_Name": { "value": "", - "b_unpack_fn": lambda f: self._get_str(f, 30, ""), + "b_unpack_fn": lambda f: self._get_str(f, 30, ), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_07_Operator_Name": { "value": "ROSETTA", - "b_unpack_fn": lambda f: self._get_str(f, 30, ""), + "b_unpack_fn": lambda f: self._get_str(f, 30, ), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_08_P_Size": { @@ -197,7 +197,7 @@ def __init__(self, filename: str = ""): }, "_15_Size_of_Points": { "value": 16, - "b_unpack_fn": lambda f: self._get_int16(f, 32), + "b_unpack_fn": self._get_int16, "b_pack_fn": self._set_int16, }, "_16_Zmin": { @@ -242,47 +242,47 @@ def __init__(self, filename: str = ""): }, "_24_Name_of_X_Axis": { "value": "X", - "b_unpack_fn": lambda f: self._get_str(f, 16, "X"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_25_Name_of_Y_Axis": { "value": "Y", - "b_unpack_fn": lambda f: self._get_str(f, 16, "Y"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_26_Name_of_Z_Axis": { "value": "Z", - "b_unpack_fn": lambda f: self._get_str(f, 16, "Z"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_27_X_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16, "um"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_28_Y_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16, "um"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_29_Z_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16, "um"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_30_X_Length_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16, "um"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_31_Y_Length_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16, "um"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_32_Z_Length_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16, "um"), + "b_unpack_fn": lambda f: self._get_str(f, 16 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_33_X_Unit_Ratio": { @@ -412,12 +412,12 @@ def __init__(self, filename: str = ""): }, "_58_T_Axis_Name": { "value": "T", - "b_unpack_fn": lambda f: self._get_str(f, 13, "Wavelength"), + "b_unpack_fn": lambda f: self._get_str(f, 13 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 13), }, "_59_T_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 13, "nm"), + "b_unpack_fn": lambda f: self._get_str(f, 13 ), "b_pack_fn": lambda f, v: self._set_str(f, v, 13), }, "_60_Comment": { @@ -1138,15 +1138,9 @@ def _read_sur_file(self): ) self._N_data_channels = self._get_work_dict_key_value("_08_P_Size") - # Determine how many objects we need to read - if self._N_data_channels > 0 and self._N_data_objects > 0: - n_objects_to_read = self._N_data_channels * self._N_data_objects - elif self._N_data_channels > 0: - n_objects_to_read = self._N_data_channels - elif self._N_data_objects > 0: - n_objects_to_read = self._N_data_objects - else: - n_objects_to_read = 1 + # Determine how many objects we need to read, at least 1 object and 1 channel + # even if metadata is set to 0 (happens sometimes) + n_objects_to_read = max(self._N_data_channels,1) * max(self._N_data_objects,1) # Lookup what object type we are dealing with and save self._Object_type = DigitalSurfHandler._mountains_object_types[ @@ -2045,11 +2039,9 @@ def post_process_binary(signal): # pack/unpack binary quantities @staticmethod - def _get_uint16(file, default=None): + def _get_uint16(file): """Read a 16-bits int with a user-definable default value if no file is given""" - if file is None: - return default b = file.read(2) return struct.unpack("i", b)[0] - else: - return struct.unpack("I", b)[0] - else: - return struct.unpack(" Date: Thu, 27 Jun 2024 11:24:38 +0200 Subject: [PATCH 131/174] Fixed typos in doc --- .../supported_formats/digitalsurf.rst | 58 +++++++++---------- rsciio/digitalsurf/_api.py | 17 +++--- 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/doc/user_guide/supported_formats/digitalsurf.rst b/doc/user_guide/supported_formats/digitalsurf.rst index 6b52cadc0..57a35395e 100644 --- a/doc/user_guide/supported_formats/digitalsurf.rst +++ b/doc/user_guide/supported_formats/digitalsurf.rst @@ -3,31 +3,30 @@ DigitalSurf format (SUR & PRO) ------------------------------ -``.sur`` and ``.pro`` is format developed by digitalsurf to import/export data in their MountainsMap scientific -analysis software. Target datasets originally result from (micro)-topography and imaging instruments: SEM, AFM, -profilometer. RGB(A) images, multilayer surfaces and profiles are also supported. Even though it is essentially -a surfaces format, 1D signals are supported for spectra and spectral maps. Specifically, this is the fileformat -used by Attolight SA for its scanning electron microscope cathodoluminescence (SEM-CL) hyperspectral maps. This -plugin was developed based on the MountainsMap software documentation. - -Support for loading ``.sur`` and ``.pro`` datasets is complete, including parsing of user/customer-specific -metadata, and opening of files containing multiple objects. Some rare specific objects (e.g. force curves) -are not supported, due to no example data being available. Those can be added upon request and providing of -example datasets. Heterogeneous data can be represented in ``.sur`` and ``.pro`` objects, for instance -floating-point/topography and rgb data can coexist along the same navigation dimension. Those are casted to -a homogeneous floating-point representation upon loading. - -Support for data saving is partial as ``.sur`` and ``.pro`` do not support all features of hyperspy signals. -First, they have limited dimensionality. Up to 3d data arrays with either 1d (series of images) or 2d -(hyperspectral studiable) navigation space can be saved. Also, ``.sur`` and ``.pro`` do not support non-uniform -axes and saving of models. Finally, ``.sur`` / ``.pro`` linearize intensities along a uniform axis to enforce -an integer-representation of the data (with scaling and offset). This means that export from float-type hyperspy -signals is inherently lossy. - -Within these limitations, all features from ``.sur`` and ``.pro`` fileformats are supported, notably data -compression and setting of custom metadata. The file writer splits a signal into the suitable digitalsurf -dataobject primarily by inspecting its dimensions and its datatype, ultimately how various axes and signal -quantity are named. The criteria are listed here below: +``.sur`` and ``.pro`` is a format developed by digitalsurf to import/export data in the MountainsMap scientific +analysis software. Target datasets are originally (micro)-topography maps and profile from imaging instruments: +SEM, AFM, profilometery etc. RGB(A) images, multilayer surfaces and profiles are also supported. Even though it +is essentially a surfaces format, 1D signals are supported for spectra and spectral maps. Specifically, this is +the format used by Attolight for saving SEM-cathodoluminescence (SEM-CL) hyperspectral maps. This plugin was +developed based on the MountainsMap software documentation. + +Support for loading ``.sur`` and ``.pro`` files is complete, including parsing of custom metadata, and opening of +files containing multiple objects. Some rare, deprecated object types (e.g. force curves) are not supported, due +to no example data being available. Those can be added upon request to the module, if provided with example data +and a explanations. Unlike hyperspy.signal, ``.sur`` and ``.pro`` objects can be used to represent heterogeneous +data. For instance, float (topography) and int (rgb data) data can coexist along the same navigation dimension. +Those are casted to a homogeneous floating-point representation upon loading. + +Support for data saving is partial, as ``.sur`` and ``.pro`` do not support all features of hyperspy signals. Up +to 3d data arrays with either 1d (series of images) or 2d (spectral maps) navigation space can be saved. ``.sur`` +and ``.pro`` also do not support non-uniform axes and fitted models. Finally, MountainsMap maps intensities along +an axis with constant spacing between numbers by enforcing an integer-representation of the data with scaling and +offset. This means that export from float data is inherently lossy. + +Within these limitations, all features from ``.sur`` and ``.pro`` fileformats are supported. Data compression and +custom metadata allows a good interoperability of hyperspy and Mountainsmap. The file writer splits a signal into +the suitable digitalsurf dataobject. Primarily by inspecting its dimension and datatype. If ambiguity remains, it +inspects the names of signal axes and ``metadata.Signal.quantity``. The criteria are listed here below: +-----------------+---------------+------------------------------------------------------------------------------+ | Nav. dimension | Sig dimension | Extension and MountainsMap subclass | @@ -48,11 +47,10 @@ quantity are named. The criteria are listed here below: | 2 | 1 | ``.sur``: hyperspectralMap (default) | +-----------------+---------------+------------------------------------------------------------------------------+ -Axes named one of ``Wavelength``, ``Energy``, ``Energy Loss``, ``E``, are considered spectral, and quantities -named one of ``Height``, ``Altitude``, ``Elevation``, ``Depth``, ``Z`` are considered surface. The difference -between Surface and IntensitySurface stems from the AFM / profilometry origin of MountainsMap. "Surface" has -the proper meaning of an open boundary of 3d space, whereas "IntensitySurface" is a mere 2D mapping of an arbitrary -quantity. +Axes named one of ``Wavelength``, ``Energy``, ``Energy Loss`` or ``E`` are considered spectral. A quantity named +one of ``Height``, ``Altitude``, ``Elevation``, ``Depth`` or ``Z`` is considered a surface. The difference between +Surface and IntensitySurface stems from the AFM / profilometry origin of MountainsMap. "Surface" has its proper +meaning of being a 2d-subset of 3d space, whereas "IntensitySurface" is a mere 2D mapping of an arbitrary quantity. API functions ^^^^^^^^^^^^^ diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 63252744b..e27bb546b 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -2361,15 +2361,16 @@ def file_writer( %s %s set_comments : str , default = 'auto' - Whether comments should be a simplified original_metadata ('auto'), - exported as the raw original_metadata dictionary ('raw'), skipped ('off'), - or supplied by the user as an additional kwarg ('custom'). + Whether comments should be a simplified version original_metadata ('auto'), + the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied + by the user as an additional kwarg ('custom'). is_special : bool , default = False - If True, NaN values in the dataset or integers reaching boundary values are - flagged in the export as non-measured and saturating, respectively. If False, - those values are kept as-is. + If True, NaN values in the dataset or integers reaching the boundary of the + signed int-representation are flagged as non-measured or saturating, + respectively. If False, those values are not flagged (converted to valid points). compressed : bool, default =True - If True, compress the data in the export file using zlib. + If True, compress the data in the export file using zlib. Can help dramatically + reduce the file size. comments : dict, default = {} Set a custom dictionnary in the comments field of the exported file. Ignored if set_comments is not set to 'custom'. @@ -2386,7 +2387,7 @@ def file_writer( Maximum size is 32.0 kB and content will be cropped if this size is exceeded. client_zone : bytes, default = b'' Set arbitrary byte-content in the client_zone field of exported file metadata. - Maximum size is 128B and and content will be cropped if this size is exceeded. + Maximum size is 128 B and and content will be cropped if this size is exceeded. """ ds = DigitalSurfHandler(filename=filename) ds.signal_dict = signal From 41f43058f925d8d7194ab87cf0d42296108cb566 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Fri, 28 Jun 2024 10:41:02 +0200 Subject: [PATCH 132/174] codecov, fix binary images, add RGBSurface tests --- rsciio/digitalsurf/_api.py | 16 ++++++++++++++-- .../tests/data/digitalsurf/test_RGBSURFACE.sur | Bin 0 -> 3357 bytes rsciio/tests/test_digitalsurf.py | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 rsciio/tests/data/digitalsurf/test_RGBSURFACE.sur diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index e27bb546b..74dff77c7 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -2166,9 +2166,7 @@ def _is_data_scaleint( self._get_work_dict_key_value("_05_Object_Type") ] if objtype in [ - "_BINARYIMAGE", "_RGBIMAGE", - "_RGBSURFACE", "_SERIESOFRGBIMAGES", "_INTENSITYIMAGE", ]: @@ -2176,6 +2174,18 @@ def _is_data_scaleint( else: return False + def _is_data_bin(self): + """Digitalsurf image formats can be binary sometimes""" + objtype = self._mountains_object_types[ + self._get_work_dict_key_value("_05_Object_Type") + ] + if objtype in [ + "_BINARYIMAGE", + ]: + return True + else: + return False + def _get_uncompressed_datasize(self) -> int: """Return size of uncompressed data in bytes""" psize = int(self._get_work_dict_key_value("_15_Size_of_Points") / 8) @@ -2257,6 +2267,8 @@ def _unpack_data(self, file, encoding="latin-1"): elif self._is_data_scaleint(): _points = (_points.astype(float) - Zmin) * scale + offset _points = np.round(_points).astype(int) + elif self._is_data_bin(): + pass else: _points = (_points.astype(float) - Zmin) * scale + offset _points[nm] = np.nan # Ints have no nans diff --git a/rsciio/tests/data/digitalsurf/test_RGBSURFACE.sur b/rsciio/tests/data/digitalsurf/test_RGBSURFACE.sur new file mode 100644 index 0000000000000000000000000000000000000000..a3a8b7da165b3ac581ad8647eea19189d6fca962 GIT binary patch literal 3357 zcmZ<>cJ}uT2yzV$c6DK3U<5*51_cTL3j+i<LsD0fvU3|NkqnF)(le#XvX(h#x1# z*nn{G=_M8sX!b>-@uSfAxw)tU)aElVG}t3B+#@&{_u(=kKo+Xo=+84)Dek~4Zs{;3 zrC>kIVYpr4;Na^R;u_@X=o9Se;##9?=-|Kwin|j)d>M!<<}jXMF#o@iHJiC&^$??SP`=@(>sQf|5N|H|I_#T_fJ-~Lo5!=cNiZqy!=1!-~T_}znA{} z%(|9Eh+P&Ai_4lcJLn+4#;mW=yS}FW76BvbKt7NlfHx7Z!X9W z{J~Ij#VNM?V5G>sme+y)nuZ>JUd}gKOs3m@TF_PMT*LBj+mcF#tNu~Lw)#ij-wlQ= zj(@kmJrKM6T~342!@1@G2Hcsaj+-&0Jv@J1@h0QUOPjQov;Wi&4wF1(sDF2X)BE?I zo)tJfHZjSx3(*VxBxTzu{_#iBA??#;nNz*Luk_wNo97?nqWC0VdgL)SUj~NrF~c{H z1rF0Z<_gVYzMwpI-2Q)CbMwO|PJ9~ek)Dr?bTqxZtTT2_JiJFn!Y%I8wCj7$E?RN* zkwS~lqm}}Rs7%G_vePC9d0kT4*S`Gz<B( z8ss-C^L%8e;d7j-btmJ&#XU=;CuJoXU4Br?ckgb&e(uiLf=7+qs}DY2^DM65+Ts4o z50d3S{W@6Bu>2_7&gKWNTi&>ykZOO;_%@SqcZ8+1fS|qw+eM2#p8iXJZBp0~zQ8Z) zZS938&3~jLUNrLDweOf-#1ZfP!J6x8=%wX$Pr~)%S7uL;VZ6;P;@KT}>-25K1#{=g z-pHQ1jjbTp>8z|xXXZ=+@BDp7H?MQfG!{Aac;gPfxfeBWOq#c)q`50GRpHvySCN&i zRf-QiZf3okwr<9!zPX1x{@sZAq|CqlR%TG&|CKvaMd^{r=G!tbC<9|`xK?Do!!(mQ zLNl4S#*sjgWA*j7)8Umz6OOaf4{e$zY~wBYkD?q4|tYrTpa zilvjT#9mSfY4-hh(peyy!+h#0#t+^9*M)3eRlh{yPIkG!=m!12Gr!!wm43|T&o^g} cT2*;3@hR)~g#NXjU>EzQdj1FgxS46a0G Date: Fri, 28 Jun 2024 15:50:02 +0000 Subject: [PATCH 133/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/digitalsurf/_api.py | 149 ++++++++++++++++--------------- rsciio/tests/registry.txt | 2 + rsciio/tests/test_digitalsurf.py | 18 ++-- 3 files changed, 92 insertions(+), 77 deletions(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 74dff77c7..752d3bf64 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -23,16 +23,15 @@ # comments can be systematically parsed into metadata and write a support for # original_metadata or other +import ast import datetime -from copy import deepcopy import logging import os -import struct -import sys import re +import struct import warnings import zlib -import ast +from copy import deepcopy # Commented for now because I don't know what purpose it serves # import traits.api as t @@ -53,9 +52,9 @@ RETURNS_DOC, SIGNAL_DOC, ) +from rsciio.utils.date_time_tools import get_date_time_from_metadata from rsciio.utils.exceptions import MountainsMapFileError from rsciio.utils.rgb_tools import is_rgb, is_rgba -from rsciio.utils.date_time_tools import get_date_time_from_metadata _logger = logging.getLogger(__name__) @@ -152,12 +151,18 @@ def __init__(self, filename: str = ""): }, "_06_Object_Name": { "value": "", - "b_unpack_fn": lambda f: self._get_str(f, 30, ), + "b_unpack_fn": lambda f: self._get_str( + f, + 30, + ), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_07_Operator_Name": { "value": "ROSETTA", - "b_unpack_fn": lambda f: self._get_str(f, 30, ), + "b_unpack_fn": lambda f: self._get_str( + f, + 30, + ), "b_pack_fn": lambda f, v: self._set_str(f, v, 30), }, "_08_P_Size": { @@ -242,47 +247,47 @@ def __init__(self, filename: str = ""): }, "_24_Name_of_X_Axis": { "value": "X", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_25_Name_of_Y_Axis": { "value": "Y", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_26_Name_of_Z_Axis": { "value": "Z", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_27_X_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_28_Y_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_29_Z_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_30_X_Length_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_31_Y_Length_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_32_Z_Length_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 16 ), + "b_unpack_fn": lambda f: self._get_str(f, 16), "b_pack_fn": lambda f, v: self._set_str(f, v, 16), }, "_33_X_Unit_Ratio": { @@ -412,12 +417,12 @@ def __init__(self, filename: str = ""): }, "_58_T_Axis_Name": { "value": "T", - "b_unpack_fn": lambda f: self._get_str(f, 13 ), + "b_unpack_fn": lambda f: self._get_str(f, 13), "b_pack_fn": lambda f, v: self._set_str(f, v, 13), }, "_59_T_Step_Unit": { "value": "um", - "b_unpack_fn": lambda f: self._get_str(f, 13 ), + "b_unpack_fn": lambda f: self._get_str(f, 13), "b_pack_fn": lambda f, v: self._set_str(f, v, 13), }, "_60_Comment": { @@ -613,7 +618,7 @@ def _split_signal_dict(self): self._split_rgb() elif is_rgba(self.signal_dict["data"]): warnings.warn( - f"A channel discarded upon saving \ + "A channel discarded upon saving \ RGBA signal in .sur format" ) self._split_rgb() @@ -636,7 +641,7 @@ def _split_signal_dict(self): self._split_rgbserie() elif is_rgba(self.signal_dict["data"]): warnings.warn( - f"Alpha channel discarded upon saving RGBA signal in .sur format" + "Alpha channel discarded upon saving RGBA signal in .sur format" ) self._split_rgbserie() else: @@ -651,7 +656,7 @@ def _split_signal_dict(self): self._split_rgb() elif is_rgba(self.signal_dict["data"]): warnings.warn( - f"A channel discarded upon saving \ + "A channel discarded upon saving \ RGBA signal in .sur format" ) self._split_rgb() @@ -846,7 +851,7 @@ def _norm_data(self, data: np.ndarray, is_special: bool): if np.issubdtype(data_type, np.complexfloating): raise MountainsMapFileError( - f"digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export" + "digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export" ) elif data_type == bool: pointsize = 16 @@ -867,7 +872,7 @@ def _norm_data(self, data: np.ndarray, is_special: bool): data_int = data.astype(np.int32) elif np.issubdtype(data_type, np.unsignedinteger): raise MountainsMapFileError( - f"digitalsurf file formats do not support unsigned int >16bits. Convert data to signed integers before export." + "digitalsurf file formats do not support unsigned int >16bits. Convert data to signed integers before export." ) elif data_type == np.int8: pointsize = 16 # Pointsize has to be 16 or 32 in surf format @@ -883,7 +888,7 @@ def _norm_data(self, data: np.ndarray, is_special: bool): Zmin, Zmax, Zscale, Zoffset = self._norm_signed_int(data, is_special) elif np.issubdtype(data_type, np.integer): raise MountainsMapFileError( - f"digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting" + "digitalsurf file formats do not support export integers larger than 32 bits. Convert data to 32-bit representation before exporting" ) elif np.issubdtype(data_type, np.floating): pointsize = 32 @@ -977,41 +982,41 @@ def _build_workdict( """Populate _work_dict with the""" if not compressed: - self._work_dict["_01_Signature"][ - "value" - ] = "DIGITAL SURF" # DSCOMPRESSED by default + self._work_dict["_01_Signature"]["value"] = ( + "DIGITAL SURF" # DSCOMPRESSED by default + ) else: - self._work_dict["_01_Signature"][ - "value" - ] = "DSCOMPRESSED" # DSCOMPRESSED by default + self._work_dict["_01_Signature"]["value"] = ( + "DSCOMPRESSED" # DSCOMPRESSED by default + ) # self._work_dict['_02_Format']['value'] = 0 # Dft. other possible value is 257 for MacintoshII computers with Motorola CPUs. Obv not supported... self._work_dict["_03_Number_of_Objects"]["value"] = self._N_data_objects # self._work_dict['_04_Version']['value'] = 1 # Version number. Always default. self._work_dict["_05_Object_Type"]["value"] = obj_type - self._work_dict["_06_Object_Name"][ - "value" - ] = object_name # Obsolete, DOS-version only (Not supported) - self._work_dict["_07_Operator_Name"][ - "value" - ] = operator_name # Should be settable from kwargs + self._work_dict["_06_Object_Name"]["value"] = ( + object_name # Obsolete, DOS-version only (Not supported) + ) + self._work_dict["_07_Operator_Name"]["value"] = ( + operator_name # Should be settable from kwargs + ) self._work_dict["_08_P_Size"]["value"] = self._N_data_channels - self._work_dict["_09_Acquisition_Type"][ - "value" - ] = 0 # AFM data only, could be inferred - self._work_dict["_10_Range_Type"][ - "value" - ] = 0 # Only 1 for high-range (z-stage scanning), AFM data only, could be inferred + self._work_dict["_09_Acquisition_Type"]["value"] = ( + 0 # AFM data only, could be inferred + ) + self._work_dict["_10_Range_Type"]["value"] = ( + 0 # Only 1 for high-range (z-stage scanning), AFM data only, could be inferred + ) self._work_dict["_11_Special_Points"]["value"] = int(is_special) - self._work_dict["_12_Absolute"][ - "value" - ] = absolute # Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred - self._work_dict["_13_Gauge_Resolution"][ - "value" - ] = 0.0 # Probably irrelevant. Only for profilometers (maybe AFM), can be inferred + self._work_dict["_12_Absolute"]["value"] = ( + absolute # Probably irrelevant in most cases. Absolute vs rel heights (for profilometers), can be inferred + ) + self._work_dict["_13_Gauge_Resolution"]["value"] = ( + 0.0 # Probably irrelevant. Only for profilometers (maybe AFM), can be inferred + ) # T-axis acts as W-axis for spectrum / hyperspectrum surfaces. if obj_type in [21]: @@ -1081,17 +1086,15 @@ def _build_workdict( data_bin = data_int.ravel().astype(fmt).tobytes() compressed_size = 0 - self._work_dict["_48_Compressed_data_size"][ - "value" - ] = compressed_size # Obsolete in case of non-compressed + self._work_dict["_48_Compressed_data_size"]["value"] = ( + compressed_size # Obsolete in case of non-compressed + ) # _49_Obsolete comment_len = len(f"{comment}".encode("latin-1")) if comment_len > 2**15: - warnings.warn( - f"Comment exceeding max length of 32.0 kB and will be cropped" - ) + warnings.warn("Comment exceeding max length of 32.0 kB and will be cropped") comment_len = np.int16(2**15) self._work_dict["_50_Comment_size"]["value"] = comment_len @@ -1099,7 +1102,7 @@ def _build_workdict( privatesize = len(private_zone) if privatesize > 2**15: warnings.warn( - f"Private size exceeding max length of 32.0 kB and will be cropped" + "Private size exceeding max length of 32.0 kB and will be cropped" ) privatesize = np.int16(2**15) @@ -1138,9 +1141,11 @@ def _read_sur_file(self): ) self._N_data_channels = self._get_work_dict_key_value("_08_P_Size") - # Determine how many objects we need to read, at least 1 object and 1 channel + # Determine how many objects we need to read, at least 1 object and 1 channel # even if metadata is set to 0 (happens sometimes) - n_objects_to_read = max(self._N_data_channels,1) * max(self._N_data_objects,1) + n_objects_to_read = max(self._N_data_channels, 1) * max( + self._N_data_objects, 1 + ) # Lookup what object type we are dealing with and save self._Object_type = DigitalSurfHandler._mountains_object_types[ @@ -1500,7 +1505,6 @@ def _build_RGB_image( def _build_RGB_image_series( self, ): - # First object dictionary hypdic = self._list_sur_file_content[0] @@ -1941,7 +1945,7 @@ def _get_comment_dict( elif method == "off": return {} elif method == "auto": - pattern = re.compile("Object_\d*_Channel_\d*") + pattern = re.compile(r"Object_\d*_Channel_\d*") omd = original_metadata # filter original metadata content of dict type and matching pattern. validfields = [ @@ -1968,7 +1972,7 @@ def _get_comment_dict( return {} else: raise MountainsMapFileError( - f"Non-valid method for setting mountainsmap file comment. Choose one of: 'auto','raw','custom','off' " + "Non-valid method for setting mountainsmap file comment. Choose one of: 'auto','raw','custom','off' " ) @staticmethod @@ -2050,7 +2054,9 @@ def _set_uint16(file, val): file.write(struct.pack(" int: return datasize def _unpack_data(self, file, encoding="latin-1"): - # Size of datapoints in bytes. Always int16 (==2) or 32 (==4) psize = int(self._get_work_dict_key_value("_15_Size_of_Points") / 8) dtype = np.int16 if psize == 2 else np.int32 @@ -2263,7 +2272,7 @@ def _unpack_data(self, file, encoding="latin-1"): # Packing data into ints or float, with or without scaling. if self._is_data_int(): - pass #Case left here for future modification + pass # Case left here for future modification elif self._is_data_scaleint(): _points = (_points.astype(float) - Zmin) * scale + offset _points = np.round(_points).astype(int) @@ -2292,7 +2301,7 @@ def _compress_data(data_int, nstreams: int = 1) -> bytes: if nstreams <= 0 or nstreams > 8: raise MountainsMapFileError( - f"Number of compression streams must be >= 1, <= 8" + "Number of compression streams must be >= 1, <= 8" ) bstr = b"" @@ -2374,14 +2383,14 @@ def file_writer( %s set_comments : str , default = 'auto' Whether comments should be a simplified version original_metadata ('auto'), - the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied + the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied by the user as an additional kwarg ('custom'). is_special : bool , default = False - If True, NaN values in the dataset or integers reaching the boundary of the - signed int-representation are flagged as non-measured or saturating, + If True, NaN values in the dataset or integers reaching the boundary of the + signed int-representation are flagged as non-measured or saturating, respectively. If False, those values are not flagged (converted to valid points). compressed : bool, default =True - If True, compress the data in the export file using zlib. Can help dramatically + If True, compress the data in the export file using zlib. Can help dramatically reduce the file size. comments : dict, default = {} Set a custom dictionnary in the comments field of the exported file. diff --git a/rsciio/tests/registry.txt b/rsciio/tests/registry.txt index b8b9f1f36..593391ed2 100644 --- a/rsciio/tests/registry.txt +++ b/rsciio/tests/registry.txt @@ -130,6 +130,8 @@ 'digitalmicrograph/Fei HAADF-UK_location.dm3' 3264325b6f79457737f6ff71e3979ebe508971a592c24e15d9ee4ba876244e56 'digitalmicrograph/test_stackbuilder_imagestack.dm3' 41070d0fd25a838a504f705e1431735192b7a97ca7dd15d9328af5e939fe74a2 'digitalsurf/test_RGB.sur' 802f3d915bf9feb7c264ef3f1242df35033da7227e5a7a5924fd37f8f49f4778 +'digitalsurf/test_RGBSURFACE.sur' 15e8b345cc5d67e7399831c881c63362fd92bc075fad8d763f3ff0d26dfe29a2 +'digitalsurf/test_isurface.sur' 6ed59a9a235c0b6dc7e15f155d0e738c5841cfc0fe78f1861b7e145f9dcaadf4 'digitalsurf/test_profile.pro' fdd9936a4b5e205b819b1d82813bb21045b702b4610e8ef8d1d0932d63344f6d 'digitalsurf/test_spectra.pro' ea1602de193b73046beb5e700fcac727fb088bf459edeec3494b0362a41bdcb1 'digitalsurf/test_spectral_map.sur' f9c863e3fd61be89c3b68cef6fa2434ffedc7e486efe2263c2241109fa58c3f7 diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 71cf75873..e604cda63 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -499,7 +499,6 @@ def test_metadata_mapping(): def test_compressdata(): - testdat = np.arange(120, dtype=np.int32) # Refuse too many / neg streams @@ -665,8 +664,8 @@ def test_norm_int_data(dtype, special, fullscale): dat[2] = minint dat[11] = maxint - Zscale = 0.0 #to avoid CodeQL error: pot. non-initialized var - Zoffset = -np.inf #to avoid CodeQL error: pot. non-initialized var + Zscale = 0.0 # to avoid CodeQL error: pot. non-initialized var + Zoffset = -np.inf # to avoid CodeQL error: pot. non-initialized var pointsize, Zmin, Zmax, Zscale, Zoffset, data_int = dh._norm_data(dat, special) off = minint + 1 if special and fullscale else dat.min() @@ -678,8 +677,9 @@ def test_norm_int_data(dtype, special, fullscale): assert Zmin == off assert Zmax == maxval + @pytest.mark.parametrize("transpose", [True, False]) -def test_writetestobjects_rgb(tmp_path,transpose): +def test_writetestobjects_rgb(tmp_path, transpose): # This is just a different test function because the # comparison of rgb data must be done differently # (due to hyperspy underlying structure) @@ -718,6 +718,7 @@ def test_writetestobjects_rgb(tmp_path,transpose): assert np.allclose(ax.axis, ax2.axis) assert np.allclose(ax.axis, ax3.axis) + @pytest.mark.parametrize( "dtype", [np.int8, np.int16, np.int32, np.float64, np.uint8, np.uint16] ) @@ -732,6 +733,7 @@ def test_writegeneric_validtypes(tmp_path, dtype, compressed): gen2 = hs.load(fgen) assert np.allclose(gen2.data, gen.data) + @pytest.mark.parametrize("compressed", [True, False]) def test_writegeneric_nans(tmp_path, compressed): """This test establishes the capability of saving a generic signal @@ -748,6 +750,7 @@ def test_writegeneric_nans(tmp_path, compressed): gen2 = hs.load(fgen) assert np.allclose(gen2.data, gen.data, equal_nan=True) + def test_writegeneric_transposedprofile(tmp_path): """This test checks the expected behaviour that a transposed profile gets correctly saved but a warning is raised.""" @@ -762,6 +765,7 @@ def test_writegeneric_transposedprofile(tmp_path): gen2 = hs.load(fgen) assert np.allclose(gen2.data, gen.data) + @pytest.mark.parametrize( "dtype", [ @@ -776,12 +780,14 @@ def test_writegeneric_failingtypes(tmp_path, dtype): with pytest.raises(MountainsMapFileError): gen.save(fgen, overwrite=True) + def test_writegeneric_failingformat(tmp_path): - gen = hs.signals.Signal1D(np.zeros((3,4,5,6))) + gen = hs.signals.Signal1D(np.zeros((3, 4, 5, 6))) fgen = tmp_path.joinpath("test.sur") with pytest.raises(MountainsMapFileError): gen.save(fgen, overwrite=True) + @pytest.mark.parametrize("dtype", [(np.uint8, "rgba8"), (np.uint16, "rgba16")]) @pytest.mark.parametrize("compressed", [True, False]) @pytest.mark.parametrize("transpose", [True, False]) @@ -815,7 +821,6 @@ def test_writegeneric_rgba(tmp_path, dtype, compressed, transpose): @pytest.mark.parametrize("compressed", [True, False]) @pytest.mark.parametrize("transpose", [True, False]) def test_writegeneric_binaryimg(tmp_path, compressed, transpose): - size = (76, 3) gen = hs.signals.Signal2D(np.random.randint(low=0, high=1, size=size, dtype=bool)) @@ -836,7 +841,6 @@ def test_writegeneric_binaryimg(tmp_path, compressed, transpose): @pytest.mark.parametrize("compressed", [True, False]) def test_writegeneric_profileseries(tmp_path, compressed): - size = (9, 655) gen = hs.signals.Signal1D(np.random.random(size=size) * 1444 + 2550.0) From 7cfd261d0b59316025fc0ac0ae16a18c5c61c519 Mon Sep 17 00:00:00 2001 From: Attolight-NTappy <123734179+Attolight-NTappy@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:29:09 +0200 Subject: [PATCH 134/174] Update doc/user_guide/supported_formats/digitalsurf.rst fixed type Co-authored-by: Eric Prestat --- doc/user_guide/supported_formats/digitalsurf.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user_guide/supported_formats/digitalsurf.rst b/doc/user_guide/supported_formats/digitalsurf.rst index 57a35395e..08f2705b9 100644 --- a/doc/user_guide/supported_formats/digitalsurf.rst +++ b/doc/user_guide/supported_formats/digitalsurf.rst @@ -36,7 +36,7 @@ inspects the names of signal axes and ``metadata.Signal.quantity``. The criteria | 0 | 2 | ``.sur``: BinaryImage (based on dtype), RGBImage (based on dtype), | | | | Surface (default) | +-----------------+---------------+------------------------------------------------------------------------------+ -| 1 | 0 | ``.pro``: same as (1,0) | +| 1 | 0 | ``.pro``: same as (0,1) | +-----------------+---------------+------------------------------------------------------------------------------+ | 1 | 1 | ``.pro``: Spectrum Serie (based on axes name), Profile Serie (default) | +-----------------+---------------+------------------------------------------------------------------------------+ From 61b932b03acc3e4419954c4b5236bc0ea6af2958 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Mon, 1 Jul 2024 14:18:06 +0200 Subject: [PATCH 135/174] Increase codecov, code cleanup --- .../supported_formats/digitalsurf.rst | 2 +- rsciio/digitalsurf/Untitled-1.ipynb | 673 ------------------ rsciio/digitalsurf/_api.py | 40 +- rsciio/tests/test_digitalsurf.py | 84 ++- 4 files changed, 100 insertions(+), 699 deletions(-) delete mode 100644 rsciio/digitalsurf/Untitled-1.ipynb diff --git a/doc/user_guide/supported_formats/digitalsurf.rst b/doc/user_guide/supported_formats/digitalsurf.rst index 57a35395e..08f2705b9 100644 --- a/doc/user_guide/supported_formats/digitalsurf.rst +++ b/doc/user_guide/supported_formats/digitalsurf.rst @@ -36,7 +36,7 @@ inspects the names of signal axes and ``metadata.Signal.quantity``. The criteria | 0 | 2 | ``.sur``: BinaryImage (based on dtype), RGBImage (based on dtype), | | | | Surface (default) | +-----------------+---------------+------------------------------------------------------------------------------+ -| 1 | 0 | ``.pro``: same as (1,0) | +| 1 | 0 | ``.pro``: same as (0,1) | +-----------------+---------------+------------------------------------------------------------------------------+ | 1 | 1 | ``.pro``: Spectrum Serie (based on axes name), Profile Serie (default) | +-----------------+---------------+------------------------------------------------------------------------------+ diff --git a/rsciio/digitalsurf/Untitled-1.ipynb b/rsciio/digitalsurf/Untitled-1.ipynb deleted file mode 100644 index c35673f23..000000000 --- a/rsciio/digitalsurf/Untitled-1.ipynb +++ /dev/null @@ -1,673 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from rsciio.digitalsurf._api import DigitalSurfHandler\n", - "import hyperspy.api as hs\n", - "import numpy as np\n", - "import pathlib\n", - "import matplotlib.pyplot as plt\n", - "%matplotlib qt\n", - "\n", - "savedir = pathlib.Path().home().joinpath(\"OneDrive - Attolight/Desktop/\")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "ddd = np.loadtxt(r\"C:\\Users\\NicolasTappy\\Attolight Dropbox\\ATT_RnD\\INJECT\\BEAMFOUR\\BeamFour-end-users_Windows\\histo2dim_500mmoffset.txt\",delimiter=',')" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "255" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "np.iinfo(np.uint8).max" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 35, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "dt = np.uint8\n", - "maxint = np.iinfo(dt).max\n", - "np.random.randint(low=0,high=maxint,size=(17,38,3),dtype=dt)\n", - "size = (5,17,38,3)\n", - "maxint = np.iinfo(dt).max\n", - "\n", - "gen = hs.signals.Signal1D(np.random.randint(low=0,high=maxint,size=size,dtype=dt))\n", - "gen\n", - "# gen.change_dtype('rgb8')" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "52c591c4ee4f44d6a582c5958ecffd12", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(HBox(children=(Label(value='Unnamed 0th axis', layout=Layout(width='15%')), IntSlider(value=0, …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "gen.change_dtype('rgb8')\n", - "gen.plot()" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Text(0, 0.5, 'Y (um)')" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "plt.matshow(ddd)\n", - "plt.xlabel('X (um)')\n", - "plt.ylabel('Y (um)')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def diffdic(a:dict,b:dict):\n", - " set1 = set(a.items())\n", - " set2 = set(b.items())\n", - " return set1^set2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import struct\n", - "def _pack_str(val, size, encoding=\"latin-1\"):\n", - " \"\"\"Write a str of defined size in bytes to a file. struct.pack\n", - " will automatically trim the string if it is too long\"\"\"\n", - " return struct.pack(\"<{:d}s\".format(size), f\"{val}\".ljust(size).encode(encoding))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "testdir = pathlib.Path(r'C:\\Program Files\\Attolight\\AttoMap Advanced 7.4\\Example Data')\n", - "testfiles = list(testdir.glob('*.sur'))+list(testdir.glob('*pro'))\n", - "list(testfiles)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "testdir = pathlib.Path(r'C:\\Program Files\\Attolight\\AttoMap Advanced 7.4\\Example Data')\n", - "testfiles = list(testdir.glob('*.sur'))+list(testdir.glob('*pro'))\n", - "savedir = pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles')\n", - "for tf in testfiles:\n", - " d = hs.load(tf)\n", - " comp = d.original_metadata.Object_0_Channel_0.Header.H01_Signature == 'DSCOMPRESSED'\n", - " nam = d.original_metadata.Object_0_Channel_0.Header.H06_Object_Name\n", - " abso = d.original_metadata.Object_0_Channel_0.Header.H12_Absolute\n", - " # print(tf.name)\n", - " # if d.original_metadata.Object_0_Channel_0.Header.H05_Object_Type == 12:\n", - " # print(d.original_metadata.Object_0_Channel_0.Header.H23_Z_Spacing)\n", - " nn = savedir.joinpath(f\"EXPORTED_{tf.name}\")\n", - " print(f\"{nn.name}: {comp}, {abso}\")\n", - " d.save(nn,object_name=nam,compressed=comp,absolute=abso,overwrite=True)\n", - " tmp = hs.load(nn)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "a = d.axes_manager[0]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "a.get_axis_dictionary()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "exptf = list(pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles').glob('*.sur'))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "i = 26\n", - "print(testfiles[i].name)\n", - "d = hs.load(testfiles[i])\n", - "print(exptf[i].name)\n", - "ed = hs.load(exptf[i])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "diffdic(d.original_metadata.Object_0_Channel_0.Header.as_dictionary(),\n", - " ed.original_metadata.Object_0_Channel_0.Header.as_dictionary())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d.plot(),ed.plot(),(d-ed).plot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import pathlib\n", - "d = pathlib.Path(r\"C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Documents\\GIT\\rosettasciio\\rsciio\\tests\\data\\digitalsurf\")\n", - "fl = list(d.iterdir())\n", - "fl" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "a = hs.load(r\"C:\\Users\\NicolasTappy\\Attolight Dropbox\\ATT_RnD\\INJECT\\hyperspectral tests\\HYP-TEST-NOLASER\\HYPCard.sur\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "savedir = pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles')\n", - "for tf in fl:\n", - " d = hs.load(tf)\n", - " try:\n", - " comp = d.original_metadata.Object_0_Channel_0.Header.H01_Signature == 'DSCOMPRESSED'\n", - " nam = d.original_metadata.Object_0_Channel_0.Header.H06_Object_Name\n", - " abso = d.original_metadata.Object_0_Channel_0.Header.H12_Absolute\n", - " except:\n", - " comp=False\n", - " nam= 'test'\n", - " abso = 0\n", - " # print(tf.name)\n", - " # if d.original_metadata.Object_0_Channel_0.Header.H05_Object_Type == 12:\n", - " # print(d.original_metadata.Object_0_Channel_0.Header.H23_Z_Spacing)\n", - " nn = savedir.joinpath(f\"EXPORTED_{tf.name}\")\n", - " print(f\"{nn.name}: {comp}, {abso}\")\n", - " d.save(nn,object_name=nam,compressed=comp,absolute=abso,overwrite=True)\n", - " tmp = hs.load(nn)\n", - "exptf = list(pathlib.Path(r'C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles').glob('*'))\n", - "exptf" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t = hs.load(r\"C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Desktop\\ds_testfiles\\EXPORTED_test_spectral_map.sur\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "b'\\x19\\x00\\x00\\x00\\x1a\\x00\\x00\\x00\\x1b\\x00\\x00\\x00\\x1c\\x00\\x00\\x00\\x1d\\x00\\x00\\x00\\x1e\\x00\\x00\\x00\\x1f\\x00\\x00\\x00 \\x00\\x00\\x00!\\x00\\x00\\x00\"\\x00\\x00\\x00#\\x00\\x00\\x00$\\x00\\x00\\x00'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "i = 4\n", - "print(fl[i].name)\n", - "# d = hs.load(fl[i])\n", - "print(exptf[i].name)\n", - "ed = hs.load(exptf[i])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "a = d.metadata\n", - "a.as_dictionary()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d = hs.load(r\"C:\\Users\\NicolasTappy\\OneDrive - Attolight\\Pictures\\Untitled.jpg\")\n", - "n = savedir.joinpath(f\"EXPORTED_Untitled.sur\")\n", - "d.save(n)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "i = 1\n", - "d1 = hs.load(fl[i])\n", - "d1.save(savedir.joinpath(fl[i].name),overwrite=True)\n", - "d2 = hs.load(savedir.joinpath(fl[i].name))\n", - "for k in ['R','G','B']:\n", - " plt.figure()\n", - " plt.imshow(d1.data[k].astype(np.int16)-d2.data[k].astype(np.int16))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "aa[0].axis" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "k.plot()" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([111., 112., 113., 114., 115., 116., 117., 118., 119., 120., 121.,\n", - " 122., 123., 124., 125., 126., 127., 128., 129., 130., 131., 132.,\n", - " 133., 134.])" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import numpy as np\n", - "from rsciio.digitalsurf import file_writer,file_reader\n", - "md = { 'General': {},\n", - " 'Signal': {}}\n", - "\n", - "ax = {'name': 'X',\n", - " 'navigate': False,\n", - " }\n", - "\n", - "sd = {\"data\": np.arange(24)+111,\n", - " \"axes\": [ax],\n", - " \"metadata\": md,\n", - " \"original_metadata\": {}}\n", - "\n", - "file_writer(\"test.pro\",sd)\n", - "file_reader('test.pro')[0]['data']" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for f in fl:\n", - " print(f.name)\n", - " d = hs.load(f)\n", - " # d.plot()\n", - " # d.save(savedir.joinpath(f.name),overwrite=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for f in fl:\n", - " print(f.name)\n", - " d = hs.load(savedir.joinpath(f.name))\n", - " # d.plot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d.plot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "k = hs.load(savedir.joinpath('test_RGB.sur'))\n", - "k.original_metadata" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for f in testrgbfiles:\n", - " print(pathlib.Path(f).name)\n", - " d = hs.load(f)\n", - " d.plot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ds = DigitalSurfHandler(savedir.joinpath('test_spectra.pro'))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d.save(savedir.joinpath('test_spectra.pro'),comment='off')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "gen = hs.signals.Signal1D(np.arange(24,dtype=np.float32))\n", - "fgen = savedir.joinpath('test.pro')\n", - "gen.save(fgen,overwrite=True,is_special=False)\n", - "gen.data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "hs.load(fgen).original_metadata" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "11.5+11.5" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": {}, - "outputs": [], - "source": [ - "from rsciio.utils import rgb_tools" - ] - }, - { - "cell_type": "code", - "execution_count": 74, - "metadata": {}, - "outputs": [], - "source": [ - "# a = np.random.randint(0,65535,size=(8,3,12,14),dtype=np.uint16)\n", - "a = np.random.randint(0,65535,size=(24,12,14),dtype=np.uint16)\n", - "a = a.reshape(8,3,12,14)" - ] - }, - { - "cell_type": "code", - "execution_count": 78, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(8, 12, 14, 3)" - ] - }, - "execution_count": 78, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "np.rollaxis(a,1,4).shape" - ] - }, - { - "cell_type": "code", - "execution_count": 53, - "metadata": {}, - "outputs": [], - "source": [ - "b = rgb_tools.regular_array2rgbx(a)" - ] - }, - { - "cell_type": "code", - "execution_count": 80, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,\n", - " 42, 43, 44, 45, 46, 47, 48], dtype=int8)" - ] - }, - "execution_count": 80, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "d=hs.signals.Signal1D(np.arange(24,dtype=np.int8))+25\n", - "d.data" - ] - }, - { - "cell_type": "code", - "execution_count": 58, - "metadata": {}, - "outputs": [], - "source": [ - "c = b[:8]" - ] - }, - { - "cell_type": "code", - "execution_count": 81, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "-128" - ] - }, - "execution_count": 81, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "- 2**(8-1)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "hsdev", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.9" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 74dff77c7..a8d56b7af 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -104,7 +104,7 @@ class DigitalSurfHandler(object): 21: "_HYPCARD", } - def __init__(self, filename: str = ""): + def __init__(self, filename: str): # We do not need to check for file existence here because # io module implements it in the load function self.filename = filename @@ -954,9 +954,6 @@ def _get_Zname_Zunit(self, metadata: dict): elif len(quantity) == 1: Zname = quantity.pop() Zunit = "" - else: - Zname = "" - Zunit = "" return Zname, Zunit @@ -1088,20 +1085,20 @@ def _build_workdict( # _49_Obsolete comment_len = len(f"{comment}".encode("latin-1")) - if comment_len > 2**15: + if comment_len >= 2**15: warnings.warn( f"Comment exceeding max length of 32.0 kB and will be cropped" ) - comment_len = np.int16(2**15) + comment_len = np.int16(2**15-1) self._work_dict["_50_Comment_size"]["value"] = comment_len privatesize = len(private_zone) - if privatesize > 2**15: + if privatesize >= 2**15: warnings.warn( f"Private size exceeding max length of 32.0 kB and will be cropped" ) - privatesize = np.int16(2**15) + privatesize = np.uint16(2**15-1) self._work_dict["_51_Private_size"]["value"] = privatesize @@ -1870,7 +1867,7 @@ def _MS_parse(str_ms, prefix, delimiter): # Title lines start with an underscore titlestart = "{:s}_".format(prefix) - keymain = None + key_main = None for line in str_ms.splitlines(): # Here we ignore any empty line or line starting with @@ @@ -1884,8 +1881,8 @@ def _MS_parse(str_ms, prefix, delimiter): key_main = line[len(titlestart) :].strip() dict_ms[key_main] = {} elif line.startswith(prefix): - if keymain is None: - keymain = "UNTITLED" + if key_main is None: + key_main = "UNTITLED" dict_ms[key_main] = {} key, *li_value = line.split(delimiter) # Key is also stripped from beginning or end whitespace @@ -2006,9 +2003,9 @@ def _stringify_dict(omd: dict): if has_units: _ = keys_queue.pop(ku_idx) vu = vals_queue.pop(ku_idx) - cmtstr += f"${k} = {v.__repr__()} {vu}\n" + cmtstr += f"${k} = {v.__str__()} {vu}\n" else: - cmtstr += f"${k} = {v.__repr__()}\n" + cmtstr += f"${k} = {v.__str__()}\n" return cmtstr @@ -2218,8 +2215,7 @@ def _unpack_data(self, file, encoding="latin-1"): # set to 0 instead of 1 in non-spectral data to compute the # space occupied by data in the file readsize = Npts_tot * psize * Wsize - # if Wsize != 0: - # readsize *= Wsize + buf = file.read(readsize) # Read the exact size of the data _points = np.frombuffer(buf, dtype=dtype) @@ -2374,15 +2370,15 @@ def file_writer( %s set_comments : str , default = 'auto' Whether comments should be a simplified version original_metadata ('auto'), - the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied - by the user as an additional kwarg ('custom'). + the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied + by the user as an additional kwarg ('custom'). is_special : bool , default = False - If True, NaN values in the dataset or integers reaching the boundary of the - signed int-representation are flagged as non-measured or saturating, - respectively. If False, those values are not flagged (converted to valid points). + If True, NaN values in the dataset or integers reaching the boundary of the + signed int-representation are flagged as non-measured or saturating, + respectively. If False, those values are not flagged (converted to valid points). compressed : bool, default =True - If True, compress the data in the export file using zlib. Can help dramatically - reduce the file size. + If True, compress the data in the export file using zlib. Can help dramatically + reduce the file size. comments : dict, default = {} Set a custom dictionnary in the comments field of the exported file. Ignored if set_comments is not set to 'custom'. diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 71cf75873..5dcd59780 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -141,7 +141,7 @@ def test_invalid_data(): - dsh = DigitalSurfHandler() + dsh = DigitalSurfHandler('untitled.sur') with pytest.raises(MountainsMapFileError): dsh._Object_type = "INVALID" @@ -435,7 +435,7 @@ def test_load_surface(): def test_choose_signal_type(): - reader = DigitalSurfHandler() + reader = DigitalSurfHandler('untitled.sur') # Empty dict should not raise error but return empty string mock_dict = {} @@ -599,6 +599,8 @@ def test_writetestobjects(tmp_path, test_object): assert np.allclose(d2.data, d.data) assert np.allclose(d2.data, d3.data) + assert d.metadata.Signal.quantity == d2.metadata.Signal.quantity + assert d.metadata.Signal.quantity == d3.metadata.Signal.quantity a = d.axes_manager.navigation_axes b = d2.axes_manager.navigation_axes @@ -607,6 +609,10 @@ def test_writetestobjects(tmp_path, test_object): for ax, ax2, ax3 in zip(a, b, c): assert np.allclose(ax.axis, ax2.axis) assert np.allclose(ax.axis, ax3.axis) + assert ax.name == ax2.name + assert ax.name == ax3.name + assert ax.units == ax2.units + assert ax.units == ax3.units a = d.axes_manager.signal_axes b = d2.axes_manager.signal_axes @@ -615,6 +621,10 @@ def test_writetestobjects(tmp_path, test_object): for ax, ax2, ax3 in zip(a, b, c): assert np.allclose(ax.axis, ax2.axis) assert np.allclose(ax.axis, ax3.axis) + assert ax.name == ax2.name + assert ax.name == ax3.name + assert ax.units == ax2.units + assert ax.units == ax3.units @pytest.mark.parametrize( @@ -650,7 +660,7 @@ def test_split(test_tuple): @pytest.mark.parametrize("special", [True, False]) @pytest.mark.parametrize("fullscale", [True, False]) def test_norm_int_data(dtype, special, fullscale): - dh = DigitalSurfHandler() + dh = DigitalSurfHandler('untitled.sur') if fullscale: minint = np.iinfo(dtype).min @@ -678,6 +688,7 @@ def test_norm_int_data(dtype, special, fullscale): assert Zmin == off assert Zmax == maxval + @pytest.mark.parametrize("transpose", [True, False]) def test_writetestobjects_rgb(tmp_path,transpose): # This is just a different test function because the @@ -718,6 +729,7 @@ def test_writetestobjects_rgb(tmp_path,transpose): assert np.allclose(ax.axis, ax2.axis) assert np.allclose(ax.axis, ax3.axis) + @pytest.mark.parametrize( "dtype", [np.int8, np.int16, np.int32, np.float64, np.uint8, np.uint16] ) @@ -732,6 +744,7 @@ def test_writegeneric_validtypes(tmp_path, dtype, compressed): gen2 = hs.load(fgen) assert np.allclose(gen2.data, gen.data) + @pytest.mark.parametrize("compressed", [True, False]) def test_writegeneric_nans(tmp_path, compressed): """This test establishes the capability of saving a generic signal @@ -748,6 +761,7 @@ def test_writegeneric_nans(tmp_path, compressed): gen2 = hs.load(fgen) assert np.allclose(gen2.data, gen.data, equal_nan=True) + def test_writegeneric_transposedprofile(tmp_path): """This test checks the expected behaviour that a transposed profile gets correctly saved but a warning is raised.""" @@ -762,6 +776,26 @@ def test_writegeneric_transposedprofile(tmp_path): gen2 = hs.load(fgen) assert np.allclose(gen2.data, gen.data) + +def test_writegeneric_transposedsurface(tmp_path,): + """This test establishes the possibility of saving RGBA surface series while discarding + A channel and warning""" + size = (44, 58) + + gen = hs.signals.Signal2D( + np.random.random(size=size)*1e4 + ) + gen = gen.T + + fgen = tmp_path.joinpath("test.sur") + + gen.save(fgen, overwrite=True) + + gen2 = hs.load(fgen) + + assert np.allclose(gen.data, gen2.data) + + @pytest.mark.parametrize( "dtype", [ @@ -776,6 +810,7 @@ def test_writegeneric_failingtypes(tmp_path, dtype): with pytest.raises(MountainsMapFileError): gen.save(fgen, overwrite=True) + def test_writegeneric_failingformat(tmp_path): gen = hs.signals.Signal1D(np.zeros((3,4,5,6))) fgen = tmp_path.joinpath("test.sur") @@ -920,3 +955,46 @@ def test_writegeneric_surfaceseries(tmp_path, dtype, compressed): gen2 = hs.load(fgen) assert np.allclose(gen.data, gen2.data) + + +def test_writegeneric_datetime(tmp_path): + + gen = hs.signals.Signal1D(np.random.rand(87)) + gen.metadata.General.date = '2024-06-30' + gen.metadata.General.time = '13:29:10' + + fgen = tmp_path.joinpath("test.pro") + gen.save(fgen) + + gen2 = hs.load(fgen) + assert gen2.original_metadata.Object_0_Channel_0.Header.H40_Seconds == 10 + assert gen2.original_metadata.Object_0_Channel_0.Header.H41_Minutes == 29 + assert gen2.original_metadata.Object_0_Channel_0.Header.H42_Hours == 13 + assert gen2.original_metadata.Object_0_Channel_0.Header.H43_Day == 30 + assert gen2.original_metadata.Object_0_Channel_0.Header.H44_Month == 6 + assert gen2.original_metadata.Object_0_Channel_0.Header.H45_Year == 2024 + assert gen2.original_metadata.Object_0_Channel_0.Header.H46_Day_of_week == 6 + + +def test_writegeneric_comments(tmp_path): + + gen = hs.signals.Signal1D(np.random.rand(87)) + fgen = tmp_path.joinpath("test.pro") + + res = "".join(["a" for i in range(2**15+2)]) + cmt = {'comment': res} + + with pytest.raises(MountainsMapFileError): + gen.save(fgen,set_comments='somethinginvalid') + + with pytest.warns(): + gen.save(fgen,set_comments='custom',comments=cmt) + + gen2 = hs.load(fgen) + assert gen2.original_metadata.Object_0_Channel_0.Parsed.UNTITLED.comment.startswith('a') + assert len(gen2.original_metadata.Object_0_Channel_0.Parsed.UNTITLED.comment) < 2**15-1 + + priv = res.encode('latin-1') + with pytest.warns(): + gen.save(fgen,private_zone=priv,overwrite=True) + From e6744334d47c69efaaf3b3bc852ab3fce3bb632d Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Mon, 1 Jul 2024 15:07:08 +0200 Subject: [PATCH 136/174] Cleanup obsolete testfile --- rsciio/digitalsurf/_api.py | 8 ++++---- rsciio/tests/data/digitalsurf/test_isurface.sur | Bin 56141 -> 0 bytes rsciio/tests/registry.txt | 1 - rsciio/tests/test_digitalsurf.py | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 rsciio/tests/data/digitalsurf/test_isurface.sur diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index a8880a25b..b33f331ab 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -2382,14 +2382,14 @@ def file_writer( set_comments : str , default = 'auto' Whether comments should be a simplified version original_metadata ('auto'), the raw original_metadata dictionary ('raw'), skipped ('off'), or supplied - by the user as an additional kwarg ('custom'). + by the user as an additional kwarg ('custom'). is_special : bool , default = False If True, NaN values in the dataset or integers reaching the boundary of the - signed int-representation are flagged as non-measured or saturating, - respectively. If False, those values are not flagged (converted to valid points). + signed int-representation are flagged as non-measured or saturating, + respectively. If False, those values are not flagged (converted to valid points). compressed : bool, default =True If True, compress the data in the export file using zlib. Can help dramatically - reduce the file size. + reduce the file size. comments : dict, default = {} Set a custom dictionnary in the comments field of the exported file. Ignored if set_comments is not set to 'custom'. diff --git a/rsciio/tests/data/digitalsurf/test_isurface.sur b/rsciio/tests/data/digitalsurf/test_isurface.sur deleted file mode 100644 index 2719726e8197eef500378a04998f2d7935ebf709..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 56141 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

{n-rzvR`z$>6l`Z#MDD|48m+PY~-Ux&GnBz;O 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 Stb+VAo_J;GgZ}||Cx8{~Jac*g diff --git a/rsciio/tests/registry.txt b/rsciio/tests/registry.txt index 593391ed2..ad2418035 100644 --- a/rsciio/tests/registry.txt +++ b/rsciio/tests/registry.txt @@ -131,7 +131,6 @@ 'digitalmicrograph/test_stackbuilder_imagestack.dm3' 41070d0fd25a838a504f705e1431735192b7a97ca7dd15d9328af5e939fe74a2 'digitalsurf/test_RGB.sur' 802f3d915bf9feb7c264ef3f1242df35033da7227e5a7a5924fd37f8f49f4778 'digitalsurf/test_RGBSURFACE.sur' 15e8b345cc5d67e7399831c881c63362fd92bc075fad8d763f3ff0d26dfe29a2 -'digitalsurf/test_isurface.sur' 6ed59a9a235c0b6dc7e15f155d0e738c5841cfc0fe78f1861b7e145f9dcaadf4 'digitalsurf/test_profile.pro' fdd9936a4b5e205b819b1d82813bb21045b702b4610e8ef8d1d0932d63344f6d 'digitalsurf/test_spectra.pro' ea1602de193b73046beb5e700fcac727fb088bf459edeec3494b0362a41bdcb1 'digitalsurf/test_spectral_map.sur' f9c863e3fd61be89c3b68cef6fa2434ffedc7e486efe2263c2241109fa58c3f7 diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 4f08ce398..7a4ab0b16 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -409,7 +409,7 @@ def test_load_spectrum(): def test_load_surface(): - fname = TEST_DATA_PATH / "test_isurface.sur" + fname = TEST_DATA_PATH / "test_surface.sur" s = hs.load(fname) md = s.metadata assert md.Signal.quantity == "CL Intensity (a.u.)" @@ -580,7 +580,7 @@ def test_get_comment_dict(): "test_spectral_map_compressed.sur", "test_spectrum.pro", "test_spectrum_compressed.pro", - "test_isurface.sur", + "test_surface.sur", "test_RGBSURFACE.sur", ], ) From ba3cf93f09ca12f8039c946df016943e84ed52a5 Mon Sep 17 00:00:00 2001 From: wieczoth Date: Wed, 3 Jul 2024 07:35:11 +0200 Subject: [PATCH 137/174] changed var scale to fields_of_views, set unit to None --- rsciio/utils/image.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rsciio/utils/image.py b/rsciio/utils/image.py index a85a77896..115ce91a9 100644 --- a/rsciio/utils/image.py +++ b/rsciio/utils/image.py @@ -41,15 +41,16 @@ def _parse_axes_from_metadata(exif_tags, sizes): if exif_tags is None: - # if no exif_tags exist, axes are set to scale of 1 pixel/pixel # return of axes must not be empty, or dimensions are lost + # if no exif_tags exist, axes are set to a scale of 1 per pixel, + # unit is set to None, hyperspy will parse it as a traits.api.undefined value offsets = [0,0] - scales = [sizes[1],sizes[0]] - unit = "pixel" + fields_of_views = [sizes[1],sizes[0]] + unit = None else: offsets = exif_tags.get("FocalPlaneXYOrigins", [0, 0]) # jpg files made with Renishaw have this tag - scales = exif_tags.get("FieldOfViewXY", [1, 1]) + fields_of_views = exif_tags.get("FieldOfViewXY", [1, 1]) unit = FocalPlaneResolutionUnit_mapping[ exif_tags.get("FocalPlaneResolutionUnit", "") @@ -60,7 +61,7 @@ def _parse_axes_from_metadata(exif_tags, sizes): "name": name, "units": unit, "size": size, - "scale": scales[i] / size, + "scale": fields_of_views[i] / size, "offset": offsets[i], "index_in_array": i, } From 48fcac900c6fee66c49e44ec95b6966c182bd83b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 3 Jul 2024 18:24:57 +0100 Subject: [PATCH 138/174] Fix setting scale when `exif_tags` exists but the `FieldOfViewXY` key is not present --- rsciio/utils/image.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/rsciio/utils/image.py b/rsciio/utils/image.py index 115ce91a9..9efc74987 100644 --- a/rsciio/utils/image.py +++ b/rsciio/utils/image.py @@ -30,8 +30,8 @@ # from https://exiftool.org/TagNames/EXIF.html # For tag 0x9210 (37392) FocalPlaneResolutionUnit_mapping = { - "": "", - 1: "", + None: None, + 1: None, 2: "inches", 3: "cm", 4: "mm", @@ -40,21 +40,20 @@ def _parse_axes_from_metadata(exif_tags, sizes): - if exif_tags is None: - # return of axes must not be empty, or dimensions are lost - # if no exif_tags exist, axes are set to a scale of 1 per pixel, - # unit is set to None, hyperspy will parse it as a traits.api.undefined value - offsets = [0,0] - fields_of_views = [sizes[1],sizes[0]] - unit = None - else: - offsets = exif_tags.get("FocalPlaneXYOrigins", [0, 0]) + # return of axes must not be empty, or dimensions are lost + # if no exif_tags exist, axes are set to a scale of 1 per pixel, + # unit is set to None, hyperspy will parse it as a traits.api.undefined value + offsets = [0, 0] + fields_of_views = [sizes[1], sizes[0]] + unit = None + if exif_tags is not None: + # Fallback to default value when tag not available + offsets = exif_tags.get("FocalPlaneXYOrigins", offsets) # jpg files made with Renishaw have this tag - fields_of_views = exif_tags.get("FieldOfViewXY", [1, 1]) - + fields_of_views = exif_tags.get("FieldOfViewXY", fields_of_views) unit = FocalPlaneResolutionUnit_mapping[ - exif_tags.get("FocalPlaneResolutionUnit", "") - ] + exif_tags.get("FocalPlaneResolutionUnit", unit) + ] axes = [ { From 881ce04c25538ebdea7129e9a30b553c56526206 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 3 Jul 2024 18:40:22 +0100 Subject: [PATCH 139/174] Add tests --- rsciio/tests/test_image.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rsciio/tests/test_image.py b/rsciio/tests/test_image.py index 83b256b4b..d56ab6023 100644 --- a/rsciio/tests/test_image.py +++ b/rsciio/tests/test_image.py @@ -298,3 +298,17 @@ def test_export_output_size_iterable_length_1(tmp_path): fname = tmp_path / "test_export_output_size_iterable_length_1.jpg" with pytest.raises(ValueError): s.save(fname, output_size=(256,)) + + +def test_missing_exif_tags(): + hs = pytest.importorskip("hyperspy.api", reason="hyperspy not installed") + import traits.api as t + + s = hs.load(testfile_dir / "jpg_no_exif_tags.jpg") + + assert s.data.shape == (182, 255) + assert s.axes_manager.signal_shape == (255, 182) + for axis in s.axes_manager.signal_axes: + assert axis.scale == 1 + assert axis.offset == 0 + assert axis.units == t.Undefined From 6b1fdcf52e69043f4fc1b1ae15d775d5e56dc15f Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 3 Jul 2024 18:43:43 +0100 Subject: [PATCH 140/174] Fix documentation link --- doc/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/index.rst b/doc/index.rst index a07954ae9..67d3c9dfa 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -41,7 +41,7 @@ Citing RosettaSciIO If RosettaSciIO has been significant to a project that leads to an academic publication, please acknowledge that fact by citing it. The DOI in the -badge below is the `Concept DOI `_ -- +badge below is the `Concept DOI `_ -- it can be used to cite the project without referring to a specific version. If you are citing RosettaSciIO because you have used it to process data, please use the DOI of the specific version that you have employed. You can From 2b38d85af15c2c785006ab7b1007ab8d55b552e2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Jul 2024 18:01:12 +0000 Subject: [PATCH 141/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/tests/registry.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/rsciio/tests/registry.txt b/rsciio/tests/registry.txt index b8b9f1f36..ba250e0aa 100644 --- a/rsciio/tests/registry.txt +++ b/rsciio/tests/registry.txt @@ -176,6 +176,7 @@ 'hspy/test_marker_point_y2_data_deleted.hdf5' 11f24a1d91b3157c12e01929d8bfee9757a5cc29281a6220c13f1638cc3ca49c 'hspy/test_rgba16.hdf5' 5d76658ae9a9416cbdcb239059ee20d640deb634120e1fa051e3199534c47270 'hspy/with_lists_etc.hdf5' 16ed9d4bcb44ba3510963c102eab888b89516921cd4acc4fdb85271407dae562 +'image/jpg_no_exif_tags.jpg' 1419d3a72f5f19094a7a1f5ae9f8ce11f7b3ad9d82f7a81a2b4ffd944ffcb3cd 'image/renishaw_wire.jpg' 21d34f130568e161a3b2c8a213aa28991880ca0265aec8bfa3c6ca4d9897540c 'impulse/NoMetadata_Synchronized data.csv' 3031a84b6df77f3cfe3808fcf993f3cf95b6a9f67179524200b3129a5de47ef5 'impulse/StubExperiment_Heat raw.csv' 114ebae61321ceed4c071d35e1240a51c2a3bfe37ff9d507cacb7a7dd3977703 From 694b7934cf3b1a907ccf22792fa93ced6ec85624 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 00:05:43 +0000 Subject: [PATCH 142/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.10 → v0.5.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.10...v0.5.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a6e9a168b..6bb01536d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.10 + rev: v0.5.0 hooks: # Run the linter. - id: ruff From c8349929b2ef8127595a3052dd491b8f18842979 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 3 Jul 2024 19:45:18 +0100 Subject: [PATCH 143/174] Fix class comparison --- rsciio/_hierarchical.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsciio/_hierarchical.py b/rsciio/_hierarchical.py index 5bf1e9851..843ee3c9f 100644 --- a/rsciio/_hierarchical.py +++ b/rsciio/_hierarchical.py @@ -74,7 +74,7 @@ def unflatten_data(data, shape, is_hdf5=False): is_hdf5 and data.dtype is not None and data.dtype.metadata.get("vlen") is not None - and data.dtype.metadata["vlen"].metadata.get("vlen") == str + and issubclass(data.dtype.metadata["vlen"].metadata.get("vlen"), str) ) except (AttributeError, KeyError): # AttributeError in case `dtype.metadata`` is None (most of the time) From ed06bad8b79db319bcc55f49c32be059e178bd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Tveitst=C3=B8l?= Date: Wed, 3 Jul 2024 13:28:54 +0200 Subject: [PATCH 144/174] Fix ROI signal axes. Added tests --- rsciio/quantumdetector/_api.py | 2 +- .../Merlin_navigation4x2_signalNx256_ROI.zip | Bin 0 -> 5841 bytes rsciio/tests/test_quantumdetector.py | 29 ++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 rsciio/tests/data/quantumdetector/Merlin_navigation4x2_signalNx256_ROI.zip diff --git a/rsciio/quantumdetector/_api.py b/rsciio/quantumdetector/_api.py index 1f3c10973..500aa4d98 100644 --- a/rsciio/quantumdetector/_api.py +++ b/rsciio/quantumdetector/_api.py @@ -145,7 +145,7 @@ def parse_file(self, path): raise TypeError("`path` must be a str or a buffer.") # read detector size - self.merlin_size = (int(head[4]), int(head[5])) + self.merlin_size = (int(head[5]), int(head[4])) # test if RAW if head[6] == "R64": # pragma: no cover diff --git a/rsciio/tests/data/quantumdetector/Merlin_navigation4x2_signalNx256_ROI.zip b/rsciio/tests/data/quantumdetector/Merlin_navigation4x2_signalNx256_ROI.zip new file mode 100644 index 0000000000000000000000000000000000000000..3c7d6fe93b46f61501598ec066abbc56611bea4b GIT binary patch literal 5841 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 Date: Wed, 3 Jul 2024 20:56:36 +0100 Subject: [PATCH 145/174] Add changelog entry --- upcoming_changes/289.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changes/289.bugfix.rst diff --git a/upcoming_changes/289.bugfix.rst b/upcoming_changes/289.bugfix.rst new file mode 100644 index 000000000..cdf59acf9 --- /dev/null +++ b/upcoming_changes/289.bugfix.rst @@ -0,0 +1 @@ +:ref:`quantumdetector-format`: Fix signal shape of data acquired in ROI mode. From 53e261752db19b022cc42f0cb7cea6c63216969d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:07:31 +0000 Subject: [PATCH 146/174] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rsciio/tests/registry.txt | 1 + rsciio/tests/test_quantumdetector.py | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/rsciio/tests/registry.txt b/rsciio/tests/registry.txt index ba250e0aa..ccbc801d7 100644 --- a/rsciio/tests/registry.txt +++ b/rsciio/tests/registry.txt @@ -265,6 +265,7 @@ 'protochips/random_csv_file.csv' be37c2ef6a4edbb66b69746d8c05cf860a3e3a321237ded84ad810b2b7c7731d 'quantumdetector/Merlin_Single_Quad.zip' 38cc7f4f580502c591e4b83c25e15b2e50cdc7e678881698dcd9b17ff5096048 'quantumdetector/Merlin_navigation4x2_ROI.zip' bde0830c13d1885d822c1df81a26ef20169b84124c372cfa7f7709be0efe78af +'quantumdetector/Merlin_navigation4x2_signalNx256_ROI.zip' 77f2faccadf9b1b4f0f80262b993bc06cabf8d70357460f8b3d92a5c554dd526 'renishaw/renishaw_test_exptime10_acc1.wdf' c056dc49abaad1e7e9744562d5219f52c7a10534ef052eefd8263ad024bcf43b 'renishaw/renishaw_test_exptime1_acc1.wdf' bc23e1f2644d37dd5b572e587bbcf6db08f33dc7e1480c232b04ef17efa63ba6 'renishaw/renishaw_test_exptime1_acc2.wdf' 7fb5fb09a079d1af672d3d37c5cbf3d950a6d0783791505c6f42d7d104790711 diff --git a/rsciio/tests/test_quantumdetector.py b/rsciio/tests/test_quantumdetector.py index c401889cc..a230623b8 100644 --- a/rsciio/tests/test_quantumdetector.py +++ b/rsciio/tests/test_quantumdetector.py @@ -41,7 +41,7 @@ TEST_DATA_DIR = Path(__file__).parent / "data" / "quantumdetector" ZIP_FILE = TEST_DATA_DIR / "Merlin_Single_Quad.zip" ZIP_FILE2 = TEST_DATA_DIR / "Merlin_navigation4x2_ROI.zip" -ZIP_FILE3 = TEST_DATA_DIR / 'Merlin_navigation4x2_signalNx256_ROI.zip' +ZIP_FILE3 = TEST_DATA_DIR / "Merlin_navigation4x2_signalNx256_ROI.zip" TEST_DATA_DIR_UNZIPPED = TEST_DATA_DIR / "unzipped" @@ -58,8 +58,10 @@ for depth in [1, 6, 12, 24] ] -SIGNAL_ROI_FNAME_LIST = ['002_merlin_test_roi_sig256x128_nav4x2_hot_pixel_52x_39y.mib', - '003_merlin_test_roi_sig256x64_nav4x2_hot_pixel_52x_39y.mib'] +SIGNAL_ROI_FNAME_LIST = [ + "002_merlin_test_roi_sig256x128_nav4x2_hot_pixel_52x_39y.mib", + "003_merlin_test_roi_sig256x64_nav4x2_hot_pixel_52x_39y.mib", +] def filter_list(fname_list, string): @@ -80,7 +82,6 @@ def setup_module(): zipped.extractall(TEST_DATA_DIR_UNZIPPED) - def teardown_module(): # necessary on windows, to help closing the files... gc.collect() @@ -422,22 +423,23 @@ def test_distributed(lazy): np.testing.assert_array_equal(s.data, s2.data) -@pytest.mark.parametrize("fname", SIGNAL_ROI_FNAME_LIST) +@pytest.mark.parametrize("fname", SIGNAL_ROI_FNAME_LIST) def test_hot_pixel_signal_ROI(fname): s = hs.load(TEST_DATA_DIR_UNZIPPED / fname) for i in s: for j in i: data = j.data - xy = np.argwhere(data==data.max()) + xy = np.argwhere(data == data.max()) assert len(xy) == 1 coord_shifted = np.array(*xy) - np.array([data.shape[0], 0]) - assert np.all(coord_shifted == np.array([-40,52])) + assert np.all(coord_shifted == np.array([-40, 52])) + -@pytest.mark.parametrize('fname', SIGNAL_ROI_FNAME_LIST) +@pytest.mark.parametrize("fname", SIGNAL_ROI_FNAME_LIST) def test_signal_shape_ROI(fname): s = hs.load(TEST_DATA_DIR_UNZIPPED / fname) assert s.axes_manager.navigation_shape == (4, 2) - if 'sig256x64' in fname: - assert s.axes_manager.signal_shape == (256,64) - if 'sig256x128' in fname: - assert s.axes_manager.signal_shape == (256,128) \ No newline at end of file + if "sig256x64" in fname: + assert s.axes_manager.signal_shape == (256, 64) + if "sig256x128" in fname: + assert s.axes_manager.signal_shape == (256, 128) From 8440db18eb96c15e90d6a1fc22818b1cbe9ba275 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Thu, 4 Jul 2024 10:24:18 +0200 Subject: [PATCH 147/174] Exposed parse_metadata to public api --- rsciio/digitalsurf/__init__.py | 4 +- rsciio/digitalsurf/_api.py | 128 +++++++++++++++++---------------- rsciio/tests/test_import.py | 5 ++ 3 files changed, 74 insertions(+), 63 deletions(-) diff --git a/rsciio/digitalsurf/__init__.py b/rsciio/digitalsurf/__init__.py index 49230cbba..4627e25ea 100644 --- a/rsciio/digitalsurf/__init__.py +++ b/rsciio/digitalsurf/__init__.py @@ -1,6 +1,6 @@ -from ._api import file_reader, file_writer +from ._api import file_reader, file_writer, parse_metadata -__all__ = ["file_reader", "file_writer"] +__all__ = ["file_reader", "file_writer", "parse_metadata"] def __dir__(): diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index b33f331ab..7689bd474 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -59,6 +59,72 @@ _logger = logging.getLogger(__name__) +def parse_metadata(cmt : str, prefix : str = '$', delimiter : str = '=') -> dict: + """ + Parse metadata from the comment field of a digitalsurf file, or any other + str in similar formatting. Return it as a hyperspy-compatible nested dict. + + Parameters + ---------- + cmt : str + Str containing contents of a digitalsurf file "comment" field. + prefix : str, default = '$' + Prefix character, must be present at the start of each line. + Otherwise, the line is ignored. '$' for digitalsurf files, + typically '' when parsing from text files. + delimiter : string, default = '=' + Character that delimit key-value pairs in digitalsurf comment. + Usually, '=' is used. + + Returns + ------- + dict_md : dict + Nested dictionnary containing comment contents. + + """ + # dict_ms is created as an empty dictionnary + dict_md = {} + # Title lines start with an underscore + titlestart = "{:s}_".format(prefix) + + key_main = None + + for line in cmt.splitlines(): + # Here we ignore any empty line or line starting with @@ + ignore = False + if not line.strip() or line.startswith("@@"): + ignore = True + # If the line must not be ignored + if not ignore: + if line.startswith(titlestart): + # We strip keys from whitespace at the end and beginning + key_main = line[len(titlestart) :].strip() + dict_md[key_main] = {} + elif line.startswith(prefix): + if key_main is None: + key_main = "UNTITLED" + dict_md[key_main] = {} + key, *li_value = line.split(delimiter) + # Key is also stripped from beginning or end whitespace + key = key[len(prefix) :].strip() + str_value = li_value[0] if len(li_value) > 0 else "" + # remove whitespace at the beginning of value + str_value = str_value.strip() + li_value = str_value.split(" ") + try: + if key == "Grating": + dict_md[key_main][key] = li_value[ + 0 + ] # we don't want to eval this one + else: + dict_md[key_main][key] = ast.literal_eval(li_value[0]) + except Exception: + dict_md[key_main][key] = li_value[0] + if len(li_value) > 1: + dict_md[key_main][key + "_units"] = li_value[1] + return dict_md + + class DigitalSurfHandler(object): """Class to read Digital Surf MountainsMap files. @@ -1657,7 +1723,7 @@ def _build_original_metadata( # Check if it is the case and append it to original metadata if yes valid_comment = self._check_comments(a["_60_Comment"], "$", "=") if valid_comment: - parsedict = self._MS_parse(a["_60_Comment"], "$", "=") + parsedict = parse_metadata(a["_60_Comment"], "$", "=") parsedict = {k.lstrip("_"): m for k, m in parsedict.items()} original_metadata_dict[key].update({"Parsed": parsedict}) @@ -1850,66 +1916,6 @@ def _check_comments(commentsstr, prefix, delimiter): # return falsiness of the string. return valid - @staticmethod - def _MS_parse(str_ms, prefix, delimiter): - """Parses a string containing metadata information. The string can be - read from the comment section of a .sur file, or, alternatively, a file - containing them with a similar formatting. - - Parameters - ---------- - str_ms: string containing metadata - prefix: string (or char) character assumed to start each line. - '$' if a .sur file. - delimiter: string that delimits the keyword from value. always '=' - - Returns - ------- - dict_ms: dictionnary in the correct hyperspy metadata format - - """ - # dict_ms is created as an empty dictionnary - dict_ms = {} - # Title lines start with an underscore - titlestart = "{:s}_".format(prefix) - - key_main = None - - for line in str_ms.splitlines(): - # Here we ignore any empty line or line starting with @@ - ignore = False - if not line.strip() or line.startswith("@@"): - ignore = True - # If the line must not be ignored - if not ignore: - if line.startswith(titlestart): - # We strip keys from whitespace at the end and beginning - key_main = line[len(titlestart) :].strip() - dict_ms[key_main] = {} - elif line.startswith(prefix): - if key_main is None: - key_main = "UNTITLED" - dict_ms[key_main] = {} - key, *li_value = line.split(delimiter) - # Key is also stripped from beginning or end whitespace - key = key[len(prefix) :].strip() - str_value = li_value[0] if len(li_value) > 0 else "" - # remove whitespace at the beginning of value - str_value = str_value.strip() - li_value = str_value.split(" ") - try: - if key == "Grating": - dict_ms[key_main][key] = li_value[ - 0 - ] # we don't want to eval this one - else: - dict_ms[key_main][key] = ast.literal_eval(li_value[0]) - except Exception: - dict_ms[key_main][key] = li_value[0] - if len(li_value) > 1: - dict_ms[key_main][key + "_units"] = li_value[1] - return dict_ms - @staticmethod def _get_comment_dict( original_metadata: dict, method: str = "auto", custom: dict = {} diff --git a/rsciio/tests/test_import.py b/rsciio/tests/test_import.py index 770014183..53b11358c 100644 --- a/rsciio/tests/test_import.py +++ b/rsciio/tests/test_import.py @@ -140,6 +140,11 @@ def test_dir_plugins(plugin): "parse_exposures", "parse_timestamps", ] + elif plugin["name"] == "DigitalSurf": + assert dir(plugin_module) == [ + "file_reader", + "file_writer", + "parse_metadata"] elif plugin["writes"] is False: assert dir(plugin_module) == ["file_reader"] else: From 3d842692f8d6bba490d8d92313761985e85343ee Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Thu, 4 Jul 2024 10:28:24 +0200 Subject: [PATCH 148/174] docstring updates --- rsciio/digitalsurf/_api.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 7689bd474..86a255938 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -68,18 +68,18 @@ def parse_metadata(cmt : str, prefix : str = '$', delimiter : str = '=') -> dict ---------- cmt : str Str containing contents of a digitalsurf file "comment" field. - prefix : str, default = '$' - Prefix character, must be present at the start of each line. - Otherwise, the line is ignored. '$' for digitalsurf files, - typically '' when parsing from text files. - delimiter : string, default = '=' + prefix : str + Prefix character, must be present at the start of each line, + otherwise the line is ignored. ``"$"`` for digitalsurf files, + typically an empty string (``""``) when parsing from text files. + Default is ``"$"``. + delimiter : str Character that delimit key-value pairs in digitalsurf comment. - Usually, '=' is used. - + Default is ``"="``. Returns ------- - dict_md : dict - Nested dictionnary containing comment contents. + dict + Nested dictionnary of the metadata. """ # dict_ms is created as an empty dictionnary From 3319edd6111d576a20bc05303271c95a435efd87 Mon Sep 17 00:00:00 2001 From: Nicolas Tappy Date: Thu, 4 Jul 2024 10:36:17 +0200 Subject: [PATCH 149/174] More linting --- rsciio/digitalsurf/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index 86a255938..fc751b4b6 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -76,11 +76,11 @@ def parse_metadata(cmt : str, prefix : str = '$', delimiter : str = '=') -> dict delimiter : str Character that delimit key-value pairs in digitalsurf comment. Default is ``"="``. + Returns ------- dict Nested dictionnary of the metadata. - """ # dict_ms is created as an empty dictionnary dict_md = {} From 53341dd1d0f425f16ae69b7548cf86b2e0b50a43 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Thu, 4 Jul 2024 09:48:26 +0100 Subject: [PATCH 150/174] ruff fixes --- rsciio/digitalsurf/_api.py | 16 ++++----- rsciio/tests/test_digitalsurf.py | 61 +++++++++++++++----------------- rsciio/tests/test_import.py | 5 +-- 3 files changed, 37 insertions(+), 45 deletions(-) diff --git a/rsciio/digitalsurf/_api.py b/rsciio/digitalsurf/_api.py index fc751b4b6..cdc78e718 100644 --- a/rsciio/digitalsurf/_api.py +++ b/rsciio/digitalsurf/_api.py @@ -59,7 +59,7 @@ _logger = logging.getLogger(__name__) -def parse_metadata(cmt : str, prefix : str = '$', delimiter : str = '=') -> dict: +def parse_metadata(cmt: str, prefix: str = "$", delimiter: str = "=") -> dict: """ Parse metadata from the comment field of a digitalsurf file, or any other str in similar formatting. Return it as a hyperspy-compatible nested dict. @@ -70,11 +70,11 @@ def parse_metadata(cmt : str, prefix : str = '$', delimiter : str = '=') -> dict Str containing contents of a digitalsurf file "comment" field. prefix : str Prefix character, must be present at the start of each line, - otherwise the line is ignored. ``"$"`` for digitalsurf files, + otherwise the line is ignored. ``"$"`` for digitalsurf files, typically an empty string (``""``) when parsing from text files. Default is ``"$"``. delimiter : str - Character that delimit key-value pairs in digitalsurf comment. + Character that delimit key-value pairs in digitalsurf comment. Default is ``"="``. Returns @@ -919,7 +919,7 @@ def _norm_data(self, data: np.ndarray, is_special: bool): raise MountainsMapFileError( "digitalsurf file formats do not support export of complex data. Convert data to real-value representations before before export" ) - elif data_type == bool: + elif np.issubdtype(data_type, bool): pointsize = 16 Zmin = 0 Zmax = 1 @@ -1157,10 +1157,8 @@ def _build_workdict( comment_len = len(f"{comment}".encode("latin-1")) if comment_len >= 2**15: - warnings.warn( - f"Comment exceeding max length of 32.0 kB and will be cropped" - ) - comment_len = np.int16(2**15-1) + warnings.warn("Comment exceeding max length of 32.0 kB and will be cropped") + comment_len = np.int16(2**15 - 1) self._work_dict["_50_Comment_size"]["value"] = comment_len @@ -1169,7 +1167,7 @@ def _build_workdict( warnings.warn( "Private size exceeding max length of 32.0 kB and will be cropped" ) - privatesize = np.uint16(2**15-1) + privatesize = np.uint16(2**15 - 1) self._work_dict["_51_Private_size"]["value"] = privatesize diff --git a/rsciio/tests/test_digitalsurf.py b/rsciio/tests/test_digitalsurf.py index 7a4ab0b16..d50d7e134 100644 --- a/rsciio/tests/test_digitalsurf.py +++ b/rsciio/tests/test_digitalsurf.py @@ -141,7 +141,7 @@ def test_invalid_data(): - dsh = DigitalSurfHandler('untitled.sur') + dsh = DigitalSurfHandler("untitled.sur") with pytest.raises(MountainsMapFileError): dsh._Object_type = "INVALID" @@ -435,7 +435,7 @@ def test_load_surface(): def test_choose_signal_type(): - reader = DigitalSurfHandler('untitled.sur') + reader = DigitalSurfHandler("untitled.sur") # Empty dict should not raise error but return empty string mock_dict = {} @@ -659,7 +659,7 @@ def test_split(test_tuple): @pytest.mark.parametrize("special", [True, False]) @pytest.mark.parametrize("fullscale", [True, False]) def test_norm_int_data(dtype, special, fullscale): - dh = DigitalSurfHandler('untitled.sur') + dh = DigitalSurfHandler("untitled.sur") if fullscale: minint = np.iinfo(dtype).min @@ -688,7 +688,6 @@ def test_norm_int_data(dtype, special, fullscale): assert Zmax == maxval - @pytest.mark.parametrize("transpose", [True, False]) def test_writetestobjects_rgb(tmp_path, transpose): # This is just a different test function because the @@ -730,7 +729,6 @@ def test_writetestobjects_rgb(tmp_path, transpose): assert np.allclose(ax.axis, ax3.axis) - @pytest.mark.parametrize( "dtype", [np.int8, np.int16, np.int32, np.float64, np.uint8, np.uint16] ) @@ -746,7 +744,6 @@ def test_writegeneric_validtypes(tmp_path, dtype, compressed): assert np.allclose(gen2.data, gen.data) - @pytest.mark.parametrize("compressed", [True, False]) def test_writegeneric_nans(tmp_path, compressed): """This test establishes the capability of saving a generic signal @@ -764,7 +761,6 @@ def test_writegeneric_nans(tmp_path, compressed): assert np.allclose(gen2.data, gen.data, equal_nan=True) - def test_writegeneric_transposedprofile(tmp_path): """This test checks the expected behaviour that a transposed profile gets correctly saved but a warning is raised.""" @@ -780,14 +776,14 @@ def test_writegeneric_transposedprofile(tmp_path): assert np.allclose(gen2.data, gen.data) -def test_writegeneric_transposedsurface(tmp_path,): +def test_writegeneric_transposedsurface( + tmp_path, +): """This test establishes the possibility of saving RGBA surface series while discarding A channel and warning""" size = (44, 58) - - gen = hs.signals.Signal2D( - np.random.random(size=size)*1e4 - ) + + gen = hs.signals.Signal2D(np.random.random(size=size) * 1e4) gen = gen.T fgen = tmp_path.joinpath("test.sur") @@ -814,7 +810,6 @@ def test_writegeneric_failingtypes(tmp_path, dtype): gen.save(fgen, overwrite=True) - def test_writegeneric_failingformat(tmp_path): gen = hs.signals.Signal1D(np.zeros((3, 4, 5, 6))) fgen = tmp_path.joinpath("test.sur") @@ -961,43 +956,45 @@ def test_writegeneric_surfaceseries(tmp_path, dtype, compressed): def test_writegeneric_datetime(tmp_path): - gen = hs.signals.Signal1D(np.random.rand(87)) - gen.metadata.General.date = '2024-06-30' - gen.metadata.General.time = '13:29:10' - + gen.metadata.General.date = "2024-06-30" + gen.metadata.General.time = "13:29:10" + fgen = tmp_path.joinpath("test.pro") gen.save(fgen) gen2 = hs.load(fgen) assert gen2.original_metadata.Object_0_Channel_0.Header.H40_Seconds == 10 assert gen2.original_metadata.Object_0_Channel_0.Header.H41_Minutes == 29 - assert gen2.original_metadata.Object_0_Channel_0.Header.H42_Hours == 13 - assert gen2.original_metadata.Object_0_Channel_0.Header.H43_Day == 30 - assert gen2.original_metadata.Object_0_Channel_0.Header.H44_Month == 6 - assert gen2.original_metadata.Object_0_Channel_0.Header.H45_Year == 2024 + assert gen2.original_metadata.Object_0_Channel_0.Header.H42_Hours == 13 + assert gen2.original_metadata.Object_0_Channel_0.Header.H43_Day == 30 + assert gen2.original_metadata.Object_0_Channel_0.Header.H44_Month == 6 + assert gen2.original_metadata.Object_0_Channel_0.Header.H45_Year == 2024 assert gen2.original_metadata.Object_0_Channel_0.Header.H46_Day_of_week == 6 def test_writegeneric_comments(tmp_path): - gen = hs.signals.Signal1D(np.random.rand(87)) fgen = tmp_path.joinpath("test.pro") - res = "".join(["a" for i in range(2**15+2)]) - cmt = {'comment': res} + res = "".join(["a" for i in range(2**15 + 2)]) + cmt = {"comment": res} with pytest.raises(MountainsMapFileError): - gen.save(fgen,set_comments='somethinginvalid') + gen.save(fgen, set_comments="somethinginvalid") with pytest.warns(): - gen.save(fgen,set_comments='custom',comments=cmt) - + gen.save(fgen, set_comments="custom", comments=cmt) + gen2 = hs.load(fgen) - assert gen2.original_metadata.Object_0_Channel_0.Parsed.UNTITLED.comment.startswith('a') - assert len(gen2.original_metadata.Object_0_Channel_0.Parsed.UNTITLED.comment) < 2**15-1 + assert gen2.original_metadata.Object_0_Channel_0.Parsed.UNTITLED.comment.startswith( + "a" + ) + assert ( + len(gen2.original_metadata.Object_0_Channel_0.Parsed.UNTITLED.comment) + < 2**15 - 1 + ) - priv = res.encode('latin-1') + priv = res.encode("latin-1") with pytest.warns(): - gen.save(fgen,private_zone=priv,overwrite=True) - + gen.save(fgen, private_zone=priv, overwrite=True) diff --git a/rsciio/tests/test_import.py b/rsciio/tests/test_import.py index 53b11358c..dbe849fb1 100644 --- a/rsciio/tests/test_import.py +++ b/rsciio/tests/test_import.py @@ -141,10 +141,7 @@ def test_dir_plugins(plugin): "parse_timestamps", ] elif plugin["name"] == "DigitalSurf": - assert dir(plugin_module) == [ - "file_reader", - "file_writer", - "parse_metadata"] + assert dir(plugin_module) == ["file_reader", "file_writer", "parse_metadata"] elif plugin["writes"] is False: assert dir(plugin_module) == ["file_reader"] else: From 1f5e4c55d569102eeadd6075aaa0a8e1a8cae61f Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Thu, 4 Jul 2024 10:07:56 +0100 Subject: [PATCH 151/174] Improve changelog entry --- upcoming_changes/280.enhancements.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/upcoming_changes/280.enhancements.rst b/upcoming_changes/280.enhancements.rst index bd637c83b..1af831919 100644 --- a/upcoming_changes/280.enhancements.rst +++ b/upcoming_changes/280.enhancements.rst @@ -1 +1,5 @@ -:ref:`DigitalSurf surfaces `: Add file_writer support, add series of RGB images / surfaces support. \ No newline at end of file +:ref:`DigitalSurf surfaces `: + +- add support for saving file - see :func:`~.digitalsurf.file_writer` +- add the :func:`~.digitalsurf.parse_metadata` function to parse metadata from ``sur`` file +- add series of RGB images / surfaces support. \ No newline at end of file From 3dd4ed3563d360553b9b78d11b8738d1f5bd4da2 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 28 Jun 2024 16:08:47 +0100 Subject: [PATCH 152/174] EMD Velox: fix reading EDS stream detector lazily with `sum_EDS_detectors=True`. When summing the stream, the same stream was used several times --- rsciio/emd/_emd_velox.py | 5 ++--- rsciio/tests/test_emd_velox.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/rsciio/emd/_emd_velox.py b/rsciio/emd/_emd_velox.py index 48632e843..0a153eec2 100644 --- a/rsciio/emd/_emd_velox.py +++ b/rsciio/emd/_emd_velox.py @@ -950,7 +950,6 @@ def stream_to_sparse_array(self, stream_data): """ # Here we load the stream data into memory, which is fine is the # arrays are small. We could load them lazily when lazy. - stream_data = self.stream_group["Data"][:].T[0] sparse_array = stream_readers.stream_to_sparse_COO_array( stream_data=stream_data, spatial_shape=self.reader.spatial_shape, @@ -967,8 +966,8 @@ def stream_to_array(self, stream_data, spectrum_image=None): Parameters ---------- - stream_data: array - spectrum_image: array or None + stream_data : numpy.ndarray + spectrum_image : numpy.ndarray or None If array, the data from the stream are added to the array. Otherwise it creates a new array and returns it. diff --git a/rsciio/tests/test_emd_velox.py b/rsciio/tests/test_emd_velox.py index 68ad2950c..4acb333da 100644 --- a/rsciio/tests/test_emd_velox.py +++ b/rsciio/tests/test_emd_velox.py @@ -405,6 +405,25 @@ def test_fei_si_4detectors(self, lazy, sum_EDS_detectors): assert len(signal) == length # TODO: add parsing azimuth_angle + @pytest.mark.parametrize("lazy", (False, True)) + def test_fei_si_4detectors_compare(self, lazy): + fname = self.fei_files_path / "fei_SI_EDS-HAADF-4detectors_2frames.emd" + s_sum_EDS = hs.load(fname, sum_EDS_detectors=True, lazy=lazy)[-1] + s = hs.load(fname, sum_EDS_detectors=False, lazy=lazy)[-4:] + if lazy: + s_sum_EDS.compute() + for s_ in s: + s_.compute() + + s2 = hs.stack(s, new_axis_name="detector").sum("detector") + + np.testing.assert_allclose(s[-1].data.sum(), 865236) + np.testing.assert_allclose(s[-2].data.sum(), 913682) + np.testing.assert_allclose(s[-3].data.sum(), 867647) + np.testing.assert_allclose(s[-4].data.sum(), 916174) + np.testing.assert_allclose(s2.data.sum(), 3562739) + np.testing.assert_allclose(s2.data, s_sum_EDS.data) + def test_fei_emd_ceta_camera(self): signal = hs.load(self.fei_files_path / "1532 Camera Ceta.emd") np.testing.assert_allclose(signal.data, np.zeros((64, 64))) From 4c7b914303671f5113a8741d55c75d7804daed98 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Thu, 4 Jul 2024 16:46:55 +0100 Subject: [PATCH 153/174] Fix reading separate EDS detector with individual frames --- rsciio/tests/test_emd_velox.py | 8 +++++--- rsciio/utils/fei_stream_readers.py | 14 +++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/rsciio/tests/test_emd_velox.py b/rsciio/tests/test_emd_velox.py index 4acb333da..c14e2903d 100644 --- a/rsciio/tests/test_emd_velox.py +++ b/rsciio/tests/test_emd_velox.py @@ -406,10 +406,12 @@ def test_fei_si_4detectors(self, lazy, sum_EDS_detectors): # TODO: add parsing azimuth_angle @pytest.mark.parametrize("lazy", (False, True)) - def test_fei_si_4detectors_compare(self, lazy): + @pytest.mark.parametrize("sum_frames", (False, True)) + def test_fei_si_4detectors_compare(self, lazy, sum_frames): fname = self.fei_files_path / "fei_SI_EDS-HAADF-4detectors_2frames.emd" - s_sum_EDS = hs.load(fname, sum_EDS_detectors=True, lazy=lazy)[-1] - s = hs.load(fname, sum_EDS_detectors=False, lazy=lazy)[-4:] + kwargs = dict(lazy=lazy, sum_frames=sum_frames) + s_sum_EDS = hs.load(fname, sum_EDS_detectors=True, **kwargs)[-1] + s = hs.load(fname, sum_EDS_detectors=False, **kwargs)[-4:] if lazy: s_sum_EDS.compute() for s_ in s: diff --git a/rsciio/utils/fei_stream_readers.py b/rsciio/utils/fei_stream_readers.py index 05bf54881..42d6991f4 100644 --- a/rsciio/utils/fei_stream_readers.py +++ b/rsciio/utils/fei_stream_readers.py @@ -363,13 +363,13 @@ def stream_to_array( dtype=dtype, ) - _fill_array_with_stream( - spectrum_image=spectrum_image, - stream=stream, - first_frame=first_frame, - last_frame=last_frame, - rebin_energy=rebin_energy, - ) + _fill_array_with_stream( + spectrum_image=spectrum_image, + stream=stream, + first_frame=first_frame, + last_frame=last_frame, + rebin_energy=rebin_energy, + ) else: if spectrum_image is None: spectrum_image = np.zeros( From 634db2f608c5423f0e2e935fc6f62c76703fb8d4 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 28 Jun 2024 17:22:32 +0100 Subject: [PATCH 154/174] Add changelog entry --- upcoming_changes/287.bugfix.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 upcoming_changes/287.bugfix.rst diff --git a/upcoming_changes/287.bugfix.rst b/upcoming_changes/287.bugfix.rst new file mode 100644 index 000000000..3f396ebed --- /dev/null +++ b/upcoming_changes/287.bugfix.rst @@ -0,0 +1,4 @@ +:ref:`EMD Velox ` fixes for reading files containing multiple EDS streams: + +- fix reading multiple EDS streams lazily with ``sum_EDS_detectors=True``, +- fix reading separate EDS stream and individual frames when using ``sum_EDS_detectors=False`` and ``sum_frames=False``. \ No newline at end of file From 7fc1ec70e42e83d8a1dc022a3b0e82f20c458d97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 13:20:48 +0000 Subject: [PATCH 155/174] Bump pypa/cibuildwheel from 2.19.1 to 2.19.2 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 2.19.1 to 2.19.2. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v2.19.1...v2.19.2) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28dd2a866..69f81d359 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels for CPython - uses: pypa/cibuildwheel@v2.19.1 + uses: pypa/cibuildwheel@v2.19.2 env: CIBW_ARCHS: ${{ matrix.CIBW_ARCHS }} From 7c5926cdb1e080e7cc71e93f513ce5bd3fab3c2b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 10 Jul 2024 15:32:14 +0100 Subject: [PATCH 156/174] Prepare 0.6 release --- CHANGES.rst | 30 +++++++++++++++++++++++++++ pyproject.toml | 2 +- upcoming_changes/280.enhancements.rst | 5 ----- upcoming_changes/281.maintenance.rst | 1 - upcoming_changes/283.bugfix.rst | 1 - upcoming_changes/287.bugfix.rst | 4 ---- upcoming_changes/289.bugfix.rst | 1 - 7 files changed, 31 insertions(+), 13 deletions(-) delete mode 100644 upcoming_changes/280.enhancements.rst delete mode 100644 upcoming_changes/281.maintenance.rst delete mode 100644 upcoming_changes/283.bugfix.rst delete mode 100644 upcoming_changes/287.bugfix.rst delete mode 100644 upcoming_changes/289.bugfix.rst diff --git a/CHANGES.rst b/CHANGES.rst index 56e880d86..584e293e3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,36 @@ https://rosettasciio.readthedocs.io/en/latest/changes.html .. towncrier release notes start +0.6 (2024-07-11) +================ + +Enhancements +------------ + +- :ref:`DigitalSurf surfaces `: + + - add support for saving file - see :func:`~.digitalsurf.file_writer` + - add the :func:`~.digitalsurf.parse_metadata` function to parse metadata from ``sur`` file + - add series of RGB images / surfaces support. (`#280 `_) + + +Bug Fixes +--------- + +- Fixes axes for JPG with no exif_tags. Return of axes while loading isn't emty anymore. (`#283 `_) +- :ref:`EMD Velox ` fixes for reading files containing multiple EDS streams: + + - fix reading multiple EDS streams lazily with ``sum_EDS_detectors=True``, + - fix reading separate EDS stream and individual frames when using ``sum_EDS_detectors=False`` and ``sum_frames=False``. (`#287 `_) +- :ref:`quantumdetector-format`: Fix signal shape of data acquired in ROI mode. (`#289 `_) + + +Maintenance +----------- + +- Add support for numpy 2 in Renishaw, Semper and Dens reader. (`#281 `_) + + 0.5 (2024-06-15) ================ diff --git a/pyproject.toml b/pyproject.toml index aa3f7da43..0ad522893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ include = ["rsciio*"] [tool.setuptools_scm] # Presence enables setuptools_scm, the version will be determine at build time from git # The version will be updated by the `prepare_release.py` script -fallback_version = "0.6.dev0" +fallback_version = "0.7.dev0" [tool.towncrier] directory = "upcoming_changes/" diff --git a/upcoming_changes/280.enhancements.rst b/upcoming_changes/280.enhancements.rst deleted file mode 100644 index 1af831919..000000000 --- a/upcoming_changes/280.enhancements.rst +++ /dev/null @@ -1,5 +0,0 @@ -:ref:`DigitalSurf surfaces `: - -- add support for saving file - see :func:`~.digitalsurf.file_writer` -- add the :func:`~.digitalsurf.parse_metadata` function to parse metadata from ``sur`` file -- add series of RGB images / surfaces support. \ No newline at end of file diff --git a/upcoming_changes/281.maintenance.rst b/upcoming_changes/281.maintenance.rst deleted file mode 100644 index 2e0389835..000000000 --- a/upcoming_changes/281.maintenance.rst +++ /dev/null @@ -1 +0,0 @@ -Add support for numpy 2 in Renishaw, Semper and Dens reader. diff --git a/upcoming_changes/283.bugfix.rst b/upcoming_changes/283.bugfix.rst deleted file mode 100644 index b50f0c993..000000000 --- a/upcoming_changes/283.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixes axes for JPG with no exif_tags. Return of axes while loading isn't emty anymore. \ No newline at end of file diff --git a/upcoming_changes/287.bugfix.rst b/upcoming_changes/287.bugfix.rst deleted file mode 100644 index 3f396ebed..000000000 --- a/upcoming_changes/287.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -:ref:`EMD Velox ` fixes for reading files containing multiple EDS streams: - -- fix reading multiple EDS streams lazily with ``sum_EDS_detectors=True``, -- fix reading separate EDS stream and individual frames when using ``sum_EDS_detectors=False`` and ``sum_frames=False``. \ No newline at end of file diff --git a/upcoming_changes/289.bugfix.rst b/upcoming_changes/289.bugfix.rst deleted file mode 100644 index cdf59acf9..000000000 --- a/upcoming_changes/289.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -:ref:`quantumdetector-format`: Fix signal shape of data acquired in ROI mode. From ea326d24a4c4b5dd845419e294f1b028919af170 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jul 2024 13:37:17 +0000 Subject: [PATCH 157/174] Bump softprops/action-gh-release from 2.0.6 to 2.0.8 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.6 to 2.0.8. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/a74c6b72af54cfa997e81df42d94703d6313a2d0...c062e08bd532815e2082a85e87e3ef29c3e6d191) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69f81d359..68090eaac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,4 +145,4 @@ jobs: uses: actions/checkout@v4 - name: Create Release id: create_release - uses: softprops/action-gh-release@a74c6b72af54cfa997e81df42d94703d6313a2d0 + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 From 27a07a635b0a09b9c51d5e7f97749066775773e7 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 5 Aug 2024 20:15:52 +0100 Subject: [PATCH 158/174] Pin towncrier to <24 until sphinxcontrib-towncrier supports towncrier >=24 --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ad522893..50cdc2d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,8 @@ doc = [ "sphinx", "sphinx-favicon", "sphinxcontrib-towncrier", - "towncrier", + # unpin when sphinxcontrib-towncrier supports towncrier >=24 + "towncrier<24", ] all = [ "rosettasciio[blockfile]", From 118990c0cfd15bb67a89dbad98dc568ed9668926 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 5 Aug 2024 22:30:29 +0100 Subject: [PATCH 159/174] Fix broken link --- rsciio/tiff/_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsciio/tiff/_api.py b/rsciio/tiff/_api.py index 79d18d82f..278a4db2f 100644 --- a/rsciio/tiff/_api.py +++ b/rsciio/tiff/_api.py @@ -154,7 +154,7 @@ def file_reader( Force read image resolution using the ``x_resolution``, ``y_resolution`` and ``resolution_unit`` tiff tags. Beware: most software don't (properly) use these tags when saving ``.tiff`` files. - See ``_. + See ``_. multipage_as_list : bool, default=False Read multipage tiff and return list with full content of every page. This utilises ``tifffile``s ``pages`` instead of ``series`` way of data access, From 7138fd171023c7294ae69241d283f694fa6428c5 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 5 Aug 2024 22:34:39 +0100 Subject: [PATCH 160/174] Make the oldest supported version are correctly installed --- .github/workflows/tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6afa18ba7..5fab9e3e5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -102,11 +102,6 @@ jobs: python --version pip --version - - name: Install oldest supported version - if: contains(matrix.LABEL, 'oldest') - run: | - pip install ${{ matrix.DEPENDENCIES }} - - name: Install hyperspy and exspy if: ${{ ! contains(matrix.LABEL, 'without-hyperspy') }} run: | @@ -138,6 +133,11 @@ jobs: run: | pip uninstall -y pyUSID + - name: Install oldest supported version + if: contains(matrix.LABEL, 'oldest') + run: | + pip install ${{ matrix.DEPENDENCIES }} + - name: Install numpy 2.0 if: ${{ ! contains(matrix.LABEL, 'oldest') && matrix.PYTHON_VERSION != '3.8' }} run: | From e8b734da22453239b81d867a943da2ca4e43add8 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 5 Aug 2024 20:23:23 +0100 Subject: [PATCH 161/174] Don't run on push dependabot and pre-commit to reduce CI usage --- .github/workflows/docs.yml | 8 +++++++- .github/workflows/package_and_test.yml | 8 +++++++- .github/workflows/tests.yml | 8 +++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e6d4e7b93..e1d8b4fe7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,6 +1,12 @@ name: Documentation -on: [push, pull_request] +on: + pull_request: + push: + branches-ignore: + - 'dependabot/*' + - 'pre-commit-ci-update-config' + workflow_dispatch: jobs: Build: diff --git a/.github/workflows/package_and_test.yml b/.github/workflows/package_and_test.yml index d52b6cfac..9074097b2 100644 --- a/.github/workflows/package_and_test.yml +++ b/.github/workflows/package_and_test.yml @@ -1,6 +1,12 @@ name: Package & Test -on: [push, pull_request] +on: + pull_request: + push: + branches-ignore: + - 'dependabot/*' + - 'pre-commit-ci-update-config' + workflow_dispatch: jobs: package_and_test: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5fab9e3e5..47a3b9fe3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,12 @@ name: Tests -on: [push, pull_request, workflow_dispatch] +on: + pull_request: + push: + branches-ignore: + - 'dependabot/*' + - 'pre-commit-ci-update-config' + workflow_dispatch: jobs: run_test_site: From 04e5aff13f7fc6f2e421c1e6f0feaeff822d64df Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 5 Aug 2024 22:41:40 +0100 Subject: [PATCH 162/174] Bump dask mininum requirement to align with hyperspy - convenient to run the test suite --- .github/workflows/tests.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 47a3b9fe3..a951807e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: PYTHON_VERSION: '3.8' # Set pillow and scikit-image version to be compatible with imageio and scipy # matplotlib needs 3.5 to support markers in hyperspy 2.0 (requires `collection.set_offset_transform`) - DEPENDENCIES: matplotlib==3.5 numpy==1.20.0 tifffile==2022.7.28 dask[array]==2021.3.1 numba==0.52 imageio==2.16 pillow==8.3.2 scikit-image==0.18.0 python-box==6.0.0 + DEPENDENCIES: matplotlib==3.5 numpy==1.20.0 tifffile==2022.7.28 dask[array]==2021.5.1 distributed==2021.5.1 numba==0.52 imageio==2.16 pillow==8.3.2 scikit-image==0.18.0 python-box==6.0.0 LABEL: '-oldest' # test minimum requirement - os: ubuntu diff --git a/pyproject.toml b/pyproject.toml index 50cdc2d82..86bff894d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] dependencies = [ - "dask[array] >=2021.3.1", + "dask[array] >=2021.5.1", "python-dateutil", "numpy >=1.20", "pint >=0.8", From 502395a5f074370f393534fc9203ef48bc923c95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 08:18:48 +0000 Subject: [PATCH 163/174] Bump pypa/cibuildwheel from 2.19.2 to 2.20.0 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 2.19.2 to 2.20.0. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v2.19.2...v2.20.0) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68090eaac..f32692704 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels for CPython - uses: pypa/cibuildwheel@v2.19.2 + uses: pypa/cibuildwheel@v2.20.0 env: CIBW_ARCHS: ${{ matrix.CIBW_ARCHS }} From 1dce7cc894ebde566aa63edf456c732688e8411f Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 5 Aug 2024 21:06:55 +0100 Subject: [PATCH 164/174] Skip cpython 3.13 for now since pint doesn't support it --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f32692704..c56dce4d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,8 @@ jobs: CIBW_TEST_SKIP: "cp38-macosx_arm64" # No need to build wheels for pypy because the pure python wheels can be used # PyPy documentation recommends no to build the C extension - CIBW_SKIP: "{pp*,*-musllinux*,*win32,*-manylinux_i686}" + # CPython 3.13 not supported yet because of pint + CIBW_SKIP: "{pp*,cp313*,*-musllinux*,*win32,*-manylinux_i686}" strategy: fail-fast: false matrix: From 1dc96af1d2c5f3ccdc8bc55d96049cec1ca52307 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 6 Aug 2024 10:07:05 +0100 Subject: [PATCH 165/174] Fix `branches-ignore` --- .github/workflows/docs.yml | 2 +- .github/workflows/package_and_test.yml | 2 +- .github/workflows/tests.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e1d8b4fe7..8e4a89f37 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches-ignore: - - 'dependabot/*' + - 'dependabot/**' - 'pre-commit-ci-update-config' workflow_dispatch: diff --git a/.github/workflows/package_and_test.yml b/.github/workflows/package_and_test.yml index 9074097b2..3af79d41e 100644 --- a/.github/workflows/package_and_test.yml +++ b/.github/workflows/package_and_test.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches-ignore: - - 'dependabot/*' + - 'dependabot/**' - 'pre-commit-ci-update-config' workflow_dispatch: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a951807e3..51a6d0024 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: pull_request: push: branches-ignore: - - 'dependabot/*' + - 'dependabot/**' - 'pre-commit-ci-update-config' workflow_dispatch: From b4a72497702eaa6165dd1c77ffd8959ee2042bb8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 19 Aug 2024 22:20:54 +0000 Subject: [PATCH 166/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.0 → v0.6.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.0...v0.6.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6bb01536d..07d60b400 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.5.0 + rev: v0.6.1 hooks: # Run the linter. - id: ruff From eba1337ee2b98ff5a5b19485b77491b516bf7e5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:27:31 +0000 Subject: [PATCH 167/174] Bump pypa/cibuildwheel from 2.20.0 to 2.21.0 Bumps [pypa/cibuildwheel](https://github.com/pypa/cibuildwheel) from 2.20.0 to 2.21.0. - [Release notes](https://github.com/pypa/cibuildwheel/releases) - [Changelog](https://github.com/pypa/cibuildwheel/blob/main/docs/changelog.md) - [Commits](https://github.com/pypa/cibuildwheel/compare/v2.20.0...v2.21.0) --- updated-dependencies: - dependency-name: pypa/cibuildwheel dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c56dce4d7..df919c121 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels for CPython - uses: pypa/cibuildwheel@v2.20.0 + uses: pypa/cibuildwheel@v2.21.0 env: CIBW_ARCHS: ${{ matrix.CIBW_ARCHS }} From e3b254ed13fb905aa1140a25c04dc8141c53da9e Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 17 Sep 2024 14:15:25 +0100 Subject: [PATCH 168/174] Remove broken links - couldn't find an equivalent --- doc/user_guide/supported_formats/hspy.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/user_guide/supported_formats/hspy.rst b/doc/user_guide/supported_formats/hspy.rst index 3d0d3ae5f..c34c6b0a9 100644 --- a/doc/user_guide/supported_formats/hspy.rst +++ b/doc/user_guide/supported_formats/hspy.rst @@ -7,9 +7,7 @@ This is `HyperSpy's `_ default format and for data process in HyperSpy, it is the only format that guarantees that no information will be lost in the writing process and that supports saving data of arbitrary dimensions. It is based on the `HDF5 open standard -`_. The HDF5 file format is supported by `many -applications -`_. +`_. Parts of the specifications are documented in :external+hyperspy:ref:`metadata_structure`. .. versionadded:: HyperSpy_v1.2 From d4326afce789f49baac7f7f874529a8f06c67cdd Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 17 Sep 2024 14:25:03 +0100 Subject: [PATCH 169/174] Add biorxiv.org to `linkcheck_ignore` since it gives a "403 Client Error: Forbidden for url" --- doc/conf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index eb7457626..101b50d73 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -121,5 +121,10 @@ towncrier_draft_working_directory = ".." +linkcheck_ignore = [ + "https://www.biorxiv.org", # 403 Client Error: Forbidden for url +] + + def setup(app): app.add_css_file("custom-styles.css") From 047b9884102f2b8319c57ce0c4d570469c396dbd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 22:48:58 +0000 Subject: [PATCH 170/174] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.1 → v0.6.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.1...v0.6.8) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07d60b400..0814f36b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.6.1 + rev: v0.6.8 hooks: # Run the linter. - id: ruff From 26bb586b96b01642b2d4ae7e50112093f574fe1a Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 5 Oct 2024 15:48:09 +0100 Subject: [PATCH 171/174] Don't use context manager when loading tiff file lazily --- rsciio/tests/test_tiff.py | 17 ++++++++++++++- rsciio/tiff/_api.py | 44 ++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/rsciio/tests/test_tiff.py b/rsciio/tests/test_tiff.py index 4d4f60a0e..ed1702a6e 100644 --- a/rsciio/tests/test_tiff.py +++ b/rsciio/tests/test_tiff.py @@ -206,10 +206,23 @@ def test_write_read_unit_imagej_with_description(): assert s3.axes_manager.navigation_shape == s.axes_manager.navigation_shape +@pytest.mark.parametrize("size", ((50, 50), (2, 50, 50))) +def test_lazy_loading(tmp_path, size): + dummy_data = np.random.random_sample(size=size) + fname = tmp_path / "dummy.tiff" + + rsciio.tiff.file_writer(fname, {"data": dummy_data}) + from_tiff = rsciio.tiff.file_reader(fname, lazy=True) + data = from_tiff[0]["data"] + + data = data.compute() + np.testing.assert_allclose(data, dummy_data) + + class TestLoadingImagesSavedWithDM: @staticmethod @pytest.mark.parametrize("lazy", [True, False]) - def test_read_unit_from_DM_stack(lazy, tmp_path): + def test_read_unit_from_DM_stack(tmp_path, lazy): s = hs.load( TEST_DATA_PATH / "test_loading_image_saved_with_DM_stack.tif", lazy=lazy ) @@ -249,6 +262,8 @@ def test_read_unit_from_DM_stack(lazy, tmp_path): np.testing.assert_allclose( s2.axes_manager[2].offset, s.axes_manager[2].offset, atol=1e-5 ) + if lazy: + s.compute() @staticmethod def test_read_unit_from_dm(): diff --git a/rsciio/tiff/_api.py b/rsciio/tiff/_api.py index 278a4db2f..d429a801b 100644 --- a/rsciio/tiff/_api.py +++ b/rsciio/tiff/_api.py @@ -93,6 +93,7 @@ def file_writer(filename, signal, export_scale=True, extratags=None, **kwds): """ data = signal["data"] + metadata = signal.get("metadata", {}) photometric = "MINISBLACK" # HyperSpy uses struct arrays to store RGBA data from rsciio.utils import rgb_tools @@ -110,7 +111,7 @@ def file_writer(filename, signal, export_scale=True, extratags=None, **kwds): "Description and export scale cannot be used at the same time, " "because it is incompability with the 'ImageJ' tiff format" ) - if export_scale: + if export_scale and "axes" in signal.keys(): kwds.update(_get_tags_dict(signal, extratags=extratags)) _logger.debug(f"kwargs passed to tifffile.py imsave: {kwds}") @@ -121,7 +122,7 @@ def file_writer(filename, signal, export_scale=True, extratags=None, **kwds): # (https://github.com/cgohlke/tifffile/issues/21) kwds["metadata"] = None - if signal["metadata"]["General"].get("date"): + if "General" in metadata.keys() and metadata["General"].get("date"): dt = get_date_time_from_metadata(signal["metadata"], formatting="datetime") kwds["datetime"] = dt @@ -190,23 +191,28 @@ def file_reader( >>> # Load a non-uniform axis from a hamamatsu streak file: >>> s = file_reader('file.tif', hamamatsu_streak_axis_type='data') """ - with TiffFile(filename, **kwds) as tiff: - if multipage_as_list: - handles = tiff.pages # use full access with pages interface - else: - handles = tiff.series # use fast access with series interface - dict_list = [ - _read_tiff( - tiff, - handle, - filename, - force_read_resolution, - lazy=lazy, - hamamatsu_streak_axis_type=hamamatsu_streak_axis_type, - **kwds, - ) - for handle in handles - ] + # We can't use context manager, because it closes the file on exit + # and the file needs to stay open when loading lazily + # close the file manually + tiff = TiffFile(filename, **kwds) + if multipage_as_list: + handles = tiff.pages # use full access with pages interface + else: + handles = tiff.series # use fast access with series interface + dict_list = [ + _read_tiff( + tiff, + handle, + filename, + force_read_resolution, + lazy=lazy, + hamamatsu_streak_axis_type=hamamatsu_streak_axis_type, + **kwds, + ) + for handle in handles + ] + if not lazy: + tiff.close() return dict_list From dacccf4078fc9ae07c3e2e9ce74ef6fd1edb10b1 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 5 Oct 2024 16:12:31 +0100 Subject: [PATCH 172/174] Support tiff files in `get_file_handle` --- doc/api/utils.rst | 15 ++++++++--- doc/conf.py | 2 +- rsciio/_docstrings.py | 5 +++- rsciio/tests/test_tiff.py | 43 ++++++++++++++++++++++++++------ rsciio/utils/tools.py | 52 +++++++++++++++++++++++++++++++-------- 5 files changed, 93 insertions(+), 24 deletions(-) diff --git a/doc/api/utils.rst b/doc/api/utils.rst index 23f62f47a..e0f8a85c3 100644 --- a/doc/api/utils.rst +++ b/doc/api/utils.rst @@ -14,12 +14,12 @@ HDF5 utility functions .. automodule:: rsciio.utils.hdf5 :members: +Generic utility functions +^^^^^^^^^^^^^^^^^^^^^^^^^ -Test utility functions -^^^^^^^^^^^^^^^^^^^^^^ +.. automodule:: rsciio.utils.tools + :members: get_file_handle -.. automodule:: rsciio.tests.registry_utils - :members: Distributed utility functions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -32,3 +32,10 @@ Logging .. automodule:: rsciio :members: set_log_level + + +Test utility functions +^^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: rsciio.tests.registry_utils + :members: \ No newline at end of file diff --git a/doc/conf.py b/doc/conf.py index 101b50d73..41706bb8b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -108,7 +108,7 @@ # -- Options for numpydoc extension ----------------------------------- numpydoc_xref_param_type = True -numpydoc_xref_ignore = {"type", "optional", "default", "of"} +numpydoc_xref_ignore = {"type", "optional", "default", "of", "File", "handle"} if Version(numpydoc.__version__) >= Version("1.6.0rc0"): numpydoc_validation_checks = {"all", "ES01", "EX01", "GL02", "GL03", "SA01", "SS06"} diff --git a/rsciio/_docstrings.py b/rsciio/_docstrings.py index 807d4ef53..2324aaffc 100644 --- a/rsciio/_docstrings.py +++ b/rsciio/_docstrings.py @@ -35,7 +35,10 @@ LAZY_DOC = """lazy : bool, default=False - Whether to open the file lazily or not. + Whether to open the file lazily or not. The file will stay open + until closed in :meth:`~hyperspy._signals.lazy.LazySignal.compute` + or closed manually. :func:`~.utils.tools.get_file_handle` + can be used to access the file handler and close it manually. """ diff --git a/rsciio/tests/test_tiff.py b/rsciio/tests/test_tiff.py index ed1702a6e..f82c5c1ca 100644 --- a/rsciio/tests/test_tiff.py +++ b/rsciio/tests/test_tiff.py @@ -19,7 +19,6 @@ import os import tempfile -import warnings import zipfile from pathlib import Path @@ -33,6 +32,7 @@ import rsciio.tiff # noqa: E402 from rsciio.utils.tests import assert_deep_almost_equal # noqa: E402 +from rsciio.utils.tools import get_file_handle # noqa: E402 TEST_DATA_PATH = Path(__file__).parent / "data" / "tiff" TEST_NPZ_DATA_PATH = Path(__file__).parent / "data" / "npz" @@ -214,10 +214,41 @@ def test_lazy_loading(tmp_path, size): rsciio.tiff.file_writer(fname, {"data": dummy_data}) from_tiff = rsciio.tiff.file_reader(fname, lazy=True) data = from_tiff[0]["data"] + fh = get_file_handle(data) + # check that the file is open + fh.fileno() data = data.compute() np.testing.assert_allclose(data, dummy_data) + # After we load to memory, we can close the file manually + fh.close() + with pytest.raises(ValueError): + # file is now closed + fh.fileno() + + +def test_lazy_loading_hyperspy_close(tmp_path): + # check that the file is closed automatically in hyperspy + dummy_data = np.random.random_sample(size=(2, 50, 50)) + fname = tmp_path / "dummy.tiff" + s = hs.signals.Signal2D(dummy_data) + s.save(fname) + + s2 = hs.load(fname, lazy=True) + fh = get_file_handle(s2.data) + print("fh", fh) + # check that the file is open + fh.fileno() + s2.compute(close_file=True) + np.testing.assert_allclose(s2.data, dummy_data) + + # when calling compute in hyperspy, + # the file should be closed automatically + with pytest.raises(ValueError): + # file is now closed + fh.fileno() + class TestLoadingImagesSavedWithDM: @staticmethod @@ -1009,14 +1040,10 @@ def test_hamamatsu_streak_loadwarnings(self): # - raise warning # - Initialise uniform data axis with pytest.raises(ValueError): - s = hs.load(fname, hamamatsu_streak_axis_type="xxx") + _ = hs.load(fname, hamamatsu_streak_axis_type="xxx") - # Explicitly calling hamamatsu_streak_axis_type='uniform' - # should NOT raise a warning - with warnings.catch_warnings(): - warnings.simplefilter("error") - s = hs.load(fname, hamamatsu_streak_axis_type="uniform") - assert s.axes_manager.all_uniform + s = hs.load(fname, hamamatsu_streak_axis_type="uniform") + assert s.axes_manager.all_uniform def test_hamamatsu_streak_scanfile(self): file = "test_hamamatsu_streak_SCAN.tif" diff --git a/rsciio/utils/tools.py b/rsciio/utils/tools.py index 42870ab04..01d69da5e 100644 --- a/rsciio/utils/tools.py +++ b/rsciio/utils/tools.py @@ -503,27 +503,59 @@ def ensure_unicode(stuff, encoding="utf8", encoding2="latin-1"): def get_file_handle(data, warn=True): - """Return file handle of a dask array when possible; currently only hdf5 file are - supported. """ - arrkey = None + Return file handle of a dask array when possible. + Currently only hdf5 and tiff file are supported. + + Parameters + ---------- + data : dask.array.Array + The dask array from which the file handle + will be retrieved. + warn : bool + Whether to warn or not when the file handle + can't be retrieved. Default is True. + + Returns + ------- + File handle or None + The file handle of the file when possible. + """ + arrkey_hdf5 = None + arrkey_tifffile = None for key in data.dask.keys(): # The if statement with both "array-original" and "original-array" # is due to dask changing the name of this key. After dask-2022.1.1 # the key is "original-array", before it is "array-original" if ("array-original" in key) or ("original-array" in key): - arrkey = key + arrkey_hdf5 = key break - if arrkey: + # For tiff files, use _load_data key + if "_load_data" in key: + arrkey_tifffile = key + if arrkey_hdf5: + try: + return data.dask[arrkey_hdf5].file + except (AttributeError, ValueError): # pragma: no cover + if warn: + _logger.warning( + "Failed to retrieve file handle, either the file is " + "already closed or it is not an hdf5 file." + ) + if arrkey_tifffile: try: - return data.dask[arrkey].file - except (AttributeError, ValueError): + # access the filehandle through the pages or series + # interfaces of tifffile + # this may be brittle and may need maintenance as + # dask or tifffile evolve + return data.dask[arrkey_tifffile][2][0].parent.filehandle._fh + except IndexError: # pragma: no cover if warn: _logger.warning( - "Failed to retrieve file handle, either " - "the file is already closed or it is not " - "an hdf5 file." + "Failed to retrieve file handle, either the file is " + "already closed or it is not a supported tiff file." ) + return None From ebae0e005a5d4a9a1e595a2619b40c866bf89338 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 5 Oct 2024 17:09:52 +0100 Subject: [PATCH 173/174] Add changelog entries --- upcoming_changes/317.bugfix.rst | 1 + upcoming_changes/317.enhancements.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 upcoming_changes/317.bugfix.rst create mode 100644 upcoming_changes/317.enhancements.rst diff --git a/upcoming_changes/317.bugfix.rst b/upcoming_changes/317.bugfix.rst new file mode 100644 index 000000000..9944a0565 --- /dev/null +++ b/upcoming_changes/317.bugfix.rst @@ -0,0 +1 @@ +Fix lazy reading of some tiff files - fix for `#316 `_. \ No newline at end of file diff --git a/upcoming_changes/317.enhancements.rst b/upcoming_changes/317.enhancements.rst new file mode 100644 index 000000000..cb29d1e73 --- /dev/null +++ b/upcoming_changes/317.enhancements.rst @@ -0,0 +1 @@ +Add support for tiff file in :func:`~.utils.tools.get_file_handle`. \ No newline at end of file From d69f282fa392d40074f3b7e93813741dd932333b Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 16 Oct 2024 14:43:26 +0100 Subject: [PATCH 174/174] Fix azure pipeline CI by using miniforge instead of deprecated mambaforge --- azure-pipelines.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index d7a2f2722..4745baa4c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -19,38 +19,38 @@ resources: # For more details on service connection endpoint, see # https://docs.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints endpoint: hyperspy # Azure DevOps service connection - ref: use_mamba + ref: use_miniforge strategy: matrix: Linux_Python310: vmImage: 'ubuntu-latest' PYTHON_VERSION: '3.10' - MAMBAFORGE_PATH: $(Agent.BuildDirectory)/mambaforge + MINIFORGE_PATH: $(Agent.BuildDirectory)/miniforge3 Linux_Python39: vmImage: 'ubuntu-latest' PYTHON_VERSION: '3.9' - MAMBAFORGE_PATH: $(Agent.BuildDirectory)/mambaforge + MINIFORGE_PATH: $(Agent.BuildDirectory)/miniforge3 Linux_Python38: vmImage: 'ubuntu-latest' PYTHON_VERSION: '3.8' - MAMBAFORGE_PATH: $(Agent.BuildDirectory)/mambaforge + MINIFORGE_PATH: $(Agent.BuildDirectory)/miniforge3 MacOS_Python38: vmImage: 'macOS-latest' PYTHON_VERSION: '3.8' - MAMBAFORGE_PATH: $(Agent.BuildDirectory)/mambaforge + MINIFORGE_PATH: $(Agent.BuildDirectory)/miniforge3 MacOS_Python310: vmImage: 'macOS-latest' PYTHON_VERSION: '3.10' - MAMBAFORGE_PATH: $(Agent.BuildDirectory)/mambaforge + MINIFORGE_PATH: $(Agent.BuildDirectory)/miniforge3 Windows_Python38: vmImage: 'windows-latest' PYTHON_VERSION: '3.8' - MAMBAFORGE_PATH: $(Agent.BuildDirectory)\mambaforge + MINIFORGE_PATH: $(Agent.BuildDirectory)\miniforge3 Windows_Python310: vmImage: 'windows-latest' PYTHON_VERSION: '3.10' - MAMBAFORGE_PATH: $(Agent.BuildDirectory)\mambaforge + MINIFORGE_PATH: $(Agent.BuildDirectory)\miniforge3 pool: vmImage: '$(vmImage)' @@ -65,7 +65,7 @@ steps: condition: ne(variables['Build.Repository.Name'], 'hyperspy/rosettasciio') displayName: Fetch tags from hyperspy/rosettasciio - template: azure_pipelines/clone_ci-scripts_repo.yml@templates -- template: azure_pipelines/install_mambaforge.yml@templates +- template: azure_pipelines/install_miniforge.yml@templates - template: azure_pipelines/activate_conda.yml@templates - template: azure_pipelines/setup_anaconda_packages.yml@templates