Repository: pyxem/kikuchipy (language: python)

Issue:
ECPMasterPattern class and readers for ECP and TKD master patterns from EMsoft's HDF5 files
#### Description of the change
<!-- Remember to branch off the develop branch for new features and the main branch for patches. -->
* Address https://github.com/pyxem/kikuchipy/issues/386 and https://github.com/pyxem/kikuchipy/issues/390. These issues can be closed after this PR is merged.
* Move most functionality in `EBSDMasterPattern` class to an abstract `KikuchiMasterPattern` class (not to be used directly), which `EBSDMasterPattern` now is a derived class of.
* Add a new `ECPMasterPattern` class also derived from `KikuchiMasterPattern`.
* Add readers for TKD (transmission kikuchi diffraction) and ECP (electron channeling pattern) master patterns from EMsoft's HDF5 files returned from their `EMTKDmaster.f90` and `EMECPmaster.f90` programs, respectively. The TKD reader returns a `EBSDMasterPattern` instance, while the ECP reader returns a `ECPMasterPattern` instance. All three master pattern readers, EBSD, TKD, and ECP, are derived from a private, generalized `EMsoftMasterPatternReader` class which holds all of the functionality in the old EBSD master pattern class. Thanks to EMsoft's generalized approach to their HDF5 files, this required *very* little extra work!
* Update documentation and tests accordingly.

The `ECPMasterPattern` class does not have other methods than rescaling/normalization of intensities yet. That is, I decided to restrict the `EBSDMasterPattern.get_patterns()` to EBSD and TKD patterns. To project a part of an ECP master pattern onto a detector, we need to define a new `ECPDetector`. The interpolation should be the same as for EBSD/TKD patterns. For now, if it is desirable to use `get_patterns()` with an ECP master pattern, one can change the signal type from `ECPMasterPattern` to `EBSDMasterPattern` with `mp_ecp.set_signal_type("EBSDMasterPattern")`.

I decided that as of now it is not necessary to create a separate `TKDMasterPattern` class, as the `EBSDMasterPattern.get_patterns()` works nicely to project parts of the TKD kikuchi sphere onto a TKD detector (`EBSDDetector`), as long as parameters like the sample tilt are set correctly.

Note that this PR is a substantial step towards supporting dictionary indexing for TKD patterns.

@IMBalENce, I would appreciate it if you could try out my branch to:
* check that you can read your ECP patterns into an `ECPMasterPattern`.
* try to figure out which methods the `ECPMasterPattern` class should have. I haven't conducted an ECP experiment before, so I wouldn't know what would be the most useful functionality to have.

See #477 for discussing ECP functionality.

#### Progress of the PR
- [x] [Docstrings for all functions](https://github.com/numpy/numpy/blob/master/doc/example.py)
- [x] Unit tests with pytest for all lines
- [x] Clean code style by [running black via pre-commit](https://kikuchipy.org/en/latest/contributing.html#code-style)
- [ ] Find out whether we should take "combinesites" True/False into account in the EMsoft master pattern readers (can be an issue when reading the Lambert projection)
- [ ] Find out how we can support some `ECPMasterPattern.get_patterns()` functionality. Should be done in a separate PR.
- [ ] Determine whether the `KikuchiMasterPattern._is_suitable_for_projection()` method should be moved to `EBSDMasterPattern`, since it shouldn't be supported for ECP master patterns
- [ ] Add TKD and ECP master patterns to the `kikuchipy.data` module for testing and documentation
- [ ] Update reference frame user guide
- [ ] Add some metadata information read in the readers to the returned signals' `original_metadata`

#### Minimal example of the bug fix or new feature
```python
>>> import kikuchipy as kp
>>> mp_tkd = kp.load("au_mc_mp_30kv_tkd.h5", projection="lambert", energy=30)
>>> mp_tkd
<EBSDMasterPattern, title: au_mc_mp_30kv_tkd, dimensions: (|1001, 1001)>
>>> mp_ecp = kp.load("ni_mc_mp_20kv_ecp.h5", projection="stereographic", energy=20)
>>> mp_ecp
<ECPMasterPattern, title: ni_mc_mp_20kv_ecp, dimensions: (|1001, 1001)>
>>> mp_ecp.projection
'stereographic'
>>> mp_ecp.hemisphere
'north'
>>> mp_ecp.phase
<name: ni. space group: Fm-3m. point group: m-3m. proper point group: 432. color: tab:blue>
```

#### For reviewers
<!-- Don't remove the checklist below. -->
- [ ] The PR title is short, concise, and will make sense 1 year later.
- [ ] New functions are imported in corresponding `__init__.py`.
- [ ] New features, API changes, and deprecations are mentioned in the unreleased
      section in `CHANGELOG.rst`.
- [ ] New contributors are added to .all-contributorsrc and the table is regenerated.

Error loading EMsoft master pattern
Apologies for yet more questions. If you would prefer I contact you in a different manor, please do say.
Trying to load in an EMSoft master pattern and am getting the following error:

```
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-12-c452c0752d36> in <module>
      1 energy = 200
----> 2 mp = kp.load(filename = 'C:/Users/mcliqac2/Dropbox (The University of Manchester)/11 Zr MIDAS/Rhys Alex Shared/Zr-hex_TEM_200kV-master.h5')

~\Anaconda3\lib\site-packages\kikuchipy\io\_io.py in load(filename, lazy, **kwargs)
    109     # Get data and metadata (from potentially multiple signals if an h5ebsd
    110     # file)
--> 111     signal_dicts = reader.file_reader(filename, lazy=lazy, **kwargs)
    112     signals = []
    113     for signal in signal_dicts:

AttributeError: 'NoneType' object has no attribute 'file_reader'
```
Add logging
We should use logging to track what our code does. Some example usages from HyperSpy:

```python
    def normalize_poissonian_noise(self, navigation_mask=None,
                                   signal_mask=None):
        [...]        
        _logger.info(
            "Scaling the data to normalize the (presumably)"
            " Poissonian noise")
```
```python
def file_reader(filename, endianess='<', mmap_mode=None,
                lazy=False, **kwds):
    _logger.debug("Reading blockfile: %s" % filename)
    [...]
    try:
        header['Note'] = note.decode("latin1").strip("\x00")
    except BaseException:
        # Not sure about the encoding so, if it fails, we carry on
        _logger.warning(
            "Reading the Note metadata of this file failed. "
            "You can help improving "
            "HyperSpy by reporting the issue in "
            "https://github.com/hyperspy/hyperspy")
    _logger.debug("File header: " + str(header))
```

I think this tutorial is a good place to start to both understand logging and use it correctly: [https://realpython.com/python-logging/](https://realpython.com/python-logging/).


Discussion / hints:
The updated documentation can be viewed in the [docs built off of this PR by ReadTheDocs](https://kikuchipy--476.org.readthedocs.build/en/476/index.html):
* [IO user guide](https://kikuchipy--476.org.readthedocs.build/en/476/user_guide/load_save_data.html#Supported-EBSD-formats)
* [IO plugins](https://kikuchipy--476.org.readthedocs.build/en/476/reference.html#module-kikuchipy.io.plugins.emsoft_ecp_master_pattern)
* [ECPMasterPattern class](https://kikuchipy--476.org.readthedocs.build/en/476/reference.html#ecpmasterpattern)
Great! thank you for the effort. I have fetched your branch and will have a play. I'll put some more thought in the methods that we need in the `ECPMasterPattern` class.
> I'll put some more thought in the methods that we need in the `ECPMasterPattern` class.

Thanks. To keep this PR on the topic of the IO plugins only, I suggest we discuss ECP functionality in https://github.com/pyxem/kikuchipy/issues/477.
Two tests are failing due to #509, and the code style fails because of changes in the stable release of black (see https://github.com/pyxem/orix/issues/273).
No need to apoligise! In general I think it's best to discuss bugs in GitHub issues rather than email because others can participate and it might be that others encounter your problem and thus find the solution here.

The error is raised because kikuchipy can't find a suitable file reader for your file.

Since kikuchipy supports reading HDF5 files produced from various software (EDAX TSL, Bruker Nano, EMsoft's EMEBSD and master pattern files, and kikuchipy's own h5ebsd format), we try to determine which of these formats the file is in by an HDF5 group name "footprint", unique to each format. The footprint of the EMsoft master pattern HDF5 file is "emdata/ebsdmaster" (capitalisation irrelevant). Can you confirm that your file has these group names? You can either do this by opening the file in an HDF5 file viewer (HDFviewer, HDFcompass [which I use]), or via Python or any other software with HDF5 support. Reading the group names of one of my EMsoft master pattern files in Python:

```python
>>> from h5py import File
>>> f = File("/home/hakon/kode/emsoft/emdata/crystal_data/al/al_mc_mp_20kv.h5", mode="r")
>>> f.keys()
<KeysViewHDF5 ['CrystalData', 'EMData', 'EMheader', 'NMLfiles', 'NMLparameters']>
>>> f["EMData"].keys()
<KeysViewHDF5 ['EBSDmaster', 'MCOpenCL']>
```

This file was produced with EMsoft v4.1 ("4_1_0_1e9deab" specifically). Which EMsoft version was your master pattern created with? I haven't created a master pattern file in a year, it might be that the format has been updated. If so, we should definitely update the reader in kikuchipy!
Ah that's the problem, the file I have is a TKD master pattern. It might be useful if kikuchipy could also read such a file in, assuming it has the same structure as an EBSD master pattern?

```
f.keys()
>>> <KeysViewHDF5 ['CrystalData', 'EMData', 'EMheader', 'NMLfiles', 'NMLparameters']>

f["EMData"].keys()
>>> <KeysViewHDF5 ['MCfoil', 'TKDmaster']>

f["EMData"]['MCfoil'].keys()
>>> <KeysViewHDF5 ['accum_e', 'accum_e_SP', 'accum_z', 'multiplier', 'numEbins', 'numzbins', 'totnum_el']>

f["EMData"]['TKDmaster'].keys()
>>> <KeysViewHDF5 ['BetheParameters', 'EkeVs', 'lastEnergy', 'mLPNH', 'mLPSH', 'masterSPNH', 'masterSPSH', 'numEbins', 'numset', 'xtalname']>
```
I've written readers for EMsoft's TKD and ECP (electron channelig pattern) master patterns in a branch in my fork, which is currently part of an open PR #476. I wrote the readers because @IMBalENce wanted easy access to EMsoft's ECP master patterns. I might wrap it up and merge soon if there is interest, but in the meantime you can create a separate Python environment (e.g. with conda) and install the branch version (output omitted)

```bash
conda create -n kp-dev python=3.9
conda activate kp-dev
pip install git+https://github.com/hakonanes/kikuchipy.git@generalize-master-pattern-class
```

You should then be able get your TKD master pattern as an [`EBSDMasterPattern`](https://kikuchipy.org/en/stable/reference.html#ebsdmasterpattern) just like [reading an EBSD master pattern](https://kikuchipy.org/en/stable/user_guide/load_save_data.html#EMsoft-EBSD-master-pattern-HDF5).
So by entering the following when using KikuchiPy, both ours, HyperSpy's and any other package we use that uses logging will get passed to `stdout`:

```python
import logging
import kikuchipy as kp
logging.getLogger('kikuchipy').setLevel(logging.DEBUG)
# Load data, do processing etc...
```

We should, whenever we need to know what KikuchiPy is doing or it is convenient when debugging, use the following in the code:

```python
import logging
_logger = logging.getLogger(__name__)  # __name__ gives package/module
```
Much relevant information from an indexing or refinement run is lost when returning only a crystal map. The best way to store this would be to write it to some easily readable file. However, maintaining IO is challenging. An alternative is to use logging for such information, so that people who want to keep this information can at least look in the log to find it.

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 877996a9..5e11b4ac 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -18,6 +18,21 @@ Unreleased
 
 Added
 -----
+- Improved handling of custom attributes ``xmap``, ``detector`` and
+  ``static_background`` in ``EBSD`` and ``hemisphere``, ``phase`` and ``projection`` in
+  EBSD/ECP master pattern classes when calling inherited HyperSpy ``Signal2D`` methods
+  ``as_lazy()``, ``change_dtype()``, ``compute()``, ``deepcopy()``,
+  ``set_signal_type()`` and ``squeeze()``.
+  (`#564 <https://github.com/pyxem/kikuchipy/pull/564>`_)
+- Reader of an electron channelig pattern (ECP) master pattern from an EMsoft HDF5 file
+  into an ``ECPMasterPattern`` signal.
+  (`#564 <https://github.com/pyxem/kikuchipy/pull/564>`_)
+- Reader of a transmission kikuchi diffraction (TKD) master pattern from an EMsoft HDF5
+  file into an ``EBSDMasterPattern`` signal.
+  (`#564 <https://github.com/pyxem/kikuchipy/pull/564>`_)
+- ``ECPMasterPattern`` class. (`#564 <https://github.com/pyxem/kikuchipy/pull/564>`_)
+- Some internal logging which can be controlled via ``kikuchipy.set_log_level()``.
+  (`#564 <https://github.com/pyxem/kikuchipy/pull/564>`_)
 - Reader of an ``EBSD`` signal from Oxford Instrument's h5ebsd format (H5OINA).
   (`#562 <https://github.com/pyxem/kikuchipy/pull/562>`_)
 - Figures of reference frames of other software added to the documentation.
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index b7edf929..6666355a 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -214,6 +214,9 @@ notebooks into tutorials. The tutorials are located in the top source directory
 ``tutorials/``, and links to these notebooks are added using
 `nbsphinx-link <https://github.com/vidartf/nbsphinx-link>`__.
 
+Writing tutorial notebooks
+--------------------------
+
 Here are some tips for writing tutorial notebooks:
 
 - All notebooks should have a Markdown cell with this message at the top, "This
@@ -278,6 +281,14 @@ When creating a server from the kikuchipy source code, Binder installs the packa
 listed in the environment.yml configuration file, which must include all doc
 dependencies listed in setup.py necessary to run the notebooks.
 
+Writing API reference
+---------------------
+
+Inherited attributes and methods are not listed in the API reference unless they are
+explicitly coded in the inheriting class. To see an example of this behaviour, see the
+source code of :class:`~kikuchipy.signals.EBSDMasterPattern`, which inherits attributes
+and methods from a private class ``KikuchiMasterPattern``.
+
 Deprecations
 ============
 
diff --git a/doc/reference/index.rst b/doc/reference/index.rst
index cefedde0..6a0b70e2 100644
--- a/doc/reference/index.rst
+++ b/doc/reference/index.rst
@@ -35,6 +35,7 @@ this:
     :toctree: generated
 
     load
+    set_log_level
 
 .. rubric:: Modules
 
diff --git a/doc/tutorials/load_save_data.ipynb b/doc/tutorials/load_save_data.ipynb
index e26e51d9..2f946b62 100644
--- a/doc/tutorials/load_save_data.ipynb
+++ b/doc/tutorials/load_save_data.ipynb
@@ -16,21 +16,20 @@
    "source": [
     "# Load and save data\n",
     "\n",
-    "In this tutorial, we will load and save EBSD patterns, EBSD master patterns, and\n",
-    "virtual backscatter electron images from and to various formats supported in\n",
-    "kikuchipy.\n",
-    "\n",
+    "In this tutorial, we will load and save electron backscatter diffraction (EBSD) patterns, Kikuchi master patterns generated with *EMsoft* and virtual backscatter electron (VBSE) images from and to various formats supported by kikuchipy."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "## Load patterns\n",
     "\n",
     "### From a file\n",
     "\n",
-    "kikuchipy can read or write experimental EBSD patterns and EBSD master patterns\n",
-    "from or to many formats (see [supported formats](#Supported-EBSD-formats)). To\n",
-    "load patterns from a file use the\n",
-    "[load()](../reference/generated/kikuchipy.load.rst) function. Let's import the\n",
-    "necessary libraries and read the nickel EBSD example dataset directly from file\n",
-    "(not via\n",
-    "[kikuchipy.data.nickel_ebsd_small()](../reference/generated/kikuchipy.data.nickel_ebsd_small.rst))"
+    "kikuchipy can read and write experimental EBSD patterns and Kikuchi master patterns from or to many formats (see [supported formats](#Supported-EBSD-formats)).\n",
+    "To load patterns from a file use, the [load()](../reference/generated/kikuchipy.load.rst) function.\n",
+    "Let's import the necessary libraries and read the nickel EBSD example dataset directly from file (not via [kikuchipy.data.nickel_ebsd_small()](../reference/generated/kikuchipy.data.nickel_ebsd_small.rst))"
    ]
   },
   {
@@ -47,9 +46,15 @@
     "import hyperspy.api as hs\n",
     "import kikuchipy as kp\n",
     "import numpy as np\n",
-    "import matplotlib.pyplot as plt\n",
-    "\n",
-    "\n",
+    "import matplotlib.pyplot as plt"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
     "datadir = \"../../kikuchipy/data/\"\n",
     "nordif_ebsd = \"nordif/Pattern.dat\"\n",
     "s = kp.load(datadir + nordif_ebsd)\n",
@@ -60,9 +65,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Or, load the stereographic projection of the northern hemisphere of an EBSD master\n",
-    "pattern for a 20 keV beam energy from a modified version of EMsoft's master\n",
-    "pattern file, returned from their `EMEBSDmaster.f90` program"
+    "Or, load the stereographic projection of the upper hemisphere of an EBSD master pattern for a 20 keV beam energy from a modified version of *EMsoft*'s master pattern file, returned from their `EMEBSDmaster.f90` program"
    ]
   },
   {
@@ -80,14 +83,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Both the stereographic and the square Lambert projections of this master pattern\n",
-    "data is available via\n",
-    "[kikuchipy.data.nickel_ebsd_master_pattern_small()](../reference/generated/kikuchipy.data.nickel_ebsd_master_pattern_small.rst).\n",
+    "Both the stereographic and the square Lambert projections of this master pattern data is available via [kikuchipy.data.nickel_ebsd_master_pattern_small()](../reference/generated/kikuchipy.data.nickel_ebsd_master_pattern_small.rst).\n",
     "\n",
-    "All file readers support accessing the data's metadata without loading it into\n",
-    "memory (with the [Dask library](https://docs.dask.org/en/latest)), which can be\n",
-    "useful when processing large data sets, one data chunk at a time, to avoid memory\n",
-    "errors"
+    "All file readers support accessing the data's metadata without loading it into memory (with the [Dask library](https://docs.dask.org/en/latest)), which can be useful when processing large data sets, one data chunk at a time, to avoid memory errors"
    ]
   },
   {
@@ -105,8 +103,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Parts or all of the data can be read into memory by calling\n",
-    "[compute()](http://hyperspy.org/hyperspy-doc/current/api/hyperspy._signals.lazy.html#hyperspy._signals.lazy.LazySignal.compute)"
+    "Parts or all of the data can be read into memory by calling [compute()](http://hyperspy.org/hyperspy-doc/current/api/hyperspy._signals.lazy.html#hyperspy._signals.lazy.LazySignal.compute)"
    ]
   },
   {
@@ -138,11 +135,8 @@
     "\n",
     "Note\n",
     "\n",
-    "When lazily loaded EBSD patterns are processed, they are processed chunk by\n",
-    "chunk, which can lead to longer processing times, so processing lazy\n",
-    "data sets should be done with some care. See the relevant\n",
-    "[HyperSpy user guide](http://hyperspy.org/hyperspy-doc/current/user_guide/big_data.html)\n",
-    "for some tips.\n",
+    "When lazily loaded EBSD patterns are processed, they are processed chunk by chunk, which can lead to longer processing times, so processing lazy data sets should be done with some care.\n",
+    "See the relevant [HyperSpy user guide](http://hyperspy.org/hyperspy-doc/current/user_guide/big_data.html) for some tips.\n",
     "\n",
     "</div>"
    ]
@@ -151,8 +145,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Data can be visualized by navigating the navigation space, showing the signal\n",
-    "in each navigation point"
+    "Data can be visualized by navigating the navigation space, showing the signal in each navigation point"
    ]
   },
   {
@@ -168,8 +161,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Upon loading, kikuchipy tries to read all scan information from the file and\n",
-    "store it in the `original_metadata` attribute"
+    "Upon loading, kikuchipy reads some scan information from the file and stores it in the `original_metadata` attribute"
    ]
   },
   {
@@ -185,8 +177,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Some information may also be stored in a standard location in the `metadata`\n",
-    "attribute; but these are not used by EBSD class methods"
+    "Other information is stored in a standard location in the `metadata` attribute in fields specified in the [HyperSpy metadata structure](https://hyperspy.org/hyperspy-doc/current/user_guide/metadata_structure.html); these are not used by methods in kikuchipy"
    ]
   },
   {
@@ -202,9 +193,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "The number of patterns in horizontal and vertical direction, pattern size in\n",
-    "pixels, scan step size and detector pixel size is stored in the `axes_manager`\n",
-    "attribute:"
+    "The number of patterns in horizontal and vertical direction, pattern size in pixels, scan step size and detector pixel size is stored in the `axes_manager` attribute"
    ]
   },
   {
@@ -220,25 +209,14 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "This information can be modified directly, and information in `metadata` and\n",
-    "`axes_manager` can be modified by the\n",
-    "[EBSD](../reference/generated/kikuchipy.signals.EBSD.rst) class methods\n",
-    "[set_scan_calibration()](../reference/generated/kikuchipy.signals.EBSD.set_scan_calibration.rst) and\n",
-    "[set_detector_calibration()](../reference/generated/kikuchipy.signals.EBSD.set_detector_calibration.rst).\n",
+    "This information can be modified directly, and information in `metadata` and `axes_manager` can be modified by the [EBSD](../reference/generated/kikuchipy.signals.EBSD.rst) class methods [set_scan_calibration()](../reference/generated/kikuchipy.signals.EBSD.set_scan_calibration.rst) and [set_detector_calibration()](../reference/generated/kikuchipy.signals.EBSD.set_detector_calibration.rst).\n",
     "\n",
-    "In addition to the `metadata`, `original_metadata` and `axes_manager` properties,\n",
-    "kikuchipy tries to read a\n",
-    "[CrystalMap](https://orix.readthedocs.io/en/stable/reference.html#crystalmap) instance\n",
-    "with indexing results into the\n",
-    "[xmap](../reference/generated/kikuchipy.signals.EBSD.xmap.rst) attribute, an\n",
-    "[EBSDDetector](../reference/generated/kikuchipy.detectors.EBSDDetector.rst)\n",
-    "instance into the\n",
-    "[detector](../reference/generated/kikuchipy.signals.EBSD.detector.rst) attribute,\n",
-    "and a static background into the\n",
-    "[static_background](../reference/generated/kikuchipy.signals.EBSD.static_background.rst)\n",
-    "attribute. The `xmap` attribute is `None` if it is not read. Some readers might\n",
-    "fill it with an identity rotation per point and the same phase ID (of a `None`\n",
-    "phase for each point)."
+    "In addition to the `metadata`, `original_metadata` and `axes_manager` properties, kikuchipy tries to read the following from the file if available:\n",
+    "- a [CrystalMap](https://orix.readthedocs.io/en/stable/reference.html#crystalmap) with indexing results into the [xmap](../reference/generated/kikuchipy.signals.EBSD.xmap.rst) attribute\n",
+    "- an [EBSDDetector](../reference/generated/kikuchipy.detectors.EBSDDetector.rst) describing the projection/pattern center (PC) values into the [detector](../reference/generated/kikuchipy.signals.EBSD.detector.rst) attribute\n",
+    "- a static background into the [static_background](../reference/generated/kikuchipy.signals.EBSD.static_background.rst) attribute.\n",
+    "\n",
+    "The `xmap` attribute is `None` if it is not read, while some readers returns an \"empty\" crystal map with points containing the identity rotation and the same phase ID of an undefined phase with no point group set."
    ]
   },
   {
@@ -250,6 +228,24 @@
     "s.xmap  # This is None unless it is set or read"
    ]
   },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "s.xmap.rotations"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "s.xmap.phase_id"
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
@@ -259,6 +255,15 @@
     "s.detector"
    ]
   },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "s.detector.pc"
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
@@ -276,13 +281,10 @@
     "\n",
     "Warning\n",
     "\n",
-    "EBSD methods inherited from HyperSpy that changes the pattern array in any way,\n",
-    "like cropping the navigation shape with `EBSD.inav`, will not crop the\n",
-    "`CrystalMap` stored in the `xmap` attribute, the `EBSDDetector` stored in the\n",
-    "`detector` attribute, or the `static_background` attribute correctly. Remember\n",
-    "for example to crop the background pattern accordingly before calling\n",
-    "`EBSD.remove_static_background()` after `EBSD.isig` is used to crop the\n",
-    "patterns.\n",
+    "The `EBSD` class inherits all methods from HyperSpy's [`Signal2D`](https://hyperspy.org/hyperspy-doc/current/api/hyperspy._signals.signal2d.html#hyperspy._signals.signal2d.Signal2D) class, often without overwriting them.\n",
+    "This means that calling some methods will not carry over the above mentioned attributes of `xmap`, `detector` and `static_background`, like cropping the navigation shape with `EBSD.inav` or the signal shape with `EBSD.isig`.\n",
+    "Furtermore, these methods will neither crop the crystal map, detector or static background accordingly.\n",
+    "Remember for example to crop the background pattern accordingly before calling `EBSD.remove_static_background()` after `EBSD.isig` is used to crop the patterns.\n",
     "\n",
     "</div>"
    ]
@@ -293,10 +295,8 @@
    "source": [
     "### From a NumPy array\n",
     "\n",
-    "An `EBSD` or `EBSDMasterPattern` signal can also be created directly from a\n",
-    "`numpy.ndarray`. To create a data set of (60 x 60) pixel patterns in a\n",
-    "(10 x 20) grid, i.e. 10 and 20 patterns in the horizontal and vertical scan\n",
-    "directions respectively, of random intensities"
+    "An `EBSD`, [EBSDMasterPattern](../reference/generated/kikuchipy.signals.EBSDMasterPattern.rst) or [ECPMasterPattern](../reference/generated/kikuchipy.signals.ECPMasterPattern.rst) (electron channeling pattern) signal can be created directly from a NumPy array.\n",
+    "To create a data set of (60 x 60) pixel patterns in a (10 x 20) grid, i.e. 10 and 20 patterns in the horizontal and vertical scan directions respectively, of random intensities"
    ]
   },
   {
@@ -315,10 +315,8 @@
    "source": [
     "### From a Dask array\n",
     "\n",
-    "When processing large data sets, it is useful to load data lazily with the\n",
-    "Dask library. This can be done upon reading patterns [from a file](#From-a-file)\n",
-    "by setting `lazy=True` when using the `load()` function, or directly from a\n",
-    "`dask.array.Array`"
+    "When processing large data sets, it is useful to load data lazily with the Dask library.\n",
+    "This can be done upon reading patterns [from a file](#From-a-file) by setting `lazy=True` when using the `load()` function, or directly from a `dask.array.Array`"
    ]
   },
   {
@@ -338,14 +336,8 @@
    "source": [
     "### From a HyperSpy signal\n",
     "\n",
-    "HyperSpy provides the method\n",
-    "[set_signal_type()](http://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal.set_signal_type)\n",
-    "to change between [BaseSignal](http://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal) subclasses, of which\n",
-    "`EBSD`, `EBSDMasterPattern` and\n",
-    "[VirtualBSEImage](../reference/generated/kikuchipy.signals.VirtualBSEImage.rst)\n",
-    "are three. To get one of these objects from a\n",
-    "[HyperSpy Signal2D](http://hyperspy.org/hyperspy-doc/current/api/hyperspy._signals.signal2d.html?highlight=signal2d#hyperspy._signals.signal2d.Signal2D)\n",
-    "object"
+    "HyperSpy provides the method [set_signal_type()](https://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal.set_signal_type) to change between [BaseSignal](https://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal) subclasses, of which `EBSD`, `EBSDMasterPattern`, `ECPMasterPattern` and [VirtualBSEImage](../reference/generated/kikuchipy.signals.VirtualBSEImage.rst) are three.\n",
+    "To get one of these signals from a [HyperSpy Signal2D](https://hyperspy.org/hyperspy-doc/current/api/hyperspy._signals.signal2d.html#hyperspy._signals.signal2d.Signal2D) signal"
    ]
   },
   {
@@ -364,28 +356,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "s_hs.set_signal_type(\"EBSD\")\n",
-    "s_hs"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "s_hs.set_signal_type(\"VirtualBSEImage\")\n",
-    "s_hs"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "s_hs.set_signal_type(\"EBSDMasterPattern\")\n",
-    "s_hs"
+    "for signal_type in [\"EBSD\", \"VirtualBSEImage\", \"ECPMasterPattern\", \"EBSDMasterPattern\"]:\n",
+    "    s_hs.set_signal_type(signal_type)\n",
+    "    print(s_hs)"
    ]
   },
   {
@@ -394,10 +367,8 @@
    "source": [
     "## Save patterns\n",
     "\n",
-    "To save experimental EBSD patterns to file use the\n",
-    "[save()](../reference/generated/kikuchipy.signals.EBSD.save.rst) method. For\n",
-    "example, to save an `EBSD` signal in an HDF5 file, with file name `patterns.h5`,\n",
-    "in our default [h5ebsd format](#h5ebsd)"
+    "To save signals to file, use the [save()](../reference/generated/kikuchipy.signals.EBSD.save.rst) method.\n",
+    "For example, to save an `EBSD` signal in an HDF5 file, with file name `patterns.h5`, in our default [h5ebsd format](#kikuchipy-h5ebsd)"
    ]
   },
   {
@@ -431,7 +402,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "If we want to save patterns in NORDIF's binary .dat format instead"
+    "If we want to save patterns in NORDIF's binary `.dat` format instead"
    ]
   },
   {
@@ -447,8 +418,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "To save an `EBSDMasterPattern` to an HDF5 file, we use the [save method inherited from HyperSpy](http://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal.save)\n",
-    "to write to [their HDF5 specification](http://hyperspy.org/hyperspy-doc/current/user_guide/io.html#hspy-hyperspy-s-hdf5-specification)"
+    "To save an `EBSDMasterPattern` to an HDF5 file, we use the [save method inherited from HyperSpy](https://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal.save) to write to their [HDF5 specification](https://hyperspy.org/hyperspy-doc/current/user_guide/io.html#hspy-hyperspy-s-hdf5-specification) or [zarr specification](https://hyperspy.org/hyperspy-doc/current/user_guide/io.html#zspy-hyperspy-s-zarr-specification)"
    ]
   },
   {
@@ -465,9 +435,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "These master patterns can then be read into an `EBSDMasterPattern` signal again\n",
-    "via HyperSpy's\n",
-    "[load()](http://hyperspy.org/hyperspy-doc/current/api/hyperspy.io.html#hyperspy.io.load)"
+    "These master patterns can then be read into an `EBSDMasterPattern` signal again via HyperSpy's [load()](http://hyperspy.org/hyperspy-doc/current/api/hyperspy.io.html#hyperspy.io.load)"
    ]
   },
   {
@@ -488,15 +456,9 @@
     "\n",
     "Note\n",
     "\n",
-    "To save results from statistical decomposition (machine learning) of patterns to\n",
-    "file see the section\n",
-    "[Saving and loading results](http://hyperspy.org/hyperspy-doc/current/user_guide/mva.html#saving-and-loading-results)\n",
-    "in HyperSpy's user guide. Note that the file extension `.hspy` must be used upon\n",
-    "saving, `s.save('patterns.hspy')`, as the default extension in kikuchipy, `.h5`,\n",
-    "yields a kikuchipy h5ebsd file where the decomposition results aren't saved. The\n",
-    "saved patterns can then be reloaded using HyperSpy's `load()` function and\n",
-    "passing the `signal_type=\"EBSD\"` parameter\n",
-    "[as explained above](#From-a-HyperSpy-signal).\n",
+    "To save results from statistical decomposition (machine learning) of patterns to file see the section [Saving and loading results](https://hyperspy.org/hyperspy-doc/current/user_guide/mva.html#saving-and-loading-results) in HyperSpy's user guide.\n",
+    "Note that the file extension `.hspy` or `.zspy` must be used upon saving, `s.save('patterns.hspy')`, as the default extension in kikuchipy, `.h5`, yields a kikuchipy h5ebsd file where the decomposition results aren't saved.\n",
+    "The saved patterns can then be reloaded using HyperSpy's `load()` function passing the `signal_type=\"EBSD\"` parameter [as explained above](#From-a-HyperSpy-signal).\n",
     "\n",
     "</div>"
    ]
@@ -509,26 +471,25 @@
     "\n",
     "Currently, kikuchipy has readers and writers for the following formats:\n",
     "\n",
-    "| Format                                                                     | Read | Write |\n",
-    "| -------------------------------------------------------------------------- | ---- | ----- |\n",
-    "| [EMsoft simulated EBSD HDF5](#EMsoft-simulated-EBSD-HDF5)                  | Yes  | No    |\n",
-    "| [EMsoft EBSD master pattern HDF5](#EMsoft-EBSD-master-pattern-HDF5)        | Yes  | No    |\n",
-    "| [kikuchipy h5ebsd](#kikuchipy-h5ebsd)                                      | Yes  | Yes   |\n",
-    "| [Bruker h5ebsd](#Bruker-h5ebsd)                                            | Yes  | No    |\n",
-    "| [EDAX h5ebsd](#EDAX-h5ebsd)                                                | Yes  | No    |\n",
-    "| [NORDIF binary](#NORDIF-binary)                                            | Yes  | Yes   |\n",
-    "| [NORDIF calibration patterns](#NORDIF-calibration-patterns)                | Yes  | No    |\n",
-    "| [Oxford Instruments binary](#Oxford-Instruments-binary)                    | Yes  | No    |\n",
-    "| [Oxford Instruments h5ebsd (H5OINA)](#Oxford-Instruments-h5ebsd-H5OINA)    | Yes  | No    |\n",
+    "| Format                                                                  | Read | Write |\n",
+    "| ------------------------------------------------------------------------| ---- | ----- |\n",
+    "| [EMsoft simulated EBSD HDF5](#EMsoft-simulated-EBSD-HDF5)               | Yes  | No    |\n",
+    "| [EMsoft EBSD master pattern HDF5](#EMsoft-EBSD-master-pattern-HDF5)     | Yes  | No    |\n",
+    "| [EMsoft ECP master pattern HDF5](#EMsoft-ECP-master-pattern-HDF5)       | Yes  | No    |\n",
+    "| [EMsoft TKD master pattern HDF5](#EMsoft-TKD-master-pattern-HDF5)       | Yes  | No    |\n",
+    "| [kikuchipy h5ebsd](#kikuchipy-h5ebsd)                                   | Yes  | Yes   |\n",
+    "| [Bruker h5ebsd](#Bruker-h5ebsd)                                         | Yes  | No    |\n",
+    "| [EDAX h5ebsd](#EDAX-h5ebsd)                                             | Yes  | No    |\n",
+    "| [NORDIF binary](#NORDIF-binary)                                         | Yes  | Yes   |\n",
+    "| [NORDIF calibration patterns](#NORDIF-calibration-patterns)             | Yes  | No    |\n",
+    "| [Oxford Instruments binary](#Oxford-Instruments-binary)                 | Yes  | No    |\n",
+    "| [Oxford Instruments h5ebsd (H5OINA)](#Oxford-Instruments-h5ebsd-H5OINA) | Yes  | No    |\n",
     "\n",
     "<div class=\"alert alert-info\">\n",
     "\n",
     "Note\n",
     "\n",
-    "If you want to process your patterns with kikuchipy, but use an unsupported EBSD\n",
-    "vendor software, or if you want to write your processed patterns to a vendor\n",
-    "format that does not support writing, please request this feature in our\n",
-    "[issue tracker](https://github.com/pyxem/kikuchipy/issues).\n",
+    "If you want to process your patterns with kikuchipy, but use an unsupported EBSD vendor software, or if you want to write your processed patterns to a vendor format that does not support writing, please request this feature in our [issue tracker](https://github.com/pyxem/kikuchipy/issues).\n",
     "\n",
     "</div>"
    ]
@@ -539,8 +500,7 @@
    "source": [
     "### EMsoft simulated EBSD HDF5\n",
     "\n",
-    "Dynamically simulated EBSD patterns returned by EMsoft's `EMEBSD.f90` program\n",
-    "as HDF5 files can be read as an `EBSD` signal"
+    "Dynamically simulated EBSD patterns returned by EMsoft's `EMEBSD.f90` program as HDF5 files can be read as an `EBSD` signal"
    ]
   },
   {
@@ -558,11 +518,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the EMsoft simulated EBSD\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.emsoft_ebsd.file_reader.rst)\n",
-    "is called, which takes the optional argument `scan_size`. Passing\n",
-    "`scan_size=(2, 5)` will reshape the pattern data shape from `(10, 10, 10)` to\n",
-    "`(2, 5, 10, 10)`"
+    "Here, the EMsoft simulated EBSD [file_reader()](../reference/generated/kikuchipy.io.plugins.emsoft_ebsd.file_reader.rst) is called, which takes the optional argument `scan_size`.\n",
+    "Passing `scan_size=(2, 5)` will reshape the pattern data shape from `(10, 10, 10)` to `(2, 5, 10, 10)`"
    ]
   },
   {
@@ -580,10 +537,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Simulated EBSD patterns can be written to the\n",
-    "[kikuchipy h5ebsd format](#h5ebsd), the [NORDIF binary format](#NORDIF-binary),\n",
-    "or to HDF5 files using HyperSpy's HDF5 specification\n",
-    "[as explained above](#Save-patterns)."
+    "Simulated EBSD patterns can be written to the [kikuchipy h5ebsd format](#kikuchipy-h5ebsd), the [NORDIF binary format](#NORDIF-binary), or to HDF5/zarr files using HyperSpy's HDF5/zarr specifications [as explained above](#Save-patterns)."
    ]
   },
   {
@@ -592,8 +546,7 @@
    "source": [
     "### EMsoft EBSD master pattern HDF5\n",
     "\n",
-    "Master patterns returned by EMsoft's `EMEBSDmaster.f90` program as HDF5 files\n",
-    "can be read as an `EBSDMasterPattern` signal"
+    "Master patterns returned by EMsoft's `EMEBSDmaster.f90` program as HDF5 files can be read as an [`EBSDMasterPattern`](../reference/generated/kikuchipy.signals.EBSDMasterPattern.rst) signal"
    ]
   },
   {
@@ -614,16 +567,13 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the EMsoft EBSD master pattern\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.emsoft_ebsd_master_pattern.file_reader.rst)\n",
-    "is called, which takes the optional arguments `projection`, `hemisphere` and\n",
-    "`energy`. The stereographic projection is read by default. Passing\n",
-    "`projection=\"lambert\"` will read the square Lambert projection instead. The\n",
-    "northern hemisphere is read by default. Passing `hemisphere=\"south\"` or\n",
-    "`hemisphere=\"both\"` will read the southern hemisphere projection or both,\n",
-    "respectively. Master patterns for all beam energies are read by default. Passing\n",
-    "`energy=(10, 20)` or `energy=15` will read the master pattern(s) with beam\n",
-    "energies from 10 to 20 keV, or just 15 keV, respectively"
+    "Here, the EMsoft EBSD master pattern [file_reader()](../reference/generated/kikuchipy.io.plugins.emsoft_ebsd_master_pattern.file_reader.rst) is called, which takes the optional arguments `projection`, `hemisphere` and `energy`.\n",
+    "The stereographic projection is read by default.\n",
+    "Passing `projection=\"lambert\"` will read the square Lambert projection instead.\n",
+    "The upper hemisphere is read by default.\n",
+    "Passing `hemisphere=\"lower\"` or `hemisphere=\"both\"` will read the lower hemisphere projection or both, respectively.\n",
+    "Master patterns for all beam energies are read by default.\n",
+    "Passing `energy=(10, 20)` or `energy=15` will read the master pattern(s) with beam energies from 10 to 20 keV, or just 15 keV, respectively"
    ]
   },
   {
@@ -645,13 +595,71 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Master patterns can be written to HDF5 files using HyperSpy's HDF5 specification\n",
-    "[as explained above](#Save-patterns).\n",
+    "Master patterns can be written to HDF5/zarr files using HyperSpy's HDF5/zarr specification [as explained above](#Save-patterns).\n",
+    "\n",
+    "See <cite data-cite=\"jackson2019dictionary\">Jackson et al. (2019)</cite> for a hands-on tutorial explaining how to simulate these patterns with EMsoft, and <cite data-cite=\"callahan2013dynamical\">Callahan & De Graef (2013)</cite> for details of the underlying theory."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### EMsoft ECP master pattern HDF5\n",
+    "\n",
+    "Master patterns returned by EMsoft's `EMECPmaster.f90` program as HDF5 files can be read as an [`ECPMasterPattern`](../reference/generated/kikuchipy.signals.ECPMasterPattern.rst) signal"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Load a dummy pattern\n",
+    "s_mp = kp.load(filename=datadir + \"emsoft_ecp_master_pattern/ecp_master_pattern.h5\")\n",
+    "\n",
+    "print(s_mp)\n",
+    "print(s_mp.projection)\n",
+    "print(s_mp.hemisphere)\n",
+    "print(s_mp.phase)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Here, the EMsoft ECP master pattern [file_reader()](../reference/generated/kikuchipy.io.plugins.emsoft_ecp_master_pattern.file_reader.rst) is called, which supports the same parameters as the [EMsoft EBSD master pattern reader](#EMsoft-EBSD-master-pattern-HDF5)."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### EMsoft TKD master pattern HDF5\n",
+    "\n",
+    "Master patterns returned by EMsoft's `EMTKDmaster.f90` program as HDF5 files can be read as an [`EBSDMasterPattern`](../reference/generated/kikuchipy.signals.EBSDMasterPattern.rst) signal"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Load a dummy pattern\n",
+    "s_mp = kp.load(filename=datadir + \"emsoft_tkd_master_pattern/tkd_master_pattern.h5\")\n",
     "\n",
-    "See <cite data-cite=\"jackson2019dictionary\">Jackson et al. (2019)</cite> for a\n",
-    "hands-on tutorial explaining how to simulate these patterns with EMsoft, and\n",
-    "<cite data-cite=\"callahan2013dynamical\">Callahan & De Graef (2013)</cite> for\n",
-    "details of the underlying theory."
+    "print(s_mp)\n",
+    "print(s_mp.projection)\n",
+    "print(s_mp.hemisphere)\n",
+    "print(s_mp.phase)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Here, the EMsoft TKD master pattern [file_reader()](../reference/generated/kikuchipy.io.plugins.emsoft_tkd_master_pattern.file_reader.rst) is called, which supports the same parameters as the [EMsoft EBSD master pattern reader](#EMsoft-EBSD-master-pattern-HDF5)."
    ]
   },
   {
@@ -660,19 +668,13 @@
    "source": [
     "### kikuchipy h5ebsd\n",
     "\n",
-    "The h5ebsd format\n",
-    "<cite data-cite=\"jackson2014h5ebsd\">Jackson et al. (2014)</cite> is based on the\n",
-    "[HDF5 open standard](https://www.hdfgroup.org/solutions/hdf5/) (Hierarchical Data Format\n",
-    "version 5). HDF5 files can be read and edited using e.g. the HDF Group's reader\n",
-    "[HDFView](https://www.hdfgroup.org/downloads/hdfview) or the Python package\n",
-    "[h5py](https://docs.h5py.org/en/stable). Upon loading an HDF5 file with\n",
-    "extension .h5, .hdf5, or .h5ebsd, the correct reader is determined from the\n",
-    "file. Other supported h5ebsd formats are listed in the\n",
-    "[table above](#Supported-EBSD-formats).\n",
+    "The h5ebsd format <cite data-cite=\"jackson2014h5ebsd\">Jackson et al. (2014)</cite> is based on the [HDF5 open standard](https://www.hdfgroup.org/solutions/hdf5/) (Hierarchical Data Format version 5).\n",
+    "HDF5 files can be read and edited using e.g. the HDF Group's reader [HDFView](https://www.hdfgroup.org/downloads/hdfview) or the Python package [h5py](https://docs.h5py.org/en/stable).\n",
+    "Upon loading an HDF5 file with extension .h5, .hdf5, or .h5ebsd, the correct reader is determined from the file.\n",
+    "Other supported h5ebsd formats are listed in the [table above](#Supported-EBSD-formats).\n",
     "\n",
-    "If an h5ebsd file contains multiple scans, as many scans as desirable can be\n",
-    "read from the file. For example, if the file contains two scans with names\n",
-    "``My awes0m4 Xcan #! with a long title`` and ``Scan 2``"
+    "If an h5ebsd file contains multiple scans, as many scans as desirable can be read from the file.\n",
+    "For example, if the file contains two scans with names ``My awes0m4 Xcan #! with a long title`` and ``Scan 2``"
    ]
   },
   {
@@ -694,10 +696,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the h5ebsd\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.kikuchipy_h5ebsd.file_reader.rst)\n",
-    "is called. If only `Scan 2` is to be read, `scan_group_names=\"Scan 2\"` can be\n",
-    "passed"
+    "Here, the h5ebsd [file_reader()](../reference/generated/kikuchipy.io.plugins.kikuchipy_h5ebsd.file_reader.rst) is called.\n",
+    "If only `Scan 2` is to be read, `scan_group_names=\"Scan 2\"` can be passed"
    ]
   },
   {
@@ -714,14 +714,10 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "The `scan_group_names` parameter is unnecessary if only the first scan in the\n",
-    "file is to be read, since reading only the first scan in the file is the default\n",
-    "behaviour.\n",
+    "The `scan_group_names` parameter is unnecessary if only the first scan in the file is to be read, since reading only the first scan in the file is the default behaviour.\n",
     "\n",
-    "So far, only [saving patterns](#Save-patterns) to kikuchipy's own h5ebsd format\n",
-    "is supported. It is possible to write a new scan with a scan name `Scan x`,\n",
-    "where `x` is an integer, to an existing, but closed, h5ebsd file in the\n",
-    "kikuchipy format, e.g. one containing only `Scan 1`, by passing"
+    "So far, only [saving patterns](#Save-patterns) to kikuchipy's own h5ebsd format is supported.\n",
+    "It is possible to write a new scan with a scan name `Scan x`, where `x` is an integer, to an existing, but closed, h5ebsd file in the kikuchipy format, e.g. one containing only `Scan 1`, by passing"
    ]
   },
   {
@@ -745,11 +741,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the h5ebsd\n",
-    "[file_writer()](../reference/generated/kikuchipy.io.plugins.kikuchipy_h5ebsd.file_writer.rst)\n",
-    "is called. The EBSD class attributes `xmap` (crystal map), `detector` (EBSD\n",
-    "detector) and `static_background` are written to and read from file if\n",
-    "available."
+    "Here, the h5ebsd [file_writer()](../reference/generated/kikuchipy.io.plugins.kikuchipy_h5ebsd.file_writer.rst) is called.\n",
+    "The EBSD class attributes `xmap` (crystal map), `detector` (EBSD detector) and `static_background` are written to and read from file if available."
    ]
   },
   {
@@ -759,16 +752,11 @@
     "### Bruker h5ebsd\n",
     "\n",
     "Bruker Nano's h5ebsd files with extension .h5, .hdf5, or .h5ebsd can be read.\n",
-    "Available parameters when calling `kikuchipy.load(\"bruker_patterns.h5\")` are\n",
-    "described in the\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.bruker_h5ebsd.file_reader.rst).\n",
+    "Available parameters when calling `kikuchipy.load(\"bruker_patterns.h5\")` are described in the [file_reader()](../reference/generated/kikuchipy.io.plugins.bruker_h5ebsd.file_reader.rst).\n",
     "As with the kikuchipy h5ebsd reader, multiple scans can be read at once.\n",
     "\n",
-    "The projection center (PC) arrays are read into the `detector` attribute, and\n",
-    "the static background pattern is read into the `static_background` attribute.\n",
-    "The orientation and phase data are so far not read, but note that this can be\n",
-    "read using\n",
-    "[orix](https://orix.readthedocs.io/en/stable/reference.html#module-orix.io.plugins.bruker_h5ebsd)."
+    "The projection center (PC) arrays are read into the `detector` attribute, and the static background pattern is read into the `static_background` attribute.\n",
+    "The orientation and phase data are so far not read, but note that this can be read using [orix](https://orix.readthedocs.io/en/stable/reference.html#module-orix.io.plugins.bruker_h5ebsd)."
    ]
   },
   {
@@ -778,13 +766,11 @@
     "### EDAX h5ebsd\n",
     "\n",
     "EDAX's h5ebsd files with extension .h5, .hdf5, or .h5ebsd can be read.\n",
-    "Available parameters when calling `kikuchipy.load(\"edax_patterns.h5\")` are\n",
-    "described in the\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.edax_h5ebsd.file_reader.rst).\n",
+    "Available parameters when calling `kikuchipy.load(\"edax_patterns.h5\")` are described in the [file_reader()](../reference/generated/kikuchipy.io.plugins.edax_h5ebsd.file_reader.rst).\n",
     "As with the kikuchipy h5ebsd reader, multiple scans can be read at once.\n",
     "\n",
-    "The projection center (PC) arrays are read into the `detector` attribute. The\n",
-    "orientation and phase data are so far not read."
+    "The projection center (PC) arrays are read into the `detector` attribute.\n",
+    "The orientation and phase data are so far not read."
    ]
   },
   {
@@ -793,12 +779,10 @@
    "source": [
     "### NORDIF binary\n",
     "\n",
-    "Patterns acquired using NORDIF's acquisition software are stored in a binary\n",
-    "file usually named \"Pattern.dat\". Scan information is stored in a separate text\n",
-    "file usually named \"Setting.txt\", and both files usually reside in the same\n",
-    "directory. If this is the case, the patterns can be loaded by passing the file\n",
-    "name as the only parameter. If this is not the case, the setting file can be\n",
-    "passed upon loading"
+    "Patterns acquired using NORDIF's acquisition software are stored in a binary file usually named \"Pattern.dat\".\n",
+    "Scan information is stored in a separate text file usually named \"Setting.txt\", and both files usually reside in the same directory.\n",
+    "If this is the case, the patterns can be loaded by passing the file name as the only parameter.\n",
+    "If this is not the case, the setting file can be passed upon loading"
    ]
   },
   {
@@ -817,11 +801,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the NORDIF\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.nordif.file_reader.rst)\n",
-    "is called. If the scan information, i.e. scan and pattern size, in the setting\n",
-    "file is incorrect or the setting file is not available, patterns can be loaded\n",
-    "by passing"
+    "Here, the NORDIF [file_reader()](../reference/generated/kikuchipy.io.plugins.nordif.file_reader.rst) is called.\n",
+    "If the scan information, i.e. scan and pattern size, in the setting file is incorrect or the setting file is not available, patterns can be loaded by passing"
    ]
   },
   {
@@ -840,15 +821,10 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "If a static background pattern named \"Background acquisition pattern.bmp\" is\n",
-    "stored in the same directory as the pattern file, this is stored in\n",
-    "`EBSD.static_background` upon loading.\n",
+    "If a static background pattern named \"Background acquisition pattern.bmp\" is stored in the same directory as the pattern file, this is stored in `EBSD.static_background` upon loading.\n",
     "\n",
-    "Patterns can also be [saved to a NORDIF binary file](#Save-patterns), upon\n",
-    "which the NORDIF\n",
-    "[file_writer()](../reference/generated/kikuchipy.io.plugins.nordif.file_writer.rst)\n",
-    "is called. Note, however, that so far no new setting file, background pattern,\n",
-    "or calibration patterns are created upon saving."
+    "Patterns can also be [saved to a NORDIF binary file](#Save-patterns), upon which the NORDIF [file_writer()](../reference/generated/kikuchipy.io.plugins.nordif.file_writer.rst) is called.\n",
+    "Note, however, that so far no new setting file, background pattern, or calibration patterns are created upon saving."
    ]
   },
   {
@@ -857,9 +833,7 @@
    "source": [
     "### NORDIF calibration patterns\n",
     "\n",
-    "NORDIF calibration patterns in bitmap format named \"Calibration (x,y).bmp\",\n",
-    "where \"x\" and \"y\" correspond to coordinates listed in the NORDIF setting file,\n",
-    "usually named \"Setting.txt\", can be loaded"
+    "NORDIF calibration patterns in bitmap format named \"Calibration (x,y).bmp\", where \"x\" and \"y\" correspond to coordinates listed in the NORDIF setting file, usually named \"Setting.txt\", can be loaded"
    ]
   },
   {
@@ -876,14 +850,10 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the NORDIF calibration patterns\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.nordif_calibration_patterns.file_reader.rst) \n",
-    "is called. Lazy loading is not supported for this reader, thus the `lazy`\n",
-    "parameter is not used.\n",
+    "Here, the NORDIF calibration patterns [file_reader()](../reference/generated/kikuchipy.io.plugins.nordif_calibration_patterns.file_reader.rst)  is called.\n",
+    "Lazy loading is not supported for this reader, thus the `lazy` parameter is not used.\n",
     "\n",
-    "If a static background pattern named \"Background calibration pattern.bmp\" is\n",
-    "stored in the same directory as the pattern file, this is stored in\n",
-    "`EBSD.static_background` upon loading."
+    "If a static background pattern named \"Background calibration pattern.bmp\" is stored in the same directory as the pattern file, this is stored in `EBSD.static_background` upon loading."
    ]
   },
   {
@@ -892,8 +862,7 @@
    "source": [
     "### Oxford Instruments binary\n",
     "\n",
-    "Uncompressed patterns stored in the Oxford Instruments binary .ebsp file format,\n",
-    "with intensities as 8-bit or 16-bit unsigned integer, can be read"
+    "Uncompressed patterns stored in the Oxford Instruments binary .ebsp file format, with intensities as 8-bit or 16-bit unsigned integer, can be read"
    ]
   },
   {
@@ -911,17 +880,11 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the Oxford Instruments binary\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.oxford_binary.file_reader.rst)\n",
-    "is called.\n",
+    "Here, the Oxford Instruments binary [file_reader()](../reference/generated/kikuchipy.io.plugins.oxford_binary.file_reader.rst) is called.\n",
     "\n",
-    "Every pattern's flattened index into the 2D navigation map, as well as their\n",
-    "entry in the file (map order isn't always the same as file order) can be\n",
-    "retrieved from `s_oxford.original_metadata.map1d_id` and\n",
-    "`s_oxford.original_metadata.file_order`, respectively. If available in the file,\n",
-    "every pattern's row and column beam position in microns can be retrieved from\n",
-    "`s_oxford.original_metadata.beam_y` and `s_oxford.original_metadata.beam_x`,\n",
-    "respectively. All these are 1D arrays."
+    "Every pattern's flattened index into the 2D navigation map, as well as their entry in the file (map order isn't always the same as file order) can be retrieved from `s_oxford.original_metadata.map1d_id` and `s_oxford.original_metadata.file_order`, respectively.\n",
+    "If available in the file, every pattern's row and column beam position in microns can be retrieved from `s_oxford.original_metadata.beam_y` and `s_oxford.original_metadata.beam_x`, respectively.\n",
+    "All these are 1D arrays."
    ]
   },
   {
@@ -937,13 +900,10 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "If the beam positions aren't present in the file, the returned signal will have\n",
-    "a single navigation dimension the size of the number of patterns.\n",
+    "If the beam positions aren't present in the file, the returned signal will have a single navigation dimension the size of the number of patterns.\n",
     "\n",
-    "Files with only the non-indexed patterns can also be read. The returned signal\n",
-    "will then have a single navigation dimension the size of the number of patterns.\n",
-    "The flattened index into the 2D navigation map mentioned above can be useful to\n",
-    "determine the location of each non-indexed pattern."
+    "Files with only the non-indexed patterns can also be read. The returned signal will then have a single navigation dimension the size of the number of patterns.\n",
+    "The flattened index into the 2D navigation map mentioned above can be useful to determine the location of each non-indexed pattern."
    ]
   },
   {
@@ -953,14 +913,11 @@
     "### Oxford Instruments h5ebsd (H5OINA)\n",
     "\n",
     "Oxford Instruments' h5ebsd files (H5OINA) with extension .h5oina can be read.\n",
-    "Available parameters when calling `kikuchipy.load(\"patterns.h5oina\")` are\n",
-    "described in the\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.oxford_h5ebsd.file_reader.rst).\n",
+    "Available parameters when calling `kikuchipy.load(\"patterns.h5oina\")` are described in the [file_reader()](../reference/generated/kikuchipy.io.plugins.oxford_h5ebsd.file_reader.rst).\n",
     "As with the kikuchipy h5ebsd reader, multiple scans can be read at once.\n",
     "\n",
-    "The projection center (PC) arrays are read into the `detector` attribute and the\n",
-    "static background pattern is read into the `static_background` attribute. The\n",
-    "orientation and phase data are so far not read."
+    "The projection center (PC) arrays are read into the `detector` attribute and the static background pattern is read into the `static_background` attribute.\n",
+    "The orientation and phase data are so far not read."
    ]
   },
   {
@@ -969,13 +926,10 @@
    "source": [
     "## From kikuchipy into other software\n",
     "\n",
-    "Patterns saved in the [h5ebsd format](#h5ebsd) can be read by the\n",
-    "dictionary indexing and related routines in\n",
-    "[EMsoft](http://vbff.materials.cmu.edu/EMsoft) using the `EMEBSD` reader. Those\n",
-    "routines in EMsoft also have a `NORDIF` reader.\n",
+    "Patterns saved in the [h5ebsd format](#h5ebsd) can be read by the dictionary indexing and related routines in [EMsoft](http://vbff.materials.cmu.edu/EMsoft) using the `EMEBSD` reader.\n",
+    "Those routines in EMsoft also have a `NORDIF` reader.\n",
     "\n",
-    "Patterns saved in the [h5ebsd format](#h5ebsd) can of course be read\n",
-    "in Python like any other HDF5 data set"
+    "Patterns saved in the [h5ebsd format](#h5ebsd) can of course be read in Python like any other HDF5 data set"
    ]
   },
   {
@@ -1009,16 +963,10 @@
    "source": [
     "## Load and save virtual BSE images\n",
     "\n",
-    "One or more virtual backscatter electron (BSE) images in a\n",
-    "[VirtualBSEImage](../reference/generated/kikuchipy.signals.VirtualBSEImage.rst)\n",
-    "signal can be read and written to file using one of HyperSpy's many readers and\n",
-    "writers. If they are only to be used internally in HyperSpy, they can be written\n",
-    "to and read back from HyperSpy's HDF5 specification\n",
-    "[as explained above for EBSD master patterns](#Save-patterns).\n",
+    "One or more virtual backscatter electron (BSE) images in a [VirtualBSEImage](../reference/generated/kikuchipy.signals.VirtualBSEImage.rst) signal can be read and written to file using one of HyperSpy's many readers and writers.\n",
+    "If they are only to be used internally in HyperSpy, they can be written to and read back from HyperSpy's HDF5/zarr specification [as explained above for EBSD master patterns](#Save-patterns).\n",
     "\n",
-    "If we want to write the images to image files, HyperSpy also provides a series\n",
-    "of image readers/writers, as explained in their\n",
-    "[IO user guide](http://hyperspy.org/hyperspy-doc/current/user_guide/io.html#images).\n",
+    "If we want to write the images to image files, HyperSpy also provides a series of image readers/writers, as explained in their [IO user guide](https://hyperspy.org/hyperspy-doc/current/user_guide/io.html#images).\n",
     "If we wanted to write them as a stack of TIFF images"
    ]
   },
@@ -1133,7 +1081,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.13"
+   "version": "3.10.6"
   }
  },
  "nbformat": 4,
diff --git a/doc/tutorials/pattern_processing.ipynb b/doc/tutorials/pattern_processing.ipynb
index 7e784937..62719fb0 100644
--- a/doc/tutorials/pattern_processing.ipynb
+++ b/doc/tutorials/pattern_processing.ipynb
@@ -16,19 +16,12 @@
    "source": [
     "# Pattern processing\n",
     "\n",
-    "The raw EBSD signal can be empirically evaluated as a superposition of a Kikuchi\n",
-    "diffraction pattern and a smooth background intensity. For pattern indexing, the\n",
-    "latter intensity is usually undesirable, while for\n",
-    "[virtual backscatter electron VBSE) imaging](virtual_backscatter_electron_imaging.rst),\n",
-    "this intensity can reveal topographical, compositional or diffraction contrast.\n",
-    "This section details methods to enhance the Kikuchi diffraction pattern and\n",
-    "manipulate detector intensities in patterns in an\n",
-    "[EBSD](../reference/generated/kikuchipy.signals.EBSD.rst) signal.\n",
-    "\n",
-    "Most of the methods operating on EBSD objects use functions that operate on the\n",
-    "individual patterns (`numpy.ndarray`). These single pattern functions are\n",
-    "available in the [kikuchipy.pattern](../reference/generated/kikuchipy.pattern.rst)\n",
-    "module.\n",
+    "The raw EBSD signal can be empirically evaluated as a superposition of a Kikuchi diffraction pattern and a smooth background intensity.\n",
+    "For pattern indexing, the latter intensity is usually undesirable, while for [virtual backscatter electron VBSE) imaging](virtual_backscatter_electron_imaging.rst), this intensity can reveal topographical, compositional or diffraction contrast.\n",
+    "This section details methods to enhance the Kikuchi diffraction pattern and manipulate detector intensities in patterns in an [EBSD](../reference/generated/kikuchipy.signals.EBSD.rst) signal.\n",
+    "\n",
+    "Most of the methods operating on EBSD objects use functions that operate on the individual patterns (`numpy.ndarray`).\n",
+    "These single pattern functions are available in the [kikuchipy.pattern](../reference/generated/kikuchipy.pattern.rst) module.\n",
     "\n",
     "Let's import the necessary libraries and read the Nickel EBSD test data set"
    ]
@@ -45,9 +38,15 @@
     "import hyperspy.api as hs\n",
     "import matplotlib.pyplot as plt\n",
     "import numpy as np\n",
-    "import kikuchipy as kp\n",
-    "\n",
-    "\n",
+    "import kikuchipy as kp"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
     "s = kp.data.nickel_ebsd_small()  # Use kp.load(\"data.h5\") to load your own data"
    ]
   },
@@ -55,12 +54,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Most methods operate inplace (indicated in their docstrings), meaning they\n",
-    "overwrite the patterns in the EBSD signal. If we instead want to keep the\n",
-    "original signal and operate on a new signal, we can create a\n",
-    "[deepcopy()](../reference/generated/kikuchipy.signals.EBSD.deepcopy.rst)\n",
-    "of the original signal. As an example here, we create a new EBSD signal from a\n",
-    "small part of the original signal"
+    "Most methods operate inplace (indicated in their docstrings), meaning they overwrite the patterns in the EBSD signal.\n",
+    "If we instead want to keep the original signal and operate on a new signal, we can create a [deepcopy()](../reference/generated/kikuchipy.signals.EBSD.deepcopy.rst) of the original signal.\n",
+    "As an example here, we create a new EBSD signal from a small part of the original signal"
    ]
   },
   {
@@ -81,9 +77,7 @@
     "\n",
     "### Remove the static background\n",
     "\n",
-    "Effects which are constant, like hot pixels or dirt on the detector, can be\n",
-    "removed by either subtracting or dividing by a static background via\n",
-    "[remove_static_background()](../reference/generated/kikuchipy.signals.EBSD.remove_static_background.rst)"
+    "Effects which are constant, like hot pixels or dirt on the detector, can be removed by either subtracting or dividing by a static background via [remove_static_background()](../reference/generated/kikuchipy.signals.EBSD.remove_static_background.rst)"
    ]
   },
   {
@@ -122,55 +116,26 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Here, the static background pattern is assumed to be stored in the\n",
-    "[EBSD.static_background](../reference/generated/kikuchipy.signals.EBSD.static_background.rst)\n",
-    "property, which can be set and changed as desired. The static background pattern\n",
-    "can also be passed to the `static_bg` parameter.\n",
-    "\n",
-    "Since version 0.6, relative intensities between patterns cannot be maintained\n",
-    "via the `relative` parameter. This was because the initial implementation filled\n",
-    "the RAM when removing the background from a large dataset, and a way around this\n",
-    "was not found.\n",
-    "\n",
-    "<div class=\"alert alert-warning\">\n",
-    "\n",
-    "Warning\n",
-    "\n",
-    "The `Acquisition_instrument.SEM.Detector.EBSD` and `Sample.Phases` metadata\n",
-    "nodes are deprecated and will be removed in v0.6.\n",
+    "Here, the static background pattern is assumed to be stored in the [EBSD.static_background](../reference/generated/kikuchipy.signals.EBSD.static_background.rst) property, which can be set and changed as desired.\n",
+    "The static background pattern can also be passed to the `static_bg` parameter.\n",
     "\n",
-    "There are three main reasons for this change: the first is that only the static\n",
-    "background array stored in the\n",
-    "`Acquisition_instrument.SEM.Detector.EBSD.static_background` node is used\n",
-    "internally, and so the remaining metadata is unnecessary. The background pattern\n",
-    "will be stored in its own `EBSD.static_background` property instead. The second\n",
-    "is that keeping track of the unnecessary metadata makes writing and maintaining\n",
-    "input/ouput plugins challenging. The third is that the\n",
-    "[EBSD.xmap](../reference/generated/kikuchipy.signals.EBSD.xmap.rst) and\n",
-    "[EBSD.detector](../reference/generated/kikuchipy.signals.EBSD.detector.rst) properties,\n",
-    "which keeps track of the\n",
-    "[CrystalMap](https://orix.readthedocs.io/en/stable/reference.html#orix.crystal_map.CrystalMap)\n",
-    "and [EBSDDetector](../reference/generated/kikuchipy.detectors.EBSDDetector.rst) for a\n",
-    "signal, respectively, should be used instead of the more \"static\" metadata.\n",
-    "\n",
-    "</div>\n",
-    "\n",
-    "The static background pattern intensities can be rescaled to each individual\n",
-    "pattern's intensity range before removal by passing `scale_bg=True`.\n",
+    "Since version 0.6, relative intensities between patterns cannot be maintained via the `relative` parameter.\n",
+    "This was because the initial implementation filled the RAM when removing the background from a large dataset, and a way around this was not found.\n",
     "\n",
+    "The static background pattern intensities can be rescaled to each individual pattern's intensity range before removal by passing `scale_bg=True`."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "### Remove the dynamic background\n",
     "\n",
-    "Uneven intensity in a static background subtracted pattern can be corrected by\n",
-    "subtracting or dividing by a dynamic background obtained by Gaussian blurring.\n",
-    "This so-called flat fielding is done with\n",
-    "[remove_dynamic_background()](../reference/generated/kikuchipy.signals.EBSD.remove_dynamic_background.rst).\n",
-    "A Gaussian window with a standard deviation set by `std` is used to blur each\n",
-    "pattern individually (dynamic) either in the spatial or frequency domain, set by\n",
-    "`filter_domain`. Blurring in the frequency domain is effectively accomplished\n",
-    "by a low-pass\n",
-    "[Fast Fourier Transform (FFT) filter](#Filtering-in-the-frequency-domain). The\n",
-    "individual Gaussian blurred dynamic backgrounds are then subtracted or divided\n",
-    "from the respective patterns, set by `operation`"
+    "Uneven intensity in a static background subtracted pattern can be corrected by subtracting or dividing by a dynamic background obtained by Gaussian blurring.\n",
+    "This so-called flat fielding is done with [remove_dynamic_background()](../reference/generated/kikuchipy.signals.EBSD.remove_dynamic_background.rst).\n",
+    "A Gaussian window with a standard deviation set by `std` is used to blur each pattern individually (dynamic) either in the spatial or frequency domain, set by `filter_domain`.\n",
+    "Blurring in the frequency domain is effectively accomplished by a low-pass [Fast Fourier Transform (FFT) filter](#Filtering-in-the-frequency-domain).\n",
+    "The individual Gaussian blurred dynamic backgrounds are then subtracted or divided from the respective patterns, set by `operation`"
    ]
   },
   {
@@ -199,15 +164,17 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "The width of the Gaussian window is truncated at the `truncated` number of\n",
-    "standard deviations. Output patterns are rescaled to fill the input patterns'\n",
-    "data type range.\n",
-    "\n",
+    "The width of the Gaussian window is truncated at the `truncated` number of standard deviations.\n",
+    "Output patterns are rescaled to fill the input patterns' data type range."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "## Get the dynamic background\n",
     "\n",
-    "The Gaussian blurred pattern removed during dynamic background correction can\n",
-    "be obtained as an EBSD signal by calling\n",
-    "[get_dynamic_background()](../reference/generated/kikuchipy.signals.EBSD.get_dynamic_background.rst)"
+    "The Gaussian blurred pattern removed during dynamic background correction can be obtained as an EBSD signal by calling [get_dynamic_background()](../reference/generated/kikuchipy.signals.EBSD.get_dynamic_background.rst)"
    ]
   },
   {
@@ -231,10 +198,7 @@
    "source": [
     "## Average neighbour patterns\n",
     "\n",
-    "The signal-to-noise ratio in patterns in an EBSD signal `s` can be improved by\n",
-    "averaging patterns with their closest neighbours within a window/kernel/mask\n",
-    "with\n",
-    "[average_neighbour_patterns()](../reference/generated/kikuchipy.signals.EBSD.average_neighbour_patterns.rst)"
+    "The signal-to-noise ratio in patterns in an EBSD signal `s` can be improved by averaging patterns with their closest neighbours within a window/kernel/mask with [average_neighbour_patterns()](../reference/generated/kikuchipy.signals.EBSD.average_neighbour_patterns.rst)"
    ]
   },
   {
@@ -257,15 +221,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "The array of averaged patterns $g(n_{\\mathrm{x}}, n_{\\mathrm{y}})$ is obtained\n",
-    "by spatially correlating a window $w(s, t)$ with the array of patterns\n",
-    "$f(n_{\\mathrm{x}}, n_{\\mathrm{y}})$, here 4D, which is padded with zeros at the\n",
-    "edges. As coordinates $n_{\\mathrm{x}}$ and $n_{\\mathrm{y}}$ are varied, the\n",
-    "window origin moves from pattern to pattern, computing the sum of products of\n",
-    "the window coefficients with the neighbour pattern intensities, defined by the\n",
-    "window shape, followed by normalizing by the sum of the window coefficients. For\n",
-    "a symmetrical window of shape $m \\times n$, this becomes\n",
-    "<cite data-cite=\"gonzalez2017digital\">Gonzalez and Woods (2017)</cite>\n",
+    "The array of averaged patterns $g(n_{\\mathrm{x}}, n_{\\mathrm{y}})$ is obtained by spatially correlating a window $w(s, t)$ with the array of patterns $f(n_{\\mathrm{x}}, n_{\\mathrm{y}})$, here 4D, which is padded with zeros at the edges.\n",
+    "As coordinates $n_{\\mathrm{x}}$ and $n_{\\mathrm{y}}$ are varied, the window origin moves from pattern to pattern, computing the sum of products of the window coefficients with the neighbour pattern intensities, defined by the window shape, followed by normalizing by the sum of the window coefficients.\n",
+    "For a symmetrical window of shape $m \\times n$, this becomes <cite data-cite=\"gonzalez2017digital\">Gonzalez and Woods (2017)</cite>\n",
     "\n",
     "\\begin{equation}\n",
     "g(n_{\\mathrm{x}}, n_{\\mathrm{y}}) =\n",
@@ -274,9 +232,8 @@
     "{\\sum_{s=-a}^a\\sum_{t=-b}^b{w(s, t)}},\n",
     "\\end{equation}\n",
     "\n",
-    "where $a = (m - 1)/2$ and $b = (n - 1)/2$. The window $w$, a\n",
-    "[Window](../reference/generated/kikuchipy.filters.Window.rst) object, can be\n",
-    "plotted"
+    "where $a = (m - 1)/2$ and $b = (n - 1)/2$.\n",
+    "The window $w$, a [Window](../reference/generated/kikuchipy.filters.Window.rst) object, can be plotted"
    ]
   },
   {
@@ -293,18 +250,10 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Any 1D or 2D window with desired coefficients can be used. This custom window\n",
-    "can be passed to the `window` parameter in\n",
-    "[average_neighbour_patterns()](../reference/generated/kikuchipy.signals.EBSD.average_neighbour_patterns.rst)\n",
-    "or [Window](../reference/generated/kikuchipy.filters.Window.rst) as a\n",
-    "`numpy.ndarray` or a `dask.array.Array`. Additionally, any window in\n",
-    "[scipy.signal.windows.get_window()](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.get_window.html)\n",
-    "passed as a string via `window` with the necessary parameters as keyword\n",
-    "arguments (like `std=1` for `window=\"gaussian\"`) can be used. To demonstrate the\n",
-    "creation and use of an asymmetrical circular window (and the use of\n",
-    "[make_circular()](../reference/generated/kikuchipy.filters.Window.make_circular.rst),\n",
-    "although we could create a circular window directly by calling\n",
-    "`window=\"circular\"` upon window initialization)"
+    "Any 1D or 2D window with desired coefficients can be used.\n",
+    "This custom window can be passed to the `window` parameter in [average_neighbour_patterns()](../reference/generated/kikuchipy.signals.EBSD.average_neighbour_patterns.rst) or [Window](../reference/generated/kikuchipy.filters.Window.rst) as a `numpy.ndarray` or a `dask.array.Array`.\n",
+    "Additionally, any window in [scipy.signal.windows.get_window()](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.get_window.html) passed as a string via `window` with the necessary parameters as keyword arguments (like `std=1` for `window=\"gaussian\"`) can be used.\n",
+    "To demonstrate the creation and use of an asymmetrical circular window (and the use of [make_circular()](../reference/generated/kikuchipy.filters.Window.make_circular.rst), although we could create a circular window directly by calling `window=\"circular\"` upon window initialization)"
    ]
   },
   {
@@ -350,30 +299,27 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "But this `(5, 4)` averaging window cannot be used with our `(3, 3)` navigation\n",
-    "shape signal.\n",
+    "But this `(5, 4)` averaging window cannot be used with our `(3, 3)` navigation shape signal.\n",
     "\n",
     "<div class=\"alert alert-info\">\n",
     "\n",
     "Note\n",
     "   \n",
-    "Neighbour pattern averaging increases the virtual interaction volume of the\n",
-    "electron beam with the sample, leading to a potential loss in spatial\n",
-    "resolution. Averaging may in some cases, like on grain boundaries, mix two\n",
-    "or more different diffraction patterns, which might be unwanted. See\n",
-    "<cite data-cite=\"wright2015introduction\">Wright et al. (2015)</cite> for a\n",
-    "discussion of this concern.\n",
-    "\n",
-    "</div>\n",
+    "Neighbour pattern averaging increases the virtual interaction volume of the electron beam with the sample, leading to a potential loss in spatial resolution.\n",
+    "Averaging may in some cases, like on grain boundaries, mix two or more different diffraction patterns, which might be unwanted.\n",
+    "See <cite data-cite=\"wright2015introduction\">Wright et al. (2015)</cite> for a discussion of this concern.\n",
     "\n",
+    "</div>"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "## Adaptive histogram equalization\n",
     "\n",
-    "Enhancing the pattern contrast with adaptive histogram equalization has been\n",
-    "found useful when comparing patterns for dictionary indexing\n",
-    "<cite data-cite=\"marquardt2017quantitative\">Marquardt et al. (2017)</cite>.\n",
-    "With [adaptive_histogram_equalization()](../reference/generated/kikuchipy.signals.EBSD.adaptive_histogram_equalization.rst), the\n",
-    "intensities in the pattern histogram are spread to cover the available range,\n",
-    "e.g. [0, 255] for patterns of `uint8` data type"
+    "Enhancing the pattern contrast with adaptive histogram equalization has been found useful when comparing patterns for dictionary indexing <cite data-cite=\"marquardt2017quantitative\">Marquardt et al. (2017)</cite>.\n",
+    "With [adaptive_histogram_equalization()](../reference/generated/kikuchipy.signals.EBSD.adaptive_histogram_equalization.rst), the intensities in the pattern histogram are spread to cover the available range, e.g. [0, 255] for patterns of `uint8` data type"
    ]
   },
   {
@@ -397,8 +343,13 @@
    "metadata": {},
    "source": [
     "The `kernel_size` parameter determines the size of the contextual regions.\n",
-    "See e.g. Fig. 5 in <cite data-cite=\"jackson2019dictionary\">Jackson et al. (2019)</cite>, also available via [EMsoft's GitHub repository wiki](https://github.com/EMsoft-org/EMsoft/wiki/DItutorial#52-determination-of-pattern-pre-processing-parameters), for the effect of varying `kernel_size`.\n",
-    "\n",
+    "See e.g. Fig. 5 in <cite data-cite=\"jackson2019dictionary\">Jackson et al. (2019)</cite>, also available via [EMsoft's GitHub repository wiki](https://github.com/EMsoft-org/EMsoft/wiki/DItutorial#52-determination-of-pattern-pre-processing-parameters), for the effect of varying `kernel_size`."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "## Filtering in the frequency domain\n",
     "\n",
     "Filtering of patterns in the frequency domain can be done with [fft_filter()](../reference/generated/kikuchipy.signals.EBSD.fft_filter.rst).\n",
@@ -441,10 +392,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Then, to multiply the FFT of each pattern with this transfer function, and\n",
-    "subsequently computing the inverse FFT (IFFT), we use\n",
-    "`fft_filter()`, and remember to shift the zero-frequency components to the\n",
-    "centre of the FFT"
+    "Then, to multiply the FFT of each pattern with this transfer function, and subsequently computing the inverse FFT (IFFT), we use `fft_filter()`, and remember to shift the zero-frequency components to the centre of the FFT"
    ]
   },
   {
@@ -467,15 +415,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Note that filtering with a spatial kernel in the frequency domain, after\n",
-    "creating the kernel's transfer function via FFT, and computing the inverse FFT\n",
-    "(IFFT), is, in this case, the same as spatially correlating the kernel with the\n",
-    "pattern.\n",
+    "Note that filtering with a spatial kernel in the frequency domain, after creating the kernel's transfer function via FFT, and computing the inverse FFT (IFFT), is, in this case, the same as spatially correlating the kernel with the pattern.\n",
     "\n",
-    "Let's demonstrate this by attempting to sharpen a pattern with a Laplacian\n",
-    "kernel in both the spatial and frequency domains and comparing the results\n",
-    "(this is a purely illustrative example, and perhaps not that practically\n",
-    "useful)"
+    "Let's demonstrate this by attempting to sharpen a pattern with a Laplacian kernel in both the spatial and frequency domains and comparing the results (this is a purely illustrative example, and perhaps not that practically useful)"
    ]
   },
   {
@@ -514,19 +456,18 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Note also that `fft_filter()` performs the filtering on the patterns with data\n",
-    "type `np.float32`, and therefore have to rescale back to the pattern's original\n",
-    "data type if necessary.\n",
-    "\n",
+    "Note also that `fft_filter()` performs the filtering on the patterns with data type `np.float32`, and therefore have to rescale back to the pattern's original data type if necessary."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "## Rescale intensity\n",
     "\n",
-    "Vendors usually write patterns to file with 8 (`uint8`) or 16 (`uint16`) bit\n",
-    "integer depth, holding [0, 2$^8$] or [0, 2$^{16}$] gray levels, respectively. To\n",
-    "avoid losing intensity information when processing, we often change data types\n",
-    "to e.g. 32 bit floating point (`float32`). However, only changing the data\n",
-    "type with [change_dtype()](http://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal.change_dtype) does not\n",
-    "rescale pattern intensities, leading to patterns not using the full available\n",
-    "data type range"
+    "Vendors usually write patterns to file with 8 (`uint8`) or 16 (`uint16`) bit integer depth, holding [0, 2$^8$] or [0, 2$^{16}$] gray levels, respectively.\n",
+    "To avoid losing intensity information when processing, we often change data types to e.g. 32 bit floating point (`float32`).\n",
+    "However, only changing the data type with [change_dtype()](http://hyperspy.org/hyperspy-doc/current/api/hyperspy.signal.html#hyperspy.signal.BaseSignal.change_dtype) does not rescale pattern intensities, leading to patterns not using the full available data type range"
    ]
   },
   {
@@ -553,10 +494,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "In these cases it is convenient to rescale intensities to a desired data type\n",
-    "range, either keeping relative intensities between patterns in a scan or not. We\n",
-    "can do this for all patterns in an EBSD signal with\n",
-    "[kikuchipy.signals.EBSD.rescale_intensity()](../reference/generated/kikuchipy.signals.EBSD.rescale_intensity.rst)"
+    "In these cases it is convenient to rescale intensities to a desired data type range, either keeping relative intensities between patterns in a scan or not.\n",
+    "We can do this for all patterns in an EBSD signal with [kikuchipy.signals.EBSD.rescale_intensity()](../reference/generated/kikuchipy.signals.EBSD.rescale_intensity.rst)"
    ]
   },
   {
@@ -579,8 +518,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Or, we can do it for a single pattern (`numpy.ndarray`) with\n",
-    "[kikuchipy.pattern.rescale_intensity()](../reference/generated/kikuchipy.pattern.rescale_intensity.rst)"
+    "Or, we can do it for a single pattern (`numpy.ndarray`) with [kikuchipy.pattern.rescale_intensity()](../reference/generated/kikuchipy.pattern.rescale_intensity.rst)"
    ]
   },
   {
@@ -599,9 +537,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "We can also stretch the pattern contrast by removing intensities outside a range\n",
-    "passed to `in_range` or at certain percentiles by passing percentages to\n",
-    "`percentiles`"
+    "We can also stretch the pattern contrast by removing intensities outside a range passed to `in_range` or at certain percentiles by passing percentages to `percentiles`"
    ]
   },
   {
@@ -645,16 +581,17 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "This can reduce the influence of outliers with exceptionally high or low\n",
-    "intensities, like hot or dead pixels.\n",
-    "\n",
+    "This can reduce the influence of outliers with exceptionally high or low intensities, like hot or dead pixels."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "## Normalize intensity\n",
     "\n",
-    "It can be useful to normalize pattern intensities to a mean value of $\\mu = 0.0$\n",
-    "and a standard deviation of e.g. $\\sigma = 1.0$ when e.g. comparing patterns or\n",
-    "calculating the [image quality](feature_maps.ipynb#Image-quality). Patterns can\n",
-    "be normalized with\n",
-    "[normalize_intensity()](../reference/generated/kikuchipy.signals.EBSD.normalize_intensity.rst)"
+    "It can be useful to normalize pattern intensities to a mean value of $\\mu = 0.0$ and a standard deviation of e.g. $\\sigma = 1.0$ when e.g. comparing patterns or calculating the [image quality](feature_maps.ipynb#Image-quality).\n",
+    "Patterns can be normalized with [normalize_intensity()](../reference/generated/kikuchipy.signals.EBSD.normalize_intensity.rst)"
    ]
   },
   {
@@ -708,7 +645,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.13"
+   "version": "3.10.6"
   }
  },
  "nbformat": 4,
diff --git a/kikuchipy/__init__.py b/kikuchipy/__init__.py
index 088fac57..9268431b 100644
--- a/kikuchipy/__init__.py
+++ b/kikuchipy/__init__.py
@@ -18,7 +18,7 @@
 from kikuchipy.release import version as __version__
 
 
-# Try to import only once
+# Attempt import only once
 try:
     import pyvista
 
@@ -26,6 +26,35 @@
 except ImportError:  # pragma: no cover
     _pyvista_installed = False
 
+
+def set_log_level(level: str):  # pragma: no cover
+    """Set level of kikuchipy logging messages.
+
+    Parameters
+    ----------
+    level
+        Any value accepted by :meth:`logging.Logger.setLevel()`. Levels
+        are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, ``"ERROR"`` and
+        ``"CRITICAL"``.
+
+    Notes
+    -----
+    See https://docs.python.org/3/howto/logging.html.
+
+    Examples
+    --------
+    >>> import kikuchipy as kp
+    >>> kp.set_log_level("DEBUG")
+    >>> s = kp.data.nickel_ebsd_small()
+    >>> s2 = s.deepcopy()  # doctest: +SKIP
+    DEBUG:kikuchipy.signals._kikuchipy_signal:Transfer custom properties when deep copying
+    """
+    import logging
+
+    logging.basicConfig()
+    logging.getLogger("kikuchipy").setLevel(level)
+
+
 __all__ = [
     "__version__",
     "_pyvista_installed",
@@ -41,6 +70,7 @@
     "pattern",
     "projections",
     "release",
+    "set_log_level",
     "signals",
     "simulations",
 ]
diff --git a/kikuchipy/data/emsoft_ebsd_master_pattern/create_dummy_emsoft_ebsdmasterpatternfile.py b/kikuchipy/data/emsoft_ebsd_master_pattern/create_dummy_emsoft_ebsd_master_pattern_file.py
similarity index 91%
rename from kikuchipy/data/emsoft_ebsd_master_pattern/create_dummy_emsoft_ebsdmasterpatternfile.py
rename to kikuchipy/data/emsoft_ebsd_master_pattern/create_dummy_emsoft_ebsd_master_pattern_file.py
index 46f0d082..697f7865 100644
--- a/kikuchipy/data/emsoft_ebsd_master_pattern/create_dummy_emsoft_ebsdmasterpatternfile.py
+++ b/kikuchipy/data/emsoft_ebsd_master_pattern/create_dummy_emsoft_ebsd_master_pattern_file.py
@@ -2,12 +2,12 @@
 
 import numpy as np
 import h5py
+
+from kikuchipy.io.plugins._h5ebsd import _dict2hdf5group
 import kikuchipy as kp
 
 
-ddir = "/home/hakon/kode/kikuchipy/kikuchipy/data/emsoft_ebsd_master_pattern/"
-fname = "master_patterns.h5"
-f = h5py.File(os.path.join(ddir, fname), mode="w")
+f = h5py.File(os.path.join(os.path.dirname(__file__), "master_patterns.h5"), mode="w")
 
 npx = 6
 signal_shape = (npx * 2 + 1,) * 2
@@ -72,7 +72,7 @@
     },
 }
 
-kp.io.plugins.h5ebsd.dict2h5ebsdgroup(dictionary=data, group=f["/"])
+_dict2hdf5group(dictionary=data, group=f["/"])
 
 # One chunked data set
 f["EMData/EBSDmaster"].create_dataset("mLPSH", data=mp_lambert_south, chunks=True)
diff --git a/kikuchipy/data/emsoft_ecp_master_pattern/create_dummy_emsoft_ecp_master_pattern_file.py b/kikuchipy/data/emsoft_ecp_master_pattern/create_dummy_emsoft_ecp_master_pattern_file.py
new file mode 100644
index 00000000..d688188c
--- /dev/null
+++ b/kikuchipy/data/emsoft_ecp_master_pattern/create_dummy_emsoft_ecp_master_pattern_file.py
@@ -0,0 +1,83 @@
+import os
+
+import numpy as np
+import h5py
+
+from kikuchipy.io.plugins._h5ebsd import _dict2hdf5group
+import kikuchipy as kp
+
+
+f = h5py.File(
+    os.path.join(os.path.dirname(__file__), "ecp_master_pattern.h5"), mode="w"
+)
+
+npx = 6
+signal_shape = (npx * 2 + 1,) * 2
+energies = np.linspace(10, 20, 11, dtype=np.float32)
+data_shape = (len(energies),) + signal_shape
+
+mp_lambert_north = np.ones((1,) + data_shape, dtype=np.float32) * energies.reshape(
+    (1, 11, 1, 1)
+)
+mp_lambert_south = mp_lambert_north
+circle = kp.filters.Window(shape=signal_shape).astype(np.float32)
+mp_spherical_north = mp_lambert_north.squeeze() * circle
+mp_spherical_south = mp_spherical_north
+
+data = {
+    "CrystalData": {
+        "AtomData": np.array(
+            [[0.1587, 0], [0.6587, 0], [0, 0.25], [1, 1], [0.005, 0.005]],
+            dtype=np.float32,
+        ),
+        "Atomtypes": np.array([13, 29], dtype=np.int32),
+        "CrystalSystem": 2,
+        "LatticeParameters": np.array([0.5949, 0.5949, 0.5821, 90, 90, 90]),
+        "Natomtypes": 2,
+        "Source": "Su Y.C., Yan J., Lu P.T., Su J.T.: Thermodynamic...",
+        "SpaceGroupNumber": 140,
+        "SpaceGroupSetting": 1,
+    },
+    "EMData": {
+        "ECPmaster": {
+            "EkeV": np.linspace(10, 20, 11, dtype=np.float32),
+            "mLPNH": mp_lambert_north,  # mLPSH written below
+            "masterSPNH": mp_spherical_north,
+            "masterSPSH": mp_spherical_south,
+            "numset": 1,
+        }
+    },
+    "NMLparameters": {
+        "ECPMasterNameList": {"dmin": 0.05, "npx": npx},
+        "MCCLNameList": {
+            "Ebinsize": energies[1] - energies[0],
+            "Ehistmin": np.min(energies),
+            "EkeV": np.max(energies),
+            "MCmode": "CSDA",
+            "dataname": "crystal_data/al2cu/al2cu_mc_mp_20kv.h5",
+            "depthmax": 100.0,
+            "depthstep": 1.0,
+            "mode": "bse1",
+            "numsx": npx,
+            "sigend": 10.0,
+            "sigstart": 0.0,
+            "sigstep": 2.0,
+            "totnum_el": 2000000000,
+        },
+        "BetheList": {"c1": 4.0, "c2": 8.0, "c3": 50.0, "sgdbdiff": 1.0},
+    },
+    "EMheader": {
+        "ECPmaster": {"ProgramName": np.array([b"EMECPmaster.f90"], dtype="S15")},
+    },
+}
+
+_dict2hdf5group(dictionary=data, group=f["/"])
+
+# One chunked data set
+f["EMData/ECPmaster"].create_dataset("mLPSH", data=mp_lambert_south, chunks=True)
+
+# One byte string with latin-1 stuff
+creation_time = b"12:30:13.559 PM\xf0\x14\x1e\xc8\xbcU"
+f["CrystalData"].create_dataset("CreationTime", data=creation_time)
+
+f.close()
diff --git a/kikuchipy/data/emsoft_ecp_master_pattern/ecp_master_pattern.h5 b/kikuchipy/data/emsoft_ecp_master_pattern/ecp_master_pattern.h5
new file mode 100644
index 00000000..ab56c05b
Binary files /dev/null and b/kikuchipy/data/emsoft_ecp_master_pattern/ecp_master_pattern.h5 differ
diff --git a/kikuchipy/data/emsoft_tkd_master_pattern/create_dummy_emsoft_tkd_master_pattern_file.py b/kikuchipy/data/emsoft_tkd_master_pattern/create_dummy_emsoft_tkd_master_pattern_file.py
new file mode 100644
index 00000000..c4d7b18b
--- /dev/null
+++ b/kikuchipy/data/emsoft_tkd_master_pattern/create_dummy_emsoft_tkd_master_pattern_file.py
@@ -0,0 +1,82 @@
+import os
+
+import numpy as np
+import h5py
+
+from kikuchipy.io.plugins._h5ebsd import _dict2hdf5group
+import kikuchipy as kp
+
+
+f = h5py.File(
+    os.path.join(os.path.dirname(__file__), "tkd_master_pattern.h5"), mode="w"
+)
+
+npx = 6
+signal_shape = (npx * 2 + 1,) * 2
+energies = np.linspace(10, 20, 11, dtype=np.float32)
+data_shape = (len(energies),) + signal_shape
+
+mp_lambert_north = np.ones((1,) + data_shape, dtype=np.float32) * energies.reshape(
+    (1, 11, 1, 1)
+)
+mp_lambert_south = mp_lambert_north
+circle = kp.filters.Window(shape=signal_shape).astype(np.float32)
+mp_spherical_north = mp_lambert_north.squeeze() * circle
+mp_spherical_south = mp_spherical_north
+
+data = {
+    "CrystalData": {
+        "AtomData": np.array(
+            [[0.1587, 0], [0.6587, 0], [0, 0.25], [1, 1], [0.005, 0.005]],
+            dtype=np.float32,
+        ),
+        "Atomtypes": np.array([13, 29], dtype=np.int32),
+        "CrystalSystem": 2,
+        "LatticeParameters": np.array([0.5949, 0.5949, 0.5821, 90, 90, 90]),
+        "Natomtypes": 2,
+        "Source": "Su Y.C., Yan J., Lu P.T., Su J.T.: Thermodynamic...",
+        "SpaceGroupNumber": 140,
+        "SpaceGroupSetting": 1,
+    },
+    "EMData": {
+        "TKDmaster": {
+            "BetheParameters": np.array([4, 8, 50, 1], dtype=np.float32),
+            "EkeVs": np.linspace(10, 20, 11, dtype=np.float32),
+            "mLPNH": mp_lambert_north,
+            "masterSPNH": mp_spherical_north,
+            "masterSPSH": mp_spherical_south,
+            "numEbins": len(energies),
+            "numset": 1,
+        }
+    },
+    "NMLparameters": {
+        "TKDMasterNameList": {"dmin": 0.05, "npx": npx},
+        "MCCLfoilNameList": {
+            "Ebinsize": energies[1] - energies[0],
+            "Ehistmin": np.min(energies),
+            "EkeV": np.max(energies),
+            "MCmode": "CSDA",
+            "dataname": "crystal_data/al2cu/al2cu_mc_mp_20kv.h5",
+            "depthmax": 100.0,
+            "depthstep": 1.0,
+            "numsx": npx,
+            "sig": -20.0,
+            "totnum_el": 2000000000,
+        },
+        "BetheList": {"c1": 4.0, "c2": 8.0, "c3": 50.0, "sgdbdiff": 1.0},
+    },
+    "EMheader": {
+        "TKDmaster": {"ProgramName": np.array([b"EMTKDmaster.f90"], dtype="S15")},
+    },
+}
+
+_dict2hdf5group(dictionary=data, group=f["/"])
+
+# One chunked data set
+f["EMData/TKDmaster"].create_dataset("mLPSH", data=mp_lambert_south, chunks=True)
+
+# One byte string with latin-1 stuff
+creation_time = b"12:30:13.559 PM\xf0\x14\x1e\xc8\xbcU"
+f["CrystalData"].create_dataset("CreationTime", data=creation_time)
+
+f.close()
diff --git a/kikuchipy/data/emsoft_tkd_master_pattern/tkd_master_pattern.h5 b/kikuchipy/data/emsoft_tkd_master_pattern/tkd_master_pattern.h5
new file mode 100644
index 00000000..9fbca8c2
Binary files /dev/null and b/kikuchipy/data/emsoft_tkd_master_pattern/tkd_master_pattern.h5 differ
diff --git a/kikuchipy/hyperspy_extension.yaml b/kikuchipy/hyperspy_extension.yaml
index 5ff91462..b5c99d3d 100644
--- a/kikuchipy/hyperspy_extension.yaml
+++ b/kikuchipy/hyperspy_extension.yaml
@@ -33,8 +33,8 @@ signals:
   EBSDMasterPattern:
     signal_type: EBSDMasterPattern
     signal_type_aliases:
-      - ebsd_masterpattern
-      - masterpattern
+      - ebsd_master_pattern
+      - master_pattern
     signal_dimension: 2
     dtype: real
     lazy: False
@@ -45,6 +45,20 @@ signals:
     dtype: real
     lazy: True
     module: kikuchipy.signals.ebsd_master_pattern
+  ECPMasterPattern:
+    signal_type: ECPMasterPattern
+    signal_type_aliases:
+      - ecp_master_pattern
+    signal_dimension: 2
+    dtype: real
+    lazy: False
+    module: kikuchipy.signals.ecp_master_pattern
+  LazyECPMasterPattern:
+    signal_type: ECPMasterPattern
+    signal_dimension: 2
+    dtype: real
+    lazy: True
+    module: kikuchipy.signals.ecp_master_pattern
   VirtualBSEImage:
     signal_type: VirtualBSEImage
     signal_type_aliases:
diff --git a/kikuchipy/io/_io.py b/kikuchipy/io/_io.py
index 92c19a70..252e198c 100644
--- a/kikuchipy/io/_io.py
+++ b/kikuchipy/io/_io.py
@@ -32,6 +32,8 @@
     edax_h5ebsd,
     emsoft_ebsd,
     emsoft_ebsd_master_pattern,
+    emsoft_ecp_master_pattern,
+    emsoft_tkd_master_pattern,
     kikuchipy_h5ebsd,
     nordif,
     nordif_calibration_patterns,
@@ -46,6 +48,8 @@
     edax_h5ebsd,
     emsoft_ebsd,
     emsoft_ebsd_master_pattern,
+    emsoft_ecp_master_pattern,
+    emsoft_tkd_master_pattern,
     hspy,
     kikuchipy_h5ebsd,
     nordif,
@@ -62,9 +66,17 @@
 
 def load(
     filename: Union[str, Path], lazy: bool = False, **kwargs
-) -> Union["EBSD", "EBSDMasterPattern", List["EBSD"], List["EBSDMasterPattern"]]:
-    """Load an :class:`~kikuchipy.signals.EBSD` or
-    :class:`~kikuchipy.signals.EBSDMasterPattern` signal from a
+) -> Union[
+    "EBSD",
+    "EBSDMasterPattern",
+    "ECPMasterPattern",
+    List["EBSD"],
+    List["EBSDMasterPattern"],
+    List["ECPMasterPattern"],
+]:
+    """Load an :class:`~kikuchipy.signals.EBSD`,
+    :class:`~kikuchipy.signals.EBSDMasterPattern` or
+    :class:`~kikuchipy.signals.ECPMasterPattern` signal from a
     supported file format.
 
     This function is a modified version of :func:`hyperspy.io.load`.
@@ -83,7 +95,7 @@ def load(
 
     Returns
     -------
-    signals
+    out
         Signal or a list of signals.
 
     Raises
@@ -122,19 +134,19 @@ def load(
     # Get data and metadata (from potentially multiple signals if an h5ebsd
     # file)
     signal_dicts = reader.file_reader(filename, lazy=lazy, **kwargs)
-    signals = []
+    out = []
     for signal in signal_dicts:
-        signals.append(_dict2signal(signal, lazy=lazy))
+        out.append(_dict2signal(signal, lazy=lazy))
         directory, filename = os.path.split(os.path.abspath(filename))
         filename, extension = os.path.splitext(filename)
-        signals[-1].tmp_parameters.folder = directory
-        signals[-1].tmp_parameters.filename = filename
-        signals[-1].tmp_parameters.extension = extension.replace(".", "")
+        out[-1].tmp_parameters.folder = directory
+        out[-1].tmp_parameters.filename = filename
+        out[-1].tmp_parameters.extension = extension.replace(".", "")
 
-    if len(signals) == 1:
-        signals = signals[0]
+    if len(out) == 1:
+        out = out[0]
 
-    return signals
+    return out
 
 
 def _dict2signal(signal_dict: dict, lazy: bool = False):
diff --git a/kikuchipy/io/plugins/__init__.py b/kikuchipy/io/plugins/__init__.py
index 783535e3..91b0e068 100644
--- a/kikuchipy/io/plugins/__init__.py
+++ b/kikuchipy/io/plugins/__init__.py
@@ -29,6 +29,8 @@
     edax_h5ebsd
     emsoft_ebsd
     emsoft_ebsd_master_pattern
+    emsoft_ecp_master_pattern
+    emsoft_tkd_master_pattern
     kikuchipy_h5ebsd
     nordif
     nordif_calibration_patterns
@@ -41,6 +43,8 @@
     "edax_h5ebsd",
     "emsoft_ebsd",
     "emsoft_ebsd_master_pattern",
+    "emsoft_ecp_master_pattern",
+    "emsoft_tkd_master_pattern",
     "kikuchipy_h5ebsd",
     "nordif",
     "nordif_calibration_patterns",
diff --git a/kikuchipy/io/plugins/_emsoft_master_pattern.py b/kikuchipy/io/plugins/_emsoft_master_pattern.py
new file mode 100644
index 00000000..d7e2d9f7
--- /dev/null
+++ b/kikuchipy/io/plugins/_emsoft_master_pattern.py
@@ -0,0 +1,335 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import abc
+import os
+from typing import List, Optional, Tuple
+
+import dask.array as da
+from h5py import Dataset, File, Group
+import numpy as np
+
+from kikuchipy.io.plugins.emsoft_ebsd import _crystaldata2phase
+from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
+
+
+class EMsoftMasterPatternReader(abc.ABC):
+    """Abstract class for readers of kikuchi diffraction master patterns
+    from EMsoft's HDF5 file format.
+    """
+
+    def __init__(
+        self,
+        filename: str,
+        energy: Optional[range] = None,
+        projection: str = "stereographic",
+        hemisphere: "str" = "upper",
+        lazy: bool = False,
+    ):
+        self.filename = filename
+        self.energy = energy
+        self.projection = projection.lower()
+        self.hemisphere = hemisphere.lower()
+        self.lazy = lazy
+
+    @property
+    @abc.abstractmethod
+    def diffraction_type(self):
+        return NotImplemented  # pragma: no cover
+
+    @property
+    @abc.abstractmethod
+    def cl_parameters_group_name(self):
+        return NotImplemented  # pragma: no cover
+
+    @property
+    @abc.abstractmethod
+    def energy_string(self):
+        return NotImplemented  # pragma: no cover
+
+    def read(self, **kwargs) -> List[dict]:
+        """Read kikuchi diffraction master patterns.
+
+        Parameters
+        ----------
+        **kwargs
+            Keyword arguments passed to h5py.File.
+
+        Returns
+        -------
+        signal_dict_list
+            Data, axes, metadata and original metadata.
+        """
+        mode = kwargs.pop("mode", "r")
+        f = File(self.filename, mode=mode, **kwargs)
+
+        _check_file_format(f, self.diffraction_type)
+
+        # Set data group names
+        diff_type = self.diffraction_type
+        signal_type = f"{diff_type}MasterPattern"
+        if diff_type == "TKD":
+            signal_type = "EBSDMasterPattern"
+        data_group_path = f"EMData/{diff_type}master"
+        name_list_name = f"{diff_type}MasterNameList"
+
+        # Set metadata and original metadata dictionary
+        md = {
+            "Signal": {"signal_type": signal_type, "record_by": "image"},
+            "General": {
+                "title": f.filename.split("/")[-1].split(".")[0],
+                "original_filename": f.filename.split("/")[-1],
+            },
+        }
+        nml_params = _hdf5group2dict(f["NMLparameters"], recursive=True)
+
+        # Get phase information and add it to both the original metadata
+        # and a Phase object
+        crystal_data = _hdf5group2dict(f["CrystalData"])
+        nml_params["CrystalData"] = crystal_data
+        phase = _crystaldata2phase(crystal_data)
+
+        # Get phase name
+        if phase.name == "":
+            try:
+                xtal_name = os.path.split(nml_params["MCCLNameList"]["xtalname"])[0]
+                phase.name = os.path.splitext(xtal_name)[0]
+            except KeyError:
+                phase.name = None
+
+        # Get data shape and slices
+        data_group = f[data_group_path]
+        energies = data_group[self.energy_string][()]
+        data_shape, data_slices = _get_data_shape_slices(
+            npx=nml_params[name_list_name]["npx"], energies=energies, energy=self.energy
+        )
+        i_min = data_slices[0].start
+        i_min = 0 if i_min is None else i_min
+        min_energy = energies[i_min]
+
+        projection = self.projection
+        hemisphere = self.hemisphere
+
+        # Get HDF5 data sets
+        datasets = _get_datasets(
+            data_group=data_group, projection=projection, hemisphere=hemisphere
+        )
+
+        # TODO: Take EMsoft NML file parameter combinesites into account
+        dataset_shape = data_shape
+        if projection == "lambert" and diff_type != "ECP":
+            data_slices = (slice(None, None),) + data_slices
+            data_shape = (data_group["numset"][:][0],) + data_shape
+
+        data_shape = (len(datasets),) + data_shape
+
+        # Set up data reading
+        data_kwargs = {}
+        if self.lazy:
+            if datasets[0].chunks is None or datasets[0].shape != dataset_shape:
+                data_kwargs["chunks"] = "auto"
+            else:
+                data_kwargs["chunks"] = datasets[0].chunks
+            data_read_func = da.from_array
+            data_stack_func = da.stack
+        else:
+            data_read_func = np.asanyarray
+            data_stack_func = np.stack
+
+        # Read data
+        data = data_read_func(datasets[0][data_slices], **data_kwargs)
+        if data_shape[0] == 2:
+            data = data_stack_func(
+                [data, data_read_func(datasets[1][data_slices], **data_kwargs)]
+            )
+
+        if projection == "lambert" and diff_type != "ECP":
+            if self.hemisphere == "both":
+                sum_axis = 1
+                data_shape = (data_shape[0],) + data_shape[2:]
+            else:
+                sum_axis = 0
+                data_shape = data_shape[1:]
+            data = data.sum(axis=sum_axis).astype(data.dtype)
+
+        # Remove 1-dimensions
+        data = data.squeeze()
+
+        if projection == "stereographic":
+            # Mirror about horizontal (flip up-down)
+            data = data[..., ::-1, :]
+
+        # Axes scales
+        group_name = f"{self.cl_parameters_group_name}NameList"
+        energy_scale = nml_params[group_name]["Ebinsize"]
+        scales = np.array([1, energy_scale, 1, 1])
+
+        ny, nx, sy, sx = data_shape
+        names = ["hemisphere", "energy", "height", "width"]
+        units = ["", "keV", "px", "px"]
+        offsets = [0, min_energy, -sy // 2, -sx // 2]
+        dim_idx = []
+        if ny != 1:
+            dim_idx.append(0)
+        if nx != 1:
+            dim_idx.append(1)
+        dim_idx += [2, 3]
+
+        # Create axis object
+        axes = [
+            {
+                "size": data.shape[i],
+                "index_in_array": i,
+                "name": names[j],
+                "scale": scales[j],
+                "offset": offsets[j],
+                "units": units[j],
+            }
+            for i, j in zip(range(data.ndim), dim_idx)
+        ]
+
+        output = {
+            "axes": axes,
+            "data": data,
+            "metadata": md,
+            "original_metadata": nml_params,
+            "phase": phase,
+            "projection": projection,
+            "hemisphere": hemisphere,
+        }
+
+        if not self.lazy:
+            f.close()
+
+        return [output]
+
+
+def _check_file_format(file: File, diffraction_type: str):
+    """Raise an error if the HDF5 file is not in EMsoft's master
+    pattern file format.
+
+    Parameters
+    ----------
+    file
+        HDF5 file.
+    diffraction_type
+        ``"EBSD"``, ``"TKD"`` or ``"ECP"``.
+
+    Raises
+    ------
+    KeyError
+        If the program that created the file is not named
+        ``"EM<diffraction_type>master.f90"``.
+    IOError
+        If the file is not in the EMsoft master pattern file format.
+    """
+    try:
+        program_name_path = f"EMheader/{diffraction_type}master/ProgramName"
+        program_name = file[program_name_path][:][0].decode()
+        if program_name != f"EM{diffraction_type}master.f90":
+            raise KeyError
+    except KeyError:
+        raise IOError(f"'{file.filename}' is not in EMsoft's master pattern format")
+
+
+def _get_data_shape_slices(
+    npx: int,
+    energies: np.ndarray,
+    energy: Optional[tuple] = None,
+) -> Tuple[Tuple, Tuple[slice, ...]]:
+    """Return data shape from half the master pattern side length,
+    number of asymmetric positions if the square Lambert projection is
+    to be read, and an energy or energy range.
+
+    Parameters
+    ----------
+    npx
+        Half the number of pixels along x-direction of the square master
+        pattern. Half is used because that is what EMsoft uses.
+    energies
+        Beam energies.
+    energy
+        Desired beam energy or energy range. If not given, it is assumed
+        that all master patterns will be read.
+
+    Returns
+    -------
+    data_shape
+        Shape of data.
+    data_slices
+        Data to get, determined from ``energy``.
+    """
+    data_shape = (npx * 2 + 1,) * 2
+    data_slices = (slice(None, None),) * 2
+    if energy is None:
+        data_slices = (slice(None, None),) + data_slices
+        data_shape = (len(energies),) + data_shape
+    elif hasattr(energy, "__iter__"):
+        i_min = np.argwhere(energies >= energy[0])[0][0]
+        i_max = np.argwhere(energies <= energy[1])[-1][0] + 1
+        data_slices = (slice(i_min, i_max),) + data_slices
+        data_shape = (i_max - i_min,) + data_shape
+    else:  # Assume integer
+        # Always returns one integer
+        index = np.abs(energies - energy).argmin()
+        data_slices = (slice(index, index + 1),) + data_slices
+        data_shape = (1,) + data_shape
+    return data_shape, data_slices
+
+
+def _get_datasets(data_group: Group, projection: str, hemisphere: str) -> List[Dataset]:
+    """Return datasets from projection and hemisphere.
+
+    Parameters
+    ----------
+    data_group
+        HDF5 data group with data sets.
+    projection
+        ``"stereographic"`` or ``"lambert"`` projection.
+    hemisphere
+        ``"upper"``, ``"lower"`` or ``"both"`` hemisphere(s).
+
+    Returns
+    -------
+    datasets
+        List of HDF5 data sets.
+    """
+    hemisphere = hemisphere.lower()
+    projection = projection.lower()
+
+    projections = {"stereographic": "masterSP", "lambert": "mLP"}
+    hemispheres = {"upper": "NH", "lower": "SH"}
+
+    if projection not in projections.keys():
+        raise ValueError(
+            f"'projection' value {projection} must be one of {list(projections.keys())}"
+        )
+
+    if hemisphere == "both":
+        dset_names = [
+            projections[projection] + hemispheres[h] for h in hemispheres.keys()
+        ]
+        datasets = [data_group[n] for n in dset_names]
+    elif hemisphere in hemispheres:
+        dset_name = projections[projection] + hemispheres[hemisphere]
+        datasets = [data_group[dset_name]]
+    else:
+        raise ValueError(
+            f"'hemisphere' value {hemisphere} must be one of {list(hemispheres.keys())}"
+        )
+
+    return datasets
diff --git a/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py b/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
index 4758d860..1f930a5c 100644
--- a/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
+++ b/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
@@ -18,16 +18,10 @@
 """Reader of simulated EBSD master patterns from an EMsoft HDF5 file.
 """
 
-import os
 from pathlib import Path
-from typing import List, Optional, Tuple, Union
+from typing import List, Optional, Union
 
-import dask.array as da
-from h5py import File, Group, Dataset
-import numpy as np
-
-from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
-from kikuchipy.io.plugins.emsoft_ebsd import _crystaldata2phase
+from kikuchipy.io.plugins._emsoft_master_pattern import EMsoftMasterPatternReader
 
 
 __all__ = ["file_reader"]
@@ -37,8 +31,8 @@
 # ----------------------
 format_name = "emsoft_ebsd_master_pattern"
 description = (
-    "Read support for electron backscatter diffraction master patterns "
-    "stored in EMsoft's HDF5 file format."
+    "Read support for simulated electron backscatter diffraction (EBSD)"
+    "master patterns stored in an EMsoft HDF5 file."
 )
 full_support = False
 # Recognised file extension
@@ -51,6 +45,12 @@
 footprint = ["emdata/ebsdmaster"]
 
 
+class EMsoftEBSDMasterPatternReader(EMsoftMasterPatternReader):
+    diffraction_type = "EBSD"
+    cl_parameters_group_name = "MCCL"  # Monte Carlo openCL
+    energy_string = "EkeVs"
+
+
 def file_reader(
     filename: Union[str, Path],
     energy: Optional[range] = None,
@@ -59,10 +59,10 @@ def file_reader(
     lazy: bool = False,
     **kwargs,
 ) -> List[dict]:
-    """Read electron backscatter diffraction master patterns from
-    EMsoft's HDF5 file format :cite:`callahan2013dynamical`.
+    """Read simulated electron backscatter diffraction master patterns
+    from EMsoft's HDF5 file format :cite:`callahan2013dynamical`.
 
-    Not ment to be used directly; use :func:`~kikuchipy.load`.
+    Not meant to be used directly; use :func:`~kikuchipy.load`.
 
     Parameters
     ----------
@@ -90,239 +90,11 @@ def file_reader(
     signal_dict_list
         Data, axes, metadata and original metadata.
     """
-    mode = kwargs.pop("mode", "r")
-    f = File(filename, mode=mode, **kwargs)
-
-    # Check if the file is valid
-    _check_file_format(f)
-
-    # Set metadata and original metadata dictionary
-    md = {
-        "Signal": {"signal_type": "EBSDMasterPattern", "record_by": "image"},
-        "General": {
-            "title": f.filename.split("/")[-1].split(".")[0],
-            "original_filename": f.filename.split("/")[-1],
-        },
-    }
-    nml_params = _hdf5group2dict(f["NMLparameters"], recursive=True)
-
-    # Get phase information and add it to both the original metadata and
-    # a Phase object
-    crystal_data = _hdf5group2dict(f["CrystalData"])
-    nml_params["CrystalData"] = crystal_data
-    phase = _crystaldata2phase(crystal_data)
-    if phase.name == "":
-        # Get the phase name
-        try:
-            xtal_name = nml_params["MCCLNameList"]["xtalname"]
-            phase.name = os.path.splitext(xtal_name)[0]
-        except KeyError:
-            pass
-
-    # Get data shape and slices
-    data_group = f["EMData/EBSDmaster"]
-    energies = data_group["EkeVs"][()]
-    data_shape, data_slices = _get_data_shape_slices(
-        npx=nml_params["EBSDMasterNameList"]["npx"], energies=energies, energy=energy
+    reader = EMsoftEBSDMasterPatternReader(
+        filename=filename,
+        energy=energy,
+        projection=projection,
+        hemisphere=hemisphere,
+        lazy=lazy,
     )
-    i_min = data_slices[0].start
-    i_min = 0 if i_min is None else i_min
-    min_energy = energies[i_min]
-
-    # Get HDF5 data sets
-    datasets = _get_datasets(
-        data_group=data_group, projection=projection, hemisphere=hemisphere
-    )
-
-    # TODO: Data shape and slices are easier to handle if the reader
-    #  was a class (in addition to file_reader()) instead of a series of
-    #  functions
-    dataset_shape = data_shape
-    if projection.lower() == "lambert":
-        data_slices = (slice(None, None),) + data_slices
-        data_shape = (data_group["numset"][:][0],) + data_shape
-
-    data_shape = (len(datasets),) + data_shape
-
-    # Set up data reading
-    data_kwargs = {}
-    if lazy:
-        if datasets[0].chunks is None or datasets[0].shape != dataset_shape:
-            data_kwargs["chunks"] = "auto"
-        else:
-            data_kwargs["chunks"] = datasets[0].chunks
-        data_read_func = da.from_array
-        data_stack_func = da.stack
-    else:
-        data_read_func = np.asanyarray
-        data_stack_func = np.stack
-
-    # Read data
-    data = data_read_func(datasets[0][data_slices], **data_kwargs)
-    if data_shape[0] == 2:
-        data = data_stack_func(
-            [data, data_read_func(datasets[1][data_slices], **data_kwargs)], axis=0
-        )
-
-    if projection.lower() == "lambert":
-        if hemisphere.lower() == "both":
-            sum_axis = 1
-            data_shape = (data_shape[0],) + data_shape[2:]
-        else:
-            sum_axis = 0
-            data_shape = data_shape[1:]
-        data = data.sum(axis=sum_axis).astype(data.dtype)
-
-    # Remove 1-dimensions
-    data = data.squeeze()
-
-    if projection.lower() == "stereographic":
-        # Mirror about horizontal (flip up-down)
-        data = data[..., ::-1, :]
-
-    # Axes scales
-    energy_scale = nml_params["MCCLNameList"]["Ebinsize"]
-    scales = np.array([1, energy_scale, 1, 1])
-
-    ny, nx, sy, sx = data_shape
-    names = ["hemisphere", "energy", "height", "width"]
-    units = ["", "keV", "px", "px"]
-    offsets = [0, min_energy, -sy // 2, -sx // 2]
-    dim_idx = []
-    if ny != 1:
-        dim_idx.append(0)
-    if nx != 1:
-        dim_idx.append(1)
-    dim_idx += [2, 3]
-
-    # Create axis object
-    axes = [
-        {
-            "size": data.shape[i],
-            "index_in_array": i,
-            "name": names[j],
-            "scale": scales[j],
-            "offset": offsets[j],
-            "units": units[j],
-        }
-        for i, j in zip(range(data.ndim), dim_idx)
-    ]
-
-    output = {
-        "axes": axes,
-        "data": data,
-        "metadata": md,
-        "original_metadata": nml_params,
-        "phase": phase,
-        "projection": projection,
-        "hemisphere": hemisphere,
-    }
-
-    if not lazy:
-        f.close()
-
-    return [output]
-
-
-def _check_file_format(file: File):
-    """Return whether the HDF file is in EMsoft's master pattern file
-    format.
-
-    Parameters
-    ----------
-    file: h5py:File
-    """
-    try:
-        program_name = file["EMheader/EBSDmaster/ProgramName"][:][0].decode()
-        if program_name != "EMEBSDmaster.f90":
-            raise KeyError
-    except KeyError:
-        raise IOError(f"'{file.filename}' is not in EMsoft's master pattern format.")
-
-
-def _get_data_shape_slices(
-    npx: int,
-    energies: np.ndarray,
-    energy: Optional[tuple] = None,
-) -> Tuple[Tuple, Tuple[slice, ...]]:
-    """Determine the data shape from half the master pattern side
-    length, number of asymmetric positions if the square Lambert
-    projection is to be read, and an energy or energy range.
-
-    Parameters
-    ----------
-    npx
-        Half the number of pixels along x-direction of the square master
-        pattern. Half is used because that is what EMsoft uses.
-    energies
-        Beam energies.
-    energy
-        Desired beam energy or energy range.
-
-    Returns
-    -------
-    data_shape
-        Shape of data.
-    data_slices
-        Data to get, determined from `energy`.
-    """
-    data_shape = (npx * 2 + 1,) * 2
-    data_slices = (slice(None, None),) * 2
-    if energy is None:
-        data_slices = (slice(None, None),) + data_slices
-        data_shape = (len(energies),) + data_shape
-    elif hasattr(energy, "__iter__"):
-        i_min = np.argwhere(energies >= energy[0])[0][0]
-        i_max = np.argwhere(energies <= energy[1])[-1][0] + 1
-        data_slices = (slice(i_min, i_max),) + data_slices
-        data_shape = (i_max - i_min,) + data_shape
-    else:  # Assume integer
-        # Always returns one integer
-        index = np.abs(energies - energy).argmin()
-        data_slices = (slice(index, index + 1),) + data_slices
-        data_shape = (1,) + data_shape
-    return data_shape, data_slices
-
-
-def _get_datasets(data_group: Group, projection: str, hemisphere: str) -> List[Dataset]:
-    """Get datasets from projection and hemisphere.
-
-    Parameters
-    ----------
-    data_group
-        HDF5 data group with data sets.
-    projection
-        ``"stereographic"`` or ``"lambert"`` projection.
-    hemisphere
-        ``"upper"`` hemisphere, ``"lower"`` hemisphere, or ``"both"``.
-
-    Returns
-    -------
-    datasets
-        List of HDF5 data sets.
-    """
-    hemisphere = hemisphere.lower()
-    projection = projection.lower()
-
-    projections = {"stereographic": "masterSP", "lambert": "mLP"}
-    hemispheres = {"upper": "NH", "lower": "SH"}
-
-    if projection not in projections.keys():
-        raise ValueError(
-            f"'projection' value {projection} must be one of " f"{projections.keys()}"
-        )
-
-    if hemisphere == "both":
-        dset_names = [
-            projections[projection] + hemispheres[h] for h in hemispheres.keys()
-        ]
-        datasets = [data_group[n] for n in dset_names]
-    elif hemisphere in hemispheres:
-        dset_name = projections[projection] + hemispheres[hemisphere]
-        datasets = [data_group[dset_name]]
-    else:
-        raise ValueError(
-            f"'hemisphere' value {hemisphere} must be one of {list(hemispheres.keys())}"
-        )
-
-    return datasets
+    return reader.read(**kwargs)
diff --git a/kikuchipy/io/plugins/emsoft_ecp_master_pattern.py b/kikuchipy/io/plugins/emsoft_ecp_master_pattern.py
new file mode 100644
index 00000000..88a580f2
--- /dev/null
+++ b/kikuchipy/io/plugins/emsoft_ecp_master_pattern.py
@@ -0,0 +1,100 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Reader of simulated ECP master patterns from an EMsoft HDF5 file.
+"""
+
+from pathlib import Path
+from typing import List, Optional, Union
+
+from kikuchipy.io.plugins._emsoft_master_pattern import EMsoftMasterPatternReader
+
+
+__all__ = ["file_reader"]
+
+
+# Plugin characteristics
+# ----------------------
+format_name = "emsoft_ecp_master_pattern"
+description = (
+    "Read support for simulated electron channeling pattern (ECP)"
+    "master patterns stored in an EMsoft HDF5 file."
+)
+full_support = False
+# Recognised file extension
+file_extensions = ["h5", "hdf5"]
+default_extension = 0
+# Writing capabilities
+writes = False
+
+# Unique HDF5 footprint
+footprint = ["emdata/ecpmaster"]
+
+
+class EMsoftECPMasterPatternReader(EMsoftMasterPatternReader):
+    diffraction_type = "ECP"
+    cl_parameters_group_name = "MCCL"  # Monte Carlo openCL
+    energy_string = "EkeV"
+
+
+def file_reader(
+    filename: Union[str, Path],
+    energy: Optional[range] = None,
+    projection: str = "stereographic",
+    hemisphere: str = "upper",
+    lazy: bool = False,
+    **kwargs,
+) -> List[dict]:
+    """Read simulated electron channeling pattern (ECP) master patterns
+    from EMsoft's HDF5 file format :cite:`callahan2013dynamical`.
+
+    Not meant to be used directly; use :func:`~kikuchipy.load`.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF file.
+    energy
+        Desired beam energy or energy range. If not given (default), all
+        available energies are read.
+    projection
+        Projection(s) to read. Options are ``"stereographic"`` (default)
+        or ``"lambert"``.
+    hemisphere
+        Projection hemisphere(s) to read. Options are ``"upper"``
+        (default), ``"lower"`` or ``"both"``. If ``"both"``, these will
+        be stacked in the vertical navigation axis.
+    lazy
+        Open the data lazily without actually reading the data from disk
+        until requested. Allows opening datasets larger than available
+        memory. Default is ``False``.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+
+    Returns
+    -------
+    signal_dict_list
+        Data, axes, metadata and original metadata.
+    """
+    reader = EMsoftECPMasterPatternReader(
+        filename=filename,
+        energy=energy,
+        projection=projection,
+        hemisphere=hemisphere,
+        lazy=lazy,
+    )
+    return reader.read(**kwargs)
diff --git a/kikuchipy/io/plugins/emsoft_tkd_master_pattern.py b/kikuchipy/io/plugins/emsoft_tkd_master_pattern.py
new file mode 100644
index 00000000..5de8b3df
--- /dev/null
+++ b/kikuchipy/io/plugins/emsoft_tkd_master_pattern.py
@@ -0,0 +1,100 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Reader of simulated TKD master patterns from an EMsoft HDF5 file.
+"""
+
+from pathlib import Path
+from typing import List, Optional, Union
+
+from kikuchipy.io.plugins._emsoft_master_pattern import EMsoftMasterPatternReader
+
+
+__all__ = ["file_reader"]
+
+
+# Plugin characteristics
+# ----------------------
+format_name = "emsoft_tkd_master_pattern"
+description = (
+    "Read support for simulated transmission kikuchi diffraction (TKD)"
+    "master patterns stored in an EMsoft HDF5 file."
+)
+full_support = False
+# Recognised file extension
+file_extensions = ["h5", "hdf5"]
+default_extension = 0
+# Writing capabilities
+writes = False
+
+# Unique HDF5 footprint
+footprint = ["emdata/tkdmaster"]
+
+
+class EMsoftTKDMasterPatternReader(EMsoftMasterPatternReader):
+    diffraction_type = "TKD"
+    cl_parameters_group_name = "MCCLfoil"  # Monte Carlo openCL
+    energy_string = "EkeVs"
+
+
+def file_reader(
+    filename: Union[str, Path],
+    energy: Optional[range] = None,
+    projection: str = "stereographic",
+    hemisphere: str = "upper",
+    lazy: bool = False,
+    **kwargs,
+) -> List[dict]:
+    """Read simulated transmission kikuchi diffraction master patterns
+    from EMsoft's HDF5 file format :cite:`callahan2013dynamical`.
+
+    Not meant to be used directly; use :func:`~kikuchipy.load`.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF file.
+    energy
+        Desired beam energy or energy range. If not given (default), all
+        available energies are read.
+    projection
+        Projection(s) to read. Options are ``"stereographic"`` (default)
+        or ``"lambert"``.
+    hemisphere
+        Projection hemisphere(s) to read. Options are ``"upper"``
+        (default), ``"lower"`` or ``"both"``. If ``"both"``, these will
+        be stacked in the vertical navigation axis.
+    lazy
+        Open the data lazily without actually reading the data from disk
+        until requested. Allows opening datasets larger than available
+        memory. Default is ``False``.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+
+    Returns
+    -------
+    signal_dict_list
+        Data, axes, metadata and original metadata.
+    """
+    reader = EMsoftTKDMasterPatternReader(
+        filename=filename,
+        energy=energy,
+        projection=projection,
+        hemisphere=hemisphere,
+        lazy=lazy,
+    )
+    return reader.read(**kwargs)
diff --git a/kikuchipy/signals/__init__.py b/kikuchipy/signals/__init__.py
index 32487065..0dd22861 100644
--- a/kikuchipy/signals/__init__.py
+++ b/kikuchipy/signals/__init__.py
@@ -32,8 +32,10 @@
 __all__ = [
     "EBSD",
     "EBSDMasterPattern",
+    "ECPMasterPattern",
     "LazyEBSD",
     "LazyEBSDMasterPattern",
+    "LazyECPMasterPattern",
     "VirtualBSEImage",
     "util",
 ]
@@ -46,9 +48,11 @@ def __dir__():
 def __getattr__(name):
     _import_mapping = {
         "EBSD": "ebsd",
-        "LazyEBSD": "ebsd",
         "EBSDMasterPattern": "ebsd_master_pattern",
+        "ECPMasterPattern": "ecp_master_pattern",
+        "LazyEBSD": "ebsd",
         "LazyEBSDMasterPattern": "ebsd_master_pattern",
+        "LazyECPMasterPattern": "ecp_master_pattern",
         "VirtualBSEImage": "virtual_bse_image",
     }
     if name in __all__:
diff --git a/kikuchipy/signals/_kikuchi_master_pattern.py b/kikuchipy/signals/_kikuchi_master_pattern.py
new file mode 100644
index 00000000..6cc52559
--- /dev/null
+++ b/kikuchipy/signals/_kikuchi_master_pattern.py
@@ -0,0 +1,376 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from copy import deepcopy
+import logging
+from typing import Any, Optional, Tuple, Union
+from warnings import warn
+
+import hyperspy.api as hs
+import numpy as np
+from orix.crystal_map import Phase
+from orix.projections import StereographicProjection
+from orix.vector import Vector3d
+from scipy.interpolate import interpn
+from tqdm import tqdm
+
+from kikuchipy.signals._kikuchipy_signal import KikuchipySignal2D
+from kikuchipy.signals.util._master_pattern import _lambert2vector
+
+
+_logger = logging.getLogger(__name__)
+
+
+class KikuchiMasterPattern(KikuchipySignal2D, hs.signals.Signal2D):
+    """General class for Kikuchi master patterns.
+
+    Not meant to be used directly, see derived classes like
+    :class:`~kikuchipy.signals.EBSDMasterPattern`.
+
+    Parameters
+    ----------
+    *args
+        See :class:`~hyperspy._signals.signal2d.Signal2D`.
+    hemisphere : str
+        Which hemisphere the data contains, either ``"upper"``,
+        ``"lower"``, or ``"both"``.
+    phase : ~orix.crystal_map.Phase
+        The phase describing the crystal structure used in the master
+        pattern simulation.
+    projection : str
+        Which projection the pattern is in, ``"stereographic"`` or
+        ``"lambert"``.
+    **kwargs
+        See :class:`~hyperspy._signals.signal2d.Signal2D`.
+    """
+
+    _custom_properties = ["hemisphere", "phase", "projection"]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._hemisphere = kwargs.get("hemisphere")
+        self._phase = kwargs.get("phase", Phase())
+        self._projection = kwargs.get("projection")
+
+    @property
+    def _has_multiple_energies(self) -> bool:
+        return "energy" in [i.name for i in self.axes_manager.navigation_axes]
+
+    @property
+    def hemisphere(self) -> str:
+        """Return or set which hemisphere(s) the signal contains.
+
+        Options are ``"upper"``, ``"lower"`` or ``"both"``.
+
+        Parameters
+        ----------
+        value : str
+            Which hemisphere(s) the signal contains.
+        """
+        return self._hemisphere
+
+    @hemisphere.setter
+    def hemisphere(self, value: str):
+        self._hemisphere = value
+
+    @property
+    def phase(self) -> Phase:
+        """Return or set the phase describing the crystal structure used
+        in the master pattern simulation.
+
+        Parameters
+        ----------
+        value : ~orix.crystal_map.Phase
+            The phase used in the master pattern simulation.
+        """
+        return self._phase
+
+    @phase.setter
+    def phase(self, value: Phase):
+        self._phase = value
+
+    @property
+    def projection(self) -> str:
+        """Return or set which projection the pattern is in.
+
+        Parameters
+        ----------
+        value : str
+            Which projection the pattern is in, either
+            ``"stereographic"`` or ``"lambert"``.
+        """
+        return self._projection
+
+    @projection.setter
+    def projection(self, value: str):
+        self._projection = value
+
+    def as_lambert(self, show_progressbar: Optional[bool] = None) -> Any:
+        """Return a new master pattern in the Lambert projection
+        :cite:`callahan2013dynamical`.
+
+        Only implemented for non-lazy signals.
+
+        Returns
+        -------
+        lambert_master_pattern
+            Master pattern in the Lambert projection with the same data
+            shape but in 32-bit floating point data dtype.
+
+        Examples
+        --------
+        >>> import hyperspy.api as hs
+        >>> import kikuchipy as kp
+        >>> mp_sp = kp.data.nickel_ebsd_master_pattern_small()
+        >>> mp_sp.projection
+        'stereographic'
+        >>> mp_lp = mp_sp.as_lambert()
+        >>> mp_lp.projection
+        'lambert'
+        >>> _ = hs.plot.plot_images([mp_sp, mp_lp], per_row=2)
+        """
+        if self.projection == "lambert":
+            warn("Already in the Lambert projection, returning a deepcopy", UserWarning)
+            return self.deepcopy()
+
+        if self._lazy is True:
+            raise NotImplementedError("Only implemented for non-lazy signals")
+
+        # Set up square arrays
+        sig_shape = self.axes_manager.signal_shape[::-1]
+        arr = np.linspace(-1, 1, sig_shape[0], dtype=np.float64)
+        x_lambert, y_lambert = np.meshgrid(arr, arr)
+        x_lambert_flat = x_lambert.ravel()
+        y_lambert_flat = y_lambert.ravel()
+
+        # Get unit vectors per array coordinate, and then the
+        # corresponding (X, Y) coordinate in the stereographic
+        # projection
+        xyz_upper = _lambert2vector(x_lambert_flat, y_lambert_flat)
+        v = Vector3d(xyz_upper)
+        sp = StereographicProjection()
+        x_stereo, y_stereo = sp.vector2xy(v)
+        x_stereo += 1
+        y_stereo += 1
+
+        # Keyword arguments for interpolation
+        kwargs = {
+            "points": (arr + 1, arr + 1),
+            "xi": (y_stereo, x_stereo),
+            "method": "splinef2d",
+        }
+
+        nav_shape = self.axes_manager.navigation_shape
+        data_out = np.zeros(self.data.shape, dtype=np.float32)
+
+        n_iterations = self.axes_manager.navigation_size
+        if n_iterations == 0:
+            n_iterations = 1
+
+        iterable = np.ndindex(nav_shape[::-1])
+        if show_progressbar or (
+            show_progressbar is None and hs.preferences.General.show_progressbar
+        ):
+            iterable = tqdm(iterable, total=n_iterations)
+
+        for idx in iterable:
+            data_i = interpn(values=self.data[idx], **kwargs)
+            data_out[idx] = data_i.reshape(sig_shape)
+
+        return self.__class__(
+            data_out,
+            axes=list(self.axes_manager.as_dictionary().values()),
+            phase=self.phase.deepcopy(),
+            projection="lambert",
+            hemisphere=deepcopy(self.hemisphere),
+        )
+
+    def plot_spherical(
+        self,
+        energy: Union[int, float, None] = None,
+        return_figure: bool = False,
+        style: str = "surface",
+        plotter_kwargs: Union[dict] = None,
+        show_kwargs: Union[dict] = None,
+    ) -> "pyvista.Plotter":
+        """Plot the master pattern sphere.
+
+        This requires the master pattern to be in the stereographic
+        projection and both hemispheres to be present.
+
+        Parameters
+        ----------
+        energy
+            Acceleration voltage in kV used to simulate the master
+            pattern to plot. If not given, the highest energy is used.
+        return_figure
+            Whether to return the :class:`pyvista.Plotter` instance for
+            further modification and then plotting. Default is
+            ``False``. If ``True``, the figure is not plotted.
+        style
+            Visualization style of the mesh, either ``"surface"``
+            (default), ``"wireframe"`` or ``"points"``. In general,
+            ``"surface"`` is recommended when zoomed out, while
+            ``"points"`` is recommended when zoomed in. See
+            :meth:`pyvista.Plotter.add_mesh` for details.
+        plotter_kwargs
+            Dictionary of keyword arguments passed to
+            :class:`pyvista.Plotter`.
+        show_kwargs
+            Dictionary of keyword arguments passed to
+            :meth:`pyvista.Plotter.show` if ``return_figure=False``.
+
+        Returns
+        -------
+        pl
+            Only returned if ``return_figure=True``.
+
+        Notes
+        -----
+        Requires :mod:`pyvista` (see :ref:`the installation guide
+        <optional-dependencies>`).
+
+        Examples
+        --------
+        >>> import kikuchipy as kp
+        >>> mp = kp.data.nickel_ebsd_master_pattern_small(projection="stereographic")
+        >>> mp.plot_spherical()
+        """
+        from kikuchipy import _pyvista_installed
+
+        if not _pyvista_installed:  # pragma: no cover
+            raise ImportError(
+                "`pyvista` is required, see the installation guide for more information"
+            )
+        else:
+            from orix.projections import InverseStereographicProjection
+            from orix.vector import Vector3d
+            import pyvista as pv
+
+        if self.projection != "stereographic" or (
+            self.hemisphere != "both" and not self.phase.point_group.contains_inversion
+        ):
+            raise ValueError(
+                "Master pattern must be in the stereographic projection, and have both "
+                "hemispheres present if the phase is non-centrosymmetric"
+            )
+
+        mp_upper, mp_lower = self._get_master_pattern_arrays_from_energy(energy)
+
+        # Remove data outside equator and combine into a 1D array
+        keep = mp_upper != 0
+        data = np.ravel(np.stack((mp_upper[keep], mp_lower[keep])))
+
+        # Get vectors for upper and lower hemispheres into a 1D array
+        size = mp_upper.shape[0]
+        x, y = np.meshgrid(np.linspace(-1, 1, size), np.linspace(-1, 1, size))
+        x = x[keep]
+        y = y[keep]
+        stereo2sphere = InverseStereographicProjection()
+        v1 = stereo2sphere.xy2vector(x.ravel(), y.ravel())
+        stereo2sphere.pole = 1
+        v2 = stereo2sphere.xy2vector(x.ravel(), y.ravel())
+        v3 = Vector3d.stack((v1, v2)).flatten()
+
+        grid = pv.StructuredGrid(v3.x, v3.y, v3.z)
+        grid.point_data["Intensity"] = data
+
+        if plotter_kwargs is None:
+            plotter_kwargs = {}
+        pl = pv.Plotter(**plotter_kwargs)
+        pl.add_mesh(pv.Sphere(radius=0.99), color="gray", lighting=False)
+        pl.add_mesh(grid, style=style, scalar_bar_args=dict(color="k"), cmap="gray")
+        pl.add_axes(color="k", xlabel="e1", ylabel="e2", zlabel="e3")
+        pl.set_background("#fafafa")
+        pl.set_viewup((0, 1, 0))
+
+        if return_figure:
+            return pl
+        else:
+            if show_kwargs is None:
+                show_kwargs = {}
+            pl.show(**show_kwargs)
+
+    def _get_master_pattern_arrays_from_energy(
+        self, energy: Union[int, float, None] = None
+    ) -> Tuple[np.ndarray, np.ndarray]:
+        """Return upper and lower master patterns created with a single,
+        given energy.
+
+        Parameters
+        ----------
+        energy
+            Acceleration voltage in kV. If only a single energy is
+            present in the signal, this will be returned no matter its
+            energy. If not given, the highest energy is used.
+
+        Returns
+        -------
+        master_upper
+            Upper hemisphere of the master pattern.
+        master_lower
+            Lower hemisphere of master pattern.
+        """
+        if self._has_multiple_energies:
+            if energy is None:
+                energy = self.axes_manager["energy"].axis[-1]
+            master_patterns = self.inav[float(energy)].data
+        else:  # Assume a single energy
+            master_patterns = self.data
+        if self.hemisphere == "both":
+            master_upper, master_lower = master_patterns
+        else:
+            master_upper = master_lower = master_patterns
+        return master_upper, master_lower
+
+    # --- Inherited methods from KikuchipySignal2D overwritten
+
+    def deepcopy(self) -> Any:
+        """Return a deep copy using :func:`copy.deepcopy`.
+
+        Returns
+        -------
+        s_new
+            Identical signal without shared memory.
+
+        Examples
+        --------
+        >>> import numpy as np
+        >>> import kikuchipy as kp
+        >>> mp = kp.data.nickel_ebsd_master_pattern_small()
+        >>> mp2 = mp.deepcopy()
+        >>> np.may_share_memory(mp.data, mp2.data)
+        False
+        """
+        return super().deepcopy()
+
+    # --- Inherited methods from Signal2D overwritten
+
+    def set_signal_type(self, signal_type: str = "") -> None:
+        if "master" in signal_type.lower():
+            properties = self._get_custom_properties()
+            super().set_signal_type(signal_type)
+            self._set_custom_properties(properties)
+        else:
+            properties = self._custom_properties
+            super().set_signal_type(signal_type)
+            _logger.info("Delete custom properties when setting signal type")
+            for name in properties:
+                try:
+                    self.__delattr__("_" + name)
+                except AttributeError:  # pragma: no cover
+                    pass
diff --git a/kikuchipy/signals/_common_image.py b/kikuchipy/signals/_kikuchipy_signal.py
similarity index 62%
rename from kikuchipy/signals/_common_image.py
rename to kikuchipy/signals/_kikuchipy_signal.py
index 89455d3f..522ea623 100644
--- a/kikuchipy/signals/_common_image.py
+++ b/kikuchipy/signals/_kikuchipy_signal.py
@@ -15,32 +15,53 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-import sys
-from typing import Union, Tuple, Optional
+from copy import deepcopy
+import gc
+import logging
+import os
+from typing import Any, Union, Tuple, Optional
 
+import dask.array as da
 from dask.diagnostics import ProgressBar
 import hyperspy.api as hs
 from hyperspy._signals.signal2d import Signal2D
+from hyperspy._lazy_signals import LazySignal2D
 from hyperspy.misc.rgb_tools import rgb_dtypes
-from hyperspy.api import preferences
 import numpy as np
 from skimage.util.dtype import dtype_range
+import yaml
 
 from kikuchipy.signals.util._dask import get_dask_array
 from kikuchipy.pattern import chunk
 
 
-class CommonImage(Signal2D):
-    """A class extending HyperSpy's Signal2D class with some common
-    intensity manipulation methods.
+_logger = logging.getLogger(__name__)
 
-    Methods inherited from HyperSpy can be found in the HyperSpy user
-    guide.
 
-    See the docstring of :class:`hyperspy.signal.BaseSignal` for a list
-    of attributes.
+# Signal type aliases defined in hyperspy_extension.yaml
+DIR_PATH = os.path.dirname(__file__)
+SIGNAL_TYPES = []
+with open(os.path.join(DIR_PATH, "../hyperspy_extension.yaml")) as f:
+    content = yaml.safe_load(f.read())
+    for _, info in content["signals"].items():
+        for k, v in info.items():
+            if k == "signal_type":
+                SIGNAL_TYPES.append(v)
+            elif k == "signal_type_aliases":
+                SIGNAL_TYPES.extend(v)
+
+
+class KikuchipySignal2D(Signal2D):
+    """General class for image signals in kikuchipy, extending
+    HyperSpy's Signal2D class with some methods for carrying over custom
+    properties and some methods for intensity manipulation.
+
+    Not meant to be used directly, see derived classes like
+    :class:`~kikuchipy.signals.EBSD`.
     """
 
+    _custom_properties = []
+
     def rescale_intensity(
         self,
         relative: bool = False,
@@ -92,7 +113,6 @@ def rescale_intensity(
 
         See Also
         --------
-        kikuchipy.pattern.rescale_intensity,
         :func:`skimage.exposure.rescale_intensity`
 
         Notes
@@ -278,3 +298,126 @@ def normalize_intensity(
                 pass
         else:
             self.data = normalized_images
+
+    def _get_custom_properties(self) -> dict:
+        """Return a dictionary of properties not in ``Signal2D``.
+
+        This is a quick way to get all custom properties of a class
+        before calling a method of ``Signal2D`` which returns a new
+        instance or operates in place on the current instance and does
+        not carry over these properties, like with ``deepcopy()``.
+
+        Returns
+        -------
+        dictionary
+            Dictionary with custom properties.
+        """
+        return {name: self.__getattribute__(name) for name in self._custom_properties}
+
+    def _set_custom_properties(
+        self,
+        properties: dict,
+        make_deepcopy: bool = False,
+        make_lazy: bool = False,
+        unmake_lazy: bool = False,
+    ):
+        """Set custom properties not in ``Signal2D``.
+
+        This is a quick way to set all custom properties of a class
+        after calling a method of ``Signal2D`` which returns a new
+        instance or operates in place on the current instance and does
+        not carry over these properties, like with ``deepcopy()``.
+
+        Parameters
+        ----------
+        properties
+            Dictionary of custom properties.
+        make_deepcopy
+            Whether to make a deepcopy of all properties before setting
+            them in this instance. Default is ``False``.
+        make_lazy
+            Whether to cast properties which are
+            :class:`~numpy.ndarray` to :class:`~dask.array.Array` before
+            setting them. Default is ``False``.
+        unmake_lazy
+            Whether to cast properties which are
+            :class:`~dask.array.Array` to :class:`~numpy.ndarray` before
+            setting them. Default is ``False``. Ignored if both this and
+            ``make_lazy`` are ``True``.
+        """
+        for name, value in properties.items():
+            if name in self._custom_properties:
+                if make_lazy and isinstance(value, np.ndarray):
+                    value = da.from_array(value)
+                elif unmake_lazy and isinstance(value, da.Array):
+                    value = value.compute()
+                if make_deepcopy:
+                    value = deepcopy(value)
+                self.__setattr__("_" + name, value)
+
+    # --- Inherited methods from Signal2D overwritten
+
+    def as_lazy(self, *args, **kwargs) -> Any:
+        s_new = super().as_lazy(*args, **kwargs)
+
+        if s_new._signal_type in SIGNAL_TYPES:
+            properties = self._get_custom_properties()
+            _logger.debug("Transfer custom properties when making lazy")
+            s_new._set_custom_properties(properties, make_lazy=True)
+
+        return s_new
+
+    def change_dtype(self, *args, **kwargs) -> None:
+        properties = self._get_custom_properties()
+
+        super().change_dtype(*args, **kwargs)
+
+        if self._signal_type in SIGNAL_TYPES:
+            _logger.debug("Transfer custom properties when changing dtype")
+            self._set_custom_properties(properties)
+        else:
+            _logger.debug("Delete custom properties when changing dtype")
+            for name in properties.keys():
+                try:
+                    self.__delattr__("_" + name)
+                except AttributeError:
+                    pass
+
+    def deepcopy(self) -> Any:
+        s_new = super().deepcopy()
+
+        if s_new._signal_type in SIGNAL_TYPES:
+            properties = self._get_custom_properties()
+            _logger.debug("Transfer custom properties when deep copying")
+            s_new._set_custom_properties(properties, make_deepcopy=True)
+
+        return s_new
+
+    def _assign_subclass(self):
+        properties = self._custom_properties
+
+        super()._assign_subclass()
+
+        if self._signal_type not in SIGNAL_TYPES:
+            _logger.debug("Delete custom properties when assigning subclass")
+            for name in properties:
+                try:
+                    self.__delattr__("_" + name)
+                except AttributeError:  # pragma: no cover
+                    pass
+
+
+class LazyKikuchipySignal2D(LazySignal2D, KikuchipySignal2D):
+    """General class for lazy image signals in kikuchipy, extending
+    HyperSpy's LazySignal2D class with some methods for carrying over
+    custom properties and some methods for intensity manipulation.
+
+    Not meant to be used directly, see derived classes like
+    :class:`~kikuchipy.signals.LazyEBSD`.
+    """
+
+    def compute(self, *args, **kwargs) -> None:
+        properties = self._get_custom_properties()
+        super().compute(*args, **kwargs)
+        gc.collect()
+        self._set_custom_properties(properties, unmake_lazy=True)
diff --git a/kikuchipy/signals/ebsd.py b/kikuchipy/signals/ebsd.py
index 670a1aa8..d01f8d9b 100644
--- a/kikuchipy/signals/ebsd.py
+++ b/kikuchipy/signals/ebsd.py
@@ -15,6 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
+from __future__ import annotations
 import copy
 import datetime
 import gc
@@ -26,7 +27,6 @@
 import dask.array as da
 from dask.diagnostics import ProgressBar
 import hyperspy.api as hs
-from hyperspy._lazy_signals import LazySignal2D
 from hyperspy.learn.mva import LearningResults
 from hyperspy.roi import BaseInteractiveROI
 from h5py import File
@@ -74,12 +74,11 @@
     _get_neighbour_dot_product_matrices,
     _get_average_dot_product_map,
 )
-from kikuchipy.signals._common_image import CommonImage
+from kikuchipy.signals._kikuchipy_signal import KikuchipySignal2D, LazyKikuchipySignal2D
 from kikuchipy.signals.virtual_bse_image import VirtualBSEImage
-from kikuchipy._util import deprecated_argument
 
 
-class EBSD(CommonImage, hs.signals.Signal2D):
+class EBSD(KikuchipySignal2D):
     """Scan of Electron Backscatter Diffraction (EBSD) patterns.
 
     This class extends HyperSpy's Signal2D class for EBSD patterns. See
@@ -109,11 +108,11 @@ class EBSD(CommonImage, hs.signals.Signal2D):
     kikuchipy.data.nickel_ebsd_large :
         An EBSD signal with ``(55, 75)`` experimental nickel patterns.
     kikuchipy.data.silicon_ebsd_moving_screen_in :
-        An EBSD signal with one experimental silicon patterns.
+        An EBSD signal with one experimental silicon pattern.
     kikuchipy.data.silicon_ebsd_moving_screen_out5mm :
-        An EBSD signal with one experimental silicon patterns.
+        An EBSD signal with one experimental silicon pattern.
     kikuchipy.data.silicon_ebsd_moving_screen_out10mm :
-        An EBSD signal with one experimental silicon patterns.
+        An EBSD signal with one experimental silicon pattern.
 
     Examples
     --------
@@ -142,11 +141,10 @@ class EBSD(CommonImage, hs.signals.Signal2D):
 
     _signal_type = "EBSD"
     _alias_signal_types = ["electron_backscatter_diffraction"]
-    _lazy = False
     _custom_properties = ["detector", "static_background", "xmap"]
 
     def __init__(self, *args, **kwargs):
-        super(EBSD, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         self._detector = kwargs.get(
             "detector",
@@ -183,7 +181,7 @@ def detector(self, value: EBSDDetector):
             self._detector = value
 
     @property
-    def xmap(self) -> CrystalMap:
+    def xmap(self) -> Union[CrystalMap, None]:
         """Return or set the crystal map containing the phases, unit
         cell rotations and auxiliary properties of the EBSD dataset.
 
@@ -502,7 +500,7 @@ def get_dynamic_background(
         dtype_out: Union[str, np.dtype, type, None] = None,
         show_progressbar: Optional[bool] = None,
         **kwargs,
-    ) -> Union["EBSD", "LazyEBSD"]:
+    ) -> Union[EBSD, LazyEBSD]:
         """Get the dynamic background per EBSD pattern in a scan.
 
         Parameters
@@ -772,7 +770,7 @@ def get_image_quality(
 
     def dictionary_indexing(
         self,
-        dictionary: "EBSD",
+        dictionary: EBSD,
         metric: Union[SimilarityMetric, str] = "ncc",
         keep_n: int = 20,
         n_per_iteration: Optional[int] = None,
@@ -1876,25 +1874,6 @@ def get_virtual_bse_intensity(
 
     # ------ Methods overwritten from hyperspy.signals.Signal2D ------ #
 
-    def as_lazy(self, *args, **kwargs) -> "EBSD":
-        new = super().as_lazy(*args, **kwargs)
-        if self.static_background is not None:
-            new._static_background = da.asarray(self.static_background)
-        return new
-
-    def deepcopy(self) -> "EBSD":
-        new = super().deepcopy()
-        try:
-            new._xmap = self.xmap.deepcopy()
-        except AttributeError:
-            pass
-        try:
-            new._static_background = self.static_background.copy()
-        except AttributeError:
-            pass
-        new._detector = self.detector.deepcopy()
-        return new
-
     def save(
         self,
         filename: Optional[str] = None,
@@ -1959,7 +1938,7 @@ def get_decomposition_model(
         self,
         components: Union[int, List[int], None] = None,
         dtype_out: Union[str, np.dtype, type] = "float32",
-    ) -> Union["EBSD", "LazyEBSD"]:
+    ) -> Union[EBSD, LazyEBSD]:
         """Get the model signal generated with the selected number of
         principal components from a decomposition.
 
@@ -2015,39 +1994,6 @@ def get_decomposition_model(
 
         return s_model
 
-    # -- Inherited methods included here for documentation purposes -- #
-
-    def rescale_intensity(
-        self,
-        relative: bool = False,
-        in_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
-        out_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
-        dtype_out: Union[
-            str, np.dtype, type, Tuple[int, int], Tuple[float, float], None
-        ] = None,
-        percentiles: Union[Tuple[int, int], Tuple[float, float], None] = None,
-        show_progressbar: Optional[bool] = None,
-    ) -> None:
-        super().rescale_intensity(
-            relative,
-            in_range,
-            out_range,
-            dtype_out,
-            percentiles,
-            show_progressbar,
-        )
-
-    def normalize_intensity(
-        self,
-        num_std: int = 1,
-        divide_by_square_root: bool = False,
-        dtype_out: Union[str, np.dtype, type, None] = None,
-        show_progressbar: Optional[bool] = None,
-    ) -> None:
-        super().normalize_intensity(
-            num_std, divide_by_square_root, dtype_out, show_progressbar
-        )
-
     # ------------------------ Private methods ----------------------- #
 
     def _check_refinement_parameters(
@@ -2135,36 +2081,67 @@ def _prepare_metric(
         metric.raise_error_if_invalid()
         return metric
 
-    def _get_custom_properties(self) -> dict:
-        return {name: self.__getattribute__(name) for name in self._custom_properties}
+    # --- Inherited methods from KikuchipySignal2D overwritten here for
+    # documentation purposes
+
+    def rescale_intensity(
+        self,
+        relative: bool = False,
+        in_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        out_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        dtype_out: Union[
+            str, np.dtype, type, Tuple[int, int], Tuple[float, float], None
+        ] = None,
+        percentiles: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        show_progressbar: Optional[bool] = None,
+    ) -> None:
+        super().rescale_intensity(
+            relative,
+            in_range,
+            out_range,
+            dtype_out,
+            percentiles,
+            show_progressbar,
+        )
+
+    def normalize_intensity(
+        self,
+        num_std: int = 1,
+        divide_by_square_root: bool = False,
+        dtype_out: Union[str, np.dtype, type, None] = None,
+        show_progressbar: Optional[bool] = None,
+    ) -> None:
+        super().normalize_intensity(
+            num_std, divide_by_square_root, dtype_out, show_progressbar
+        )
+
+    def as_lazy(self, *args, **kwargs) -> LazyEBSD:
+        return super().as_lazy(*args, **kwargs)
 
-    def _set_custom_properties(self, properties: dict):
-        for name, value in properties.items():
-            self.__setattr__("_" + name, value)
+    def change_dtype(self, *args, **kwargs) -> None:
+        super().change_dtype(*args, **kwargs)
+        if isinstance(self, EBSD) and isinstance(
+            self._static_background, (np.ndarray, da.Array)
+        ):
+            self._static_background = self._static_background.astype(self.data.dtype)
 
+    def deepcopy(self) -> EBSD:
+        return super().deepcopy()
 
-class LazyEBSD(LazySignal2D, EBSD):
-    """Lazy implementation of the ``EBSD`` class.
 
-    This class extends HyperSpy's LazySignal2D class for EBSD patterns.
-    Methods inherited from HyperSpy can be found in the HyperSpy
-    documentation.
+class LazyEBSD(LazyKikuchipySignal2D, EBSD):
+    """Lazy implementation of :class:`~kikuchipy.signals.EBSD`.
 
-    See docstring of :class:`EBSD` for attributes and methods.
-    """
+    See the documentation of ``EBSD`` for attributes and methods.
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    This class extends HyperSpy's
+    :class:`~hyperspy._signals.signal2d.LazySignal2D` class for EBSD
+    patterns. See the documentation of that class for how to create
+    this signal and the list of inherited attributes and methods.
+    """
 
     def compute(self, *args, **kwargs) -> None:
-        properties = self._get_custom_properties()
-
         super().compute(*args, **kwargs)
-        gc.collect()  # Don't sink
-
-        self._set_custom_properties(properties)
-        if isinstance(self.static_background, da.Array):
-            self._static_background = self._static_background.compute()
 
     def get_decomposition_model_write(
         self,
diff --git a/kikuchipy/signals/ebsd_master_pattern.py b/kikuchipy/signals/ebsd_master_pattern.py
index 21eb40ea..c00db4e0 100644
--- a/kikuchipy/signals/ebsd_master_pattern.py
+++ b/kikuchipy/signals/ebsd_master_pattern.py
@@ -15,37 +15,30 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-import copy
+from __future__ import annotations
 import gc
 from typing import Optional, Tuple, Union
-import warnings
 
 import dask.array as da
 from dask.diagnostics import ProgressBar
 import hyperspy.api as hs
-from hyperspy._lazy_signals import LazySignal2D
-from hyperspy._signals.signal2d import Signal2D
 import numpy as np
 from orix.crystal_map import CrystalMap, Phase, PhaseList
-from orix.projections import StereographicProjection
 from orix.quaternion import Rotation
-from orix.vector import Vector3d
 from skimage.util.dtype import dtype_range
-from scipy.interpolate import interpn
-from tqdm import tqdm
 
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
 from kikuchipy.signals import LazyEBSD, EBSD
-from kikuchipy.signals._common_image import CommonImage
+from kikuchipy.signals._kikuchi_master_pattern import KikuchiMasterPattern
+from kikuchipy.signals._kikuchipy_signal import LazyKikuchipySignal2D
 from kikuchipy.signals.util._dask import get_chunking
 from kikuchipy.signals.util._master_pattern import (
     _get_direction_cosines_for_single_pc_from_detector,
-    _lambert2vector,
     _project_patterns_from_master_pattern,
 )
 
 
-class EBSDMasterPattern(CommonImage, Signal2D):
+class EBSDMasterPattern(KikuchiMasterPattern):
     """Simulated Electron Backscatter Diffraction (EBSD) master pattern.
 
     This class extends HyperSpy's Signal2D class for EBSD master
@@ -86,75 +79,13 @@ class EBSDMasterPattern(CommonImage, Signal2D):
     >>> s.hemisphere
     'upper'
     >>> s.phase
-    <name: ni/ni. space group: Fm-3m. point group: m-3m. proper point group: 432. color: tab:blue>
+    <name: ni. space group: Fm-3m. point group: m-3m. proper point group: 432. color: tab:blue>
     >>> s.projection
     'stereographic'
     """
 
     _signal_type = "EBSDMasterPattern"
     _alias_signal_types = ["ebsd_master_pattern", "master_pattern"]
-    _lazy = False
-
-    # ---------------------- Custom properties ----------------------- #
-
-    def __init__(self, *args, **kwargs):
-        Signal2D.__init__(self, *args, **kwargs)
-        self._hemisphere = kwargs.pop("hemisphere", None)
-        self._phase = kwargs.pop("phase", Phase())
-        self._projection = kwargs.pop("projection", None)
-
-    @property
-    def _has_multiple_energies(self):
-        return "energy" in [i.name for i in self.axes_manager.navigation_axes]
-
-    @property
-    def hemisphere(self) -> str:
-        """Return or set which hemisphere the data contains.
-
-        Options are ``"upper"``, ``"lower"`` or ``"both"``.
-
-        Parameters
-        ----------
-        value : str
-            Which projection the pattern is in.
-        """
-        return self._hemisphere
-
-    @hemisphere.setter
-    def hemisphere(self, value: str):
-        self._hemisphere = value
-
-    @property
-    def phase(self) -> Phase:
-        """Return or set the phase describing the crystal structure used
-        in the master pattern simulation.
-
-        Parameters
-        ----------
-        value : ~orix.crystal_map.Phase
-            The phase used in the master pattern simulation.
-        """
-        return self._phase
-
-    @phase.setter
-    def phase(self, value: Phase):
-        self._phase = value
-
-    @property
-    def projection(self) -> str:
-        """Return or set which projection the pattern is in,
-        ``"stereographic"`` or ``"lambert"``.
-
-        Parameters
-        ----------
-        value : str
-            Which projection the pattern is in.
-        """
-        return self._projection
-
-    @projection.setter
-    def projection(self, value: str):
-        self._projection = value
 
     def get_patterns(
         self,
@@ -342,234 +273,109 @@ def get_patterns(
 
         return out
 
-    def plot_spherical(
-        self,
-        energy: Union[int, float, None] = None,
-        return_figure: bool = False,
-        style: str = "surface",
-        plotter_kwargs: Union[dict] = None,
-        show_kwargs: Union[dict] = None,
-    ) -> "pyvista.Plotter":
-        """Plot the master pattern sphere.
-
-        This requires the master pattern to be in the stereographic
-        projection and both hemispheres to be present.
+    def _is_suitable_for_projection(self, raise_if_not: bool = False) -> bool:
+        """Check whether the master pattern is suitable for projection
+        onto an EBSD detector and return a bool or raise an error
+        message if desired.
 
         Parameters
         ----------
-        energy
-            Acceleration voltage in kV used to simulate the master
-            pattern to plot. If not passed, the highest energy is used.
-        return_figure
-            Whether to return the :class:`pyvista.Plotter` instance for
-            further modification and then plotting. Default is
-            ``False``. If ``True``, the figure is not plotted.
-        style
-            Visualization style of the mesh, either ``"surface"``
-            (default), ``"wireframe"`` or ``"points"``. In general,
-            ``"surface"`` is recommended when zoomed out, while
-            ``"points"`` is recommended when zoomed in. See
-            :meth:`pyvista.Plotter.add_mesh` for details.
-        plotter_kwargs
-            Dictionary of keyword arguments passed to
-            :class:`pyvista.Plotter`.
-        show_kwargs
-            Dictionary of keyword arguments passed to
-            :meth:`pyvista.Plotter.show` if ``return_figure=False``.
+        raise_if_not
 
         Returns
         -------
-        pl
-            Only returned if ``return_figure=True``.
-
-        Notes
-        -----
-        Requires :mod:`pyvista` (see :ref:`the installation guide
-        <optional-dependencies>`).
-
-        Examples
-        --------
-        >>> import kikuchipy as kp
-        >>> mp = kp.data.nickel_ebsd_master_pattern_small(projection="stereographic")
-        >>> mp.plot_spherical()
+        suitable
+
+        Raises
+        ------
+        NotImplementedError
+            If master pattern is not in the Lambert projection.
+        AttributeError
+            If master pattern attribute `phase` does not have a valid
+            point group, or the point group does not have inversion
+            symmetry but only one of the hemispheres are available in
+            the signal.
         """
-        from kikuchipy import _pyvista_installed
-
-        if not _pyvista_installed:  # pragma: no cover
-            raise ImportError(
-                "`pyvista` is required, see the installation guide for more information"
+        suitable = True
+        error = None
+        if self.projection != "lambert":
+            error = NotImplementedError(
+                "Master pattern must be in the square Lambert projection"
             )
+            suitable = False
+        pg = self.phase.point_group
+        if pg is None:
+            error = AttributeError(
+                "Master pattern `phase` attribute must have a valid point group"
+            )
+            suitable = False
+        elif self.hemisphere != "both" and not pg.contains_inversion:
+            error = AttributeError(
+                "For point groups without inversion symmetry, like the current "
+                f"{pg.name}, both hemispheres must be present in the master pattern "
+                "signal"
+            )
+            suitable = False
+        if not suitable and raise_if_not:
+            raise error
         else:
-            from orix.projections import InverseStereographicProjection
-            from orix.vector import Vector3d
-            import pyvista as pv
+            return suitable
 
-        if self.projection != "stereographic" or (
-            self.hemisphere != "both" and not self.phase.point_group.contains_inversion
-        ):
-            raise ValueError(
-                "Master pattern must be in the stereographic projection, and have both "
-                "hemispheres present if the phase is non-centrosymmetric"
-            )
+    # --- Inherited methods and properties from KikuchiMasterPattern
+    # overwritten. If the inherited properties or methods are not
+    # altered, they are included for documentation purposes.
 
-        mp_upper, mp_lower = self._get_master_pattern_arrays_from_energy(energy)
-
-        # Remove data outside equator and combine into a 1D array
-        keep = mp_upper != 0
-        data = np.ravel(np.stack((mp_upper[keep], mp_lower[keep])))
-
-        # Get vectors for upper and lower hemispheres into a 1D array
-        size = mp_upper.shape[0]
-        x, y = np.meshgrid(np.linspace(-1, 1, size), np.linspace(-1, 1, size))
-        x = x[keep]
-        y = y[keep]
-        stereo2sphere = InverseStereographicProjection()
-        v1 = stereo2sphere.xy2vector(x.ravel(), y.ravel())
-        stereo2sphere.pole = 1
-        v2 = stereo2sphere.xy2vector(x.ravel(), y.ravel())
-        v3 = Vector3d.stack((v1, v2)).flatten()
-
-        grid = pv.StructuredGrid(v3.x, v3.y, v3.z)
-        grid.point_data["Intensity"] = data
-
-        if plotter_kwargs is None:
-            plotter_kwargs = {}
-        pl = pv.Plotter(**plotter_kwargs)
-        pl.add_mesh(pv.Sphere(radius=0.99), color="gray", lighting=False)
-        pl.add_mesh(grid, style=style, scalar_bar_args=dict(color="k"), cmap="gray")
-        pl.add_axes(color="k", xlabel="e1", ylabel="e2", zlabel="e3")
-        pl.set_background("#fafafa")
-        pl.set_viewup((0, 1, 0))
-
-        if return_figure:
-            return pl
-        else:
-            if show_kwargs is None:
-                show_kwargs = {}
-            pl.show(**show_kwargs)
+    @property
+    def _has_multiple_energies(self) -> bool:
+        return super()._has_multiple_energies
 
-    def as_lambert(
-        self, show_progressbar: Optional[bool] = None
-    ) -> "EBSDMasterPattern":
-        """Return a new master pattern in the Lambert projection
-        :cite:`callahan2013dynamical`.
+    @property
+    def hemisphere(self) -> str:
+        return super().hemisphere
 
-        Only implemented for non-lazy signals.
+    @hemisphere.setter
+    def hemisphere(self, value: str):
+        super(EBSDMasterPattern, type(self)).hemisphere.fset(self, value)
 
-        Returns
-        -------
-        lambert_master_pattern
-            Master pattern in the Lambert projection with the same data
-            shape but in 32-bit floating point data dtype.
-
-        Examples
-        --------
-        >>> import hyperspy.api as hs
-        >>> import kikuchipy as kp
-        >>> mp_sp = kp.data.nickel_ebsd_master_pattern_small()
-        >>> mp_sp.projection
-        'stereographic'
-        >>> mp_lp = mp_sp.as_lambert()
-        >>> mp_lp.projection
-        'lambert'
-        >>> _ = hs.plot.plot_images([mp_sp, mp_lp], per_row=2)
-        """
-        if self.projection == "lambert":
-            warnings.warn(
-                "Already in the Lambert projection, returning a deepcopy", UserWarning
-            )
-            return self.deepcopy()
-
-        if self._lazy is True:
-            raise NotImplementedError("Only implemented for non-lazy signals")
-
-        # Set up square arrays
-        sig_shape = self.axes_manager.signal_shape[::-1]
-        arr = np.linspace(-1, 1, sig_shape[0], dtype=np.float64)
-        x_lambert, y_lambert = np.meshgrid(arr, arr)
-        x_lambert_flat = x_lambert.ravel()
-        y_lambert_flat = y_lambert.ravel()
-
-        # Get unit vectors per array coordinate, and then the
-        # corresponding (X, Y) coordinate in the stereographic
-        # projection
-        xyz_upper = _lambert2vector(x_lambert_flat, y_lambert_flat)
-        v = Vector3d(xyz_upper)
-        sp = StereographicProjection()
-        x_stereo, y_stereo = sp.vector2xy(v)
-        x_stereo += 1
-        y_stereo += 1
-
-        # Keyword arguments for interpolation
-        kwargs = {
-            "points": (arr + 1, arr + 1),
-            "xi": (y_stereo, x_stereo),
-            "method": "splinef2d",
-        }
-
-        nav_shape = self.axes_manager.navigation_shape
-        data_out = np.zeros(self.data.shape, dtype=np.float32)
-
-        n_iterations = self.axes_manager.navigation_size
-        if n_iterations == 0:
-            n_iterations = 1
-
-        iterable = np.ndindex(nav_shape[::-1])
-        if show_progressbar or (
-            show_progressbar is None and hs.preferences.General.show_progressbar
-        ):
-            iterable = tqdm(iterable, total=n_iterations)
-
-        for idx in iterable:
-            data_i = interpn(values=self.data[idx], **kwargs)
-            data_out[idx] = data_i.reshape(sig_shape)
-
-        return self.__class__(
-            data_out,
-            axes=list(self.axes_manager.as_dictionary().values()),
-            phase=self.phase.deepcopy(),
-            projection="lambert",
-            hemisphere=copy.deepcopy(self.hemisphere),
-        )
+    @property
+    def phase(self) -> Phase:
+        return super().phase
 
-    # ------ Methods overwritten from hyperspy.signals.Signal2D ------ #
+    @phase.setter
+    def phase(self, value: Phase):
+        super(EBSDMasterPattern, type(self)).phase.fset(self, value)
 
-    def deepcopy(self) -> "EBSDMasterPattern":
-        """Return a deep copy using :func:`copy.deepcopy`.
+    @property
+    def projection(self) -> str:
+        return super().projection
 
-        Parameters
-        ----------
-        new
-            Identical master pattern without shared memory.
-        """
-        new = super().deepcopy()
-        new.phase = self.phase.deepcopy()
-        new.projection = copy.deepcopy(self.projection)
-        new.hemisphere = copy.deepcopy(self.hemisphere)
-        return new
+    @projection.setter
+    def projection(self, value: str):
+        super(EBSDMasterPattern, type(self)).projection.fset(self, value)
 
-    # -- Inherited methods included here for documentation purposes -- #
+    def as_lambert(self, show_progressbar: Optional[bool] = None) -> EBSDMasterPattern:
+        return super().as_lambert(show_progressbar=show_progressbar)
 
-    def rescale_intensity(
+    def plot_spherical(
         self,
-        relative: bool = False,
-        in_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
-        out_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
-        dtype_out: Union[
-            str, np.dtype, Tuple[int, int], Tuple[float, float], None
-        ] = None,
-        percentiles: Union[Tuple[int, int], Tuple[float, float], None] = None,
-        show_progressbar: Optional[bool] = None,
-    ) -> None:
-        super().rescale_intensity(
-            relative,
-            in_range,
-            out_range,
-            dtype_out,
-            percentiles,
-            show_progressbar,
+        energy: Union[int, float, None] = None,
+        return_figure: bool = False,
+        style: str = "surface",
+        plotter_kwargs: Union[dict] = None,
+        show_kwargs: Union[dict] = None,
+    ) -> "pyvista.Plotter":
+        return super().plot_spherical(
+            energy=energy,
+            return_figure=return_figure,
+            style=style,
+            plotter_kwargs=plotter_kwargs,
+            show_kwargs=show_kwargs,
         )
 
+    # --- Inherited methods from KikuchipySignal overwritten. If the
+    # inherited methods are not altered, they are included for
+    # documentation purposes.
+
     def normalize_intensity(
         self,
         num_std: int = 1,
@@ -578,93 +384,52 @@ def normalize_intensity(
         show_progressbar: Optional[bool] = None,
     ) -> None:
         super().normalize_intensity(
-            num_std,
-            divide_by_square_root,
-            dtype_out,
-            show_progressbar,
+            num_std=num_std,
+            divide_by_square_root=divide_by_square_root,
+            dtype_out=dtype_out,
+            show_progressbar=show_progressbar,
         )
 
-    # ------------------------ Private methods ----------------------- #
-
-    def _get_master_pattern_arrays_from_energy(
-        self, energy: Union[int, float, None] = None
-    ) -> Tuple[np.ndarray, np.ndarray]:
-        """Return upper and lower master patterns created with a single,
-        given energy.
-
-        Parameters
-        ----------
-        energy
-            Acceleration voltage in kV. If only a single energy is
-            present in the signal, this will be returned no matter its
-            energy. If not given, the highest energy is used.
-
-        Returns
-        -------
-        master_upper
-            Upper hemisphere of the master pattern.
-        master_lower
-            Lower hemisphere of master pattern.
-        """
-        if self._has_multiple_energies:
-            if energy is None:
-                energy = self.axes_manager["energy"].axis[-1]
-            master_patterns = self.inav[float(energy)].data
-        else:  # Assume a single energy
-            master_patterns = self.data
-        if self.hemisphere == "both":
-            master_upper, master_lower = master_patterns
-        else:
-            master_upper = master_lower = master_patterns
-        return master_upper, master_lower
+    def rescale_intensity(
+        self,
+        relative: bool = False,
+        in_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        out_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        dtype_out: Union[
+            str, np.dtype, type, Tuple[int, int], Tuple[float, float], None
+        ] = None,
+        percentiles: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        show_progressbar: Optional[bool] = None,
+    ) -> None:
+        super().rescale_intensity(
+            relative=relative,
+            in_range=in_range,
+            out_range=out_range,
+            dtype_out=dtype_out,
+            percentiles=percentiles,
+            show_progressbar=show_progressbar,
+        )
 
-    def _is_suitable_for_projection(self, raise_if_not: bool = False) -> bool:
-        """Check whether the master pattern is suitable for projection
-        onto an EBSD detector and return a bool or raise an error
-        message if desired.
-        """
-        suitable = True
-        error = None
-        if self.projection != "lambert":
-            error = NotImplementedError(
-                "Master pattern must be in the square Lambert projection"
-            )
-            suitable = False
-        pg = self.phase.point_group
-        if pg is None:
-            error = AttributeError(
-                "Master pattern `phase` attribute must have a valid point group"
-            )
-            suitable = False
-        elif self.hemisphere != "both" and not pg.contains_inversion:
-            error = AttributeError(
-                "For point groups without inversion symmetry, like the current "
-                f"{pg.name}, both hemispheres must be present in the master pattern "
-                "signal"
-            )
-            suitable = False
-        if not suitable and raise_if_not:
-            raise error
-        else:
-            return suitable
+    # --- Inherited methods from Signal2D overwritten. If the inherited
+    # methods are not altered, they are included for documentation
+    # purposes.
 
+    def deepcopy(self) -> EBSDMasterPattern:
+        return super().deepcopy()
 
-class LazyEBSDMasterPattern(EBSDMasterPattern, LazySignal2D):
-    """Lazy implementation of the ``EBSDMasterPattern`` class.
 
-    This class extends HyperSpy's LazySignal2D class for EBSD master
-    patterns.
+class LazyEBSDMasterPattern(LazyKikuchipySignal2D, EBSDMasterPattern):
+    """Lazy implementation of
+    :class:`~kikuchipy.signals.EBSDMasterPattern`.
 
-    See the docstring of :class:`EBSDMasterPattern` for attributes and
+    See the documentation of ``EBSDMasterPattern`` for attributes and
     methods.
 
-    See the documentation of
-    :class:`~hyperspy._signals.signal2d.LazySignal2D` for how to
-    initialize a ``LazyEBSDMasterPattern`` signal and the list of
-    inherited attributes and methods.
+    This class extends HyperSpy's
+    :class:`~hyperspy._signals.signal2d.LazySignal2D` class for EBSD
+    master patterns. See the documentation of that class for how to
+    create this signal and the list of inherited attributes and methods.
     """
 
-    _lazy = True
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def compute(self, *args, **kwargs) -> None:
+        super().compute(*args, **kwargs)
diff --git a/kikuchipy/signals/ecp_master_pattern.py b/kikuchipy/signals/ecp_master_pattern.py
new file mode 100644
index 00000000..bb015ca2
--- /dev/null
+++ b/kikuchipy/signals/ecp_master_pattern.py
@@ -0,0 +1,166 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import annotations
+from typing import Optional, Tuple, Union
+
+import numpy as np
+from orix.crystal_map import Phase
+
+from kikuchipy.signals._kikuchi_master_pattern import KikuchiMasterPattern
+from kikuchipy.signals._kikuchipy_signal import LazyKikuchipySignal2D
+
+
+class ECPMasterPattern(KikuchiMasterPattern):
+    """Simulated Electron Channeling Pattern (ECP) master pattern.
+
+    This class extends HyperSpy's Signal2D class for ECP master
+    patterns.
+
+    See the documentation of
+    :class:`~hyperspy._signals.signal2d.Signal2D` for the list of
+    inherited attributes and methods.
+
+    Parameters
+    ----------
+    *args
+        See :class:`~hyperspy._signals.signal2d.Signal2D`.
+    hemisphere : str
+        Which hemisphere the data contains, either ``"upper"``,
+        ``"lower"``, or ``"both"``.
+    phase : ~orix.crystal_map.Phase
+        The phase describing the crystal structure used in the master
+        pattern simulation.
+    projection : str
+        Which projection the pattern is in, ``"stereographic"`` or
+        ``"lambert"``.
+    **kwargs
+        See :class:`~hyperspy._signals.signal2d.Signal2D`.
+    """
+
+    _signal_type = "ECPMasterPattern"
+    _alias_signal_types = ["ecp_master_pattern"]
+
+    # --- Inherited methods and properties from KikuchiMasterPattern
+    # overwritten. If the inherited properties or methods are not
+    # altered, they are included for documentation purposes. Have to
+    # include both getters and setters to include the getter docstring.
+
+    @property
+    def _has_multiple_energies(self) -> bool:
+        return super()._has_multiple_energies
+
+    @property
+    def hemisphere(self) -> str:
+        return super().hemisphere
+
+    @hemisphere.setter
+    def hemisphere(self, value: str):
+        super(ECPMasterPattern, type(self)).hemisphere.fset(self, value)
+
+    @property
+    def phase(self) -> Phase:
+        return super().phase
+
+    @phase.setter
+    def phase(self, value: Phase):
+        super(ECPMasterPattern, type(self)).phase.fset(self, value)
+
+    @property
+    def projection(self) -> str:
+        return super().projection
+
+    @projection.setter
+    def projection(self, value: str):
+        super(ECPMasterPattern, type(self)).projection.fset(self, value)
+
+    def as_lambert(self, show_progressbar: Optional[bool] = None) -> ECPMasterPattern:
+        return super().as_lambert(show_progressbar=show_progressbar)
+
+    def plot_spherical(
+        self,
+        energy: Union[int, float, None] = None,
+        return_figure: bool = False,
+        style: str = "surface",
+        plotter_kwargs: Union[dict] = None,
+        show_kwargs: Union[dict] = None,
+    ) -> "pyvista.Plotter":
+        return super().plot_spherical(
+            energy=energy,
+            return_figure=return_figure,
+            style=style,
+            plotter_kwargs=plotter_kwargs,
+            show_kwargs=show_kwargs,
+        )
+
+    # --- Inherited methods from CommonImage overwritten
+
+    def normalize_intensity(
+        self,
+        num_std: int = 1,
+        divide_by_square_root: bool = False,
+        dtype_out: Union[str, np.dtype, type, None] = None,
+        show_progressbar: Optional[bool] = None,
+    ) -> None:
+        super().normalize_intensity(
+            num_std=num_std,
+            divide_by_square_root=divide_by_square_root,
+            dtype_out=dtype_out,
+            show_progressbar=show_progressbar,
+        )
+
+    def rescale_intensity(
+        self,
+        relative: bool = False,
+        in_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        out_range: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        dtype_out: Union[
+            str, np.dtype, type, Tuple[int, int], Tuple[float, float], None
+        ] = None,
+        percentiles: Union[Tuple[int, int], Tuple[float, float], None] = None,
+        show_progressbar: Optional[bool] = None,
+    ) -> None:
+        super().rescale_intensity(
+            relative=relative,
+            in_range=in_range,
+            out_range=out_range,
+            dtype_out=dtype_out,
+            percentiles=percentiles,
+            show_progressbar=show_progressbar,
+        )
+
+    # --- Inherited methods from Signal2D overwritten
+
+    def deepcopy(self) -> ECPMasterPattern:
+        return super().deepcopy()
+
+
+class LazyECPMasterPattern(LazyKikuchipySignal2D, ECPMasterPattern):
+    """Lazy implementation of
+    :class:`~kikuchipy.signals.ECPMasterPattern`.
+
+    See the documentation of ``ECPMasterPattern`` for attributes and
+    methods.
+
+    This class extends HyperSpy's
+    :class:`~hyperspy._signals.signal2d.LazySignal2D` class for ECP
+    master patterns. See the documentation of that class for how to
+    create this signal and the list of inherited attributes and methods.
+    """
+
+    def compute(self, *args, **kwargs) -> None:
+        super().compute(*args, **kwargs)
diff --git a/kikuchipy/signals/virtual_bse_image.py b/kikuchipy/signals/virtual_bse_image.py
index c9f00963..1c1581e8 100644
--- a/kikuchipy/signals/virtual_bse_image.py
+++ b/kikuchipy/signals/virtual_bse_image.py
@@ -17,13 +17,12 @@
 
 from typing import Optional, Tuple, Union
 
-from hyperspy._signals.signal2d import Signal2D
 import numpy as np
 
-from kikuchipy.signals._common_image import CommonImage
+from kikuchipy.signals._kikuchipy_signal import KikuchipySignal2D
 
 
-class VirtualBSEImage(CommonImage, Signal2D):
+class VirtualBSEImage(KikuchipySignal2D):
     """Virtual backscatter electron (BSE) image(s).
 
     This class extends HyperSpy's Signal2D class for virtual BSE images.
@@ -36,9 +35,6 @@ class VirtualBSEImage(CommonImage, Signal2D):
     _alias_signal_types = ["virtual_backscatter_electron_image"]
     _lazy = False
 
-    def __init__(self, *args, **kwargs):
-        Signal2D.__init__(self, *args, **kwargs)
-
     # -- Inherited methods included here for documentation purposes -- #
 
     def rescale_intensity(
diff --git a/kikuchipy/simulations/kikuchi_pattern_simulator.py b/kikuchipy/simulations/kikuchi_pattern_simulator.py
index b5e7cf77..6c2e8dc0 100644
--- a/kikuchipy/simulations/kikuchi_pattern_simulator.py
+++ b/kikuchipy/simulations/kikuchi_pattern_simulator.py
@@ -66,7 +66,7 @@
 from orix.crystal_map import Phase
 from orix.plot._util import Arrow3D
 from orix.quaternion import Rotation
-from orix.vector import Miller, Vector3d
+from orix.vector import Vector3d
 
 from kikuchipy import _pyvista_installed
 from kikuchipy.detectors import EBSDDetector
@@ -512,7 +512,7 @@ def plot(
 
         # Invert the intensity
         if scaling in ["linear", "square"]:
-            intensity = intensity / np.max(intensity)
+            intensity /= np.max(intensity)
             intensity = abs(intensity - intensity.min() - intensity.max())
         color = np.full((ref.size, 3), intensity[:, np.newaxis])  # RGB
 
diff --git a/setup.cfg b/setup.cfg
index 5626581c..cc98a940 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -11,7 +11,7 @@ addopts =
     --ignore=examples/*/*.py
     # Scripts for creating dummy data
     --ignore=kikuchipy/data/bruker_h5ebsd/create_bruker_h5ebsd_file.py
-    --ignore=kikuchipy/data/bruker_h5ebsd/create_oxford_h5ebsd_file.py
+    --ignore=kikuchipy/data/oxford_h5ebsd/create_oxford_h5ebsd_file.py
     --ignore=kikuchipy/data/emsoft_ebsd/create_dummy_emsoft_ebsd_file.py
     --ignore=kikuchipy/data/oxford_binary/create_oxford_binary_file.py
     --ignore-glob=kikuchipy/data/emsoft_ebsd_master_pattern/*.py
diff --git a/setup.py b/setup.py
index 54262ba0..c0df52f9 100644
--- a/setup.py
+++ b/setup.py
@@ -149,6 +149,7 @@
         "numpy              >= 1.19",
         "orix               >= 0.9",
         "pooch              >= 0.13",
+        "pyyaml",
         "tqdm               >= 0.5.2",
         "scikit-image       >= 0.16.2",
         "scikit-learn",


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e05271cd..a6dec959 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -77,10 +77,7 @@ jobs:
           pip install wheel
           pip install -U -e .'[tests]'
 
-      # TODO: Allow installing on 3.10 once vtk is available on 3.10,
-      # see https://pypi.org/project/vtk/#files
       - name: Install optional dependencies
-        if: ${{ matrix.python-version == 3.9 }}
         shell: bash
         run: |
           pip install -e .'[viz]'
@@ -98,7 +95,7 @@ jobs:
           /sbin/ldconfig -p | grep stdc++ # Temporarily check location
 
       - name: Set up headless display necessary for PyVista on Windows
-        if: ${{ matrix.os == 'windows-latest' && matrix.python-version == 3.9 }}
+        if: ${{ matrix.os == 'windows-latest' }}
         uses: pyvista/setup-headless-display-action@v1
 
       - name: Display package versions
@@ -109,17 +106,17 @@ jobs:
         if: ${{ matrix.os == 'ubuntu-latest' }}
         continue-on-error: true
         run: |
-          xvfb-run pytest --doctest-modules --ignore-glob=kikuchipy/*/tests kikuchipy/*.py
+          xvfb-run pytest --doctest-modules --doctest-continue-on-failure --ignore-glob=kikuchipy/*/tests kikuchipy/*.py
 
       - name: Run tests in a virtual X server environment on Ubuntu
-        if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == 3.9 }}
+        if: ${{ matrix.os == 'ubuntu-latest' }}
         run: |
           sudo apt-get update
           sudo apt-get install xvfb
           xvfb-run pytest --cov=kikuchipy --pyargs kikuchipy -n 2
 
-      - name: Run tests
-        if: ${{ ! ( matrix.os == 'ubuntu-latest' && matrix.python-version == 3.9 ) }}
+      - name: Run tests on Windows and macOS
+        if: ${{ matrix.os != 'ubuntu-latest' }}
         run: |
           pytest --cov=kikuchipy --pyargs kikuchipy -n 2
 
diff --git a/kikuchipy/_util/tests/test_import.py b/kikuchipy/_util/tests/test_import.py
index ff40ce96..e45f3605 100644
--- a/kikuchipy/_util/tests/test_import.py
+++ b/kikuchipy/_util/tests/test_import.py
@@ -206,6 +206,7 @@ def test_dir(self):
             "pattern",
             "projections",
             "release",
+            "set_log_level",
             "signals",
             "simulations",
         ]
@@ -284,6 +285,8 @@ def test_dir_io_plugins(self):
             "edax_h5ebsd",
             "emsoft_ebsd",
             "emsoft_ebsd_master_pattern",
+            "emsoft_ecp_master_pattern",
+            "emsoft_tkd_master_pattern",
             "kikuchipy_h5ebsd",
             "nordif",
             "nordif_calibration_patterns",
@@ -324,8 +327,10 @@ def test_dir_signals(self):
         assert dir(kikuchipy.signals) == [
             "EBSD",
             "EBSDMasterPattern",
+            "ECPMasterPattern",
             "LazyEBSD",
             "LazyEBSDMasterPattern",
+            "LazyECPMasterPattern",
             "VirtualBSEImage",
             "util",
         ]
diff --git a/kikuchipy/_util/tests/test_logging.py b/kikuchipy/_util/tests/test_logging.py
new file mode 100644
index 00000000..12acc0c1
--- /dev/null
+++ b/kikuchipy/_util/tests/test_logging.py
@@ -0,0 +1,38 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+
+import kikuchipy as kp
+
+
+class TestLogging:
+    def test_logging(self, caplog, dummy_signal):
+        logger = logging.getLogger("kikuchipy")
+        assert logger.level == 0  # Warning
+
+        # No info messages are logged
+        _ = dummy_signal.deepcopy()
+        assert len(caplog.records) == 0
+
+        kp.set_log_level("DEBUG")
+        assert logger.level == 10
+
+        # Info messages are logged
+        _ = dummy_signal.deepcopy()
+        for record in caplog.records:
+            assert record.levelname == "DEBUG"
diff --git a/kikuchipy/io/plugins/tests/test_emsoft_ebsd_master_pattern.py b/kikuchipy/io/plugins/tests/test_emsoft_ebsd_master_pattern.py
index 300fdb3a..335a3b5c 100644
--- a/kikuchipy/io/plugins/tests/test_emsoft_ebsd_master_pattern.py
+++ b/kikuchipy/io/plugins/tests/test_emsoft_ebsd_master_pattern.py
@@ -23,11 +23,6 @@
 
 from kikuchipy import load
 from kikuchipy.conftest import assert_dictionary
-from kikuchipy.io.plugins.emsoft_ebsd_master_pattern import (
-    _check_file_format,
-    _get_data_shape_slices,
-    _get_datasets,
-)
 from kikuchipy.signals.ebsd_master_pattern import (
     EBSDMasterPattern,
     LazyEBSDMasterPattern,
@@ -109,77 +104,6 @@ def test_projection_lambert(self):
         assert s.data.shape == (2, 11, 13, 13)
         assert_dictionary(s.axes_manager.as_dictionary(), setup_axes_manager())
 
-    def test_check_file_format(self, save_path_hdf5):
-        with File(save_path_hdf5, mode="w") as f:
-            g1 = f.create_group("EMheader")
-            g2 = g1.create_group("EBSDmaster")
-            g2.create_dataset(
-                "ProgramName", data=np.array([b"EMEBSDmasterr.f90"], dtype="S17")
-            )
-            with pytest.raises(IOError, match=".* is not in EMsoft's master "):
-                _check_file_format(f)
-
-    @pytest.mark.parametrize(
-        (
-            "npx, energies, energy, expected_shape, expected_slices, "
-            "expected_min_max_energy"
-        ),
-        [
-            (
-                25,
-                np.linspace(1, 10, 10),
-                None,
-                (10, 51, 51),
-                (slice(None, None), slice(None, None), slice(None, None)),
-                (1, 10),
-            ),
-            (
-                64,
-                np.linspace(10, 20, 11) * 1.5,
-                (17.3, 24.7),
-                (5, 129, 129),
-                (slice(2, 7), slice(None, None), slice(None, None)),
-                (18, 24),
-            ),
-            (
-                64,
-                np.linspace(10, 20, 11) * 1.5,
-                15,
-                (1, 129, 129),
-                (slice(0, 1), slice(None, None), slice(None, None)),
-                (15, 15),
-            ),
-            (
-                64,
-                np.linspace(10, 20, 11) * 1.5,
-                23,
-                (1, 129, 129),
-                (slice(5, 6), slice(None, None), slice(None, None)),
-                (22.5, 22.5),
-            ),
-        ],
-    )
-    def test_get_data_shape_slices(
-        self,
-        npx,
-        energies,
-        energy,
-        expected_shape,
-        expected_slices,
-        expected_min_max_energy,
-    ):
-        data_shape, data_slices = _get_data_shape_slices(
-            npx=npx, energies=energies, energy=energy
-        )
-
-        assert data_shape == expected_shape
-        assert data_slices == expected_slices
-
-        keep_energies = energies[data_slices[0]]
-        assert np.allclose(
-            (keep_energies.min(), keep_energies.max()), expected_min_max_energy
-        )
-
     @pytest.mark.parametrize("projection", ["stereographic", "lambert"])
     def test_load_lazy(self, projection):
         """The Lambert projection's lower hemisphere is stored chunked."""
@@ -191,40 +115,6 @@ def test_load_lazy(self, projection):
 
         assert isinstance(s, EBSDMasterPattern)
 
-    @pytest.mark.parametrize(
-        "projection, hemisphere, dataset_names",
-        [
-            ("stereographic", "Upper", ["masterSPNH"]),
-            ("stereographic", "both", ["masterSPNH", "masterSPSH"]),
-            ("lambert", "lower", ["mLPSH"]),
-            ("Lambert", "BOTH", ["mLPNH", "mLPSH"]),
-        ],
-    )
-    def test_get_datasets(self, projection, hemisphere, dataset_names):
-        with File(EMSOFT_FILE) as f:
-            datasets = _get_datasets(
-                data_group=f["EMData/EBSDmaster"],
-                projection=projection,
-                hemisphere=hemisphere,
-            )
-            assert [i.name.split("/")[-1] for i in datasets] == dataset_names
-
-    @pytest.mark.parametrize(
-        "projection, hemisphere, error_msg",
-        [
-            ("stereographicl", "upper", "'projection' value stereographicl "),
-            ("lambert", "east", "'hemisphere' value east "),
-        ],
-    )
-    def test_get_datasets_raises(self, projection, hemisphere, error_msg):
-        with File(EMSOFT_FILE) as f:
-            with pytest.raises(ValueError, match=error_msg):
-                _ = _get_datasets(
-                    data_group=f["EMData/EBSDmaster"],
-                    projection=projection,
-                    hemisphere=hemisphere,
-                )
-
     @pytest.mark.parametrize(
         "energy, energy_slice, desired_shape, desired_mean_energies",
         [
diff --git a/kikuchipy/io/plugins/tests/test_emsoft_ecp_master_pattern.py b/kikuchipy/io/plugins/tests/test_emsoft_ecp_master_pattern.py
new file mode 100644
index 00000000..28cab61b
--- /dev/null
+++ b/kikuchipy/io/plugins/tests/test_emsoft_ecp_master_pattern.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+import pytest
+
+import kikuchipy as kp
+
+
+DIR_PATH = os.path.dirname(__file__)
+EMSOFT_FILE = os.path.join(
+    DIR_PATH, "../../../data/emsoft_ecp_master_pattern/ecp_master_pattern.h5"
+)
+
+
+class TestEMsoftECPMasterPatternReader:
+    """All other functionality supported by the reader is tested in the
+    EBSD master pattern reader tests.
+    """
+
+    @pytest.mark.parametrize(
+        "lazy, class_type",
+        [(False, kp.signals.ECPMasterPattern), (True, kp.signals.LazyECPMasterPattern)],
+    )
+    def test_file_reader(self, lazy, class_type):
+        s = kp.load(EMSOFT_FILE, lazy=lazy)
+        assert isinstance(s, class_type)
diff --git a/kikuchipy/io/plugins/tests/test_emsoft_master_pattern.py b/kikuchipy/io/plugins/tests/test_emsoft_master_pattern.py
new file mode 100644
index 00000000..9d706c8d
--- /dev/null
+++ b/kikuchipy/io/plugins/tests/test_emsoft_master_pattern.py
@@ -0,0 +1,141 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+from h5py import File
+import numpy as np
+import pytest
+
+from kikuchipy.io.plugins._emsoft_master_pattern import (
+    _check_file_format,
+    _get_data_shape_slices,
+    _get_datasets,
+)
+
+
+DIR_PATH = os.path.dirname(__file__)
+EMSOFT_FILE = os.path.join(
+    DIR_PATH, "../../../data/emsoft_ebsd_master_pattern/master_patterns.h5"
+)
+
+
+class TestEMsoftEBSDMasterPatternReader:
+    def test_check_file_format(self, save_path_hdf5):
+        with File(save_path_hdf5, mode="w") as f:
+            g1 = f.create_group("EMheader")
+            g2 = g1.create_group("EBSDmaster")
+            g2.create_dataset(
+                "ProgramName", data=np.array([b"EMEBSDmasterr.f90"], dtype="S17")
+            )
+            with pytest.raises(IOError, match=".* is not in EMsoft's master "):
+                _check_file_format(f, "EBSD")
+
+    @pytest.mark.parametrize(
+        (
+            "npx, energies, energy, expected_shape, expected_slices, "
+            "expected_min_max_energy"
+        ),
+        [
+            (
+                25,
+                np.linspace(1, 10, 10),
+                None,
+                (10, 51, 51),
+                (slice(None, None), slice(None, None), slice(None, None)),
+                (1, 10),
+            ),
+            (
+                64,
+                np.linspace(10, 20, 11) * 1.5,
+                (17.3, 24.7),
+                (5, 129, 129),
+                (slice(2, 7), slice(None, None), slice(None, None)),
+                (18, 24),
+            ),
+            (
+                64,
+                np.linspace(10, 20, 11) * 1.5,
+                15,
+                (1, 129, 129),
+                (slice(0, 1), slice(None, None), slice(None, None)),
+                (15, 15),
+            ),
+            (
+                64,
+                np.linspace(10, 20, 11) * 1.5,
+                23,
+                (1, 129, 129),
+                (slice(5, 6), slice(None, None), slice(None, None)),
+                (22.5, 22.5),
+            ),
+        ],
+    )
+    def test_get_data_shape_slices(
+        self,
+        npx,
+        energies,
+        energy,
+        expected_shape,
+        expected_slices,
+        expected_min_max_energy,
+    ):
+        data_shape, data_slices = _get_data_shape_slices(
+            npx=npx, energies=energies, energy=energy
+        )
+
+        assert data_shape == expected_shape
+        assert data_slices == expected_slices
+
+        keep_energies = energies[data_slices[0]]
+        assert np.allclose(
+            (keep_energies.min(), keep_energies.max()), expected_min_max_energy
+        )
+
+    @pytest.mark.parametrize(
+        "projection, hemisphere, dataset_names",
+        [
+            ("stereographic", "Upper", ["masterSPNH"]),
+            ("stereographic", "both", ["masterSPNH", "masterSPSH"]),
+            ("lambert", "lower", ["mLPSH"]),
+            ("Lambert", "BOTH", ["mLPNH", "mLPSH"]),
+        ],
+    )
+    def test_get_datasets(self, projection, hemisphere, dataset_names):
+        with File(EMSOFT_FILE) as f:
+            datasets = _get_datasets(
+                data_group=f["EMData/EBSDmaster"],
+                projection=projection,
+                hemisphere=hemisphere,
+            )
+            assert [i.name.split("/")[-1] for i in datasets] == dataset_names
+
+    @pytest.mark.parametrize(
+        "projection, hemisphere, error_msg",
+        [
+            ("stereographicl", "upper", "'projection' value stereographicl "),
+            ("lambert", "east", "'hemisphere' value east "),
+        ],
+    )
+    def test_get_datasets_raises(self, projection, hemisphere, error_msg):
+        with File(EMSOFT_FILE) as f:
+            with pytest.raises(ValueError, match=error_msg):
+                _ = _get_datasets(
+                    data_group=f["EMData/EBSDmaster"],
+                    projection=projection,
+                    hemisphere=hemisphere,
+                )
diff --git a/kikuchipy/io/plugins/tests/test_emsoft_tkd_master_pattern.py b/kikuchipy/io/plugins/tests/test_emsoft_tkd_master_pattern.py
new file mode 100644
index 00000000..fd9329e4
--- /dev/null
+++ b/kikuchipy/io/plugins/tests/test_emsoft_tkd_master_pattern.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+import pytest
+
+from kikuchipy import load
+from kikuchipy.signals.ebsd_master_pattern import (
+    EBSDMasterPattern,
+    LazyEBSDMasterPattern,
+)
+
+
+DIR_PATH = os.path.dirname(__file__)
+EMSOFT_FILE = os.path.join(
+    DIR_PATH, "../../../data/emsoft_tkd_master_pattern/tkd_master_pattern.h5"
+)
+
+
+class TestEMsoftTKDMasterPatternReader:
+    """All other functionality supported by the reader is tested in the
+    EBSD master pattern reader tests.
+    """
+
+    @pytest.mark.parametrize(
+        "lazy, class_type", [(False, EBSDMasterPattern), (True, LazyEBSDMasterPattern)]
+    )
+    def test_file_reader(self, lazy, class_type):
+        s = load(EMSOFT_FILE, lazy=lazy)
+        assert isinstance(s, class_type)
diff --git a/kikuchipy/signals/tests/test_ebsd.py b/kikuchipy/signals/tests/test_ebsd.py
index 700cedb7..80d59c86 100644
--- a/kikuchipy/signals/tests/test_ebsd.py
+++ b/kikuchipy/signals/tests/test_ebsd.py
@@ -19,7 +19,7 @@
 
 import dask
 import dask.array as da
-from hyperspy.utils.roi import RectangularROI
+import hyperspy.api as hs
 import matplotlib.pyplot as plt
 import numpy as np
 from orix.crystal_map import CrystalMap, Phase
@@ -51,17 +51,6 @@ def test_init(self):
         s1 = kp.signals.EBSD(array1)
         assert array1.shape == s1.axes_manager.shape
 
-    def test_as_lazy(self, dummy_signal):
-        lazy_signal = dummy_signal.as_lazy()
-
-        # Assert that lazy attribute and class changed, while metadata was not
-        # changed
-        assert lazy_signal._lazy is True
-        assert lazy_signal.__class__ == kp.signals.LazyEBSD
-        assert_dictionary(
-            dummy_signal.metadata.as_dictionary(), lazy_signal.metadata.as_dictionary()
-        )
-
     def test_set_scan_calibration(self, dummy_signal):
         (new_step_x, new_step_y) = (2, 3)
         dummy_signal.set_scan_calibration(step_x=new_step_x, step_y=new_step_y)
@@ -80,6 +69,176 @@ def test_set_detector_calibration(self, dummy_signal):
         assert dx.offset, dy.offset == -centre
 
 
+class TestEBSDXmapProperty:
+    def test_init_xmap(self, dummy_signal):
+        """The attribute is set correctly."""
+        assert dummy_signal.xmap is None
+
+        ssim = kp.load(EMSOFT_FILE)
+        xmap = ssim.xmap
+        assert xmap.phases[0].name == "ni"
+
+    def test_attribute_carry_over_from_lazy(self):
+        ssim = kp.load(EMSOFT_FILE, lazy=True)
+        xmap_lazy = ssim.xmap.deepcopy()
+        assert xmap_lazy.phases[0].name == "ni"
+
+        ssim.compute()
+        xmap = ssim.xmap
+        assert xmap.phases[0].name == "ni"
+        assert np.allclose(xmap.rotations.data, xmap_lazy.rotations.data)
+
+    def test_set_xmap(self, get_single_phase_xmap):
+        s = kp.data.nickel_ebsd_large(lazy=True)
+        nav_shape = s.axes_manager.navigation_shape[::-1]
+        step_sizes = (1.5, 1.5)
+
+        # Should succeed
+        xmap_good = get_single_phase_xmap(nav_shape=nav_shape, step_sizes=step_sizes)
+        s.xmap = xmap_good
+
+        # Should fail
+        xmap_bad = get_single_phase_xmap(
+            nav_shape=nav_shape[::-1], step_sizes=step_sizes
+        )
+
+        with pytest.raises(ValueError, match="The `xmap` shape"):
+            s.xmap = xmap_bad
+
+        s.axes_manager["x"].scale = 2
+        with pytest.warns(UserWarning, match="The `xmap` step size"):
+            s.xmap = xmap_good
+
+        s2 = s.inav[:, :-2]
+        with pytest.raises(ValueError, match="The `xmap` shape"):
+            s2.axes_manager["x"].scale = 1
+            s2.axes_manager["x"].name = "x2"
+            with pytest.warns(UserWarning, match="The signal navigation axes"):
+                s2.xmap = xmap_good
+
+    def test_attribute_carry_over_from_deepcopy(self, get_single_phase_xmap):
+        s = kp.data.nickel_ebsd_small(lazy=True)
+        nav_axes = s.axes_manager.navigation_axes[::-1]
+        nav_shape = tuple(a.size for a in nav_axes)
+        nav_scales = tuple(a.scale for a in nav_axes)
+
+        xmap = get_single_phase_xmap(nav_shape=nav_shape, step_sizes=nav_scales)
+        s.xmap = xmap
+
+        s2 = s.deepcopy()
+        r1 = s.xmap.rotations.data
+        r2 = s2.xmap.rotations.data
+        assert not np.may_share_memory(r1, r2)
+        assert np.allclose(r1, r2)
+
+        s._static_background = -1
+        s3 = s.deepcopy()
+        assert s3.static_background == -1
+
+
+class TestEBSDDetectorProperty:
+    def test_attribute_carry_over_from_deepcopy(self, dummy_signal):
+        dummy_signal2 = dummy_signal.deepcopy()
+
+        pc1 = dummy_signal.detector.pc
+        pc2 = dummy_signal2.detector.pc
+        assert not np.may_share_memory(pc1, pc2)
+        assert np.allclose(pc1, pc2)
+
+    def test_attribute_carry_over_from_lazy(self, dummy_signal):
+        dummy_signal_lazy = dummy_signal.deepcopy().as_lazy()
+        dummy_signal_lazy.compute()
+        pc = dummy_signal.detector.pc
+        pc_lazy = dummy_signal_lazy.detector.pc
+        assert not np.may_share_memory(pc, pc_lazy)
+        assert np.allclose(pc, pc_lazy)
+
+    def test_set_detector(self):
+        s = kp.data.nickel_ebsd_small(lazy=True)
+        sig_shape = s.axes_manager.signal_shape[::-1]
+
+        # Success
+        detector_good = kp.detectors.EBSDDetector(shape=sig_shape)
+        s.detector = detector_good
+
+        # Failure
+        with pytest.raises(ValueError, match="Detector and signal must have the same"):
+            s.detector = kp.detectors.EBSDDetector(shape=(59, 60))
+        with pytest.raises(ValueError, match="Detector must have exactly one "):
+            s.detector = kp.detectors.EBSDDetector(
+                shape=sig_shape, pc=np.ones((3, 4, 3))
+            )
+
+    @pytest.mark.parametrize(
+        "detector, signal_nav_shape, compatible, error_msg_start",
+        [
+            (((1,), (5, 5)), (2, 3), True, None),
+            (((2, 3), (5, 5)), (2, 3), True, None),
+            (((1,), (5, 4)), (2, 3), False, "Detector and signal must have the same"),
+            (((3, 2), (5, 5)), (2, 3), False, "Detector must have exactly"),
+            (((2, 3), (5, 5)), (), False, "Detector must have exactly"),
+        ],
+        indirect=["detector"],
+    )
+    def test_compatible_with_signal(
+        self, detector, signal_nav_shape, compatible, error_msg_start
+    ):
+        s = kp.signals.EBSD(np.ones(signal_nav_shape + (5, 5), dtype=int))
+        func_kwargs = dict(
+            detector=detector,
+            navigation_shape=s.axes_manager.navigation_shape[::-1],
+            signal_shape=s.axes_manager.signal_shape[::-1],
+        )
+        assert (
+            kp.signals.util._detector._detector_is_compatible_with_signal(**func_kwargs)
+            == compatible
+        )
+        if not compatible:
+            with pytest.raises(ValueError, match=error_msg_start):
+                kp.signals.util._detector._detector_is_compatible_with_signal(
+                    raise_if_not=True, **func_kwargs
+                )
+
+
+class TestStaticBackgroundProperty:
+    def test_background_carry_over_from_deepcopy(self, dummy_signal):
+        dummy_signal2 = dummy_signal.deepcopy()
+        bg1 = dummy_signal.static_background
+        bg2 = dummy_signal2.static_background
+        assert not np.may_share_memory(bg1, bg2)
+        assert np.allclose(bg1, bg2)
+
+    def test_background_carry_over_from_lazy(self, dummy_signal):
+        dummy_signal_lazy = dummy_signal.deepcopy().as_lazy()
+        assert isinstance(dummy_signal_lazy.static_background, da.Array)
+        dummy_signal_lazy.compute()
+        bg = dummy_signal.static_background
+        bg_lazy = dummy_signal_lazy.static_background
+        assert isinstance(bg_lazy, np.ndarray)
+        assert not np.may_share_memory(bg, bg_lazy)
+        assert np.allclose(bg, bg_lazy)
+
+    def test_set_background(self):
+        s = kp.data.nickel_ebsd_small(lazy=True)
+        sig_shape = s.axes_manager.signal_shape[::-1]
+        # Success
+        bg_good = np.arange(np.prod(sig_shape), dtype=s.data.dtype).reshape(sig_shape)
+        s.static_background = bg_good
+        # Warns, but allows
+        with pytest.warns(
+            UserWarning,
+            match="Background pattern has different data type from patterns",
+        ):
+            dtype = np.uint16
+            s.static_background = bg_good.astype(dtype)
+            assert s.static_background.dtype == dtype
+        with pytest.warns(
+            UserWarning, match="Background pattern has different shape from patterns"
+        ):
+            s.static_background = bg_good[:, :-2]
+            assert s.static_background.shape == (sig_shape[0], sig_shape[1] - 2)
+
+
 class TestRemoveStaticBackgroundEBSD:
     @pytest.mark.parametrize(
         "operation, answer",
@@ -709,13 +868,13 @@ def test_virtual_backscatter_electron_imaging(self, dummy_signal, out_signal_axe
         dummy_signal.axes_manager.navigation_axes[0].name = "x"
         dummy_signal.axes_manager.navigation_axes[1].name = "y"
 
-        roi = RectangularROI(left=0, top=0, right=1, bottom=1)
+        roi = hs.roi.RectangularROI(left=0, top=0, right=1, bottom=1)
         dummy_signal.plot_virtual_bse_intensity(roi, out_signal_axes=out_signal_axes)
 
         plt.close("all")
 
     def test_get_virtual_image(self, dummy_signal):
-        roi = RectangularROI(left=0, top=0, right=1, bottom=1)
+        roi = hs.roi.RectangularROI(left=0, top=0, right=1, bottom=1)
         virtual_image_signal = dummy_signal.get_virtual_bse_intensity(roi)
         assert (
             virtual_image_signal.data.shape
@@ -723,7 +882,7 @@ def test_get_virtual_image(self, dummy_signal):
         )
 
     def test_virtual_backscatter_electron_imaging_raises(self, dummy_signal):
-        roi = RectangularROI(0, 0, 1, 1)
+        roi = hs.roi.RectangularROI(0, 0, 1, 1)
         with pytest.raises(ValueError):
             _ = dummy_signal.get_virtual_bse_intensity(roi, out_signal_axes=(0, 1, 2))
 
@@ -1105,174 +1264,6 @@ def test_normalize_intensity_lazy(self, dummy_signal):
         assert np.allclose(np.mean(lazy_signal.data.compute()), 0, atol=1e-6)
 
 
-class TestEBSDXmapProperty:
-    def test_init_xmap(self, dummy_signal):
-        """The attribute is set correctly."""
-        assert dummy_signal.xmap is None
-
-        ssim = kp.load(EMSOFT_FILE)
-        xmap = ssim.xmap
-        assert xmap.phases[0].name == "ni"
-
-    def test_attribute_carry_over_from_lazy(self):
-        ssim = kp.load(EMSOFT_FILE, lazy=True)
-        xmap_lazy = ssim.xmap.deepcopy()
-        assert xmap_lazy.phases[0].name == "ni"
-
-        ssim.compute()
-        xmap = ssim.xmap
-        assert xmap.phases[0].name == "ni"
-        assert np.allclose(xmap.rotations.data, xmap_lazy.rotations.data)
-
-    def test_set_xmap(self, get_single_phase_xmap):
-        s = kp.data.nickel_ebsd_large(lazy=True)
-        nav_shape = s.axes_manager.navigation_shape[::-1]
-        step_sizes = (1.5, 1.5)
-
-        # Should succeed
-        xmap_good = get_single_phase_xmap(nav_shape=nav_shape, step_sizes=step_sizes)
-        s.xmap = xmap_good
-
-        # Should fail
-        xmap_bad = get_single_phase_xmap(
-            nav_shape=nav_shape[::-1], step_sizes=step_sizes
-        )
-
-        with pytest.raises(ValueError, match="The `xmap` shape"):
-            s.xmap = xmap_bad
-
-        s.axes_manager["x"].scale = 2
-        with pytest.warns(UserWarning, match="The `xmap` step size"):
-            s.xmap = xmap_good
-
-        s2 = s.inav[:, :-2]
-        with pytest.raises(ValueError, match="The `xmap` shape"):
-            s2.axes_manager["x"].scale = 1
-            s2.axes_manager["x"].name = "x2"
-            with pytest.warns(UserWarning, match="The signal navigation axes"):
-                s2.xmap = xmap_good
-
-    def test_attribute_carry_over_from_deepcopy(self, get_single_phase_xmap):
-        s = kp.data.nickel_ebsd_small(lazy=True)
-        nav_axes = s.axes_manager.navigation_axes[::-1]
-        nav_shape = tuple(a.size for a in nav_axes)
-        nav_scales = tuple(a.scale for a in nav_axes)
-
-        xmap = get_single_phase_xmap(nav_shape=nav_shape, step_sizes=nav_scales)
-        s.xmap = xmap
-
-        s2 = s.deepcopy()
-        r1 = s.xmap.rotations.data
-        r2 = s2.xmap.rotations.data
-        assert not np.may_share_memory(r1, r2)
-        assert np.allclose(r1, r2)
-
-        s._static_background = -1
-        s3 = s.deepcopy()
-        assert s3.static_background is None
-
-
-class TestEBSDDetectorProperty:
-    def test_attribute_carry_over_from_deepcopy(self, dummy_signal):
-        dummy_signal2 = dummy_signal.deepcopy()
-
-        pc1 = dummy_signal.detector.pc
-        pc2 = dummy_signal2.detector.pc
-        assert not np.may_share_memory(pc1, pc2)
-        assert np.allclose(pc1, pc2)
-
-    def test_attribute_carry_over_from_lazy(self, dummy_signal):
-        dummy_signal_lazy = dummy_signal.deepcopy().as_lazy()
-        dummy_signal_lazy.compute()
-        pc = dummy_signal.detector.pc
-        pc_lazy = dummy_signal_lazy.detector.pc
-        assert not np.may_share_memory(pc, pc_lazy)
-        assert np.allclose(pc, pc_lazy)
-
-    def test_set_detector(self):
-        s = kp.data.nickel_ebsd_small(lazy=True)
-        sig_shape = s.axes_manager.signal_shape[::-1]
-
-        # Success
-        detector_good = kp.detectors.EBSDDetector(shape=sig_shape)
-        s.detector = detector_good
-
-        # Failure
-        with pytest.raises(ValueError, match="Detector and signal must have the same"):
-            s.detector = kp.detectors.EBSDDetector(shape=(59, 60))
-        with pytest.raises(ValueError, match="Detector must have exactly one "):
-            s.detector = kp.detectors.EBSDDetector(
-                shape=sig_shape, pc=np.ones((3, 4, 3))
-            )
-
-    @pytest.mark.parametrize(
-        "detector, signal_nav_shape, compatible, error_msg_start",
-        [
-            (((1,), (5, 5)), (2, 3), True, None),
-            (((2, 3), (5, 5)), (2, 3), True, None),
-            (((1,), (5, 4)), (2, 3), False, "Detector and signal must have the same"),
-            (((3, 2), (5, 5)), (2, 3), False, "Detector must have exactly"),
-            (((2, 3), (5, 5)), (), False, "Detector must have exactly"),
-        ],
-        indirect=["detector"],
-    )
-    def test_compatible_with_signal(
-        self, detector, signal_nav_shape, compatible, error_msg_start
-    ):
-        s = kp.signals.EBSD(np.ones(signal_nav_shape + (5, 5), dtype=int))
-        func_kwargs = dict(
-            detector=detector,
-            navigation_shape=s.axes_manager.navigation_shape[::-1],
-            signal_shape=s.axes_manager.signal_shape[::-1],
-        )
-        assert (
-            kp.signals.util._detector._detector_is_compatible_with_signal(**func_kwargs)
-            == compatible
-        )
-        if not compatible:
-            with pytest.raises(ValueError, match=error_msg_start):
-                kp.signals.util._detector._detector_is_compatible_with_signal(
-                    raise_if_not=True, **func_kwargs
-                )
-
-
-class TestStaticBackgroundProperty:
-    def test_background_carry_over_from_deepcopy(self, dummy_signal):
-        dummy_signal2 = dummy_signal.deepcopy()
-        bg1 = dummy_signal.static_background
-        bg2 = dummy_signal2.static_background
-        assert not np.may_share_memory(bg1, bg2)
-        assert np.allclose(bg1, bg2)
-
-    def test_background_carry_over_from_lazy(self, dummy_signal):
-        dummy_signal_lazy = dummy_signal.deepcopy().as_lazy()
-        dummy_signal_lazy.compute()
-        bg = dummy_signal.static_background
-        bg_lazy = dummy_signal_lazy.static_background
-        assert not np.may_share_memory(bg, bg_lazy)
-        assert np.allclose(bg, bg_lazy)
-
-    def test_set_background(self):
-        s = kp.data.nickel_ebsd_small(lazy=True)
-        sig_shape = s.axes_manager.signal_shape[::-1]
-        # Success
-        bg_good = np.arange(np.prod(sig_shape), dtype=s.data.dtype).reshape(sig_shape)
-        s.static_background = bg_good
-        # Warns, but allows
-        with pytest.warns(
-            UserWarning,
-            match="Background pattern has different data type from patterns",
-        ):
-            dtype = np.uint16
-            s.static_background = bg_good.astype(dtype)
-            assert s.static_background.dtype == dtype
-        with pytest.warns(
-            UserWarning, match="Background pattern has different shape from patterns"
-        ):
-            s.static_background = bg_good[:, :-2]
-            assert s.static_background.shape == (sig_shape[0], sig_shape[1] - 2)
-
-
 class TestDictionaryIndexing:
     def test_dictionary_indexing_doesnt_change_data(self, dummy_signal):
         """Scores are all 1 for a dictionary containing all patterns
@@ -2264,3 +2255,43 @@ def test_dp_matrices_large(self):
         s = kp.signals.LazyEBSD(da.ones(nav_shape + (96, 96), dtype=np.uint8))
         dp_matrices = s.get_neighbour_dot_product_matrices()
         assert dp_matrices.shape == nav_shape + (1, 1)
+
+
+class TestSignal2DMethods:
+    """Test methods inherited from Signal2D."""
+
+    def test_as_lazy(self):
+        """Lazy attribute and class change while metadata is constant."""
+        s = kp.data.nickel_ebsd_small()
+        s_lazy = s.as_lazy()
+        assert s_lazy._lazy
+        assert isinstance(s_lazy, kp.signals.LazyEBSD)
+        assert_dictionary(s.metadata.as_dictionary(), s_lazy.metadata.as_dictionary())
+
+    def test_change_dtype(self, dummy_signal):
+        """Custom properties carry over and their data type are set
+        correctly.
+        """
+        assert dummy_signal.data.dtype.name == "uint8"
+        dummy_signal.change_dtype("float32")
+        assert dummy_signal.data.dtype.name == "float32"
+        assert dummy_signal.static_background.dtype.name == "float32"
+
+    def test_squeeze(self, dummy_signal):
+        """Custom properties carry over."""
+        s2 = dummy_signal.squeeze()
+        assert np.allclose(dummy_signal.static_background, s2.static_background)
+
+    def test_fft(self, dummy_signal):
+        """Test call to ``BaseSignal.fft()`` because it proved
+        challenging to overwrite ``BaseSignal.change_dtype()`` in
+        ``KikuchipySignal2D``, and the implementation may be unstable.
+        """
+        s_fft = dummy_signal.fft()
+        assert isinstance(s_fft, hs.signals.ComplexSignal2D)
+        assert not hasattr(s_fft, "_xmap")
+
+    def test_set_signal_type(self, dummy_signal):
+        """Custom properties does not carry over."""
+        s_mp = dummy_signal.set_signal_type("EBSDMasterPattern")
+        assert not hasattr(s_mp, "_xmap")
diff --git a/kikuchipy/signals/tests/test_ebsd_master_pattern.py b/kikuchipy/signals/tests/test_ebsd_master_pattern.py
index 938d2dec..80866985 100644
--- a/kikuchipy/signals/tests/test_ebsd_master_pattern.py
+++ b/kikuchipy/signals/tests/test_ebsd_master_pattern.py
@@ -26,7 +26,6 @@
 import pytest
 
 import kikuchipy as kp
-from kikuchipy import load
 from kikuchipy.data import nickel_ebsd_master_pattern_small
 from kikuchipy.io.plugins.tests.test_emsoft_ebsd_master_pattern import (
     setup_axes_manager,
@@ -71,13 +70,13 @@ def test_init_no_metadata(self):
         assert s.projection == "lambert"
         assert s.hemisphere == "both"
 
-    def test_ebsd_masterpattern_lazy_data_init(self):
+    def test_ebsd_master_pattern_lazy_data_init(self):
         s = kp.signals.EBSDMasterPattern(da.zeros((2, 10, 11, 11)))
 
         assert isinstance(s, kp.signals.EBSDMasterPattern)
         assert isinstance(s.data, da.Array)
 
-    def test_ebsd_masterpattern_lazy_init(self):
+    def test_ebsd_master_pattern_lazy_init(self):
         s = kp.signals.LazyEBSDMasterPattern(da.zeros((2, 10, 11, 11)))
 
         assert isinstance(s, kp.signals.LazyEBSDMasterPattern)
@@ -105,7 +104,7 @@ def test_get_master_pattern_arrays_from_energy(self):
 class TestIO:
     @pytest.mark.parametrize("save_path_hdf5", ["hspy"], indirect=["save_path_hdf5"])
     def test_save_load_hspy(self, save_path_hdf5):
-        s = load(EMSOFT_FILE)
+        s = kp.load(EMSOFT_FILE)
 
         axes_manager = setup_axes_manager(["energy", "height", "width"])
 
@@ -208,7 +207,7 @@ def test_get_direction_cosines(self):
         assert np.allclose(dc, dc2)
 
     def test_get_patterns(self):
-        emsoft_key = load(EMSOFT_EBSD_FILE)
+        emsoft_key = kp.load(EMSOFT_EBSD_FILE)
         emsoft_key = emsoft_key.data[0]
 
         r = Rotation.from_euler(np.radians([120, 45, 60]))
diff --git a/kikuchipy/signals/tests/test_ecp_master_pattern.py b/kikuchipy/signals/tests/test_ecp_master_pattern.py
new file mode 100644
index 00000000..dbc5049a
--- /dev/null
+++ b/kikuchipy/signals/tests/test_ecp_master_pattern.py
@@ -0,0 +1,111 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+import dask.array as da
+import numpy as np
+from orix.crystal_map import Phase
+import pytest
+
+import kikuchipy as kp
+
+
+DIR_PATH = os.path.dirname(__file__)
+ECP_FILE = os.path.join(
+    DIR_PATH, "../../data/emsoft_ecp_master_pattern/ecp_master_pattern.h5"
+)
+
+
+class TestECPMasterPattern:
+    def test_init_no_metadata(self):
+        s = kp.signals.ECPMasterPattern(
+            np.zeros((2, 10, 11, 11)),
+            projection="lambert",
+            hemisphere="both",
+            phase=Phase("a"),
+        )
+        assert isinstance(s.phase, Phase)
+        assert s.phase.name == "a"
+        assert s.projection == "lambert"
+        assert s.hemisphere == "both"
+
+    def test_init_lazy_ecp_master_pattern(self):
+        s = kp.signals.LazyECPMasterPattern(da.zeros((2, 10, 11, 11)))
+        assert isinstance(s, kp.signals.LazyECPMasterPattern)
+        assert isinstance(s.data, da.Array)
+
+    def test_set_custom_properties(self):
+        s = kp.load(ECP_FILE)
+
+        # phase
+        s.phase = Phase("b")
+        assert s.phase.name == "b"
+
+        # projection
+        s.projection = "spherical"
+        assert s.projection == "spherical"
+
+        # hemisphere
+        s.hemisphere = "east"
+        assert s.hemisphere == "east"
+
+    def test_get_master_pattern_arrays_from_energy(self):
+        """Get upper and lower hemisphere of master pattern of the
+        last energy axis without providing the energy parameter.
+        """
+        shape = (2, 11, 11)
+        data = np.arange(np.prod(shape)).reshape(shape)
+        mp = kp.signals.EBSDMasterPattern(
+            data,
+            axes=[
+                {"size": 2, "name": "energy"},
+                {"size": 11, "name": "x"},
+                {"size": 11, "name": "y"},
+            ],
+        )
+        mp_upper, mp_lower = mp._get_master_pattern_arrays_from_energy()
+        assert np.allclose(mp_upper, data[1])
+        assert np.allclose(mp_lower, data[1])
+
+    @pytest.mark.skipif(not kp._pyvista_installed, reason="PyVista is not installed")
+    def test_plot_spherical(self):
+        """Cover inherited method only included for documentation
+        purposes (tested rigorously elsewhere).
+        """
+        s = kp.load(ECP_FILE)
+        s.plot_spherical()
+
+    def test_inherited_methods(self):
+        """Cover inherited method only included for documentation
+        purposes (tested rigorously elsewhere).
+        """
+        s = kp.load(ECP_FILE)
+
+        # as_lambert()
+        s2 = s.as_lambert()
+        assert s2.projection == "lambert"
+
+        # normalize_intensity()
+        s.normalize_intensity()
+
+        # rescale_intensity()
+        s.rescale_intensity()
+
+        # deepcopy()
+        s3 = s.deepcopy()
+        assert not np.may_share_memory(s.data, s3.data)
diff --git a/kikuchipy/signals/tests/test_kikuchi_master_pattern.py b/kikuchipy/signals/tests/test_kikuchi_master_pattern.py
new file mode 100644
index 00000000..8f10ae0c
--- /dev/null
+++ b/kikuchipy/signals/tests/test_kikuchi_master_pattern.py
@@ -0,0 +1,46 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy 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.
+#
+# kikuchipy 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 kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import numpy as np
+from orix.crystal_map import Phase
+
+import kikuchipy as kp
+
+
+class TestKikuchiMasterPattern:
+    def test_set_signal_type(self):
+        """Custom properties of projection, hemisphere and phase carry
+        over in a call to `set_signal_type()`.
+        """
+        phase = Phase("a", point_group="m-3m")
+        s = kp.signals.EBSDMasterPattern(
+            np.zeros((2, 10, 11, 11)),
+            projection="lambert",
+            hemisphere="both",
+            phase=phase,
+        )
+
+        # Carry over
+        s.set_signal_type("ECPMasterPattern")
+        assert s.phase.name == "a"
+        assert np.may_share_memory(s.phase.point_group.data, phase.point_group.data)
+        assert s.projection == "lambert"
+        assert s.hemisphere == "both"
+
+        # Does not carry over
+        s.set_signal_type("EBSD")
+        assert not hasattr(s, "_phase")