From f0cc69a0f726ef12ce141f610c772791939f896c Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Thu, 12 Feb 2026 17:47:14 +0000 Subject: [PATCH 01/10] Add I16 Loader --- src/cdiutils/io/__init__.py | 2 + src/cdiutils/io/i16.py | 574 ++++++++++++++++++++++++++++++++++++ src/cdiutils/io/loader.py | 4 + 3 files changed, 580 insertions(+) create mode 100644 src/cdiutils/io/i16.py diff --git a/src/cdiutils/io/__init__.py b/src/cdiutils/io/__init__.py index 569c9ae3..0cbcdf20 100755 --- a/src/cdiutils/io/__init__.py +++ b/src/cdiutils/io/__init__.py @@ -9,6 +9,7 @@ from .cxi import CXIFile, load_cxi, save_as_cxi from .id01 import ID01Loader, SpecLoader from .id27 import ID27Loader +from .i16 import I16Loader from .loader import Loader, h5_safe_load from .nanomax import NanoMAXLoader from .p10 import P10Loader @@ -21,6 +22,7 @@ "ID01Loader", "ID27Loader", "P10Loader", + "I16Loader", "SpecLoader", "SIXSLoader", "CristalLoader", diff --git a/src/cdiutils/io/i16.py b/src/cdiutils/io/i16.py new file mode 100644 index 00000000..a9aa1da2 --- /dev/null +++ b/src/cdiutils/io/i16.py @@ -0,0 +1,574 @@ +import warnings + +import dateutil.parser +import fabio +import numpy as np +import silx.io + +from cdiutils.io.loader import H5TypeLoader, Loader, h5_safe_load + + +class I16Loader(H5TypeLoader): + """ + Data loader for Diamond Light Source I16 beamline. + + Loads data from NeXus files. + + Attributes: + angle_names: Mapping from canonical names to ID01 motor names: + + - ``sample_outofplane_angle`` -> ``"eta"`` + - ``sample_inplane_angle`` -> ``"chi"`` + - ``detector_outofplane_angle`` -> ``"delta"`` + - ``detector_inplane_angle`` -> ``"gamma"`` + + authorised_detector_names: Tuple of supported detectors: + ``("merlin")``. + + Examples: + Basic usage with factory pattern: + + >>> from cdiutils.io import Loader + >>> loader = Loader.from_setup( + ... beamline_setup="i16", + ... sample_name="PtNP", + ... experiment_file_path="/dls/i16/data/2026/mm12345-1/12345.nxs" + ... ) + + Direct instantiation: + + >>> from cdiutils.io.i16 import I16Loader + >>> loader = I16Loader( + ... experiment_file_path=="/dls/i16/data/2026/mm12345-1/12345.nxs", + ... sample_name="PtNP", + ... detector_name="merlin" + ... ) + + Load data with preprocessing: + + >>> data, angles = loader.load_data( + ... roi=(100, 400, 150, 450), + ... rocking_angle_binning=2 + ... ) + + See Also: + :class:`Loader` for factory method and base class documentation. + """ + + angle_names = { + "sample_outofplane_angle": "eta", + "sample_inplane_angle": "mu", + "detector_outofplane_angle": "delta", + "detector_inplane_angle": "gam", + } + authorised_detector_names = ("merlin", ) + + def __init__( + self, + experiment_file_path: str, + detector_name: str = None, + flat_field: np.ndarray | str = None, + alien_mask: np.ndarray | str = None, + **kwargs, + ): + """ + Initialise I16 data loader with experiment file and metadata. + + Args: + experiment_file_path: Path to Nexus scan file + detector_name: Detector identifier (``"mpxgaas"``, + ``"mpx1x4"``, or ``"eiger2M"``). If None, automatically + detected from first available scan. + flat_field: Flat-field correction array or path to .npy/.npz + file. Shape must match detector's 2D frame. Applied + multiplicatively to raw data. + alien_mask: Bad pixel mask array or path. Binary mask with + 1 = bad pixel, 0 = good pixel. Combined with detector's + chip gap mask. + **kwargs: Additional parameters (currently unused, reserved + for future extensions). + + Raises: + FileNotFoundError: If ``experiment_file_path`` does not + exist. + ValueError: If ``detector_name`` is not in + :attr:`authorised_detector_names`. + KeyError: If ``scan`` or ``sample_name`` do not match HDF5 + structure. + + Examples: + Minimal setup (auto-detect detector): + + >>> loader = I16Loader( + ... experiment_file_path="/data/id01/PtNP.h5" + ... ) + + With flat-field and detector specification: + + >>> loader = I16Loader( + ... experiment_file_path="/data/id01/sample.h5", + ... detector_name="merlin", + ... flat_field="/path/to/flatfield.npy" + ... ) + """ + super().__init__( + experiment_file_path, + None, + None, + detector_name, + flat_field, + alien_mask, + ) + + @h5_safe_load + def get_detector_name( + self, start_scan: int = 1, max_attempts: int = 5 + ) -> str: + """ + Auto-detect detector from HDF5 file scan metadata. + + Searches through scan groups to find which authorised detector + is present in the measurement data. Used when detector is not + explicitly specified during initialisation. + + Args: + start_scan: Scan number to begin search. Recursively + increments if scan not found or contains no detector. + max_attempts: Maximum number of scans to check before + giving up. + + Returns: + First matching detector name from + :attr:`authorised_detector_names` found in file. + + Raises: + ValueError: If no detector found after ``max_attempts`` + scans, or if multiple detectors found in same scan + (ambiguous configuration). + KeyError: If HDF5 structure does not match expected + ``{sample}_{scan}.1/measurement/`` format. + + Notes: + Recursion avoids issues with missing or incomplete + scans. For files with both Eiger and Maxipix data, + explicitly specify ``detector_name`` to avoid ambiguity. + """ + + msg = "Please provide a detector_name (str)." + + # Try to find the detector name in the current scan number + key_path = f"{self.sample_name}_{start_scan}.1/measurement/" + + # If we've exceeded max attempts, raise an error + if start_scan > max_attempts: + raise ValueError( + f"No detector found after checking {max_attempts} scans.\n" + f"{msg}" + ) + + # Check if the key path exists + if key_path not in self.h5file: + # Try the next scan number recursively + return self.get_detector_name(start_scan + 1, max_attempts) + + # Look for detector names in the current scan + detector_names = [] + for key in self.authorised_detector_names: + if key in self.h5file[key_path]: + detector_names.append(key) + + if len(detector_names) == 0: + # Try the next scan number recursively + return self.get_detector_name(start_scan + 1, max_attempts) + + if len(detector_names) > 1: + raise ValueError( + f"Several detector names found ({detector_names}).\n" + f"Not handled yet.\n{msg}" + ) + + return detector_names[0] + + @h5_safe_load + def load_det_calib_params( + self, scan: int = None, sample_name: str = None + ) -> dict: + """ + Load detector calibration from scan metadata. + + Retrieves calibration parameters stored in BLISS HDF5 file + during detector alignment. Returns parameters compatible with + xrayutilities conventions. + + Args: + scan: Scan number to load calibration from. If None, uses + ``self.scan``. + sample_name: Sample name for HDF5 path construction. If + None, uses ``self.sample_name``. + + Returns: + dict: Calibration parameters with keys: + + - ``"cch1"``: Direct beam row (y) position in pixels + - ``"cch2"``: Direct beam column (x) position in pixels + - ``"pwidth1"``: Pixel height in metres + - ``"pwidth2"``: Pixel width in metres + - ``"distance"``: Sample-to-detector distance in metres + - ``"tiltazimuth"``: Detector azimuthal tilt (0.0, not + calibrated by BLISS) + - ``"tilt"``: Detector polar tilt (0.0, not calibrated) + - ``"detrot"``: Detector rotation (0.0, not calibrated) + + Raises: + KeyError: If scan/sample combination does not exist in HDF5 + file or if detector name is incorrect. + + Examples: + Load calibration for current scan: + + >>> loader = ID01Loader( + ... experiment_file_path="/data/id01/sample.h5", + ... scan=42, + ... sample_name="sample" + ... ) + >>> calib = loader.load_det_calib_params() + >>> print(f"Direct beam at ({calib['cch1']}, {calib['cch2']})") + + Load from different scan: + + >>> calib = loader.load_det_calib_params(scan=15) + + Notes: + Tilt angles (``tiltazimuth``, ``tilt``, ``detrot``) are set + to 0.0 as BLISS does not calibrate these. For accurate tilt + values, run detector calibration notebook or use PyNX's + ``cdi_findcenter`` utility. + + See Also: + :doc:`/user_guide/detector_calibration` for calibration + procedures and angle definitions. + """ + instrument = self.h5file['entry/instrument'] + detector = instrument[self.detector_name] + module = detector["module"] + try: + return { + "cch1": float(instrument['merlin_centre_i'][()]) if 'merlin_centre_i' in instrument else 147, + "cch2": float(instrument['merlin_centre_j'][()]) if 'merlin_centre_j' in instrument else 335, + "pwidth1": float(module['fast_pixel_direction'][()]), + "pwidth2": float(module['slow_pixel_direction'][()]), + "distance": float(detector['transformations/origin_offset'][()]), + "tiltazimuth": 0.0, + "tilt": 0.0, + "detrot": 0.0, + } + except KeyError as exc: + raise KeyError( + f"key_path is wrong (key_path='{key_path}'). " + "Are sample_name, scan number or detector name correct?" + ) from exc + + @h5_safe_load + def load_detector_shape( + self, + ) -> tuple: + """ + Load detector's native pixel array dimensions from scan. + + Returns: + Two-element tuple ``(n_rows, n_columns)`` with detector's + full frame shape (e.g., ``(2164, 1030)`` for Eiger2M). + + Raises: + KeyError: If detector not found in HDF5 file. + """ + # /entry/instrument/merlin/module/data_size + instrument = self.h5file['entry/instrument'] + detector = instrument[self.detector_name] + module = detector["module"] + return module['data_size'][()] + + @h5_safe_load + def load_detector_data( + self, + roi: tuple[slice] = None, + rocking_angle_binning: int = None, + binning_method: str = "sum", + ) -> np.ndarray: + """ + Load raw detector frames from BLISS HDF5 file. + + Retrieves 3D detector data array with optional ROI selection, + binning, flat-field correction, and masking applied via + :meth:`Loader.bin_flat_mask`. + + Args: + scan: Scan number. If None, uses ``self.scan``. + sample_name: Sample name for HDF5 path. If None, uses + ``self.sample_name``. + roi: Region of interest as tuple of slices or integers. See + :meth:`Loader._check_roi` for format. Applied before + binning to reduce memory usage. + rocking_angle_binning: Binning factor along rocking curve + (frame) axis. If None or 1, no binning performed. + binning_method: Binning operation (``"sum"``, ``"mean"``, or + ``"max"``). Default ``"sum"`` preserves total counts. + + Returns: + Preprocessed detector data with shape + ``(n_frames//binning, n_y, n_x)``. Data type is uint16 + (Maxipix) or uint32 (Eiger). + + Raises: + KeyError: If scan/sample/detector combination does not exist + in HDF5 file. + + Examples: + Full detector, no preprocessing: + + >>> data = loader.load_detector_data(scan=42) + >>> data.shape + (51, 2164, 1030) + + With ROI and binning: + + >>> data = loader.load_detector_data( + ... scan=42, + ... roi=(100, 400, 150, 450), + ... rocking_angle_binning=2, + ... binning_method="sum" + ... ) + >>> # Returns (25, 300, 300) array + + See Also: + :meth:`load_data` for combined data + motor positions. + """ + key_path = f"entry/instrument/{self.detector_name}/data" + roi = self._check_roi(roi) + try: + if rocking_angle_binning: + # we first apply the roi for axis1 and axis2 + data = self.h5file[key_path][(slice(None), roi[1], roi[2])] + else: + data = self.h5file[key_path][roi] + except KeyError as exc: + raise KeyError( + f"key_path is wrong (key_path='{key_path}'). " + "Are sample_name, scan number or detector name correct?" + ) from exc + + return self.bin_flat_mask( + data, + roi, + self.flat_field, + self.alien_mask, + rocking_angle_binning, + binning_method, + ) + + @h5_safe_load + def load_motor_positions( + self, + roi: tuple[slice] = None, + rocking_angle_binning: int = None, + ) -> dict: + """ + Load diffractometer motor angles for scan. + + Retrieves sample and detector motor positions, applying same ROI + and binning as detector data to maintain synchronisation. + + Args: + roi: ROI tuple matching detector data ROI. Only first + element (rocking curve axis) is used. If None, full scan + loaded. + rocking_angle_binning: Binning factor matching detector + binning. Angles are averaged (mean) when binned. + + Returns: + dict: Motor angles with canonical keys (see + :attr:`angle_names` for ID01-specific mapping): + + - ``"sample_outofplane_angle"``: eta values (degrees) + - ``"sample_inplane_angle"``: phi values (degrees) + - ``"detector_outofplane_angle"``: delta values + (degrees) + - ``"detector_inplane_angle"``: nu values (degrees) + + Values are scalars (if motor fixed) or 1D arrays (if + scanned). Array lengths match binned detector's first + dimension. + + Raises: + KeyError: If scan/sample combination not found in HDF5 file. + + Examples: + Load angles matching data: + + >>> data = loader.load_detector_data( + ... scan=42, + ... roi=(10, 40, 100, 400), + ... rocking_angle_binning=2 + ... ) + >>> angles = loader.load_motor_positions( + ... scan=42, + ... roi=(slice(10, 40),), + ... rocking_angle_binning=2 + ... ) + >>> angles["sample_outofplane_angle"].shape + (15,) # (40-10)//2 = 15 + + See Also: + :meth:`load_data` for combined data + angles loading. + """ + angles = self.load_angles( + key_path=f"entry/instrument/diffractometer_sample/" + ) + + # ensure angles dictionary has correct keys and defaults to 0.0 + # if missing + formatted_angles = { + key: angles.get(name, 0.0) + for key, name in I16Loader.angle_names.items() + } + self.rocking_angle = self.get_rocking_angle(formatted_angles) + + scan_axis_roi = self._check_roi(roi)[0] + + # format the angles and map them back to their corresponding keys + formatted_values = self.format_scanned_counters( + *formatted_angles.values(), + scan_axis_roi=scan_axis_roi, + rocking_angle_binning=rocking_angle_binning, + ) + + # return a dictionary mapping original angle keys to their + # formatted values. This is possible because Python maintains + # order ! + return dict(zip(formatted_angles.keys(), formatted_values)) + + @h5_safe_load + def load_energy(self) -> float: + """ + Load X-ray beam energy for scan. + + Returns: + Beam energy in eV (converted from monochromator energy in + keV). Returns scalar or array depending on whether energy + was scanned. + + Warns: + UserWarning: If energy key (``"mononrj"``) not found in HDF5 + file, returns None. + + Examples: + >>> energy = loader.load_energy() + >>> print(f"Energy: {energy/1e3:.2f} keV") + """ + energy = self.h5file["entry/sample/beam/incident_energy"][()] * 1e3 + return float(energy) + + @h5_safe_load + def show_scan_attributes( + self, + ) -> None: + """ + Print HDF5 keys available for scan (debugging utility). + + Displays top-level group structure for specified scan, useful + for inspecting file organisation and finding custom metadata. + """ + print(self.h5file['entry'].keys()) + + @h5_safe_load + def load_measurement_parameters( + self, parameter_name: str + ) -> tuple: + """ + Load custom measurement data from scan. + + Retrieves arbitrary datasets stored under + ``{scan}/measurement/`` HDF5 group. Useful for accessing + non-standard counters or experimental metadata. + + Args: + parameter_name: Dataset name under measurement group (e.g., + ``"mu"``, ``"chi"``, custom IOC counters). + scan: Scan number. If None, uses ``self.scan``. + sample_name: Sample name. If None, uses ``self.sample_name``. + + Returns: + Dataset contents (type depends on stored data: array, + scalar, or string). + """ + key_path = "entry/measurement" + return self.h5file[f"{key_path}/{parameter_name}"][()] + + @h5_safe_load + def load_instrument_parameters( + self, + instrument_parameter: str, + ) -> tuple: + """ + Load instrument metadata from scan. + + Retrieves datasets under ``{scan}/instrument/`` group, including + positioners, detectors, and beamline equipment metadata. + + Args: + instrument_parameter: Dataset path under instrument group + (e.g., ``"positioners/delta"``, ``"eiger2M/roi_mode"``). + + Returns: + Dataset contents (type depends on stored data). + """ + key_path = "entry/instrument" + return self.h5file[f"{key_path}/{instrument_parameter}"][()] + + @h5_safe_load + def load_sample_parameters( + self, + sam_parameter: str, + ) -> tuple: + """ + Load sample metadata from scan. + + Retrieves sample-specific information stored under + ``{scan}/sample/`` group (e.g., temperature, pressure, notes). + + Args: + sam_parameter: Dataset name under sample group. + + Returns: + Dataset contents (type depends on stored data). + """ + key_path = "entry/sample" + return self.h5file[f"{key_path}/{sam_parameter}"][()] + + @h5_safe_load + def get_start_time(self, scan: int = None, sample_name: str = None) -> str: + """ + Get scan acquisition start timestamp. + + Parses ISO 8601 timestamp stored by BLISS into datetime object + for temporal analysis or logging. + + Args: + scan: Scan number. If None, uses ``self.scan``. + sample_name: Sample name. If None, uses ``self.sample_name``. + + Returns: + ISO-formatted timestamp string parsable by + :func:`dateutil.parser.isoparse`. + """ + key_path = "entry/start_time" + return dateutil.parser.isoparse(self.h5file[key_path][()]) + + +def safe(func): + def wrap(self, *args, **kwargs): + with silx.io.open(self.experiment_file_path) as self.specfile: + return func(self, *args, **kwargs) + + return wrap + diff --git a/src/cdiutils/io/loader.py b/src/cdiutils/io/loader.py index 0d79b3ca..b62474ea 100644 --- a/src/cdiutils/io/loader.py +++ b/src/cdiutils/io/loader.py @@ -220,6 +220,10 @@ def from_setup(cls, beamline_setup: str, **metadata) -> "Loader": return P10Loader(hutch="EH2", **metadata) return P10Loader(**metadata) + if "i16" in beamline_setup.lower(): + from . import I16Loader + return I16Loader(**metadata) + if beamline_setup.lower() == "cristal": from . import CristalLoader From 7556c72f9d17b0898127c5104aca9039d9468ee0 Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Mon, 16 Feb 2026 18:56:49 +0000 Subject: [PATCH 02/10] Add geometry for I16 --- src/cdiutils/geometry.py | 15 ++++++++++++++- src/cdiutils/io/i16.py | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cdiutils/geometry.py b/src/cdiutils/geometry.py index 4d7e629c..10410821 100644 --- a/src/cdiutils/geometry.py +++ b/src/cdiutils/geometry.py @@ -169,6 +169,7 @@ def from_setup( - ``"NanoMAX"``: MAX IV NanoMAX - ``"CRISTAL"``: SOLEIL CRISTAL - ``"ID27"``: ESRF ID27 + - ``"I16"``: DLS I16 beamline_setup: Deprecated. Use ``beamline`` instead. sample_orientation: Sample mounting style: @@ -287,11 +288,23 @@ def from_setup( sample_surface_normal=[0, 1, 0], # default sample facing up name="ID27", ) + + if beamline.lower() == "i16": + geometry = cls( + sample_circles=["x-", "y-"], # In plane rotation only + detector_circles=["y-", "x-"], # no circle, values dummy + detector_axis0_orientation="y-", + detector_axis1_orientation="x-", + beam_direction=[1, 0, 0], + sample_surface_normal=[0, 1, 0], # default sample facing up + name="I16", + ) + if geometry is None: raise NotImplementedError( f"The beamline name {beamline} is not valid. Available:\n" "'ID01', 'ID01SPEC', 'ID27', 'P10', 'P10EH2', 'SIXS2022' " - "and NanoMAX." + "'I16' and 'NanoMAX'." ) # if the sample orientation is provided, override any default diff --git a/src/cdiutils/io/i16.py b/src/cdiutils/io/i16.py index a9aa1da2..41620ed5 100644 --- a/src/cdiutils/io/i16.py +++ b/src/cdiutils/io/i16.py @@ -39,7 +39,7 @@ class I16Loader(H5TypeLoader): >>> from cdiutils.io.i16 import I16Loader >>> loader = I16Loader( - ... experiment_file_path=="/dls/i16/data/2026/mm12345-1/12345.nxs", + ... experiment_file_path="/dls/i16/data/2026/mm12345-1/12345.nxs", ... sample_name="PtNP", ... detector_name="merlin" ... ) From a1fedb4017a7a35bbc490e866030de645ee9d12e Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Wed, 25 Feb 2026 11:59:36 +0000 Subject: [PATCH 03/10] improve i16 load motor values and geometry --- src/cdiutils/geometry.py | 10 ++++---- src/cdiutils/io/i16.py | 46 ++++++++++++++++++++++------------- src/cdiutils/pipeline/bcdi.py | 6 ++--- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/cdiutils/geometry.py b/src/cdiutils/geometry.py index 10410821..d6b679f2 100644 --- a/src/cdiutils/geometry.py +++ b/src/cdiutils/geometry.py @@ -291,12 +291,12 @@ def from_setup( if beamline.lower() == "i16": geometry = cls( - sample_circles=["x-", "y-"], # In plane rotation only - detector_circles=["y-", "x-"], # no circle, values dummy - detector_axis0_orientation="y-", - detector_axis1_orientation="x-", + sample_circles=["x-", "y+"], # In plane rotation only + detector_circles=["x-", "y+"], # no circle, values dummy + detector_axis0_orientation="x+", + detector_axis1_orientation="y+", beam_direction=[1, 0, 0], - sample_surface_normal=[0, 1, 0], # default sample facing up + sample_surface_normal=[0, 0, 1], # default sample facing up name="I16", ) diff --git a/src/cdiutils/io/i16.py b/src/cdiutils/io/i16.py index 41620ed5..58f9fce6 100644 --- a/src/cdiutils/io/i16.py +++ b/src/cdiutils/io/i16.py @@ -113,11 +113,11 @@ def __init__( """ super().__init__( experiment_file_path, - None, - None, - detector_name, - flat_field, - alien_mask, + scan=None, + sample_name=None, + detector_name=detector_name or "merlin", + flat_field=flat_field, + alien_mask=alien_mask, ) @h5_safe_load @@ -226,10 +226,8 @@ def load_det_calib_params( Examples: Load calibration for current scan: - >>> loader = ID01Loader( - ... experiment_file_path="/data/id01/sample.h5", - ... scan=42, - ... sample_name="sample" + >>> loader = I16Loader( + ... experiment_file_path="/dls/i16/data/20XX/mmXXXX-1/12345.nxs", ... ) >>> calib = loader.load_det_calib_params() >>> print(f"Direct beam at ({calib['cch1']}, {calib['cch2']})") @@ -253,10 +251,10 @@ def load_det_calib_params( module = detector["module"] try: return { - "cch1": float(instrument['merlin_centre_i'][()]) if 'merlin_centre_i' in instrument else 147, - "cch2": float(instrument['merlin_centre_j'][()]) if 'merlin_centre_j' in instrument else 335, - "pwidth1": float(module['fast_pixel_direction'][()]), - "pwidth2": float(module['slow_pixel_direction'][()]), + "cch1": float(instrument['merlin_centre_i'][()]) if 'merlin_centre_i' in instrument else 159, + "cch2": float(instrument['merlin_centre_j'][()]) if 'merlin_centre_j' in instrument else 348, + "pwidth1": float(module['fast_pixel_direction'][()].squeeze()), + "pwidth2": float(module['slow_pixel_direction'][()].squeeze()), "distance": float(detector['transformations/origin_offset'][()]), "tiltazimuth": 0.0, "tilt": 0.0, @@ -264,7 +262,7 @@ def load_det_calib_params( } except KeyError as exc: raise KeyError( - f"key_path is wrong (key_path='{key_path}'). " + f"key_path is wrong (key_path='{module.name}'). " "Are sample_name, scan number or detector name correct?" ) from exc @@ -366,6 +364,22 @@ def load_detector_data( binning_method, ) + @h5_safe_load + def load_angles(self) -> dict: + diffractometer = self.h5file['entry/instrument/diffractometer_sample'] + measurement = self.h5file['entry/measurement'] + angles = {} + for name in self.angle_names.values(): + if name is not None: + if name in measurement: + # measurement contains scanned array + angles[name] = measurement[name][()] + else: + # diffractometer_sample contains metadata (value at start) + angles[name] = diffractometer[name][()] + + return angles + @h5_safe_load def load_motor_positions( self, @@ -421,9 +435,7 @@ def load_motor_positions( See Also: :meth:`load_data` for combined data + angles loading. """ - angles = self.load_angles( - key_path=f"entry/instrument/diffractometer_sample/" - ) + angles = self.load_angles() # ensure angles dictionary has correct keys and defaults to 0.0 # if missing diff --git a/src/cdiutils/pipeline/bcdi.py b/src/cdiutils/pipeline/bcdi.py index 870f77d2..988bece2 100755 --- a/src/cdiutils/pipeline/bcdi.py +++ b/src/cdiutils/pipeline/bcdi.py @@ -357,9 +357,9 @@ def preprocess(self, **params) -> None: self._load() self._from_2d_to_3d_shape() self.logger.info( - "The preprocessing output shape is: and " - f"{self.params['preprocess_shape']} will be used for the " - "determination of the ROI dimensions." + "The preprocessing output shape is: " + f"{self.params['preprocess_shape']} and will be used " + "for the determination of the ROI dimensions." ) # Filter, crop and centre the detector data. self.cropped_detector_data, roi = self._crop_centre( From b41675b42dd3edbbce275da216a4f6090eeafdfe Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Fri, 24 Apr 2026 10:47:40 +0100 Subject: [PATCH 04/10] correct i16 geometry --- src/cdiutils/geometry.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cdiutils/geometry.py b/src/cdiutils/geometry.py index d6b679f2..2473ac42 100644 --- a/src/cdiutils/geometry.py +++ b/src/cdiutils/geometry.py @@ -291,12 +291,12 @@ def from_setup( if beamline.lower() == "i16": geometry = cls( - sample_circles=["x-", "y+"], # In plane rotation only - detector_circles=["x-", "y+"], # no circle, values dummy - detector_axis0_orientation="x+", - detector_axis1_orientation="y+", + sample_circles=["x-", "y+"], # eta, mu + detector_circles=["y+", "x-"], # gam, delta (this doesn't match spec but same as id01) + detector_axis0_orientation="y+", + detector_axis1_orientation="x-", beam_direction=[1, 0, 0], - sample_surface_normal=[0, 0, 1], # default sample facing up + sample_surface_normal=[0, 1, 0], # CXI z,y,x default sample facing up name="I16", ) From 03b0a605d19244aa7d03f083502129207041f4c2 Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Mon, 27 Apr 2026 17:05:51 +0100 Subject: [PATCH 05/10] Add i16_bcdi_pipeline notebook --- src/cdiutils/__init__.py | 2 +- src/cdiutils/io/i16.py | 83 +--- .../scripts/prepare_bcdi_notebooks.py | 11 +- .../templates/i16_bcdi_pipeline.ipynb | 430 ++++++++++++++++++ 4 files changed, 462 insertions(+), 64 deletions(-) create mode 100644 src/cdiutils/templates/i16_bcdi_pipeline.ipynb diff --git a/src/cdiutils/__init__.py b/src/cdiutils/__init__.py index cf8d0aa7..2b323d77 100755 --- a/src/cdiutils/__init__.py +++ b/src/cdiutils/__init__.py @@ -3,7 +3,7 @@ Imaging processing, analysis and visualisation workflows. """ -__version__ = "0.2.1" +__version__ = "0.2.1_i16" __author__ = "Clément Atlan" __email__ = "c.atlan@outlook.com" __license__ = "MIT" diff --git a/src/cdiutils/io/i16.py b/src/cdiutils/io/i16.py index 58f9fce6..3b16079f 100644 --- a/src/cdiutils/io/i16.py +++ b/src/cdiutils/io/i16.py @@ -1,11 +1,10 @@ -import warnings import dateutil.parser -import fabio import numpy as np +import h5py import silx.io -from cdiutils.io.loader import H5TypeLoader, Loader, h5_safe_load +from cdiutils.io.loader import H5TypeLoader, h5_safe_load class I16Loader(H5TypeLoader): @@ -127,67 +126,16 @@ def get_detector_name( """ Auto-detect detector from HDF5 file scan metadata. - Searches through scan groups to find which authorised detector - is present in the measurement data. Used when detector is not - explicitly specified during initialisation. - - Args: - start_scan: Scan number to begin search. Recursively - increments if scan not found or contains no detector. - max_attempts: Maximum number of scans to check before - giving up. - - Returns: - First matching detector name from - :attr:`authorised_detector_names` found in file. - - Raises: - ValueError: If no detector found after ``max_attempts`` - scans, or if multiple detectors found in same scan - (ambiguous configuration). - KeyError: If HDF5 structure does not match expected - ``{sample}_{scan}.1/measurement/`` format. - - Notes: - Recursion avoids issues with missing or incomplete - scans. For files with both Eiger and Maxipix data, - explicitly specify ``detector_name`` to avoid ambiguity. + Returns the name of the first NXdetector in the instrument group. """ - msg = "Please provide a detector_name (str)." - - # Try to find the detector name in the current scan number - key_path = f"{self.sample_name}_{start_scan}.1/measurement/" - - # If we've exceeded max attempts, raise an error - if start_scan > max_attempts: - raise ValueError( - f"No detector found after checking {max_attempts} scans.\n" - f"{msg}" - ) - - # Check if the key path exists - if key_path not in self.h5file: - # Try the next scan number recursively - return self.get_detector_name(start_scan + 1, max_attempts) - - # Look for detector names in the current scan - detector_names = [] - for key in self.authorised_detector_names: - if key in self.h5file[key_path]: - detector_names.append(key) - - if len(detector_names) == 0: - # Try the next scan number recursively - return self.get_detector_name(start_scan + 1, max_attempts) - - if len(detector_names) > 1: - raise ValueError( - f"Several detector names found ({detector_names}).\n" - f"Not handled yet.\n{msg}" - ) - - return detector_names[0] + # Get detctor name from first NXdetector in instrument + instrument = self.h5file['entry/instrument'] + for name, object in instrument.items(): + nx_class = object.attrs.get('NX_class') + if nx_class and nx_class.astype(str) == 'NXdetector': + return name + raise KeyError('No NXdetector found in HDF5 file') @h5_safe_load def load_det_calib_params( @@ -576,6 +524,17 @@ def get_start_time(self, scan: int = None, sample_name: str = None) -> str: key_path = "entry/start_time" return dateutil.parser.isoparse(self.h5file[key_path][()]) + @h5_safe_load + def get_hkl(self) -> tuple[int, int, int]: + """ + Return the HKL value from the NeXus file + """ + key_paths = [ + '/entry/instrument/diffractometer_sample/h', + '/entry/instrument/diffractometer_sample/k', + '/entry/instrument/diffractometer_sample/l' + ] + return tuple([round(self.h5file[k][()]) for k in key_paths]) def safe(func): def wrap(self, *args, **kwargs): diff --git a/src/cdiutils/scripts/prepare_bcdi_notebooks.py b/src/cdiutils/scripts/prepare_bcdi_notebooks.py index 6e52a4a9..75d707af 100644 --- a/src/cdiutils/scripts/prepare_bcdi_notebooks.py +++ b/src/cdiutils/scripts/prepare_bcdi_notebooks.py @@ -37,6 +37,12 @@ def main() -> None: "already exist." ), ) + parser.add_argument( + "--i16", + default=False, + action="store_true", + help="Create I16 version of the notebooks.", + ) args = parser.parse_args() @@ -44,7 +50,10 @@ def main() -> None: templates_dir = get_templates_path() # Update paths to notebooks in the examples directory - bcdi_notebook = os.path.join(templates_dir, "bcdi_pipeline.ipynb") + if args.i16 or os.environ.get('BEAMLINE') == 'i16': # on DLS I16, create a specific notebook + bcdi_notebook = os.path.join(templates_dir, "i16_bcdi_pipeline.ipynb") + else: + bcdi_notebook = os.path.join(templates_dir, "bcdi_pipeline.ipynb") step_by_step_notebook = os.path.join( templates_dir, "step_by_step_bcdi_analysis.ipynb" ) diff --git a/src/cdiutils/templates/i16_bcdi_pipeline.ipynb b/src/cdiutils/templates/i16_bcdi_pipeline.ipynb new file mode 100644 index 00000000..451036c3 --- /dev/null +++ b/src/cdiutils/templates/i16_bcdi_pipeline.ipynb @@ -0,0 +1,430 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# **BCDI Pipeline for I16**\n", + "### A Notebook to Run the `BcdiPipeline` Instance \n", + "\n", + "This notebook provides a structured workflow for running a **Bragg Coherent Diffraction Imaging (BCDI) pipeline**. \n", + "\n", + "The `BcdiPipeline` class handles the entire process, including: \n", + "- **Pre-processing** → Data preparation and corrections. \n", + "- **Phase retrieval** → Running PyNX algorithms to reconstruct the phase. \n", + "- **Post-processing** → Refining, analysing (get the strain!), and visualising results. \n", + "\n", + "You can provide **either**: \n", + "- A **YAML parameter file** for full automation. \n", + "- A **Python dictionary** for interactive control in this notebook. \n" + ] + }, + { + "cell_type": "code", + "metadata": { + "tags": [] + }, + "source": [ + "# import required packages\n", + "import os\n", + "\n", + "# I16 local workstations - set GPU usage to opencl for PyNX\n", + "os.environ[\"PYNX_PU\"] = \"opencl\"\n", + "\n", + "import cdiutils # core library for BCDI processing" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## **Specify I16 Scan File**" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "visit_path: str = \"\" # location of scan files\n", + "scan_number: int = 0 # scan number\n", + "sample_name: str = \"\" # Optional: provide a sample name for separating folders\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **General Parameters**\n", + "Here, define the key parameters for **accessing and saving data** before running the pipeline. \n", + "- **These parameters must be set manually by the user** before execution. \n", + "- The output data will be saved in a structured directory format based on `sample_name` and `scan`. However, you can change the directory path if you like.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# define the key parameters (must be filled in by the user)\n", + "beamline_setup: str = \"i16\" # example: \"ID01\" (provide the beamline setup)\n", + "experiment_file_path: str = os.path.join(visit_path, f\"{scan_number}.nxs\")\n", + "scan: int = scan_number\n", + "\n", + "_loader = cdiutils.Loader.from_setup(\n", + " beamline_setup=beamline_setup,\n", + " experiment_file_path=experiment_file_path,\n", + ")\n", + "hkl = _loader.get_hkl()\n", + "detector_name = _loader.get_detector_name()\n", + "\n", + "# choose where to save the results (default: current working directory)\n", + "dump_dir = os.getcwd() + f\"/results/{sample_name}/S{scan}/\"\n", + "\n", + "# load the parameters and parse them into the BcdiPipeline class instance\n", + "params = cdiutils.pipeline.get_params_from_variables(dir(), globals())\n", + "bcdi_pipeline = cdiutils.BcdiPipeline(params=params)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Pre-Processing** \n", + "\n", + "If you need to update specific parameters, you can **pass them directly** into the `preprocess` method. \n", + "\n", + "### **Main Parameters**\n", + "- `preprocess_shape` → The shape of the cropped window used throughout the processes. \n", + " - Can be a **tuple of 2 or 3 values**. \n", + " - If only **2 values**, the entire rocking curve is used. \n", + "\n", + "- `voxel_reference_methods` → A `list` (or a single value) defining how to centre the data. \n", + " - Can include `\"com\"`, `\"max\"`, or a `tuple` of `int` (specific voxel position). \n", + " - Example:\n", + " ```python\n", + " voxel_reference_methods = [(70, 200, 200), \"com\", \"com\"]\n", + " ```\n", + " - This centres a box of size `preprocess_shape` around `(70, 200, 200)`, then iteratively refines it using `\"com\"` (only computed within this box).\n", + " - Useful when `\"com\"` fails due to artifacts or `\"max\"` fails due to hot pixels. \n", + " - Default: `[\"max\", \"com\", \"com\"]`. \n", + "\n", + "- `rocking_angle_binning` → If you want to bin in the **rocking curve direction**, provide a binning factor (ex.: `2`). \n", + "\n", + "- `light_loading` → If `True`, loads only the **ROI of the data** based on `voxel_reference_methods` and `preprocess_output_shape`. \n", + "\n", + "- `hot_pixel_filter` → Removes isolated hot pixels. \n", + " - Default: `False`. \n", + "\n", + "- `background_level` → Sets the background intensity to be removed. \n", + " - Example: `3`. \n", + " - Default: `None`. \n", + "\n", + "- `hkl` → Defines the **Bragg reflection** measured to extend *d*-spacing values to the lattice parameter. \n", + " - Default: `[1, 1, 1]`. \n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "bcdi_pipeline.preprocess(\n", + " preprocess_shape=(150, 150), # define cropped window size\n", + " voxel_reference_methods=[\"max\", \"com\", \"com\"], # centring method sequence\n", + " hot_pixel_filter=False, # remove isolated hot pixels\n", + " background_level=None, # background intensity level to remove\n", + " hkl=hkl\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **[PyNX](https://pynx.esrf.fr/en/latest/index.html) Phase Retrieval**\n", + "See the [pynx.cdi](https://pynx.esrf.fr/en/latest/scripts/pynx-cdi-id01.html) documentation for details on the phasing algorithms used here. \n", + "\n", + "**Algorithm recipe**\n", + "\n", + "You can either: \n", + "- provide the exact chain of algorithms. \n", + "- or specify the number of iterations for **RAAR**, **HIO**, and **ER**. \n", + "\n", + "```python\n", + "algorithm = None # ex: \"(Sup * (ER**20)) ** 10, (Sup*(HIO**20)) ** 15, (Sup*(RAAR**20)) ** 25\"\n", + "nb_raar = 500\n", + "nb_hio = 300\n", + "nb_er = 200\n", + "psf = \"pseudo-voigt,1,0.05,20\"\n", + "```\n", + "**Support-related parameters**\n", + "```python\n", + "support = \"auto\" # ex: bcdi_pipeline.pynx_phasing_dir + \"support.cxi\" (path to an existing support)\n", + "```\n", + ">_Note: If strain seems to large, don't use \"auto\" (autocorrelation) but use \"circle\" or \"square\", in combination with \"support_size\"_ \n", + "```python\n", + "support_threshold = \"0.15, 0.40\" # must be a string\n", + "support_update_period = 20\n", + "support_only_shrink = False\n", + "support_post_expand = None # ex: \"-1,1\" or \"-1,2,-1\"\n", + "support_update_border_n = None\n", + "support_smooth_width_begin = 2\n", + "support_smooth_width_end = 0.5\n", + "```\n", + "**Other parameters**\n", + "```python\n", + "positivity = False\n", + "beta = 0.9 # β parameter in HIO and RAAR\n", + "detwin = True\n", + "rebin = \"1, 1, 1\" # must be a string\n", + "```\n", + "**Number of Runs & Reconstructions to Keep**\n", + "```python\n", + "nb_run = 20 # total number of runs\n", + "nb_run_keep = 10 # number of reconstructions to keep\n", + "```\n", + "\n", + "**Override defaults in `phase_retrieval`**\n", + "\n", + "You can override any default parameter directly in the phase_retrieval method:\n", + "```python\n", + "bcdi_pipeline.phase_retrieval(nb_run=50, nb_run_keep=25)\n", + "```\n", + "If a parameter is not provided, the default value is used.\n", + "\n", + "### **Phase Retrieval GUI**\n", + "You can also launch a **Graphical User Interface (GUI)** to interactively set parameters and run phase retrieval. \n", + "```python\n", + "bcdi_pipeline.phase_retrieval_gui()\n", + "```\n", + "In that case, you can take care of the the result analysis, the selection of the best reconstructions, and the mode decomposition. Then, simply jump to the post-processing step cell." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "bcdi_pipeline.phase_retrieval(\n", + " clear_former_results=True,\n", + " nb_run=20,\n", + " nb_run_keep=10,\n", + " # support=bcdi_pipeline.pynx_phasing_dir + \"support.cxi\"\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Analyse the phasing results**\n", + "\n", + "This step evaluates the quality of the phase retrieval results by sorting reconstructions based on a `sorting_criterion`. \n", + "\n", + "##### **Available Sorting Criteria**\n", + "- `\"mean_to_max\"` → Difference between the mean of the **Gaussian fit of the amplitude histogram** and its maximum value. A **smaller difference** indicates a more homogeneous reconstruction. \n", + "- `\"sharpness\"` → Sum of the amplitude within the support raised to the power of 4. **Lower values** indicate greater homogeneity. \n", + "- `\"std\"` → **Standard deviation** of the amplitude. \n", + "- `\"llk\"` → **Log-likelihood** of the reconstruction. \n", + "- `\"llkf\"` → **Free log-likelihood** of the reconstruction. \n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "bcdi_pipeline.analyse_phasing_results(\n", + " sorting_criterion=\"mean_to_max\", # selects the sorting method\n", + " # Optional parameters\n", + " # plot_phasing_results=False, # uncomment to disable plotting\n", + " # plot_phase=True, # uncomment to enable phase plotting\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Optionally, generate a support for further phasing attempts** \n", + "\n", + "##### **Parameters**\n", + "- `run` → set to either: \n", + " - `\"best\"` to use the best reconstruction. \n", + " - an **integer** corresponding to the specific run you want. \n", + "- `output_path` → the location to save the generated support. By default, it will be saved in the `pynx_phasing` folder. \n", + "- `fill` → whether to fill the support if it contains holes. \n", + " - Default: `False`.\n", + "- `verbose` → whether to print logs and display a plot of the support. \n", + "\n", + "" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# bcdi_pipeline.generate_support_from(\"best\", fill=False) # uncomment to generate a support" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Selection of the best reconstructions & mode decomposition**\n", + "\n", + "You can select the best reconstructions based on a **sorting criterion** and keep a specified number of top candidates. \n", + "\n", + "##### **Parameters**\n", + "- `nb_of_best_sorted_runs` → the number of best reconstructions to keep, selected based on the `sorting_criterion` used in the `analyse_phasing_results` method above. \n", + "- `best_runs` → instead of selecting based on sorting, you can manually specify a list of reconstruction numbers.\n", + "\n", + "By default, the **best reconstructions** are automatically selected. \n", + "\n", + "Once the best candidates are chosen, `mode_decomposition` analyses them to extract dominant features. \n", + "\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# define how many of the best candidates to keep\n", + "number_of_best_candidates: int = 5\n", + "\n", + "# select the best reconstructions based on the sorting criterion\n", + "bcdi_pipeline.select_best_candidates(\n", + " nb_of_best_sorted_runs=number_of_best_candidates\n", + " # best_runs=[10] # uncomment to manually select a specific run\n", + ")\n", + "\n", + "# perform mode decomposition on the selected reconstructions\n", + "bcdi_pipeline.mode_decomposition()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Post-processing**\n", + "\n", + "This stage includes several key operations: \n", + "- **orthogonalisation** of the reconstructed data. \n", + "- **phase manipulation**: \n", + " - phase unwrapping \n", + " - phase ramp removal \n", + "- **computation of physical properties**: \n", + " - displacement field \n", + " - strain \n", + " - d-spacing \n", + "- **visualisation**: Generate multiple plots for analysis. \n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "bcdi_pipeline.postprocess(\n", + " isosurface=0.3, # threshold for isosurface\n", + " voxel_size=None, # use default voxel size if not provided\n", + " flip=False, # whether to flip the reconstruction if you got the twin image (enantiomorph)\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **3D interactive plot**\n", + "\n", + "Display an interactive 3D isosurface of the final reconstruction and explore different quantities for colouring.\n", + "\n", + "What you can do\n", + "- Visualise an isosurface of the reconstructed object (amplitude / support).\n", + "- Colour the surface by different quantities: amplitude, phase, displacement, strain, d-spacing, etc.\n", + "- Interactively adjust:\n", + " - isosurface threshold (isosurface level)\n", + " - colormap and value range\n", + "- Rotate, zoom and pan the scene with the mouse; use the toolbar to reset view or save a screenshot.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "bcdi_pipeline.show_3d_final_result()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Feedback & Issue Reporting** \n", + "\n", + "If you have **comments, suggestions, or encounter any issues**, please reach out: \n", + "\n", + "📧 **Email:** [clement.atlan@esrf.fr](mailto:clement.atlan@esrf.fr?subject=cdiutils) \n", + "🐙 **GitHub Issues:** [Report an issue](https://github.com/clatlan/cdiutils/issues) \n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Credits\n", + "This notebook was created by Clément Atlan, ESRF, 2025. It is part of the `cdiutils` package, which provides tools for BCDI data analysis and visualisation.\n", + "If you have used this notebook or the `cdiutils` package in your research, please consider citing the package https://github.com/clatlan/cdiutils/\n", + "You'll find the citation information in the `cdiutils` package documentation.\n", + "\n", + "```bibtex\n", + "@software{Atlan_Cdiutils_A_python,\n", + "author = {Atlan, Clement},\n", + "doi = {10.5281/zenodo.7656853},\n", + "license = {MIT},\n", + "title = {{Cdiutils: A python package for Bragg Coherent Diffraction Imaging processing, analysis and visualisation workflows}},\n", + "url = {https://github.com/clatlan/cdiutils},\n", + "version = {0.2.0}\n", + "}\n", + "```\n", + "\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 42b2ea43ad14e34fdb368f3d5f97e6bd5521675c Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Tue, 28 Apr 2026 10:18:41 +0100 Subject: [PATCH 06/10] Add i16_bcdi_pipeline notebook --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9f0407d3..6bf386c6 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ where = ["src"] "cdiutils" = [ "pipeline/pynx-id01-cdi_template.slurm", "templates/bcdi_pipeline.ipynb", + "templates/i16_bcdi_pipeline.ipynb", "templates/step_by_step_bcdi_analysis.ipynb", "templates/detector_calibration.ipynb", "plot/lut_*.npz" From 2f325b6ceb51f7062d01870e75f265ede85a8bf4 Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Fri, 1 May 2026 16:18:53 +0100 Subject: [PATCH 07/10] Flip detector direction --- src/cdiutils/io/i16.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cdiutils/io/i16.py b/src/cdiutils/io/i16.py index 3b16079f..9303a561 100644 --- a/src/cdiutils/io/i16.py +++ b/src/cdiutils/io/i16.py @@ -297,6 +297,9 @@ def load_detector_data( data = self.h5file[key_path][(slice(None), roi[1], roi[2])] else: data = self.h5file[key_path][roi] + # Rotate data so horizontal detector axis is vertical + # data = np.transpose(data, (0, 2, 1)) + data = np.flip(np.transpose(data, (0, 2, 1)), (1, )) except KeyError as exc: raise KeyError( f"key_path is wrong (key_path='{key_path}'). " From f5fe7963830b2d33dbf20b1b9ac532a6fa2a4526 Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Tue, 5 May 2026 12:43:08 +0100 Subject: [PATCH 08/10] Fix geometry Fix geometry in geometry.py, remove changes to loader. Also, improved docs. --- src/cdiutils/geometry.py | 7 ++- src/cdiutils/io/i16.py | 60 ++++++++----------- .../templates/i16_bcdi_pipeline.ipynb | 2 +- 3 files changed, 30 insertions(+), 39 deletions(-) diff --git a/src/cdiutils/geometry.py b/src/cdiutils/geometry.py index 2473ac42..da3f092f 100644 --- a/src/cdiutils/geometry.py +++ b/src/cdiutils/geometry.py @@ -291,10 +291,11 @@ def from_setup( if beamline.lower() == "i16": geometry = cls( - sample_circles=["x-", "y+"], # eta, mu + sample_circles=["x-", "y+"], # eta, mu or eta, phi when chi==90 + # sample_circles=["x-", "z+", "x-", "y+"], # TODO: phi, chi, eta, mu (how do I add these angles in the loader?) detector_circles=["y+", "x-"], # gam, delta (this doesn't match spec but same as id01) - detector_axis0_orientation="y+", - detector_axis1_orientation="x-", + detector_axis0_orientation="x+", # at Stokes=0, merlin detector horiz. pixels low=high delta + detector_axis1_orientation="y-", # at Stokes=0, merlin detector vert. pixels low=low gamma beam_direction=[1, 0, 0], sample_surface_normal=[0, 1, 0], # CXI z,y,x default sample facing up name="I16", diff --git a/src/cdiutils/io/i16.py b/src/cdiutils/io/i16.py index 9303a561..ffff34e7 100644 --- a/src/cdiutils/io/i16.py +++ b/src/cdiutils/io/i16.py @@ -14,10 +14,10 @@ class I16Loader(H5TypeLoader): Loads data from NeXus files. Attributes: - angle_names: Mapping from canonical names to ID01 motor names: + angle_names: Mapping from canonical names to I16 Eulerian pseudo-motor names: - ``sample_outofplane_angle`` -> ``"eta"`` - - ``sample_inplane_angle`` -> ``"chi"`` + - ``sample_inplane_angle`` -> ``"mu"`` - ``detector_outofplane_angle`` -> ``"delta"`` - ``detector_inplane_angle`` -> ``"gamma"`` @@ -30,7 +30,6 @@ class I16Loader(H5TypeLoader): >>> from cdiutils.io import Loader >>> loader = Loader.from_setup( ... beamline_setup="i16", - ... sample_name="PtNP", ... experiment_file_path="/dls/i16/data/2026/mm12345-1/12345.nxs" ... ) @@ -54,6 +53,11 @@ class I16Loader(H5TypeLoader): :class:`Loader` for factory method and base class documentation. """ + # I16 is a 6-circle kappa diffractometer, motors are in the Kappa convention + # but the Eulerian convention is also stored (CXI basis here): + # sample rotations: phi (x-), chi (z+), eta (x-), mu (y+), + # detector rotations: delta (x-), gamma (y+) + # TODO: How to add additional angles below? angle_names = { "sample_outofplane_angle": "eta", "sample_inplane_angle": "mu", @@ -99,13 +103,13 @@ def __init__( Minimal setup (auto-detect detector): >>> loader = I16Loader( - ... experiment_file_path="/data/id01/PtNP.h5" + ... experiment_file_path="/dls/i16/data/2026/mm12345-1/123456.nxs", ... ) With flat-field and detector specification: >>> loader = I16Loader( - ... experiment_file_path="/data/id01/sample.h5", + ... experiment_file_path="/dls/i16/data/2026/mm12345-1/123456.nxs", ... detector_name="merlin", ... flat_field="/path/to/flatfield.npy" ... ) @@ -144,15 +148,13 @@ def load_det_calib_params( """ Load detector calibration from scan metadata. - Retrieves calibration parameters stored in BLISS HDF5 file + Retrieves calibration parameters stored in NeXus file during detector alignment. Returns parameters compatible with xrayutilities conventions. Args: - scan: Scan number to load calibration from. If None, uses - ``self.scan``. - sample_name: Sample name for HDF5 path construction. If - None, uses ``self.sample_name``. + scan: Unused. + sample_name: Unused. Returns: dict: Calibration parameters with keys: @@ -168,8 +170,7 @@ def load_det_calib_params( - ``"detrot"``: Detector rotation (0.0, not calibrated) Raises: - KeyError: If scan/sample combination does not exist in HDF5 - file or if detector name is incorrect. + KeyError: If fields are not available in NeXus file. Examples: Load calibration for current scan: @@ -186,9 +187,8 @@ def load_det_calib_params( Notes: Tilt angles (``tiltazimuth``, ``tilt``, ``detrot``) are set - to 0.0 as BLISS does not calibrate these. For accurate tilt - values, run detector calibration notebook or use PyNX's - ``cdi_findcenter`` utility. + to 0.0. For accurate tilt values, run detector calibration + notebook or use PyNX's ``cdi_findcenter`` utility. See Also: :doc:`/user_guide/detector_calibration` for calibration @@ -242,16 +242,13 @@ def load_detector_data( binning_method: str = "sum", ) -> np.ndarray: """ - Load raw detector frames from BLISS HDF5 file. + Load raw detector frames from I16 NeXus file. Retrieves 3D detector data array with optional ROI selection, binning, flat-field correction, and masking applied via :meth:`Loader.bin_flat_mask`. Args: - scan: Scan number. If None, uses ``self.scan``. - sample_name: Sample name for HDF5 path. If None, uses - ``self.sample_name``. roi: Region of interest as tuple of slices or integers. See :meth:`Loader._check_roi` for format. Applied before binning to reduce memory usage. @@ -266,20 +263,18 @@ def load_detector_data( (Maxipix) or uint32 (Eiger). Raises: - KeyError: If scan/sample/detector combination does not exist - in HDF5 file. + KeyError: If detector does not exist in HDF5 file. Examples: Full detector, no preprocessing: - >>> data = loader.load_detector_data(scan=42) + >>> data = loader.load_detector_data() >>> data.shape (51, 2164, 1030) With ROI and binning: >>> data = loader.load_detector_data( - ... scan=42, ... roi=(100, 400, 150, 450), ... rocking_angle_binning=2, ... binning_method="sum" @@ -289,7 +284,7 @@ def load_detector_data( See Also: :meth:`load_data` for combined data + motor positions. """ - key_path = f"entry/instrument/{self.detector_name}/data" + key_path = f"/entry/instrument/{self.detector_name}/data" roi = self._check_roi(roi) try: if rocking_angle_binning: @@ -297,9 +292,6 @@ def load_detector_data( data = self.h5file[key_path][(slice(None), roi[1], roi[2])] else: data = self.h5file[key_path][roi] - # Rotate data so horizontal detector axis is vertical - # data = np.transpose(data, (0, 2, 1)) - data = np.flip(np.transpose(data, (0, 2, 1)), (1, )) except KeyError as exc: raise KeyError( f"key_path is wrong (key_path='{key_path}'). " @@ -355,10 +347,9 @@ def load_motor_positions( :attr:`angle_names` for ID01-specific mapping): - ``"sample_outofplane_angle"``: eta values (degrees) - - ``"sample_inplane_angle"``: phi values (degrees) - - ``"detector_outofplane_angle"``: delta values - (degrees) - - ``"detector_inplane_angle"``: nu values (degrees) + - ``"sample_inplane_angle"``: mu values (degrees) + - ``"detector_outofplane_angle"``: delta values (degrees) + - ``"detector_inplane_angle"``: nu/ gamma values (degrees) Values are scalars (if motor fixed) or 1D arrays (if scanned). Array lengths match binned detector's first @@ -371,12 +362,10 @@ def load_motor_positions( Load angles matching data: >>> data = loader.load_detector_data( - ... scan=42, ... roi=(10, 40, 100, 400), ... rocking_angle_binning=2 ... ) >>> angles = loader.load_motor_positions( - ... scan=42, ... roi=(slice(10, 40),), ... rocking_angle_binning=2 ... ) @@ -390,6 +379,7 @@ def load_motor_positions( # ensure angles dictionary has correct keys and defaults to 0.0 # if missing + # TODO: only canonical angles are stored here, unclear how to add additional angles formatted_angles = { key: angles.get(name, 0.0) for key, name in I16Loader.angle_names.items() @@ -421,14 +411,14 @@ def load_energy(self) -> float: was scanned. Warns: - UserWarning: If energy key (``"mononrj"``) not found in HDF5 + UserWarning: If energy key not found in HDF5 file, returns None. Examples: >>> energy = loader.load_energy() >>> print(f"Energy: {energy/1e3:.2f} keV") """ - energy = self.h5file["entry/sample/beam/incident_energy"][()] * 1e3 + energy = self.h5file["entry/sample/beam/incident_energy"][()] * 1e3 # keV -> eV return float(energy) @h5_safe_load diff --git a/src/cdiutils/templates/i16_bcdi_pipeline.ipynb b/src/cdiutils/templates/i16_bcdi_pipeline.ipynb index 451036c3..1e1f617b 100644 --- a/src/cdiutils/templates/i16_bcdi_pipeline.ipynb +++ b/src/cdiutils/templates/i16_bcdi_pipeline.ipynb @@ -46,7 +46,7 @@ "cell_type": "code", "source": [ "visit_path: str = \"\" # location of scan files\n", - "scan_number: int = 0 # scan number\n", + "scan_number: int = 12345 # scan number\n", "sample_name: str = \"\" # Optional: provide a sample name for separating folders\n" ], "outputs": [], From ade733127c2f27261f72708a1283bc7826929b78 Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Mon, 11 May 2026 11:38:26 +0100 Subject: [PATCH 09/10] ruff check ruff format and ruff check --fix --- examples/bcdi_pipeline_example.ipynb | 104 ++++++------- examples/bcdi_reconstruction_analysis.ipynb | 142 ++++++++++-------- examples/pole_figure.ipynb | 26 ++-- src/cdiutils/geometry.py | 16 +- src/cdiutils/io/__init__.py | 2 +- src/cdiutils/io/i16.py | 56 +++---- src/cdiutils/io/loader.py | 1 + .../scripts/prepare_bcdi_notebooks.py | 4 +- .../templates/i16_bcdi_pipeline.ipynb | 68 ++++----- 9 files changed, 226 insertions(+), 193 deletions(-) diff --git a/examples/bcdi_pipeline_example.ipynb b/examples/bcdi_pipeline_example.ipynb index 74b1d233..26b8fd28 100644 --- a/examples/bcdi_pipeline_example.ipynb +++ b/examples/bcdi_pipeline_example.ipynb @@ -487,7 +487,7 @@ ")\n", "\n", "# perform mode decomposition on the selected reconstructions\n", - "bcdi_pipeline.mode_decomposition()\n" + "bcdi_pipeline.mode_decomposition()" ] }, { @@ -1386,9 +1386,9 @@ "scene": { "annotations": [], "aspectratio": { - "x": 1.2464591606043993, + "x": 1.246459160604399, "y": 0.6196507368679935, - "z": 1.2947173737350615 + "z": 1.2947173737350617 }, "bgcolor": "rgba(0,0,0,0)", "domain": { @@ -1417,7 +1417,7 @@ "minexponent": 3, "nticks": 0, "range": [ - 99.37376499176025, + 99.37376499176024, 713.6424398422241 ], "rangemode": "normal", @@ -1696,7 +1696,7 @@ "rgb(18,47,108)" ], [ - 0.011764705882352941, + 0.01176470588235294, "rgb(19,48,110)" ], [ @@ -1708,7 +1708,7 @@ "rgb(22,51,114)" ], [ - 0.023529411764705882, + 0.02352941176470588, "rgb(23,52,116)" ], [ @@ -1732,7 +1732,7 @@ "rgb(28,58,127)" ], [ - 0.047058823529411764, + 0.04705882352941176, "rgb(29,60,129)" ], [ @@ -1776,39 +1776,39 @@ "rgb(38,73,150)" ], [ - 0.09019607843137255, + 0.09019607843137256, "rgb(39,74,152)" ], [ - 0.09411764705882353, + 0.09411764705882351, "rgb(39,76,155)" ], [ - 0.09803921568627451, + 0.09803921568627452, "rgb(40,77,157)" ], [ - 0.10196078431372549, + 0.10196078431372547, "rgb(41,79,159)" ], [ - 0.10588235294117647, + 0.10588235294117648, "rgb(42,80,161)" ], [ - 0.10980392156862745, + 0.10980392156862744, "rgb(43,81,163)" ], [ - 0.11372549019607843, + 0.11372549019607844, "rgb(43,83,165)" ], [ - 0.11764705882352941, + 0.1176470588235294, "rgb(44,84,167)" ], [ - 0.12156862745098039, + 0.1215686274509804, "rgb(45,86,169)" ], [ @@ -1852,7 +1852,7 @@ "rgb(51,101,189)" ], [ - 0.16470588235294117, + 0.16470588235294115, "rgb(51,103,191)" ], [ @@ -1860,7 +1860,7 @@ "rgb(52,104,193)" ], [ - 0.17254901960784313, + 0.1725490196078431, "rgb(52,106,195)" ], [ @@ -1876,7 +1876,7 @@ "rgb(53,111,201)" ], [ - 0.18823529411764706, + 0.18823529411764703, "rgb(54,112,202)" ], [ @@ -1884,7 +1884,7 @@ "rgb(54,114,204)" ], [ - 0.19607843137254902, + 0.19607843137254904, "rgb(54,116,206)" ], [ @@ -1892,7 +1892,7 @@ "rgb(54,117,208)" ], [ - 0.20392156862745098, + 0.20392156862745095, "rgb(54,119,210)" ], [ @@ -1900,7 +1900,7 @@ "rgb(54,121,211)" ], [ - 0.21176470588235294, + 0.21176470588235297, "rgb(54,122,213)" ], [ @@ -1916,7 +1916,7 @@ "rgb(53,128,218)" ], [ - 0.22745098039215686, + 0.2274509803921569, "rgb(53,129,220)" ], [ @@ -1924,19 +1924,19 @@ "rgb(53,131,221)" ], [ - 0.23529411764705882, + 0.2352941176470588, "rgb(52,133,223)" ], [ - 0.23921568627450981, + 0.2392156862745098, "rgb(52,135,224)" ], [ - 0.24313725490196078, + 0.2431372549019608, "rgb(51,137,226)" ], [ - 0.24705882352941178, + 0.24705882352941175, "rgb(51,139,227)" ], [ @@ -2056,7 +2056,7 @@ "rgb(105,188,250)" ], [ - 0.36470588235294116, + 0.3647058823529411, "rgb(108,190,251)" ], [ @@ -2084,7 +2084,7 @@ "rgb(131,199,251)" ], [ - 0.39215686274509803, + 0.392156862745098, "rgb(135,201,251)" ], [ @@ -2100,7 +2100,7 @@ "rgb(147,205,250)" ], [ - 0.40784313725490196, + 0.4078431372549019, "rgb(151,206,250)" ], [ @@ -2120,7 +2120,7 @@ "rgb(167,212,249)" ], [ - 0.42745098039215684, + 0.4274509803921568, "rgb(171,213,249)" ], [ @@ -2128,7 +2128,7 @@ "rgb(175,215,248)" ], [ - 0.43529411764705883, + 0.4352941176470588, "rgb(179,216,248)" ], [ @@ -2136,7 +2136,7 @@ "rgb(182,217,247)" ], [ - 0.44313725490196076, + 0.4431372549019608, "rgb(186,219,247)" ], [ @@ -2144,7 +2144,7 @@ "rgb(190,220,247)" ], [ - 0.45098039215686275, + 0.4509803921568627, "rgb(194,222,246)" ], [ @@ -2164,7 +2164,7 @@ "rgb(208,227,244)" ], [ - 0.47058823529411764, + 0.4705882352941176, "rgb(211,228,244)" ], [ @@ -2172,7 +2172,7 @@ "rgb(214,229,243)" ], [ - 0.47843137254901963, + 0.4784313725490196, "rgb(216,231,242)" ], [ @@ -2180,15 +2180,15 @@ "rgb(219,232,241)" ], [ - 0.48627450980392156, + 0.4862745098039216, "rgb(220,233,241)" ], [ - 0.49019607843137253, + 0.4901960784313726, "rgb(222,233,240)" ], [ - 0.49411764705882355, + 0.4941176470588235, "rgb(223,234,238)" ], [ @@ -2604,11 +2604,11 @@ "rgb(16,96,26)" ], [ - 0.9019607843137255, + 0.9019607843137256, "rgb(16,94,25)" ], [ - 0.9058823529411765, + 0.9058823529411764, "rgb(15,93,23)" ], [ @@ -2624,7 +2624,7 @@ "rgb(12,88,20)" ], [ - 0.9215686274509803, + 0.9215686274509804, "rgb(11,87,19)" ], [ @@ -2636,15 +2636,15 @@ "rgb(10,84,17)" ], [ - 0.9333333333333333, + 0.9333333333333332, "rgb(9,82,16)" ], [ - 0.9372549019607843, + 0.9372549019607844, "rgb(8,81,15)" ], [ - 0.9411764705882353, + 0.9411764705882352, "rgb(7,79,14)" ], [ @@ -2660,7 +2660,7 @@ "rgb(5,75,11)" ], [ - 0.9568627450980393, + 0.9568627450980391, "rgb(4,73,10)" ], [ @@ -2676,15 +2676,15 @@ "rgb(2,69,8)" ], [ - 0.9725490196078431, + 0.9725490196078432, "rgb(2,67,7)" ], [ - 0.9764705882352941, + 0.976470588235294, "rgb(1,66,6)" ], [ - 0.9803921568627451, + 0.9803921568627452, "rgb(1,64,5)" ], [ @@ -2692,11 +2692,11 @@ "rgb(0,63,4)" ], [ - 0.9882352941176471, + 0.9882352941176472, "rgb(0,61,4)" ], [ - 0.9921568627450981, + 0.992156862745098, "rgb(0,60,3)" ], [ @@ -2785,7 +2785,7 @@ }, "up": { "x": -0.09960017411944162, - "y": 0.9674560666171167, + "y": 0.9674560666171168, "z": 0.23261247705382 } }, diff --git a/examples/bcdi_reconstruction_analysis.ipynb b/examples/bcdi_reconstruction_analysis.ipynb index a0a6662a..ab849590 100644 --- a/examples/bcdi_reconstruction_analysis.ipynb +++ b/examples/bcdi_reconstruction_analysis.ipynb @@ -55,9 +55,7 @@ "metadata": {}, "outputs": [], "source": [ - "results_dir = (\n", - " \"path/to/the/results/directory\" # Replace with the actual path to your results directory\n", - ")" + "results_dir = \"path/to/the/results/directory\" # Replace with the actual path to your results directory" ] }, { @@ -103,7 +101,7 @@ "explorer = cdiutils.io.CXIExplorer(cxi_path)\n", "\n", "# Launch the interactive browser\n", - "explorer.explore()\n" + "explorer.explore()" ] }, { @@ -167,8 +165,14 @@ "source": [ "# List of quantities to extract from CXI files\n", "quantities = (\n", - " \"support\", \"het_strain\", \"het_strain_from_dspacing\", \"dspacing\",\n", - " \"amplitude\", \"displacement\", \"phase\", \"lattice_parameter\"\n", + " \"support\",\n", + " \"het_strain\",\n", + " \"het_strain_from_dspacing\",\n", + " \"dspacing\",\n", + " \"amplitude\",\n", + " \"displacement\",\n", + " \"phase\",\n", + " \"lattice_parameter\",\n", " # Add or remove quantities based on your needs\n", ")\n", "\n", @@ -177,9 +181,7 @@ "# Path to the results directory\n", "\n", "# Initialize a dictionary to store the structural properties\n", - "structural_properties = {\n", - " condition: {} for condition, _, _ in table\n", - "}\n", + "structural_properties = {condition: {} for condition, _, _ in table}\n", "\n", "# Path template for post-processed data\n", "path_template = results_dir + \"{}/S{}/S{}_postprocessed_data.cxi\"\n", @@ -187,17 +189,21 @@ "# Load data for each condition\n", "for condition, sample_name, scan in table:\n", " path = path_template.format(sample_name, scan, scan)\n", - " \n", + "\n", " # Load all specified quantities from the CXI file\n", " structural_properties[condition] = cdiutils.io.load_cxi(path, *quantities)\n", - " voxel_sizes[condition] = cdiutils.io.load_cxi(path, \"voxel_size\")\n", + " voxel_sizes[condition] = cdiutils.io.load_cxi(path, \"voxel_size\")\n", "\n", "# Apply support mask: set values outside the support to NaN\n", "for key in quantities:\n", - " if key != \"support\" and key != \"amplitude\": # Keep amplitude outside support\n", + " if (\n", + " key != \"support\" and key != \"amplitude\"\n", + " ): # Keep amplitude outside support\n", " for condition, _, _ in table:\n", - " structural_properties[condition][key] *= cdiutils.utils.zero_to_nan(\n", - " structural_properties[condition][\"support\"]\n", + " structural_properties[condition][key] *= (\n", + " cdiutils.utils.zero_to_nan(\n", + " structural_properties[condition][\"support\"]\n", + " )\n", " )" ] }, @@ -312,28 +318,26 @@ "quantity = \"het_strain\" # Change this to any quantity from your list\n", "\n", "# Plot the selected quantity for each condition\n", - "for (condition, _, _) in table:\n", + "for condition, _, _ in table:\n", " fig, axes = cdiutils.plot.plot_volume_slices(\n", " structural_properties[condition][quantity],\n", " title=condition,\n", " cmap=plot_configs[quantity][\"cmap\"],\n", - " \n", " # comment this block if you don't need real size extents\n", " voxel_size=voxel_sizes[condition],\n", " data_centre=(0, 0, 0),\n", " show=False,\n", " convention=\"cxi\",\n", - " \n", " # Adjust these colouring limits based on your data\n", " vmin=-0.05,\n", " vmax=0.05,\n", " )\n", - " \n", + "\n", " # comment this block if you don't need real size extents\n", " for ax in axes.flat:\n", " ax.set_xlim(-300, 300) # nm\n", - " ax.set_ylim(-300, 300) #nm\n", - " \n", + " ax.set_ylim(-300, 300) # nm\n", + "\n", " # comment this block if you don't need real size extents\n", " cdiutils.plot.add_labels(axes)\n", " display(fig)" @@ -391,20 +395,22 @@ "fig = cdiutils.plot.plot_multiple_volume_slices(\n", " *[structural_properties[c][quantity] for c, _, _ in table],\n", " voxel_sizes=[voxel_sizes[c] for c, _, _ in table], # For physical units\n", - " data_labels=[c for c, _, _ in table], # Label each dataset\n", - " data_centres=[(0, 0, 0) for _ in table], # Center of each dataset\n", - " convention=\"cxi\", # Use CXI convention for views \n", + " data_labels=[c for c, _, _ in table], # Label each dataset\n", + " data_centres=[(0, 0, 0) for _ in table], # Center of each dataset\n", + " convention=\"cxi\", # Use CXI convention for views\n", " # data_stacking=\"v\", # Stack datasets vertically\n", " # pvs_args={\"views\": [\"z+\", \"y+\", \"x+\"]}, # Specific view directions\n", - " cbar_args={\"location\": \"right\", # Colorbar on the right\n", - " \"title\": plot_configs[quantity][\"title\"]}, # Title from configs\n", - " xlim=(-300, 300), # Consistent x limits in the same units as voxel size\n", - " ylim=(-300, 300), # Consistent y limits in the same units as voxel size\n", - " cmap=plot_configs[quantity][\"cmap\"], # Apply a custom colormap\n", - " vmin=-0.05, # Set min value for colormap\n", - " vmax=0.05, # Set max value for colormap\n", - " remove_ticks=True, # Clean appearance without ticks\n", - " title=f\"{quantity} Comparison\" # Title above the figure\n", + " cbar_args={\n", + " \"location\": \"right\", # Colorbar on the right\n", + " \"title\": plot_configs[quantity][\"title\"],\n", + " }, # Title from configs\n", + " xlim=(-300, 300), # Consistent x limits in the same units as voxel size\n", + " ylim=(-300, 300), # Consistent y limits in the same units as voxel size\n", + " cmap=plot_configs[quantity][\"cmap\"], # Apply a custom colormap\n", + " vmin=-0.05, # Set min value for colormap\n", + " vmax=0.05, # Set max value for colormap\n", + " remove_ticks=True, # Clean appearance without ticks\n", + " title=f\"{quantity} Comparison\", # Title above the figure\n", ")" ] }, @@ -467,7 +473,7 @@ " \"dspacing\": None, # Set to None for automatic range\n", " \"amplitude\": None,\n", " \"displacement\": -0.2,\n", - " \"phase\": -np.pi/2,\n", + " \"phase\": -np.pi / 2,\n", "}\n", "vmaxs = {\n", " \"support\": 1,\n", @@ -476,7 +482,7 @@ " \"dspacing\": None,\n", " \"amplitude\": None,\n", " \"displacement\": 0.2,\n", - " \"phase\": np.pi/2,\n", + " \"phase\": np.pi / 2,\n", "}\n", "for key in plot_configs.keys():\n", " if key not in vmins:\n", @@ -489,21 +495,23 @@ "# Then use custom_quantities instead of quantities in the loop below\n", "\n", "# For each quantity, plot all conditions\n", - "for quantity in custom_quantities: # Change to custom_quantities if defined above\n", + "for (\n", + " quantity\n", + ") in custom_quantities: # Change to custom_quantities if defined above\n", " fig = cdiutils.plot.plot_multiple_volume_slices(\n", " *[structural_properties[c][quantity] for c, _, _ in table],\n", " voxel_sizes=[voxel_sizes[c] for c, _, _ in table],\n", " data_labels=[c for c, _, _ in table],\n", " data_centres=[(0, 0, 0) for _ in table],\n", - " convention=\"cxi\", \n", + " convention=\"cxi\",\n", " cbar_args={\"title\": plot_configs[quantity][\"title\"]},\n", - " xlim=(-300, 300), \n", + " xlim=(-300, 300),\n", " ylim=(-300, 300),\n", " cmap=plot_configs[quantity][\"cmap\"],\n", " vmin=vmins[quantity],\n", " vmax=vmaxs[quantity],\n", " remove_ticks=True,\n", - " title=f\"{quantity.capitalize()} Comparison\"\n", + " title=f\"{quantity.capitalize()} Comparison\",\n", " )" ] }, @@ -536,21 +544,21 @@ "# Load reciprocal space data for each condition\n", "for condition, sample_name, scan in table:\n", " path = path_template.format(sample_name, scan, scan)\n", - " \n", + "\n", " # Load orthogonalized detector data\n", " reciprocal_space_data[condition][\"ortho_data\"] = cdiutils.io.load_cxi(\n", " path, \"orthogonalised_detector_data\"\n", " )\n", - " \n", + "\n", " # Get q-space information\n", " reciprocal_space_data[condition][\"q_spacing\"] = []\n", " for ax in (\"qx_xu\", \"qy_xu\", \"qz_xu\"):\n", " reciprocal_space_data[condition][\"q_spacing\"].append(\n", " np.mean(\n", " np.diff(cdiutils.io.load_cxi(path, f\"entry_1/result_2/{ax}\"))\n", - " ) \n", + " )\n", " )\n", - " \n", + "\n", " # Get q-space center\n", " reciprocal_space_data[condition][\"q_centre\"] = cdiutils.io.load_cxi(\n", " path, \"entry_1/result_2/q_lab_shift\"\n", @@ -615,7 +623,7 @@ ], "source": [ "# Plot reciprocal space data for each condition\n", - "for (condition, _, _) in table:\n", + "for condition, _, _ in table:\n", " fig, axes = cdiutils.plot.plot_volume_slices(\n", " reciprocal_space_data[condition][\"ortho_data\"],\n", " voxel_size=reciprocal_space_data[condition][\"q_spacing\"],\n", @@ -624,7 +632,7 @@ " cmap=\"turbo\",\n", " norm=LogNorm(1e-1), # Log scale for diffraction patterns\n", " convention=\"xu\",\n", - " show=False\n", + " show=False,\n", " )\n", " # Add appropriate labels for reciprocal space\n", " cdiutils.plot.add_labels(axes, space=\"rcp\", convention=\"xu\")\n", @@ -689,9 +697,9 @@ "# Example: Calculate average lattice parameter for each condition\n", "avg_lat_par = {}\n", "for condition, _, _ in table:\n", - " lat_par_data = structural_properties[condition][\"lattice_parameter\"] \n", + " lat_par_data = structural_properties[condition][\"lattice_parameter\"]\n", " support = structural_properties[condition][\"support\"]\n", - " \n", + "\n", " # Calculate average within support\n", " avg_lat_par[condition] = np.nanmean(lat_par_data[support > 0])\n", " print(\n", @@ -745,18 +753,17 @@ } ], "source": [ - "quantity = \"het_strain_from_dspacing\" \n", + "quantity = \"het_strain_from_dspacing\"\n", "\n", "colors = {\n", " \"overall\": \"lightcoral\",\n", " \"bulk\": \"limegreen\",\n", - " \"surface\": \"dodgerblue\"\n", + " \"surface\": \"dodgerblue\",\n", "}\n", "\n", "\n", "fig, axes = plt.subplots(\n", - " len(table), 3, layout=\"tight\", sharex=True,\n", - " figsize=(6, 1.5*len(table))\n", + " len(table), 3, layout=\"tight\", sharex=True, figsize=(6, 1.5 * len(table))\n", ")\n", "\n", "for i, (condition, _, _) in enumerate(table):\n", @@ -765,7 +772,7 @@ " structural_properties[condition][\"support\"],\n", " bins=50,\n", " density=False, # If False you get counts, if True you get density\n", - " region=\"all\"\n", + " region=\"all\",\n", " )\n", " # Plot histograms and KDEs\n", " for j, region in enumerate(histograms.keys()):\n", @@ -775,17 +782,26 @@ " *kdes[region],\n", " color=colors[region],\n", " fwhm=True, # Set to True for FWHM plot,\n", - " \n", " # comment/uncomment lines below to play with the plot options\n", " bar_args={\"edgecolor\": \"w\", \"label\": \"strain histogram\"},\n", - " kde_args={\"fill\": True, \"fill_alpha\": 0.45, \"color\": \"k\", \"lw\": 0.2},\n", + " kde_args={\n", + " \"fill\": True,\n", + " \"fill_alpha\": 0.45,\n", + " \"color\": \"k\",\n", + " \"lw\": 0.2,\n", + " },\n", " )\n", - " \n", + "\n", " # Plot the mean\n", " axes[i, j].plot(\n", - " means[region], 0, color=colors[region], ms=4,\n", - " markeredgecolor=\"k\", marker=\"o\", mew=0.5,\n", - " label=f\"Mean = {means[region]:.4f} %\"\n", + " means[region],\n", + " 0,\n", + " color=colors[region],\n", + " ms=4,\n", + " markeredgecolor=\"k\",\n", + " marker=\"o\",\n", + " mew=0.5,\n", + " label=f\"Mean = {means[region]:.4f} %\",\n", " )\n", "\n", " axes[i, j].legend(\n", @@ -793,9 +809,9 @@ " )\n", " axes[i, j].set_xlim(-0.06, 0.06) # change this according to your data\n", " axes[0, j].set_title(f\"{region.capitalize()}\")\n", - " axes[len(table)-1, j].set_xlabel(\"Strain (%)\")\n", + " axes[len(table) - 1, j].set_xlabel(\"Strain (%)\")\n", " axes[i, 0].set_ylabel(condition)\n", - " \n", + "\n", " print(\n", " f\"Average {quantity} in {condition}: \"\n", " f\"{means['overall']:.5f} +/- {stds['overall']:.5f}\"\n", @@ -805,8 +821,7 @@ "# cdiutils.plot.save_fig(\n", "# \"output.svg\" # 'svg' if you want to edit with inkscape, 'pdf', 'png'...\n", "# dpi=300,\n", - "# )\n", - " " + "# )" ] }, { @@ -872,12 +887,11 @@ ], "source": [ "for condition, _, _ in table:\n", - " strain_data = structural_properties[condition][\"het_strain\"] \n", + " strain_data = structural_properties[condition][\"het_strain\"]\n", " support = structural_properties[condition][\"support\"]\n", " cdiutils.pipeline.PipelinePlotter.strain_statistics(\n", " strain_data, support, title=condition\n", - " )\n", - " " + " )" ] }, { diff --git a/examples/pole_figure.ipynb b/examples/pole_figure.ipynb index e528eab3..58c25b62 100644 --- a/examples/pole_figure.ipynb +++ b/examples/pole_figure.ipynb @@ -13,8 +13,8 @@ "metadata": {}, "outputs": [], "source": [ - "from matplotlib.colors import LogNorm\n", "import numpy as np\n", + "from matplotlib.colors import LogNorm\n", "\n", "import cdiutils\n", "\n", @@ -36,7 +36,7 @@ "metadata": {}, "outputs": [], "source": [ - "path = (\"path/to/data.cxi\")" + "path = \"path/to/data.cxi\"" ] }, { @@ -79,15 +79,15 @@ " shift = cxi[\"entry_1/result_2/q_space_shift\"]\n", "\n", "print(qx.shape, qy.shape, qz.shape, data.shape)\n", - "voxel_size = (\n", - " np.diff(qx).mean(),\n", - " np.diff(qy).mean(),\n", - " np.diff(qz).mean()\n", - ")\n", + "voxel_size = (np.diff(qx).mean(), np.diff(qy).mean(), np.diff(qz).mean())\n", "\n", "fig, axes = cdiutils.plot.plot_volume_slices(\n", - " data, voxel_size=voxel_size, data_centre=shift,\n", - " norm=LogNorm(), convention=\"xu\", show=False\n", + " data,\n", + " voxel_size=voxel_size,\n", + " data_centre=shift,\n", + " norm=LogNorm(),\n", + " convention=\"xu\",\n", + " show=False,\n", ")\n", "\n", "cdiutils.plot.add_labels(axes, convention=\"xu\")\n", @@ -168,11 +168,13 @@ " data,\n", " [qx, qy, qz],\n", " radius=0.020,\n", - " dr=0.0002, \n", + " dr=0.0002,\n", " axis=\"2\",\n", - " norm=LogNorm(1, ),\n", + " norm=LogNorm(\n", + " 1,\n", + " ),\n", " verbose=True,\n", - ")\n" + ")" ] } ], diff --git a/src/cdiutils/geometry.py b/src/cdiutils/geometry.py index da3f092f..2808b44f 100644 --- a/src/cdiutils/geometry.py +++ b/src/cdiutils/geometry.py @@ -291,13 +291,23 @@ def from_setup( if beamline.lower() == "i16": geometry = cls( - sample_circles=["x-", "y+"], # eta, mu or eta, phi when chi==90 + sample_circles=[ + "x-", + "y+", + ], # eta, mu or eta, phi when chi==90 # sample_circles=["x-", "z+", "x-", "y+"], # TODO: phi, chi, eta, mu (how do I add these angles in the loader?) - detector_circles=["y+", "x-"], # gam, delta (this doesn't match spec but same as id01) + detector_circles=[ + "y+", + "x-", + ], # gam, delta (this doesn't match spec but same as id01) detector_axis0_orientation="x+", # at Stokes=0, merlin detector horiz. pixels low=high delta detector_axis1_orientation="y-", # at Stokes=0, merlin detector vert. pixels low=low gamma beam_direction=[1, 0, 0], - sample_surface_normal=[0, 1, 0], # CXI z,y,x default sample facing up + sample_surface_normal=[ + 0, + 1, + 0, + ], # CXI z,y,x default sample facing up name="I16", ) diff --git a/src/cdiutils/io/__init__.py b/src/cdiutils/io/__init__.py index 0cbcdf20..dbbdc57c 100755 --- a/src/cdiutils/io/__init__.py +++ b/src/cdiutils/io/__init__.py @@ -7,9 +7,9 @@ from .cristal import CristalLoader from .cxi import CXIFile, load_cxi, save_as_cxi +from .i16 import I16Loader from .id01 import ID01Loader, SpecLoader from .id27 import ID27Loader -from .i16 import I16Loader from .loader import Loader, h5_safe_load from .nanomax import NanoMAXLoader from .p10 import P10Loader diff --git a/src/cdiutils/io/i16.py b/src/cdiutils/io/i16.py index ffff34e7..c1fc5ab1 100644 --- a/src/cdiutils/io/i16.py +++ b/src/cdiutils/io/i16.py @@ -1,7 +1,5 @@ - import dateutil.parser import numpy as np -import h5py import silx.io from cdiutils.io.loader import H5TypeLoader, h5_safe_load @@ -64,7 +62,7 @@ class I16Loader(H5TypeLoader): "detector_outofplane_angle": "delta", "detector_inplane_angle": "gam", } - authorised_detector_names = ("merlin", ) + authorised_detector_names = ("merlin",) def __init__( self, @@ -134,12 +132,12 @@ def get_detector_name( """ # Get detctor name from first NXdetector in instrument - instrument = self.h5file['entry/instrument'] + instrument = self.h5file["entry/instrument"] for name, object in instrument.items(): - nx_class = object.attrs.get('NX_class') - if nx_class and nx_class.astype(str) == 'NXdetector': + nx_class = object.attrs.get("NX_class") + if nx_class and nx_class.astype(str) == "NXdetector": return name - raise KeyError('No NXdetector found in HDF5 file') + raise KeyError("No NXdetector found in HDF5 file") @h5_safe_load def load_det_calib_params( @@ -194,16 +192,22 @@ def load_det_calib_params( :doc:`/user_guide/detector_calibration` for calibration procedures and angle definitions. """ - instrument = self.h5file['entry/instrument'] + instrument = self.h5file["entry/instrument"] detector = instrument[self.detector_name] module = detector["module"] try: return { - "cch1": float(instrument['merlin_centre_i'][()]) if 'merlin_centre_i' in instrument else 159, - "cch2": float(instrument['merlin_centre_j'][()]) if 'merlin_centre_j' in instrument else 348, - "pwidth1": float(module['fast_pixel_direction'][()].squeeze()), - "pwidth2": float(module['slow_pixel_direction'][()].squeeze()), - "distance": float(detector['transformations/origin_offset'][()]), + "cch1": float(instrument["merlin_centre_i"][()]) + if "merlin_centre_i" in instrument + else 159, + "cch2": float(instrument["merlin_centre_j"][()]) + if "merlin_centre_j" in instrument + else 348, + "pwidth1": float(module["fast_pixel_direction"][()].squeeze()), + "pwidth2": float(module["slow_pixel_direction"][()].squeeze()), + "distance": float( + detector["transformations/origin_offset"][()] + ), "tiltazimuth": 0.0, "tilt": 0.0, "detrot": 0.0, @@ -229,10 +233,10 @@ def load_detector_shape( KeyError: If detector not found in HDF5 file. """ # /entry/instrument/merlin/module/data_size - instrument = self.h5file['entry/instrument'] + instrument = self.h5file["entry/instrument"] detector = instrument[self.detector_name] module = detector["module"] - return module['data_size'][()] + return module["data_size"][()] @h5_safe_load def load_detector_data( @@ -309,8 +313,8 @@ def load_detector_data( @h5_safe_load def load_angles(self) -> dict: - diffractometer = self.h5file['entry/instrument/diffractometer_sample'] - measurement = self.h5file['entry/measurement'] + diffractometer = self.h5file["entry/instrument/diffractometer_sample"] + measurement = self.h5file["entry/measurement"] angles = {} for name in self.angle_names.values(): if name is not None: @@ -418,7 +422,9 @@ def load_energy(self) -> float: >>> energy = loader.load_energy() >>> print(f"Energy: {energy/1e3:.2f} keV") """ - energy = self.h5file["entry/sample/beam/incident_energy"][()] * 1e3 # keV -> eV + energy = ( + self.h5file["entry/sample/beam/incident_energy"][()] * 1e3 + ) # keV -> eV return float(energy) @h5_safe_load @@ -431,12 +437,10 @@ def show_scan_attributes( Displays top-level group structure for specified scan, useful for inspecting file organisation and finding custom metadata. """ - print(self.h5file['entry'].keys()) + print(self.h5file["entry"].keys()) @h5_safe_load - def load_measurement_parameters( - self, parameter_name: str - ) -> tuple: + def load_measurement_parameters(self, parameter_name: str) -> tuple: """ Load custom measurement data from scan. @@ -523,16 +527,16 @@ def get_hkl(self) -> tuple[int, int, int]: Return the HKL value from the NeXus file """ key_paths = [ - '/entry/instrument/diffractometer_sample/h', - '/entry/instrument/diffractometer_sample/k', - '/entry/instrument/diffractometer_sample/l' + "/entry/instrument/diffractometer_sample/h", + "/entry/instrument/diffractometer_sample/k", + "/entry/instrument/diffractometer_sample/l", ] return tuple([round(self.h5file[k][()]) for k in key_paths]) + def safe(func): def wrap(self, *args, **kwargs): with silx.io.open(self.experiment_file_path) as self.specfile: return func(self, *args, **kwargs) return wrap - diff --git a/src/cdiutils/io/loader.py b/src/cdiutils/io/loader.py index b62474ea..2e4badde 100644 --- a/src/cdiutils/io/loader.py +++ b/src/cdiutils/io/loader.py @@ -222,6 +222,7 @@ def from_setup(cls, beamline_setup: str, **metadata) -> "Loader": if "i16" in beamline_setup.lower(): from . import I16Loader + return I16Loader(**metadata) if beamline_setup.lower() == "cristal": diff --git a/src/cdiutils/scripts/prepare_bcdi_notebooks.py b/src/cdiutils/scripts/prepare_bcdi_notebooks.py index 75d707af..f6d2d646 100644 --- a/src/cdiutils/scripts/prepare_bcdi_notebooks.py +++ b/src/cdiutils/scripts/prepare_bcdi_notebooks.py @@ -50,7 +50,9 @@ def main() -> None: templates_dir = get_templates_path() # Update paths to notebooks in the examples directory - if args.i16 or os.environ.get('BEAMLINE') == 'i16': # on DLS I16, create a specific notebook + if ( + args.i16 or os.environ.get("BEAMLINE") == "i16" + ): # on DLS I16, create a specific notebook bcdi_notebook = os.path.join(templates_dir, "i16_bcdi_pipeline.ipynb") else: bcdi_notebook = os.path.join(templates_dir, "bcdi_pipeline.ipynb") diff --git a/src/cdiutils/templates/i16_bcdi_pipeline.ipynb b/src/cdiutils/templates/i16_bcdi_pipeline.ipynb index 1e1f617b..85ccc852 100644 --- a/src/cdiutils/templates/i16_bcdi_pipeline.ipynb +++ b/src/cdiutils/templates/i16_bcdi_pipeline.ipynb @@ -21,9 +21,11 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { "tags": [] }, + "outputs": [], "source": [ "# import required packages\n", "import os\n", @@ -32,25 +34,23 @@ "os.environ[\"PYNX_PU\"] = \"opencl\"\n", "\n", "import cdiutils # core library for BCDI processing" - ], - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": "## **Specify I16 Scan File**" }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "visit_path: str = \"\" # location of scan files\n", "scan_number: int = 12345 # scan number\n", - "sample_name: str = \"\" # Optional: provide a sample name for separating folders\n" - ], - "outputs": [], - "execution_count": null + "sample_name: str = \"\" # Optional: provide a sample name for separating folders" + ] }, { "cell_type": "markdown", @@ -64,7 +64,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# define the key parameters (must be filled in by the user)\n", "beamline_setup: str = \"i16\" # example: \"ID01\" (provide the beamline setup)\n", @@ -84,9 +86,7 @@ "# load the parameters and parse them into the BcdiPipeline class instance\n", "params = cdiutils.pipeline.get_params_from_variables(dir(), globals())\n", "bcdi_pipeline = cdiutils.BcdiPipeline(params=params)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -128,18 +128,18 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "bcdi_pipeline.preprocess(\n", " preprocess_shape=(150, 150), # define cropped window size\n", " voxel_reference_methods=[\"max\", \"com\", \"com\"], # centring method sequence\n", " hot_pixel_filter=False, # remove isolated hot pixels\n", " background_level=None, # background intensity level to remove\n", - " hkl=hkl\n", + " hkl=hkl,\n", ")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -206,7 +206,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "bcdi_pipeline.phase_retrieval(\n", " clear_former_results=True,\n", @@ -214,9 +216,7 @@ " nb_run_keep=10,\n", " # support=bcdi_pipeline.pynx_phasing_dir + \"support.cxi\"\n", ")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -236,7 +236,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "bcdi_pipeline.analyse_phasing_results(\n", " sorting_criterion=\"mean_to_max\", # selects the sorting method\n", @@ -244,9 +246,7 @@ " # plot_phasing_results=False, # uncomment to disable plotting\n", " # plot_phase=True, # uncomment to enable phase plotting\n", ")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -270,12 +270,12 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# bcdi_pipeline.generate_support_from(\"best\", fill=False) # uncomment to generate a support" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -297,7 +297,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# define how many of the best candidates to keep\n", "number_of_best_candidates: int = 5\n", @@ -310,9 +312,7 @@ "\n", "# perform mode decomposition on the selected reconstructions\n", "bcdi_pipeline.mode_decomposition()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -334,16 +334,16 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "bcdi_pipeline.postprocess(\n", " isosurface=0.3, # threshold for isosurface\n", " voxel_size=None, # use default voxel size if not provided\n", " flip=False, # whether to flip the reconstruction if you got the twin image (enantiomorph)\n", ")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -364,12 +364,12 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "bcdi_pipeline.show_3d_final_result()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", From 7618a08b4f8969a8220ef3b70294931e2497f481 Mon Sep 17 00:00:00 2001 From: Dan Porter Date: Mon, 11 May 2026 11:41:46 +0100 Subject: [PATCH 10/10] ruff check ruff format and ruff check --fix --- src/cdiutils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cdiutils/__init__.py b/src/cdiutils/__init__.py index 2b323d77..cf8d0aa7 100755 --- a/src/cdiutils/__init__.py +++ b/src/cdiutils/__init__.py @@ -3,7 +3,7 @@ Imaging processing, analysis and visualisation workflows. """ -__version__ = "0.2.1_i16" +__version__ = "0.2.1" __author__ = "Clément Atlan" __email__ = "c.atlan@outlook.com" __license__ = "MIT"