diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 103fa4dd..bf9b41a8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,15 +11,20 @@ Unreleased Added ----- +- We can now read 2D crystal maps from Channel Text Files (CTFs) using ``io.load()``. Changed ------- +- Phase names in crystal maps read from .ang files with ``io.load()`` now prefer to use + the abbreviated "Formula" instead of "MaterialName" in the file header. Removed ------- Deprecated ---------- +- ``loadang()`` and ``loadctf()`` are deprecated and will be removed in the next minor + release. Please use ``io.load()`` instead. Fixed ----- diff --git a/examples/plotting/interactive_xmap.py b/examples/plotting/interactive_xmap.py new file mode 100644 index 00000000..d3925535 --- /dev/null +++ b/examples/plotting/interactive_xmap.py @@ -0,0 +1,98 @@ +""" +============================ +Interactive crystal map plot +============================ + +This example shows how to use +:doc:`matplotlib event connections ` to +add an interactive click function to a :class:`~orix.crystal_map.CrystalMap` plot. +Here, we navigate an inverse pole figure (IPF) map and retreive the phase name and +corresponding Euler angles from the location of the click. + +.. note:: + + This example uses the interactive capabilities of Matplotlib, and this will not + appear in the static documentation. + Please run this code on your machine to see the interactivity. + + You can copy and paste individual parts, or download the entire example using the + link at the bottom of the page. +""" + +import matplotlib.pyplot as plt +import numpy as np + +from orix import data, plot +from orix.crystal_map import CrystalMap + +xmap = data.sdss_ferrite_austenite(allow_download=True) +print(xmap) + +pg_laue = xmap.phases[1].point_group.laue +O_au = xmap["austenite"].orientations +O_fe = xmap["ferrite"].orientations + +# Get IPF colors +ipf_key = plot.IPFColorKeyTSL(pg_laue) +rgb_au = ipf_key.orientation2color(O_au) +rgb_fe = ipf_key.orientation2color(O_fe) + +# Combine IPF color arrays +rgb_all = np.zeros((xmap.size, 3)) +phase_id_au = xmap.phases.id_from_name("austenite") +phase_id_fe = xmap.phases.id_from_name("ferrite") +rgb_all[xmap.phase_id == phase_id_au] = rgb_au +rgb_all[xmap.phase_id == phase_id_fe] = rgb_fe + + +def select_point(xmap: CrystalMap, rgb_all: np.ndarray) -> tuple[int, int]: + """Return location of interactive user click on image. + + Interactive function for showing the phase name and Euler angles + from the click-position. + """ + fig = xmap.plot( + rgb_all, + overlay="dp", + return_figure=True, + figure_kwargs={"figsize": (12, 8)}, + ) + ax = fig.axes[0] + ax.set_title("Click position") + + # Extract array in the plot with IPF colors + dot product overlay + rgb_dp_2d = ax.images[0].get_array() + + x = y = 0 + + def on_click(event): + x, y = (event.xdata, event.ydata) + if x is None: + print("Please click inside the IPF map") + return + print(x, y) + + # Extract location in crystal map and extract phase name and + # Euler angles + xmap_yx = xmap[int(np.round(y)), int(np.round(x))] + phase_name = xmap_yx.phases_in_data[:].name + eu = xmap_yx.rotations.to_euler(degrees=True)[0].round(2) + + # Format Euler angles + eu_str = "(" + ", ".join(np.array_str(eu)[1:-1].split()) + ")" + + plt.clf() + plt.imshow(rgb_dp_2d) + plt.plot(x, y, "+", c="k", markersize=15, markeredgewidth=3) + plt.title( + f"Phase: {phase_name}, Euler angles: $(\phi_1, \Phi, \phi_2)$ = {eu_str}" + ) + plt.draw() + + fig.canvas.mpl_connect("button_press_event", on_click) + + return x, y + + +x, y = select_point(xmap, rgb_all) +plt.show() diff --git a/orix/crystal_map/crystal_map.py b/orix/crystal_map/crystal_map.py index aae07db9..d74feb82 100644 --- a/orix/crystal_map/crystal_map.py +++ b/orix/crystal_map/crystal_map.py @@ -17,7 +17,7 @@ # along with orix. If not, see . import copy -from typing import Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union import matplotlib.pyplot as plt import numpy as np @@ -350,12 +350,12 @@ def y(self) -> Union[None, np.ndarray]: @property def dx(self) -> float: """Return the x coordinate step size.""" - return self._step_size_from_coordinates(self._x) + return _step_size_from_coordinates(self._x) @property def dy(self) -> float: """Return the y coordinate step size.""" - return self._step_size_from_coordinates(self._y) + return _step_size_from_coordinates(self._y) @property def row(self) -> Union[None, np.ndarray]: @@ -1039,29 +1039,9 @@ def plot( if return_figure: return fig - @staticmethod - def _step_size_from_coordinates(coordinates: np.ndarray) -> float: - """Return step size in input ``coordinates`` array. - - Parameters - ---------- - coordinates - Linear coordinate array. - - Returns - ------- - step_size - Step size in ``coordinates`` array. - """ - unique_sorted = np.sort(np.unique(coordinates)) - step_size = 0 - if unique_sorted.size != 1: - step_size = unique_sorted[1] - unique_sorted[0] - return step_size - def _data_slices_from_coordinates(self, only_is_in_data: bool = True) -> tuple: - """Return a tuple of slices defining the current data extent in - all directions. + """Return a slices defining the current data extent in all + directions. Parameters ---------- @@ -1072,23 +1052,14 @@ def _data_slices_from_coordinates(self, only_is_in_data: bool = True) -> tuple: Returns ------- slices - Data slice in each existing dimension, in (z, y, x) order. + Data slice in each existing direction in (y, x) order. """ if only_is_in_data: coordinates = self._coordinates else: coordinates = self._all_coordinates - - # Loop over dimension coordinates and step sizes - slices = [] - for coords, step in zip(coordinates.values(), self._step_sizes.values()): - if coords is not None and step != 0: - c_min, c_max = np.min(coords), np.max(coords) - i_min = int(np.around(c_min / step)) - i_max = int(np.around((c_max / step) + 1)) - slices.append(slice(i_min, i_max)) - - return tuple(slices) + slices = _data_slices_from_coordinates(coordinates, self._step_sizes) + return slices def _data_shape_from_coordinates(self, only_is_in_data: bool = True) -> tuple: """Return data shape based upon coordinate arrays. @@ -1102,7 +1073,7 @@ def _data_shape_from_coordinates(self, only_is_in_data: bool = True) -> tuple: Returns ------- data_shape - Shape of data in all existing dimensions, in (z, y, x) order. + Shape of data in each existing direction in (y, x) order. """ data_shape = [] for dim_slice in self._data_slices_from_coordinates(only_is_in_data): @@ -1110,13 +1081,70 @@ def _data_shape_from_coordinates(self, only_is_in_data: bool = True) -> tuple: return tuple(data_shape) +def _data_slices_from_coordinates( + coords: Dict[str, np.ndarray], steps: Union[Dict[str, float], None] = None +) -> Tuple[slice]: + """Return a list of slices defining the current data extent in all + directions. + + Parameters + ---------- + coords + Dictionary with coordinate arrays. + steps + Dictionary with step sizes in each direction. If not given, they + are computed from *coords*. + + Returns + ------- + slices + Data slice in each direction. + """ + if steps is None: + steps = { + "x": _step_size_from_coordinates(coords["x"]), + "y": _step_size_from_coordinates(coords["y"]), + } + slices = [] + for coords, step in zip(coords.values(), steps.values()): + if coords is not None and step != 0: + c_min, c_max = np.min(coords), np.max(coords) + i_min = int(np.around(c_min / step)) + i_max = int(np.around((c_max / step) + 1)) + slices.append(slice(i_min, i_max)) + slices = tuple(slices) + return slices + + +def _step_size_from_coordinates(coordinates: np.ndarray) -> float: + """Return step size in input *coordinates* array. + + Parameters + ---------- + coordinates + Linear coordinate array. + + Returns + ------- + step_size + Step size in *coordinates* array. + """ + unique_sorted = np.sort(np.unique(coordinates)) + if unique_sorted.size != 1: + step_size = unique_sorted[1] - unique_sorted[0] + else: + step_size = 0 + return step_size + + def create_coordinate_arrays( shape: Optional[tuple] = None, step_sizes: Optional[tuple] = None ) -> Tuple[dict, int]: - """Create flattened coordinate arrays from a given map shape and + """Return flattened coordinate arrays from a given map shape and step sizes, suitable for initializing a - :class:`~orix.crystal_map.CrystalMap`. Arrays for 1D or 2D maps can - be returned. + :class:`~orix.crystal_map.CrystalMap`. + + Arrays for 1D or 2D maps can be returned. Parameters ---------- @@ -1125,13 +1153,13 @@ def create_coordinate_arrays( and ten columns. step_sizes Map step sizes. If not given, it is set to 1 px in each map - direction given by ``shape``. + direction given by *shape*. Returns ------- d - Dictionary with keys ``"y"`` and ``"x"``, depending on the - length of ``shape``, with coordinate arrays. + Dictionary with keys ``"x"`` and ``"y"``, depending on the + length of *shape*, with coordinate arrays. map_size Number of map points. @@ -1145,10 +1173,10 @@ def create_coordinate_arrays( >>> create_coordinate_arrays((2, 3), (1.5, 1.5)) ({'x': array([0. , 1.5, 3. , 0. , 1.5, 3. ]), 'y': array([0. , 0. , 0. , 1.5, 1.5, 1.5])}, 6) """ - if shape is None: + if not shape: shape = (5, 10) ndim = len(shape) - if step_sizes is None: + if not step_sizes: step_sizes = (1,) * ndim if ndim == 3 or len(step_sizes) > 2: diff --git a/orix/io/__init__.py b/orix/io/__init__.py index d2371bf7..c1434776 100644 --- a/orix/io/__init__.py +++ b/orix/io/__init__.py @@ -37,6 +37,7 @@ from h5py import File, is_hdf5 import numpy as np +from orix._util import deprecated from orix.crystal_map import CrystalMap from orix.io.plugins import plugin_list from orix.io.plugins._h5ebsd import hdf5group2dict @@ -45,7 +46,6 @@ extensions = [plugin.file_extensions for plugin in plugin_list if plugin.writes] -# Lists what will be imported when calling "from orix.io import *" __all__ = [ "loadang", "loadctf", @@ -54,6 +54,8 @@ ] +# TODO: Remove after 0.13.0 +@deprecated(since="0.13", removal="0.14", alternative="io.load") def loadang(file_string: str) -> Rotation: """Load ``.ang`` files. @@ -73,6 +75,8 @@ def loadang(file_string: str) -> Rotation: return Rotation.from_euler(euler) +# TODO: Remove after 0.13.0 +@deprecated(since="0.13", removal="0.14", alternative="io.load") def loadctf(file_string: str) -> Rotation: """Load ``.ctf`` files. diff --git a/orix/io/plugins/__init__.py b/orix/io/plugins/__init__.py index 0397150e..6485b624 100644 --- a/orix/io/plugins/__init__.py +++ b/orix/io/plugins/__init__.py @@ -28,15 +28,17 @@ ang bruker_h5ebsd + ctf emsoft_h5ebsd orix_hdf5 """ -from orix.io.plugins import ang, bruker_h5ebsd, emsoft_h5ebsd, orix_hdf5 +from orix.io.plugins import ang, bruker_h5ebsd, ctf, emsoft_h5ebsd, orix_hdf5 plugin_list = [ ang, bruker_h5ebsd, + ctf, emsoft_h5ebsd, orix_hdf5, ] diff --git a/orix/io/plugins/ang.py b/orix/io/plugins/ang.py index e82232d5..521b27bb 100644 --- a/orix/io/plugins/ang.py +++ b/orix/io/plugins/ang.py @@ -43,10 +43,12 @@ def file_reader(filename: str) -> CrystalMap: - """Return a crystal map from a file in EDAX TLS's .ang format. The - map in the input is assumed to be 2D. + """Return a crystal map from a file in EDAX TLS's .ang format. - Many vendors produce an .ang file. Supported vendors are: + The map in the input is assumed to be 2D. + + Many vendors/programs produce an .ang file. Files from the following + vendors/programs are tested: * EDAX TSL * NanoMegas ASTAR Index @@ -72,20 +74,19 @@ def file_reader(filename: str) -> CrystalMap: with open(filename) as f: header = _get_header(f) - # Get phase names and crystal symmetries from header (potentially empty) - phase_ids, phase_names, symmetries, lattice_constants = _get_phases_from_header( - header - ) - structures = [] - for name, abcABG in zip(phase_names, lattice_constants): - structures.append(Structure(title=name, lattice=Lattice(*abcABG))) + # Phase information, potentially empty + phases = _get_phases_from_header(header) + phases["structures"] = [] + lattice_constants = phases.pop("lattice_constants") + for name, abcABG in zip(phases["names"], lattice_constants): + structure = Structure(title=name, lattice=Lattice(*abcABG)) + phases["structures"].append(structure) # Read all file data file_data = np.loadtxt(filename) # Get vendor and column names - n_rows, n_cols = file_data.shape - vendor, column_names = _get_vendor_columns(header, n_cols) + vendor, column_names = _get_vendor_columns(header, file_data.shape[1]) # Data needed to create a CrystalMap object data_dict = { @@ -98,18 +99,13 @@ def file_reader(filename: str) -> CrystalMap: "prop": {}, } for column, name in enumerate(column_names): - if name in data_dict.keys(): + if name in data_dict: data_dict[name] = file_data[:, column] else: data_dict["prop"][name] = file_data[:, column] # Add phase list to dictionary - data_dict["phase_list"] = PhaseList( - names=phase_names, - point_groups=symmetries, - structures=structures, - ids=phase_ids, - ) + data_dict["phase_list"] = PhaseList(**phases) # Set which data points are not indexed # TODO: Add not-indexed convention for INDEX ASTAR @@ -149,9 +145,12 @@ def _get_header(file: TextIOWrapper) -> List[str]: """ header = [] line = file.readline() - while line.startswith("#"): + i = 0 + # Prevent endless loop by not reading past 1 000 lines + while line.startswith("#") and i < 1_000: header.append(line.rstrip()) line = file.readline() + i += 1 return header @@ -174,15 +173,13 @@ def _get_vendor_columns(header: List[str], n_cols_file: int) -> Tuple[str, List[ column_names List of column names. """ - # Assume EDAX TSL by default - vendor = "tsl" - - # Determine vendor by searching for the vendor footprint in the header + # Determine vendor by searching for vendor footprint in header vendor_footprint = { "emsoft": "EMsoft", "astar": "ACOM", "orix": "Column names: phi1, Phi, phi2", } + vendor = "tsl" # Default guess footprint_line = None for name, footprint in vendor_footprint.items(): for line in header: @@ -307,9 +304,7 @@ def _get_vendor_columns(header: List[str], n_cols_file: int) -> Tuple[str, List[ return vendor, vendor_column_names -def _get_phases_from_header( - header: List[str], -) -> Tuple[List[int], List[str], List[str], List[List[float]]]: +def _get_phases_from_header(header: List[str]) -> dict: """Return phase names and symmetries detected in an .ang file header. @@ -320,43 +315,38 @@ def _get_phases_from_header( Returns ------- - ids - Phase IDs. - phase_names - List of names of detected phases. - phase_point_groups - List of point groups of detected phase. - lattice_constants - List of list of lattice parameters of detected phases. + phase_dict + Dictionary with the following keys (and types): "ids" (int), + "names" (str), "point_groups" (str), "lattice_constants" (list + of floats). Notes ----- - Regular expressions are used to collect phase name, formula and - point group. This function have been tested with files from the - following vendor's formats: EDAX TSL OIM Data Collection v7, ASTAR - Index, and EMsoft v4/v5. + This function has been tested with files from the following vendor's + formats: EDAX TSL OIM Data Collection v7, ASTAR Index, and EMsoft + v4/v5. """ - regexps = { - "id": "# Phase([ \t]+)([0-9 ]+)", - "name": "# MaterialName([ \t]+)([A-z0-9 ]+)", - "formula": "# Formula([ \t]+)([A-z0-9 ]+)", - "point_group": "# Symmetry([ \t]+)([A-z0-9 ]+)", + str_patterns = { + "ids": "# Phase([ \t]+)([0-9 ]+)", + "names": "# MaterialName([ \t]+)([A-z0-9 ]+)", + "formulas": "# Formula([ \t]+)([A-z0-9 ]+)", + "point_groups": "# Symmetry([ \t]+)([A-z0-9 ]+)", "lattice_constants": r"# LatticeConstants([ \t+])(.*)", } phases = { - "name": [], - "formula": [], - "point_group": [], + "ids": [], + "names": [], + "formulas": [], + "point_groups": [], "lattice_constants": [], - "id": [], } for line in header: - for key, exp in regexps.items(): + for key, exp in str_patterns.items(): match = re.search(exp, line) if match: group = re.split("[ \t]", line.lstrip("# ").rstrip(" ")) group = list(filter(None, group)) - if key == "name": + if key == "names": group = " ".join(group[1:]) # Drop "MaterialName" elif key == "lattice_constants": group = [float(i) for i in group[1:]] @@ -364,22 +354,24 @@ def _get_phases_from_header( group = group[-1] phases[key].append(group) - # Check if formula is empty (sometimes the case for ASTAR Index) - names = phases["formula"] - if len(names) == 0 or any([i != "" for i in names]): - names = phases["name"] + n_phases = len(phases["names"]) + + # Use formulas in place of material names if they are all valid + formulas = phases.pop("formulas") + if len(formulas) == n_phases and all([len(name) for name in formulas]): + phases["names"] = formulas # Ensure each phase has an ID (hopefully found in the header) - phase_ids = [int(i) for i in phases["id"]] - n_phases = len(phases["name"]) - if len(phase_ids) == 0: + phase_ids = [int(i) for i in phases["ids"]] + if not len(phase_ids): phase_ids += [i for i in range(n_phases)] elif n_phases - len(phase_ids) > 0 and len(phase_ids) != 0: next_id = max(phase_ids) + 1 n_left = n_phases - len(phase_ids) phase_ids += [i for i in range(next_id, next_id + n_left)] + phases["ids"] = phase_ids - return phase_ids, names, phases["point_group"], phases["lattice_constants"] + return phases def file_writer( @@ -424,31 +416,31 @@ def file_writer( Which map property to use as the image quality. If not given (default), ``"iq"`` or ``"imagequality"``, if present, is used, otherwise just zeros. If the property has more than one value - per point and ``index`` is not given, only the first value is + per point and *index* is not given, only the first value is used. confidence_index_prop Which map property to use as the confidence index. If not given (default), ``"ci"``, ``"confidenceindex"``, ``"scores"``, or ``"correlation"``, if present, is used, otherwise just zeros. If - the property has more than one value per point and ``index`` is + the property has more than one value per point and *index* is not given, only the first value is used. detector_signal_prop Which map property to use as the detector signal. If not given (default), ``"ds"``, or ``"detector_signal"``, if present, is used, otherwise just zeros. If the property has more than one - value per point and ``index`` is not given, only the first value + value per point and *index* is not given, only the first value is used. pattern_fit_prop Which map property to use as the pattern fit. If not given (default), ``"fit"`` or ``"patternfit"``, if present, is used, otherwise just zeros. If the property has more than one value - per point and ``index`` is not given, only the first value is + per point and *index* is not given, only the first value is used. extra_prop One or multiple properties to add as extra columns in the .ang file, as a string or a list of strings. If not given (default), no extra properties are added. If a property has more than one - value per point and ``index`` is not given, only the first value + value per point and *index* is not given, only the first value is used. """ header = _get_header_from_phases(xmap) @@ -598,7 +590,7 @@ def _get_header_from_phases(xmap: CrystalMap) -> str: phase_name = phase.name if phase_name == "": phase_name = f"phase{phase_id}" - if phase.point_group is None: + if not phase.point_group: point_group_name = "1" else: proper_point_group = phase.point_group.proper_subgroup @@ -649,7 +641,7 @@ def _get_nrows_ncols_step_sizes(xmap: CrystalMap) -> Tuple[int, int, float, floa dy dx """ - nrows, ncols = (1, 1) + nrows = ncols = 1 dy, dx = xmap.dy, xmap.dx if xmap.ndim == 1: ncols = xmap.shape[0] @@ -680,7 +672,7 @@ def _get_prop_arrays( prop_names: List[str], desired_prop_names: List[str], map_size: int, - index: Union[int, None], + index: Optional[int], decimals: int = 5, ) -> np.ndarray: """Return a 2D array (n_points, n_properties) with desired property @@ -736,7 +728,7 @@ def _get_prop_array( expected_prop_names: List[str], prop_names: List[str], prop_names_lower_arr: np.ndarray, - index: Union[int, None], + index: Optional[int], decimals: int = 5, fill_value: Union[int, float, bool] = 0, ) -> Union[np.ndarray, None]: @@ -745,9 +737,8 @@ def _get_prop_array( Reasons for why the property cannot be read: - * Property name isn't among the crystal map properties - * Property has only one value per point, but ``index`` is not - ``None`` + * Property name isn't among the crystal map properties + * Property has only one value per point, but *index* is not ``None`` Parameters ---------- @@ -766,10 +757,10 @@ def _get_prop_array( Property array or none if none found. """ kwargs = dict(decimals=decimals, fill_value=fill_value) - if len(prop_names_lower_arr) == 0 and prop_name is None: + if not len(prop_names_lower_arr) and not prop_name: return else: - if prop_name is None: + if not prop_name: # Search for a suitable property for k in expected_prop_names: is_equal = k == prop_names_lower_arr @@ -783,6 +774,6 @@ def _get_prop_array( # Return the single array even if `index` is given return xmap.get_map_data(prop_name, **kwargs) else: - if index is None: + if not index: index = 0 return xmap.get_map_data(xmap.prop[prop_name][:, index], **kwargs) diff --git a/orix/io/plugins/ctf.py b/orix/io/plugins/ctf.py new file mode 100644 index 00000000..e271a5e7 --- /dev/null +++ b/orix/io/plugins/ctf.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +# Copyright 2018-2024 the orix developers +# +# This file is part of orix. +# +# orix 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. +# +# orix 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 orix. If not, see . + +"""Reader of a crystal map from a file in the Channel Text File (CTF) +format. +""" + +from io import TextIOWrapper +import re +from typing import Dict, List, Tuple + +from diffpy.structure import Lattice, Structure +import numpy as np + +from orix.crystal_map import CrystalMap, PhaseList +from orix.crystal_map.crystal_map import _data_slices_from_coordinates +from orix.quaternion import Rotation + +__all__ = ["file_reader"] + +# Plugin description +format_name = "ctf" +file_extensions = ["ctf"] +writes = False +writes_this = None + + +def file_reader(filename: str) -> CrystalMap: + """Return a crystal map from a file in the Channel Text File (CTF) + format. + + The map in the input is assumed to be 2D. + + Many vendors/programs produce a .ctf files. Files from the following + vendors/programs are tested: + + * Oxford Instruments AZtec + * Bruker Esprit + * NanoMegas ASTAR Index + * EMsoft (from program `EMdpmerge`) + * MTEX + + All points with a phase of 0 are classified as not indexed. + + Parameters + ---------- + filename + Path to file. + + Returns + ------- + xmap + Crystal map. + + Notes + ----- + Files written by MTEX do not contain information of the space group. + + Files written by EMsoft have the column names for mean angular + deviation (MAD), band contrast (BC), and band slope (BS) renamed to + DP (dot product), OSM (orientation similarity metric), and IQ (image + quality), respectively. + """ + with open(filename, "r") as f: + header, data_starting_row, vendor = _get_header(f) + + # Phase information, potentially empty + phases = _get_phases_from_header(header) + phases["structures"] = [] + lattice_constants = phases.pop("lattice_constants") + for name, abcABG in zip(phases["names"], lattice_constants): + structure = Structure(title=name, lattice=Lattice(*abcABG)) + phases["structures"].append(structure) + + file_data = np.loadtxt(filename, skiprows=data_starting_row) + + # Data needed to create a crystal map + data_dict = { + "euler1": None, + "euler2": None, + "euler3": None, + "x": None, + "y": None, + "phase_id": None, + "prop": {}, + } + column_names = [ + "phase_id", + "x", + "y", + "bands", + "error", + "euler1", + "euler2", + "euler3", + "MAD", # Mean angular deviation + "BC", # Band contrast + "BS", # Band slope + ] + emsoft_mapping = {"MAD": "DP", "BC": "OSM", "BS": "IQ"} + for column, name in enumerate(column_names): + if name in data_dict: + data_dict[name] = file_data[:, column] + else: + if vendor == "emsoft" and name in emsoft_mapping: + name = emsoft_mapping[name] + data_dict["prop"][name] = file_data[:, column] + + if vendor == "astar": + data_dict = _fix_astar_coords(header, data_dict) + + data_dict["phase_list"] = PhaseList(**phases) + + # Set which data points are not indexed + not_indexed = data_dict["phase_id"] == 0 + data_dict["phase_id"][not_indexed] = -1 + + # Set scan unit + data_dict["scan_unit"] = "um" + + # Create rotations + data_dict["rotations"] = Rotation.from_euler( + np.column_stack( + (data_dict.pop("euler1"), data_dict.pop("euler2"), data_dict.pop("euler3")) + ), + degrees=True, + ) + + return CrystalMap(**data_dict) + + +def _get_header(file: TextIOWrapper) -> Tuple[List[str], int, List[str]]: + """Return file header, row number of start of data in file, and the + detected vendor(s). + + Parameters + ---------- + file + File object. + + Returns + ------- + header + List with header lines as individual elements. + data_starting_row + The starting row number for the data lines + vendor + Vendor detected based on some header pattern. Default is to + assume Oxford/Bruker, ``"oxford_or_bruker"`` (assuming no + difference between the two vendor's CTF formats). Other options + are ``"emsoft"``, ``"astar"``, and ``"mtex"``. + """ + vendor = [] + vendor_pattern = { + "emsoft": re.compile( + ( + r"EMsoft v\. ([A-Za-z0-9]+(_[A-Za-z0-9]+)+); BANDS=pattern index, " + r"MAD=CI, BC=OSM, BS=IQ" + ), + ), + "astar": re.compile(r"Author[\t\s]File created from ACOM RES results"), + "mtex": re.compile("(?<=)Created from mtex"), + } + + header = [] + line = file.readline() + i = 0 + # Prevent endless loop by not reading past 1 000 lines + while not line.startswith("Phase\tX\tY") and i < 1_000: + for k, v in vendor_pattern.items(): + if v.search(line): + vendor.append(k) + header.append(line.rstrip()) + i += 1 + line = file.readline() + + vendor = vendor[0] if len(vendor) == 1 else "oxford_or_bruker" + + return header, i + 1, vendor + + +def _get_phases_from_header(header: List[str]) -> dict: + """Return phase names and symmetries detected in a .ctf file header. + + Parameters + ---------- + header + List with header lines as individual elements. + vendor + Vendor of the file. + + Returns + ------- + phase_dict + Dictionary with the following keys (and types): "ids" (int), + "names" (str), "space_groups" (int), "point_groups" (str), + "lattice_constants" (list of floats). + + Notes + ----- + This function has been tested with files from the following vendor's + formats: Oxford AZtec HKL v5/v6 and EMsoft v4/v5. + """ + phases = { + "ids": [], + "names": [], + "point_groups": [], + "space_groups": [], + "lattice_constants": [], + } + for i, line in enumerate(header): + if line.startswith("Phases"): + break + + n_phases = int(line.split("\t")[1]) + + # Laue classes + laue_ids = [ + "-1", + "2/m", + "mmm", + "4/m", + "4/mmm", + "-3", + "-3m", + "6/m", + "6/mmm", + "m3", + "m-3m", + ] + + for j in range(n_phases): + phase_data = header[i + 1 + j].split("\t") + phases["ids"].append(j + 1) + abcABG = ";".join(phase_data[:2]) + abcABG = abcABG.split(";") + abcABG = [float(i.replace(",", ".")) for i in abcABG] + phases["lattice_constants"].append(abcABG) + phases["names"].append(phase_data[2]) + laue_id = int(phase_data[3]) + phases["point_groups"].append(laue_ids[laue_id - 1]) + sg = int(phase_data[4]) + if sg == 0: + sg = None + phases["space_groups"].append(sg) + + return phases + + +def _fix_astar_coords(header: List[str], data_dict: dict) -> dict: + """Return the data dictionary with coordinate arrays possibly fixed + for ASTAR Index files. + + Parameters + ---------- + header + List with header lines. + data_dict + Dictionary for creating a crystal map. + + Returns + ------- + data_dict + Dictionary with possibly fixed coordinate arrays. + + Notes + ----- + ASTAR Index files may have fewer decimals in the coordinate columns + than in the X/YSteps header values (e.g. X_1 = 0.0019 vs. + XStep = 0.00191999995708466). This may cause our crystal map + algorithm for finding the map shape to fail. We therefore run this + algorithm and compare the found shape to the shape given in the + file. If they are different, we use our own coordinate arrays. + """ + coords = {k: data_dict[k] for k in ["x", "y"]} + slices = _data_slices_from_coordinates(coords) + found_shape = (slices[0].stop + 1, slices[1].stop + 1) + cells = _get_xy_cells(header) + shape = (cells["y"], cells["x"]) + if found_shape != shape: + steps = _get_xy_step(header) + y, x = np.indices(shape, dtype=np.float64) + y *= steps["y"] + x *= steps["x"] + data_dict["y"] = y.ravel() + data_dict["x"] = x.ravel() + return data_dict + + +def _get_xy_step(header: List[str]) -> Dict[str, float]: + pattern_step = re.compile(r"(?<=[XY]Step[\t\s])(.*)") + steps = {"x": None, "y": None} + for line in header: + match = pattern_step.search(line) + if match: + step = float(match.group(0).replace(",", ".")) + if line.startswith("XStep"): + steps["x"] = step + elif line.startswith("YStep"): + steps["y"] = step + return steps + + +def _get_xy_cells(header: List[str]) -> Dict[str, int]: + pattern_cells = re.compile(r"(?<=[XY]Cells[\t\s])(.*)") + cells = {"x": None, "y": None} + for line in header: + match = pattern_cells.search(line) + if match: + step = int(match.group(0)) + if line.startswith("XCells"): + cells["x"] = step + elif line.startswith("YCells"): + cells["y"] = step + return cells diff --git a/orix/tests/conftest.py b/orix/tests/conftest.py index 22d8a961..da5b7a40 100644 --- a/orix/tests/conftest.py +++ b/orix/tests/conftest.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with orix. If not, see . -import gc import os from tempfile import TemporaryDirectory @@ -30,6 +29,10 @@ from orix.quaternion import Rotation +def pytest_sessionstart(session): # pragma: no cover + plt.rcParams["backend"] = "agg" + + @pytest.fixture def rotations(): return Rotation([(2, 4, 6, 8), (-1, -3, -5, -7)]) @@ -40,102 +43,9 @@ def eu(): return np.random.rand(10, 3) -ANGFILE_TSL_HEADER = ( - "# TEM_PIXperUM 1.000000\n" - "# x-star 0.413900\n" - "# y-star 0.729100\n" - "# z-star 0.514900\n" - "# WorkingDistance 27.100000\n" - "#\n" - "# Phase 2\n" - "# MaterialName Aluminum\n" - "# Formula Al\n" - "# Info \n" - "# Symmetry 43\n" - "# LatticeConstants 4.040 4.040 4.040 90.000 90.000 90.000\n" - "# NumberFamilies 69\n" - "# hklFamilies 1 -1 -1 1 8.469246 1\n" - "# ElasticConstants -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000\n" - "# Categories0 0 0 0 0 \n" - "# Phase 3\n" - "# MaterialName Iron Titanium Oxide\n" - "# Formula FeTiO3\n" - "# Info \n" - "# Symmetry 32\n" - "# LatticeConstants 5.123 5.123 13.760 90.000 90.000 120.000\n" - "# NumberFamilies 60\n" - "# hklFamilies 3 0 0 1 100.000000 1\n" - "# ElasticConstants -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000\n" - "# Categories0 0 0 0 0\n" - "#\n" - "# GRID: SqrGrid\n" - "# XSTEP: 0.100000\n" - "# YSTEP: 0.100000\n" - "# NCOLS_ODD: 42\n" - "# NCOLS_EVEN: 42\n" - "# NROWS: 13\n" - "#\n" - "# OPERATOR: sem\n" - "#\n" - "# SAMPLEID: \n" - "#\n" - "# SCANID: \n" - "#\n" -) - -ANGFILE_ASTAR_HEADER = ( - "# File created from ACOM RES results\n" - "# ni-dislocations.res\n" - "# \n" - "# \n" - "# MaterialName Nickel\n" - "# Formula\n" - "# Symmetry 43\n" - "# LatticeConstants 3.520 3.520 3.520 90.000 90.000 90.000\n" - "# NumberFamilies 4\n" - "# hklFamilies 1 1 1 1 0.000000\n" - "# hklFamilies 2 0 0 1 0.000000\n" - "# hklFamilies 2 2 0 1 0.000000\n" - "# hklFamilies 3 1 1 1 0.000000\n" - "#\n" - "# GRID: SqrGrid#\n" -) +# ---------------------------- IO fixtures --------------------------- # -ANGFILE_EMSOFT_HEADER = ( - "# TEM_PIXperUM 1.000000\n" - "# x-star 0.446667\n" - "# y-star 0.586875\n" - "# z-star 0.713450\n" - "# WorkingDistance 0.000000\n" - "#\n" - "# Phase 1\n" - "# MaterialName austenite\n" - "# Formula austenite\n" - "# Info patterns indexed using EMsoft::EMEBSDDI\n" - "# Symmetry 43\n" - "# LatticeConstants 3.595 3.595 3.595 90.000 90.000 90.000\n" - "# NumberFamilies 0\n" - "# Phase 2\n" - "# MaterialName ferrite/ferrite\n" - "# Formula ferrite/ferrite\n" - "# Info patterns indexed using EMsoft::EMEBSDDI\n" - "# Symmetry 43\n" - "# LatticeConstants 2.867 2.867 2.867 90.000 90.000 90.000\n" - "# NumberFamilies 0\n" - "# GRID: SqrGrid\n" - "# XSTEP: 1.500000\n" - "# YSTEP: 1.500000\n" - "# NCOLS_ODD: 13\n" - "# NCOLS_EVEN: 13\n" - "# NROWS: 42\n" - "#\n" - "# OPERATOR: Håkon Wiik Ånes\n" - "#\n" - "# SAMPLEID:\n" - "#\n" - "# SCANID:\n" - "#\n" -) +# ----------------------------- .ang file ---------------------------- # @pytest.fixture() @@ -143,7 +53,6 @@ def temp_ang_file(): with TemporaryDirectory() as tempdir: f = open(os.path.join(tempdir, "temp_ang_file.ang"), mode="w+") yield f - gc.collect() # Garbage collection so that file can be used by multiple tests @pytest.fixture(params=["h5"]) @@ -155,7 +64,48 @@ def temp_file_path(request): with TemporaryDirectory() as tmp: file_path = os.path.join(tmp, "data_temp." + ext) yield file_path - gc.collect() + + +ANGFILE_TSL_HEADER = r"""# TEM_PIXperUM 1.000000 +# x-star 0.413900 +# y-star 0.729100 +# z-star 0.514900 +# WorkingDistance 27.100000 +# +# Phase 2 +# MaterialName Aluminum +# Formula Al +# Info +# Symmetry 43 +# LatticeConstants 4.040 4.040 4.040 90.000 90.000 90.000 +# NumberFamilies 69 +# hklFamilies 1 -1 -1 1 8.469246 1 +# ElasticConstants -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 +# Categories0 0 0 0 0 +# Phase 3 +# MaterialName Iron Titanium Oxide +# Formula FeTiO3 +# Info +# Symmetry 32 +# LatticeConstants 5.123 5.123 13.760 90.000 90.000 120.000 +# NumberFamilies 60 +# hklFamilies 3 0 0 1 100.000000 1 +# ElasticConstants -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 +# Categories0 0 0 0 0 +# +# GRID: SqrGrid +# XSTEP: 0.100000 +# YSTEP: 0.100000 +# NCOLS_ODD: 42 +# NCOLS_EVEN: 42 +# NROWS: 13 +# +# OPERATOR: sem +# +# SAMPLEID: +# +# SCANID: +#""" @pytest.fixture( @@ -237,7 +187,23 @@ def angfile_tsl(tmpdir, request): ) yield f - gc.collect() + + +ANGFILE_ASTAR_HEADER = r"""# File created from ACOM RES results +# ni-dislocations.res +# +# +# MaterialName Nickel +# Formula +# Symmetry 43 +# LatticeConstants 3.520 3.520 3.520 90.000 90.000 90.000 +# NumberFamilies 4 +# hklFamilies 1 1 1 1 0.000000 +# hklFamilies 2 0 0 1 0.000000 +# hklFamilies 2 2 0 1 0.000000 +# hklFamilies 3 1 1 1 0.000000 +# +# GRID: SqrGrid#""" @pytest.fixture( @@ -302,7 +268,41 @@ def angfile_astar(tmpdir, request): ) yield f - gc.collect() + + +ANGFILE_EMSOFT_HEADER = r"""# TEM_PIXperUM 1.000000 +# x-star 0.446667 +# y-star 0.586875 +# z-star 0.713450 +# WorkingDistance 0.000000 +# +# Phase 1 +# MaterialName austenite +# Formula austenite +# Info patterns indexed using EMsoft::EMEBSDDI +# Symmetry 43 +# LatticeConstants 3.595 3.595 3.595 90.000 90.000 90.000 +# NumberFamilies 0 +# Phase 2 +# MaterialName ferrite/ferrite +# Formula ferrite/ferrite +# Info patterns indexed using EMsoft::EMEBSDDI +# Symmetry 43 +# LatticeConstants 2.867 2.867 2.867 90.000 90.000 90.000 +# NumberFamilies 0 +# GRID: SqrGrid +# XSTEP: 1.500000 +# YSTEP: 1.500000 +# NCOLS_ODD: 13 +# NCOLS_EVEN: 13 +# NROWS: 42 +# +# OPERATOR: Håkon Wiik Ånes +# +# SAMPLEID: +# +# SCANID: +#""" @pytest.fixture( @@ -359,7 +359,445 @@ def angfile_emsoft(tmpdir, request): ) yield f - gc.collect() + + +# ----------------------------- .ctf file ---------------------------- # + +# Variable map shape and step sizes +CTF_OXFORD_HEADER = r"""Channel Text File +Prj standard steel sample +Author +JobMode Grid +XCells %i +YCells %i +XStep %.4f +YStep %.4f +AcqE1 0.0000 +AcqE2 0.0000 +AcqE3 0.0000 +Euler angles refer to Sample Coordinate system (CS0)! Mag 180.0000 Coverage 97 Device 0 KV 20.0000 TiltAngle 70.0010 TiltAxis 0 DetectorOrientationE1 0.9743 DetectorOrientationE2 89.4698 DetectorOrientationE3 2.7906 WorkingDistance 14.9080 InsertionDistance 185.0 +Phases 2 +3.660;3.660;3.660 90.000;90.000;90.000 Iron fcc 11 225 Some reference +2.867;2.867;2.867 90.000;90.000;90.000 Iron bcc 11 229 Some other reference +Phase X Y Bands Error Euler1 Euler2 Euler3 MAD BC BS""" + + +@pytest.fixture( + params=[ + ( + (7, 13), # map_shape + (0.1, 0.1), # step_sizes + np.random.choice([1, 2], 7 * 13), # phase_id + np.array([[4.48549, 0.95242, 0.79150], [1.34390, 0.27611, 0.82589]]), # R + ) + ] +) +def ctf_oxford(tmpdir, request): + """Create a dummy CTF file in Oxford Instrument's format from input. + + 10% of points are non-indexed (phase ID of 0 and MAD = 0). + + Parameters expected in `request` + -------------------------------- + map_shape : tuple of ints + Map shape to create. + step_sizes : tuple of floats + Step sizes in x and y coordinates in microns. + phase_id : numpy.ndarray + Array of map size with phase IDs in header. + rotations : numpy.ndarray + A sample, smaller than the map size, of Euler angle triplets. + """ + # Unpack parameters + (ny, nx), (dy, dx), phase_id, R_example = request.param + + # File columns + d, map_size = create_coordinate_arrays((ny, nx), (dy, dx)) + x, y = d["x"], d["y"] + rng = np.random.default_rng() + bands = rng.integers(8, size=map_size, dtype=np.uint8) + err = np.zeros(map_size, dtype=np.uint8) + mad = rng.random(map_size) + bc = rng.integers(150, 200, map_size) + bs = rng.integers(190, 255, map_size) + R_idx = np.random.choice(np.arange(len(R_example)), map_size) + R = R_example[R_idx] + R = np.rad2deg(R) + + # Insert 10% non-indexed points + non_indexed_points = np.random.choice( + np.arange(map_size), replace=False, size=int(map_size * 0.1) + ) + phase_id[non_indexed_points] = 0 + R[non_indexed_points] = 0.0 + bands[non_indexed_points] = 0 + err[non_indexed_points] = 3 + mad[non_indexed_points] = 0.0 + bc[non_indexed_points] = 0 + bs[non_indexed_points] = 0 + + CTF_OXFORD_HEADER2 = CTF_OXFORD_HEADER % (nx, ny, dx, dy) + + f = tmpdir.join("oxford.ctf") + np.savetxt( + fname=f, + X=np.column_stack( + (phase_id, x, y, bands, err, R[:, 0], R[:, 1], R[:, 2], mad, bc, bs) + ), + fmt="%-4i%-8.4f%-8.4f%-4i%-4i%-11.4f%-11.4f%-11.4f%-8.4f%-4i%-i", + header=CTF_OXFORD_HEADER2, + comments="", + ) + + yield f + + +# Variable map shape, comma as decimal separator in fixed step size +CTF_BRUKER_HEADER = r"""Channel Text File +Prj unnamed +Author [Unknown] +JobMode Grid +XCells %i +YCells %i +XStep 0,001998 +YStep 0,001998 +AcqE1 0 +AcqE2 0 +AcqE3 0 +Euler angles refer to Sample Coordinate system (CS0)! Mag 150000,000000 Coverage 100 Device 0 KV 30,000000 TiltAngle 0 TiltAxis 0 +Phases 1 +4,079000;4,079000;4,079000 90,000000;90,000000;90,000000 Gold 11 225 +Phase X Y Bands Error Euler1 Euler2 Euler3 MAD BC BS""" + + +@pytest.fixture( + params=[ + ( + (7, 13), # map_shape + np.array([[4.48549, 0.95242, 0.79150], [1.34390, 0.27611, 0.82589]]), # R + ) + ] +) +def ctf_bruker(tmpdir, request): + """Create a dummy CTF file in Bruker's format from input. + + Identical to Oxford files except for the following: + + * All band slopes (BS) may be set to 255 + * Decimal separators in header may be with comma + + Parameters expected in `request` + -------------------------------- + map_shape : tuple of ints + Map shape to create. + rotations : numpy.ndarray + A sample, smaller than the map size, of Euler angle triplets. + """ + # Unpack parameters + (ny, nx), R_example = request.param + dy = dx = 0.001998 + + # File columns + d, map_size = create_coordinate_arrays((ny, nx), (dy, dx)) + x, y = d["x"], d["y"] + rng = np.random.default_rng() + bands = rng.integers(8, size=map_size, dtype=np.uint8) + err = np.zeros(map_size, dtype=np.uint8) + mad = rng.random(map_size) + bc = rng.integers(50, 105, map_size) + bs = np.full(map_size, 255, dtype=np.uint8) + R_idx = np.random.choice(np.arange(len(R_example)), map_size) + R = R_example[R_idx] + R = np.rad2deg(R) + + # Insert 10% non-indexed points + phase_id = np.ones(map_size, dtype=np.uint8) + non_indexed_points = np.random.choice( + np.arange(map_size), replace=False, size=int(map_size * 0.1) + ) + phase_id[non_indexed_points] = 0 + R[non_indexed_points] = 0.0 + bands[non_indexed_points] = 0 + err[non_indexed_points] = 3 + mad[non_indexed_points] = 0.0 + bc[non_indexed_points] = 0 + bs[non_indexed_points] = 0 + + CTF_BRUKER_HEADER2 = CTF_BRUKER_HEADER % (nx, ny) + + f = tmpdir.join("bruker.ctf") + np.savetxt( + fname=f, + X=np.column_stack( + (phase_id, x, y, bands, err, R[:, 0], R[:, 1], R[:, 2], mad, bc, bs) + ), + fmt="%-4i%-8.4f%-8.4f%-4i%-4i%-11.4f%-11.4f%-11.4f%-8.4f%-4i%-i", + header=CTF_BRUKER_HEADER2, + comments="", + ) + + yield f + + +# Variable map shape, small fixed step size +CTF_ASTAR_HEADER = r"""Channel Text File +Prj C:\some\where\scan.res +Author File created from ACOM RES results +JobMode Grid +XCells %i +YCells %i +XStep 0.00191999995708466 +YStep 0.00191999995708466 +AcqE1 0 +AcqE2 0 +AcqE3 0 +Euler angles refer to Sample Coordinate system (CS0)! Mag 200 Coverage 100 Device 0 KV 20 TiltAngle 70 TiltAxis 0 +Phases 1 +4.0780;4.0780;4.0780 90;90;90 _mineral 'Gold' 'Gold' 11 225 +Phase X Y Bands Error Euler1 Euler2 Euler3 MAD BC BS""" + + +@pytest.fixture( + params=[ + ( + (7, 13), # map_shape + np.array([[4.48549, 0.95242, 0.79150], [1.34390, 0.27611, 0.82589]]), # R + ) + ] +) +def ctf_astar(tmpdir, request): + """Create a dummy CTF file in NanoMegas ASTAR's format from input. + + Identical to Oxford files except for the following: + + * Bands = 6 (always?) + * Error = 0 (always?) + * Only two decimals in Euler angles + + Parameters expected in `request` + -------------------------------- + map_shape : tuple of ints + Map shape to create. + rotations : numpy.ndarray + A sample, smaller than the map size, of Euler angle triplets. + """ + # Unpack parameters + (ny, nx), R_example = request.param + dy = dx = 0.00191999995708466 + + # File columns + d, map_size = create_coordinate_arrays((ny, nx), (dy, dx)) + x, y = d["x"], d["y"] + rng = np.random.default_rng() + bands = np.full(map_size, 6, dtype=np.uint8) + err = np.zeros(map_size, dtype=np.uint8) + mad = rng.random(map_size) + bc = rng.integers(0, 60, map_size) + bs = rng.integers(35, 42, map_size) + R_idx = np.random.choice(np.arange(len(R_example)), map_size) + R = R_example[R_idx] + R = np.rad2deg(R) + + # Insert 10% non-indexed points + phase_id = np.ones(map_size, dtype=np.uint8) + non_indexed_points = np.random.choice( + np.arange(map_size), replace=False, size=int(map_size * 0.1) + ) + phase_id[non_indexed_points] = 0 + R[non_indexed_points] = 0.0 + bands[non_indexed_points] = 0 + err[non_indexed_points] = 3 + mad[non_indexed_points] = 0.0 + bc[non_indexed_points] = 0 + bs[non_indexed_points] = 0 + + CTF_ASTAR_HEADER2 = CTF_ASTAR_HEADER % (nx, ny) + + f = tmpdir.join("astar.ctf") + np.savetxt( + fname=f, + X=np.column_stack( + (phase_id, x, y, bands, err, R[:, 0], R[:, 1], R[:, 2], mad, bc, bs) + ), + fmt="%-4i%-8.4f%-8.4f%-4i%-4i%-9.2f%-9.2f%-9.2f%-8.4f%-4i%-i", + header=CTF_ASTAR_HEADER2, + comments="", + ) + + yield f + + +# Variable map shape and step sizes +CTF_EMSOFT_HEADER = r"""Channel Text File +EMsoft v. 4_1_1_9d5269a; BANDS=pattern index, MAD=CI, BC=OSM, BS=IQ +Author Me +JobMode Grid +XCells %i +YCells %i +XStep %.2f +YStep %.2f +AcqE1 0 +AcqE2 0 +AcqE3 0 +Euler angles refer to Sample Coordinate system (CS0)! Mag 30 Coverage 100 Device 0 KV 0.0 TiltAngle 0.00 TiltAxis 0 +Phases 1 +3.524;3.524;3.524 90.000;90.000;90.000 Ni 11 225 +Phase X Y Bands Error Euler1 Euler2 Euler3 MAD BC BS""" + + +@pytest.fixture( + params=[ + ( + (7, 13), # map_shape + (1, 2), # step_sizes + np.array([[4.48549, 0.95242, 0.79150], [1.34390, 0.27611, 0.82589]]), # R + ) + ] +) +def ctf_emsoft(tmpdir, request): + """Create a dummy CTF file in EMsoft's format from input. + + Identical to Oxford files except for the following: + + * Bands = dictionary index + * Error = 0 + * Only three decimals in Euler angles + + Parameters expected in `request` + -------------------------------- + map_shape : tuple of ints + Map shape to create. + step_sizes : tuple of floats + Step sizes in x and y coordinates in microns. + rotations : numpy.ndarray + A sample, smaller than the map size, of Euler angle triplets. + """ + # Unpack parameters + (ny, nx), (dy, dx), R_example = request.param + + # File columns + d, map_size = create_coordinate_arrays((ny, nx), (dy, dx)) + x, y = d["x"], d["y"] + rng = np.random.default_rng() + bands = rng.integers(0, 333_000, map_size) + err = np.zeros(map_size, dtype=np.uint8) + mad = rng.random(map_size) + bc = rng.integers(60, 140, map_size) + bs = rng.integers(60, 120, map_size) + R_idx = np.random.choice(np.arange(len(R_example)), map_size) + R = R_example[R_idx] + R = np.rad2deg(R) + + # Insert 10% non-indexed points + phase_id = np.ones(map_size, dtype=np.uint8) + non_indexed_points = np.random.choice( + np.arange(map_size), replace=False, size=int(map_size * 0.1) + ) + phase_id[non_indexed_points] = 0 + R[non_indexed_points] = 0.0 + bands[non_indexed_points] = 0 + mad[non_indexed_points] = 0.0 + bc[non_indexed_points] = 0 + bs[non_indexed_points] = 0 + + CTF_EMSOFT_HEADER2 = CTF_EMSOFT_HEADER % (nx, ny, dx, dy) + + f = tmpdir.join("emsoft.ctf") + np.savetxt( + fname=f, + X=np.column_stack( + (phase_id, x, y, bands, err, R[:, 0], R[:, 1], R[:, 2], mad, bc, bs) + ), + fmt="%-4i%-8.4f%-8.4f%-7i%-4i%-10.3f%-10.3f%-10.3f%-8.4f%-4i%-i", + header=CTF_EMSOFT_HEADER2, + comments="", + ) + + yield f + + +# Variable map shape and step sizes +CTF_MTEX_HEADER = r"""Channel Text File +Prj /some/where/mtex.ctf +Author Me Again +JobMode Grid +XCells %i +YCells %i +XStep %.4f +YStep %.4f +AcqE1 0.0000 +AcqE2 0.0000 +AcqE3 0.0000 +Euler angles refer to Sample Coordinate system (CS0)! Mag 0.0000 Coverage 0 Device 0 KV 0.0000 TiltAngle 0.0000 TiltAxis 0 DetectorOrientationE1 0.0000 DetectorOrientationE2 0.0000 DetectorOrientationE3 0.0000 WorkingDistance 0.0000 InsertionDistance 0.0000 +Phases 1 +4.079;4.079;4.079 90.000;90.000;90.000 Gold 11 0 Created from mtex +Phase X Y Bands Error Euler1 Euler2 Euler3 MAD BC BS""" + + +@pytest.fixture( + params=[ + ( + (7, 13), # map_shape + (1, 2), # step_sizes + np.array([[4.48549, 0.95242, 0.79150], [1.34390, 0.27611, 0.82589]]), # R + ) + ] +) +def ctf_mtex(tmpdir, request): + """Create a dummy CTF file in MTEX's format from input. + + Identical to Oxford files except for the properties Bands, Error, + MAD, BC, and BS are all equal to 0. + + Parameters expected in `request` + -------------------------------- + map_shape : tuple of ints + Map shape to create. + step_sizes : tuple of floats + Step sizes in x and y coordinates in microns. + rotations : numpy.ndarray + A sample, smaller than the map size, of Euler angle triplets. + """ + # Unpack parameters + (ny, nx), (dy, dx), R_example = request.param + + # File columns + d, map_size = create_coordinate_arrays((ny, nx), (dy, dx)) + x, y = d["x"], d["y"] + bands = np.zeros(map_size) + err = np.zeros(map_size) + mad = np.zeros(map_size) + bc = np.zeros(map_size) + bs = np.zeros(map_size) + R_idx = np.random.choice(np.arange(len(R_example)), map_size) + R = R_example[R_idx] + R = np.rad2deg(R) + + # Insert 10% non-indexed points + phase_id = np.ones(map_size, dtype=np.uint8) + non_indexed_points = np.random.choice( + np.arange(map_size), replace=False, size=int(map_size * 0.1) + ) + phase_id[non_indexed_points] = 0 + R[non_indexed_points] = 0.0 + + CTF_MTEX_HEADER2 = CTF_MTEX_HEADER % (nx, ny, dx, dy) + + f = tmpdir.join("mtex.ctf") + np.savetxt( + fname=f, + X=np.column_stack( + (phase_id, x, y, bands, err, R[:, 0], R[:, 1], R[:, 2], mad, bc, bs) + ), + fmt="%-4i%-8.4f%-8.4f%-4i%-4i%-11.4f%-11.4f%-11.4f%-8.4f%-4i%-i", + header=CTF_MTEX_HEADER2, + comments="", + ) + + yield f + + +# ---------------------------- HDF5 files ---------------------------- # @pytest.fixture( @@ -486,7 +924,6 @@ def temp_emsoft_h5ebsd_file(tmpdir, request): phase_group.create_dataset(name, data=np.array([data], dtype=np.dtype("S"))) yield f - gc.collect() @pytest.fixture( @@ -602,7 +1039,68 @@ def temp_bruker_h5ebsd_file(tmpdir, request): data_group.create_dataset("phi2", data=rot[:, 2]) yield f - gc.collect() + + +# --------------------------- Other files ---------------------------- # + + +@pytest.fixture +def cif_file(tmpdir): + """Actual CIF file of beta double prime phase often seen in Al-Mg-Si + alloys. + """ + file_contents = """#====================================================================== + +# CRYSTAL DATA + +#---------------------------------------------------------------------- + +data_VESTA_phase_1 + + +_chemical_name_common '' +_cell_length_a 15.50000 +_cell_length_b 4.05000 +_cell_length_c 6.74000 +_cell_angle_alpha 90 +_cell_angle_beta 105.30000 +_cell_angle_gamma 90 +_space_group_name_H-M_alt 'C 2/m' +_space_group_IT_number 12 + +loop_ +_space_group_symop_operation_xyz + 'x, y, z' + '-x, -y, -z' + '-x, y, -z' + 'x, -y, z' + 'x+1/2, y+1/2, z' + '-x+1/2, -y+1/2, -z' + '-x+1/2, y+1/2, -z' + 'x+1/2, -y+1/2, z' + +loop_ + _atom_site_label + _atom_site_occupancy + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_adp_type + _atom_site_B_iso_or_equiv + _atom_site_type_symbol + Mg(1) 1.0 0.000000 0.000000 0.000000 Biso 1.000000 Mg + Mg(2) 1.0 0.347000 0.000000 0.089000 Biso 1.000000 Mg + Mg(3) 1.0 0.423000 0.000000 0.652000 Biso 1.000000 Mg + Si(1) 1.0 0.054000 0.000000 0.649000 Biso 1.000000 Si + Si(2) 1.0 0.190000 0.000000 0.224000 Biso 1.000000 Si + Al 1.0 0.211000 0.000000 0.626000 Biso 1.000000 Al""" + f = open(tmpdir.join("betapp.cif"), mode="w") + f.write(file_contents) + f.close() + yield f.name + + +# ----------------------- Crystal map fixtures ----------------------- # @pytest.fixture( @@ -665,65 +1163,6 @@ def crystal_map(crystal_map_input): return CrystalMap(**crystal_map_input) -@pytest.fixture -def cif_file(tmpdir): - """Actual CIF file of beta double prime phase often seen in Al-Mg-Si - alloys. - """ - file_contents = """ -#====================================================================== - -# CRYSTAL DATA - -#---------------------------------------------------------------------- - -data_VESTA_phase_1 - - -_chemical_name_common '' -_cell_length_a 15.50000 -_cell_length_b 4.05000 -_cell_length_c 6.74000 -_cell_angle_alpha 90 -_cell_angle_beta 105.30000 -_cell_angle_gamma 90 -_space_group_name_H-M_alt 'C 2/m' -_space_group_IT_number 12 - -loop_ -_space_group_symop_operation_xyz - 'x, y, z' - '-x, -y, -z' - '-x, y, -z' - 'x, -y, z' - 'x+1/2, y+1/2, z' - '-x+1/2, -y+1/2, -z' - '-x+1/2, y+1/2, -z' - 'x+1/2, -y+1/2, z' - -loop_ - _atom_site_label - _atom_site_occupancy - _atom_site_fract_x - _atom_site_fract_y - _atom_site_fract_z - _atom_site_adp_type - _atom_site_B_iso_or_equiv - _atom_site_type_symbol - Mg(1) 1.0 0.000000 0.000000 0.000000 Biso 1.000000 Mg - Mg(2) 1.0 0.347000 0.000000 0.089000 Biso 1.000000 Mg - Mg(3) 1.0 0.423000 0.000000 0.652000 Biso 1.000000 Mg - Si(1) 1.0 0.054000 0.000000 0.649000 Biso 1.000000 Si - Si(2) 1.0 0.190000 0.000000 0.224000 Biso 1.000000 Si - Al 1.0 0.211000 0.000000 0.626000 Biso 1.000000 Al" -""" - f = open(tmpdir.join("betapp.cif"), mode="w") - f.write(file_contents) - f.close() - yield f.name - gc.collect() - - # ---------- Rotation representations for conversion tests ----------- # # NOTE to future test writers on unittest data: # All the data below can be recreated using 3Drotations, which is diff --git a/orix/tests/io/test_ang.py b/orix/tests/io/test_ang.py index 082c4f70..4e091f93 100644 --- a/orix/tests/io/test_ang.py +++ b/orix/tests/io/test_ang.py @@ -77,7 +77,8 @@ indirect=["angfile_astar"], ) def test_loadang(angfile_astar, expected_data): - loaded_data = loadang(angfile_astar) + with pytest.warns(np.VisibleDeprecationWarning): + loaded_data = loadang(angfile_astar) assert np.allclose(loaded_data.data, expected_data) @@ -175,7 +176,7 @@ def test_load_ang_tsl( assert xmap.phases.size == 2 # Including non-indexed assert xmap.phases.ids == [-1, 0] phase = xmap.phases[0] - assert phase.name == "Aluminum" + assert phase.name == "Al" assert phase.point_group.name == "432" @pytest.mark.parametrize( @@ -500,12 +501,12 @@ def test_get_phases_from_header( "#", "# GRID: SqrGrid#", ] - ids, names, point_groups, lattice_constants = _get_phases_from_header(header) + phases = _get_phases_from_header(header) - assert names == expected_names - assert point_groups == expected_point_groups - assert np.allclose(lattice_constants, expected_lattice_constants) - assert np.allclose(ids, expected_phase_id) + assert phases["names"] == expected_names + assert phases["point_groups"] == expected_point_groups + assert np.allclose(phases["lattice_constants"], expected_lattice_constants) + assert np.allclose(phases["ids"], expected_phase_id) class TestAngWriter: diff --git a/orix/tests/io/test_ctf.py b/orix/tests/io/test_ctf.py new file mode 100644 index 00000000..62a11239 --- /dev/null +++ b/orix/tests/io/test_ctf.py @@ -0,0 +1,445 @@ +# Copyright 2018-2024 the orix developers +# +# This file is part of orix. +# +# orix 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. +# +# orix 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 orix. If not, see . + +from diffpy.structure import Atom, Lattice, Structure +import numpy as np +import pytest + +from orix import io +from orix.crystal_map import PhaseList + + +class TestCTFReader: + @pytest.mark.parametrize( + "ctf_oxford, map_shape, step_sizes, R_example", + [ + ( + ( + (5, 3), + (0.1, 0.1), + np.random.choice([1, 2], 5 * 3), + np.array( + [[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]] + ), + ), + (5, 3), + (0.1, 0.1), + np.array([[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]]), + ), + ( + ( + (8, 8), + (1.0, 1.5), + np.random.choice([1, 2], 8 * 8), + np.array( + [[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]] + ), + ), + (8, 8), + (1.0, 1.5), + np.array([[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]]), + ), + ], + indirect=["ctf_oxford"], + ) + def test_load_ctf_oxford( + self, + ctf_oxford, + map_shape, + step_sizes, + R_example, + ): + xmap = io.load(ctf_oxford) + + # Fraction of non-indexed points + non_indexed_fraction = int(np.prod(map_shape) * 0.1) + assert non_indexed_fraction == np.sum(~xmap.is_indexed) + + # Properties + assert list(xmap.prop.keys()) == ["bands", "error", "MAD", "BC", "BS"] + + # Coordinates + ny, nx = map_shape + dy, dx = step_sizes + assert np.allclose(xmap.x, np.tile(np.arange(nx) * dx, ny)) + assert np.allclose(xmap.y, np.sort(np.tile(np.arange(ny) * dy, nx))) + assert xmap.scan_unit == "um" + + # Map shape and size + assert xmap.shape == map_shape + assert xmap.size == np.prod(map_shape) + + # Attributes are within expected ranges or have a certain value + assert xmap.bands.min() >= 0 + assert xmap.error.min() >= 0 + assert np.allclose(xmap["not_indexed"].bands, 0) + assert not any(np.isclose(xmap["not_indexed"].error, 0)) + assert np.allclose(xmap["not_indexed"].MAD, 0) + assert np.allclose(xmap["not_indexed"].BC, 0) + assert np.allclose(xmap["not_indexed"].BS, 0) + + # Rotations + R_unique = np.unique(xmap["indexed"].rotations.to_euler(), axis=0) + assert np.allclose( + np.sort(R_unique, axis=0), np.sort(R_example, axis=0), atol=1e-5 + ) + assert np.allclose(xmap["not_indexed"].rotations.to_euler()[0], 0) + + # Phases + phases = PhaseList( + names=["Iron fcc", "Iron bcc"], + space_groups=[225, 229], + structures=[ + Structure(lattice=Lattice(3.66, 3.66, 3.66, 90, 90, 90)), + Structure(lattice=Lattice(2.867, 2.867, 2.867, 90, 90, 90)), + ], + ) + + assert all(np.isin(xmap.phase_id, [-1, 1, 2])) + assert np.allclose(xmap["not_indexed"].phase_id, -1) + assert xmap.phases.ids == [-1, 1, 2] + for (_, phase), (_, phase_test) in zip(xmap["indexed"].phases_in_data, phases): + assert phase.name == phase_test.name + assert phase.space_group.number == phase_test.space_group.number + assert np.allclose( + phase.structure.lattice.abcABG(), phase_test.structure.lattice.abcABG() + ) + + @pytest.mark.parametrize( + "ctf_bruker, map_shape, R_example", + [ + ( + ( + (5, 3), + np.array( + [[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]] + ), + ), + (5, 3), + np.array([[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]]), + ), + ( + ( + (8, 8), + np.array( + [[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]] + ), + ), + (8, 8), + np.array([[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]]), + ), + ], + indirect=["ctf_bruker"], + ) + def test_load_ctf_bruker(self, ctf_bruker, map_shape, R_example): + xmap = io.load(ctf_bruker) + + # Fraction of non-indexed points + non_indexed_fraction = int(np.prod(map_shape) * 0.1) + assert non_indexed_fraction == np.sum(~xmap.is_indexed) + + # Properties + assert list(xmap.prop.keys()) == ["bands", "error", "MAD", "BC", "BS"] + + # Coordinates + ny, nx = map_shape + dy = dx = 0.001998 + assert np.allclose(xmap.x, np.tile(np.arange(nx) * dx, ny), atol=1e-4) + assert np.allclose(xmap.y, np.sort(np.tile(np.arange(ny) * dy, nx)), atol=1e-4) + assert xmap.scan_unit == "um" + + # Map shape and size + assert xmap.shape == map_shape + assert xmap.size == np.prod(map_shape) + + # Attributes are within expected ranges or have a certain value + assert xmap.bands.min() >= 0 + assert xmap.error.min() >= 0 + assert np.allclose(xmap["not_indexed"].bands, 0) + assert not any(np.isclose(xmap["not_indexed"].error, 0)) + assert np.allclose(xmap["not_indexed"].MAD, 0) + assert np.allclose(xmap["not_indexed"].BC, 0) + assert np.allclose(xmap["not_indexed"].BS, 0) + + # Rotations + R_unique = np.unique(xmap["indexed"].rotations.to_euler(), axis=0) + assert np.allclose( + np.sort(R_unique, axis=0), np.sort(R_example, axis=0), atol=1e-5 + ) + assert np.allclose(xmap["not_indexed"].rotations.to_euler()[0], 0) + + # Phases + assert all(np.isin(xmap.phase_id, [-1, 1])) + assert np.allclose(xmap["not_indexed"].phase_id, -1) + assert xmap.phases.ids == [-1, 1] + phase = xmap.phases[1] + assert phase.name == "Gold" + assert phase.space_group.number == 225 + assert phase.structure.lattice.abcABG() == (4.079, 4.079, 4.079, 90, 90, 90) + + @pytest.mark.parametrize( + "ctf_astar, map_shape, R_example", + [ + ( + ( + (5, 3), + np.array( + [[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]] + ), + ), + (5, 3), + np.array([[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]]), + ), + ( + ( + (8, 8), + np.array( + [[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]] + ), + ), + (8, 8), + np.array([[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]]), + ), + ], + indirect=["ctf_astar"], + ) + def test_load_ctf_astar(self, ctf_astar, map_shape, R_example): + xmap = io.load(ctf_astar) + + # Fraction of non-indexed points + non_indexed_fraction = int(np.prod(map_shape) * 0.1) + assert non_indexed_fraction == np.sum(~xmap.is_indexed) + + # Properties + assert list(xmap.prop.keys()) == ["bands", "error", "MAD", "BC", "BS"] + + # Coordinates + ny, nx = map_shape + dy = dx = 0.00191999995708466 + assert np.allclose(xmap.x, np.tile(np.arange(nx) * dx, ny), atol=1e-4) + assert np.allclose(xmap.y, np.sort(np.tile(np.arange(ny) * dy, nx)), atol=1e-4) + assert xmap.scan_unit == "um" + + # Map shape and size + assert xmap.shape == map_shape + assert xmap.size == np.prod(map_shape) + + # Attributes are within expected ranges or have a certain value + assert np.allclose(xmap["indexed"].bands, 6) + assert np.allclose(xmap["indexed"].error, 0) + assert np.allclose(xmap["not_indexed"].bands, 0) + assert not any(np.isclose(xmap["not_indexed"].error, 0)) + assert np.allclose(xmap["not_indexed"].MAD, 0) + assert np.allclose(xmap["not_indexed"].BC, 0) + assert np.allclose(xmap["not_indexed"].BS, 0) + + # Rotations + R_unique = np.unique(xmap["indexed"].rotations.to_euler(), axis=0) + assert np.allclose( + np.sort(R_unique, axis=0), np.sort(R_example, axis=0), atol=1e-5 + ) + assert np.allclose(xmap["not_indexed"].rotations.to_euler()[0], 0) + + # Phases + assert all(np.isin(xmap.phase_id, [-1, 1])) + assert np.allclose(xmap["not_indexed"].phase_id, -1) + assert xmap.phases.ids == [-1, 1] + phase = xmap.phases[1] + assert phase.name == "_mineral 'Gold' 'Gold'" + assert phase.space_group.number == 225 + assert phase.structure.lattice.abcABG() == (4.078, 4.078, 4.078, 90, 90, 90) + + @pytest.mark.parametrize( + "ctf_emsoft, map_shape, step_sizes, R_example", + [ + ( + ( + (5, 3), + (0.1, 0.2), + np.array( + [[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]] + ), + ), + (5, 3), + (0.1, 0.2), + np.array([[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]]), + ), + ( + ( + (8, 8), + (1.0, 1.5), + np.array( + [[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]] + ), + ), + (8, 8), + (1.0, 1.5), + np.array([[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]]), + ), + ], + indirect=["ctf_emsoft"], + ) + def test_load_ctf_emsoft( + self, + ctf_emsoft, + map_shape, + step_sizes, + R_example, + ): + xmap = io.load(ctf_emsoft) + + # Fraction of non-indexed points + non_indexed_fraction = int(np.prod(map_shape) * 0.1) + assert non_indexed_fraction == np.sum(~xmap.is_indexed) + + # Properties + assert list(xmap.prop.keys()) == ["bands", "error", "DP", "OSM", "IQ"] + + # Coordinates + ny, nx = map_shape + dy, dx = step_sizes + assert np.allclose(xmap.x, np.tile(np.arange(nx) * dx, ny)) + assert np.allclose(xmap.y, np.sort(np.tile(np.arange(ny) * dy, nx))) + assert xmap.scan_unit == "um" + + # Map shape and size + assert xmap.shape == map_shape + assert xmap.size == np.prod(map_shape) + + # Attributes are within expected ranges or have a certain value + assert xmap.bands.max() <= 333_000 + assert np.allclose(xmap.error, 0) + assert np.allclose(xmap["not_indexed"].bands, 0) + assert np.allclose(xmap["not_indexed"].DP, 0) + assert np.allclose(xmap["not_indexed"].OSM, 0) + assert np.allclose(xmap["not_indexed"].IQ, 0) + + # Rotations + R_unique = np.unique(xmap["indexed"].rotations.to_euler(), axis=0) + assert np.allclose( + np.sort(R_unique, axis=0), np.sort(R_example, axis=0), atol=1e-3 + ) + assert np.allclose(xmap["not_indexed"].rotations.to_euler()[0], 0) + + # Phases + phases = PhaseList( + names=["Ni"], + space_groups=[225], + structures=[ + Structure(lattice=Lattice(3.524, 3.524, 3.524, 90, 90, 90)), + ], + ) + + assert all(np.isin(xmap.phase_id, [-1, 1])) + assert np.allclose(xmap["not_indexed"].phase_id, -1) + assert xmap.phases.ids == [-1, 1] + for (_, phase), (_, phase_test) in zip(xmap["indexed"].phases_in_data, phases): + assert phase.name == phase_test.name + assert phase.space_group.number == phase_test.space_group.number + assert np.allclose( + phase.structure.lattice.abcABG(), phase_test.structure.lattice.abcABG() + ) + + @pytest.mark.parametrize( + "ctf_mtex, map_shape, step_sizes, R_example", + [ + ( + ( + (5, 3), + (0.1, 0.2), + np.array( + [[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]] + ), + ), + (5, 3), + (0.1, 0.2), + np.array([[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]]), + ), + ( + ( + (8, 8), + (1.0, 1.5), + np.array( + [[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]] + ), + ), + (8, 8), + (1.0, 1.5), + np.array([[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]]), + ), + ], + indirect=["ctf_mtex"], + ) + def test_load_ctf_mtex( + self, + ctf_mtex, + map_shape, + step_sizes, + R_example, + ): + xmap = io.load(ctf_mtex) + + # Fraction of non-indexed points + non_indexed_fraction = int(np.prod(map_shape) * 0.1) + assert non_indexed_fraction == np.sum(~xmap.is_indexed) + + # Properties + assert list(xmap.prop.keys()) == ["bands", "error", "MAD", "BC", "BS"] + + # Coordinates + ny, nx = map_shape + dy, dx = step_sizes + assert np.allclose(xmap.x, np.tile(np.arange(nx) * dx, ny)) + assert np.allclose(xmap.y, np.sort(np.tile(np.arange(ny) * dy, nx))) + assert xmap.scan_unit == "um" + + # Map shape and size + assert xmap.shape == map_shape + assert xmap.size == np.prod(map_shape) + + # Attributes are within expected ranges or have a certain value + assert np.allclose(xmap.bands, 0) + assert np.allclose(xmap.error, 0) + assert np.allclose(xmap.MAD, 0) + assert np.allclose(xmap.BC, 0) + assert np.allclose(xmap.BS, 0) + + # Rotations + R_unique = np.unique(xmap["indexed"].rotations.to_euler(), axis=0) + assert np.allclose( + np.sort(R_unique, axis=0), np.sort(R_example, axis=0), atol=1e-5 + ) + assert np.allclose(xmap["not_indexed"].rotations.to_euler()[0], 0) + + # Phases + phases = PhaseList( + names=["Gold"], + point_groups=["m-3m"], + structures=[ + Structure(lattice=Lattice(4.079, 4.079, 4.079, 90, 90, 90)), + ], + ) + + assert all(np.isin(xmap.phase_id, [-1, 1])) + assert np.allclose(xmap["not_indexed"].phase_id, -1) + assert xmap.phases.ids == [-1, 1] + for (_, phase), (_, phase_test) in zip(xmap["indexed"].phases_in_data, phases): + assert phase.name == phase_test.name + assert phase.point_group.name == phase_test.point_group.name + assert np.allclose( + phase.structure.lattice.abcABG(), phase_test.structure.lattice.abcABG() + ) diff --git a/orix/tests/io/test_io.py b/orix/tests/io/test_io.py index 50df1567..12719c47 100644 --- a/orix/tests/io/test_io.py +++ b/orix/tests/io/test_io.py @@ -73,7 +73,7 @@ def test_load_no_filename_match(self): with pytest.raises(IOError, match=f"No filename matches '{fname}'."): _ = load(fname) - @pytest.mark.parametrize("temp_file_path", ["ctf"], indirect=["temp_file_path"]) + @pytest.mark.parametrize("temp_file_path", ["ktf"], indirect=["temp_file_path"]) def test_load_unsupported_format(self, temp_file_path): np.savetxt(temp_file_path, X=np.random.rand(100, 8)) with pytest.raises(IOError, match=f"Could not read "): @@ -146,11 +146,12 @@ def test_save_overwrite( assert crystal_map2.phases[0].name == expected_phase_name +# TODO: Remove after 0.13.0 def test_loadctf(): - """Crude test of the ctf loader""" z = np.random.rand(100, 8) fname = "temp.ctf" np.savetxt(fname, z) - _ = loadctf(fname) + with pytest.warns(np.VisibleDeprecationWarning): + _ = loadctf(fname) os.remove(fname) diff --git a/orix/tests/test_crystal_map.py b/orix/tests/test_crystal_map.py index 6acf9f02..a04c65d8 100644 --- a/orix/tests/test_crystal_map.py +++ b/orix/tests/test_crystal_map.py @@ -22,6 +22,7 @@ import pytest from orix.crystal_map import CrystalMap, Phase, PhaseList, create_coordinate_arrays +from orix.crystal_map.crystal_map import _data_slices_from_coordinates from orix.plot import CrystalMapPlot from orix.quaternion import Orientation, Rotation from orix.quaternion.symmetry import C2, C3, C4, O @@ -1090,6 +1091,11 @@ def test_coordinate_axes(self, crystal_map_input, expected_coordinate_axes): xmap = CrystalMap(**crystal_map_input) assert xmap._coordinate_axes == expected_coordinate_axes + def test_data_slices_from_coordinates_no_steps(self): + d, _ = create_coordinate_arrays((3, 4), step_sizes=(0.1, 0.2)) + slices = _data_slices_from_coordinates(d) + assert slices == (slice(0, 4, None), slice(0, 3, None)) + class TestCrystalMapPlotMethod: def test_plot(self, crystal_map): diff --git a/setup.cfg b/setup.cfg index 27a67e7c..f59f4c06 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,12 +8,17 @@ addopts = # Examples --ignore=examples/*/*.py doctest_optionflags = NORMALIZE_WHITESPACE +filterwarnings = + # From setuptools + ignore:Deprecated call to \`pkg_resources:DeprecationWarning + ignore:pkg_resources is deprecated as an API:DeprecationWarning [coverage:run] source = orix omit = setup.py orix/__init__.py + orix/tests/**/*.py relative_files = True [coverage:report] @@ -23,7 +28,6 @@ precision = 2 known_excludes = .* .*/** - .git/** *.code-workspace **/*.pyc **/*.nbi