"""Compressed ``.npz`` export of ebsdsim master patterns.
The on-disk format stores the **fundamental-sector** intensities only (the
minimal, symmetry-reduced representation), always keeps the per-energy-bin
intermediates, and is always compressed. The embedded point-group operators
and fundamental-sector normals make the file self-describing: it can be
expanded back into full Lambert hemispheres with NumPy alone (see
:mod:`ebsdsim.mploader`), without installing ebsdsim.
Arrays written
--------------
``fundamental_sector`` : float32 ``(n_energy, n_site, n_k)``
The symmetry-reduced intensities for every direction, packed along an
energy axis and a site axis (raw, un-normalized). The energy axis is
``1 + n_bins`` long when ``n_bins > 1`` (index 0 is the energy-integrated
weighted sum, index ``1 + b`` is bin ``b``) and length 1 otherwise. The
site axis is ``1 + n_sites`` long when ``n_sites > 1`` (index 0 is the
site mean, index ``1 + s`` is site ``s``) and length 1 otherwise. The
north/south hemisphere of each of the ``n_k`` directions is carried by the
sign column of ``fundamental_kij`` (the per-group N/S split is irregular,
so hemisphere is not a separate array axis).
``fundamental_kij`` : int32 ``(n_k, 3)``
Lambert pixel indices ``(i, j, sign)`` for each fundamental-sector
direction (``sign > 0`` north, ``sign < 0`` south).
``fundamental_khat`` : float32 ``(n_k, 3)``
Unit propagation directions for each fundamental-sector pixel.
``pg_operators`` : float64 ``(n_ops, 3, 3)``
Proper/improper point-group rotation matrices.
``fs_normals`` : float64 ``(n_normals, 3)``
Inward normals bounding the fundamental sector.
``bin_voltages_kv`` / ``bin_weights`` : float32 ``(n_bins,)``
Dynamical voltage (kV) and energy weight of each saved bin.
``site_weights`` : float32 ``(n_sites,)``
Normalized occupancy × multiplicity weights used for the site-integrated
marginal (index 0 on the site axis). Per-site slices are raw intensities.
``meta_json`` : uint8 ``(n_bytes,)``
UTF-8 JSON metadata blob (decode with ``bytes(arr).decode("utf-8")``).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
from ebsdsim.elements import element_symbol
from ebsdsim.pg_ops import fs_normals, pg_num_to_symbol, point_group_operators
from ebsdsim.types import Cell
from ebsdsim.weights import reduce_over_sites, site_weights_from_meta_cell
if TYPE_CHECKING: # pragma: no cover
from ebsdsim.api import MasterPattern
_NM_TO_ANGSTROM = 10.0
_NM_SQ_TO_ANGSTROM_SQ = 100.0
def _stack_bins(bin_patterns: list[np.ndarray], n_k: int, n_sites: int) -> np.ndarray:
if not bin_patterns:
return np.zeros((0, n_k, n_sites), dtype=np.float32)
out = np.empty((len(bin_patterns), n_k, n_sites), dtype=np.float32)
for b, pat in enumerate(bin_patterns):
out[b] = np.asarray(pat, dtype=np.float32).reshape(n_k, n_sites)
return out
def _consolidate_fundamental_sector(
integrated_fs: np.ndarray, # (n_k, n_sites)
bin_fs: np.ndarray, # (n_bins, n_k, n_sites)
site_weights: np.ndarray | None = None,
) -> tuple[np.ndarray, dict[str, Any]]:
"""Pack integrated + per-bin and mean + per-site into one ``(E, S, n_k)`` array.
Mirrors the in-memory ``MasterPattern.data`` axis convention (minus the
hemisphere and Lambert image axes): energy index 0 is the energy-integrated
weighted sum, ``1 + b`` is bin ``b`` (a distinct slice only when
``n_bins > 1``); site index 0 is the site mean, ``1 + s`` is site ``s`` (a
distinct slice only when ``n_sites > 1``). Values are raw (un-normalized)
so the loader can reproduce the display normalization.
"""
integrated_fs = np.asarray(integrated_fs, dtype=np.float32)
bin_fs = np.asarray(bin_fs, dtype=np.float32)
n_k, n_sites = int(integrated_fs.shape[0]), int(integrated_fs.shape[1])
n_bins = int(bin_fs.shape[0])
multi_bin = n_bins > 1
multi_site = n_sites > 1
e_dim = 1 + n_bins if multi_bin else 1
s_dim = 1 + n_sites if multi_site else 1
out = np.empty((e_dim, s_dim, n_k), dtype=np.float32)
def _fill(e_idx: int, src: np.ndarray) -> None: # src: (n_k, n_sites)
if multi_site:
out[e_idx, 0] = reduce_over_sites(src, site_weights)
for s in range(n_sites):
out[e_idx, 1 + s] = src[:, s]
else:
out[e_idx, 0] = src[:, 0]
_fill(0, integrated_fs)
if multi_bin:
for b in range(n_bins):
_fill(1 + b, bin_fs[b])
axes_meta = {
"dims": ["energy", "site", "direction"],
"energy_integrated_index": 0,
"bin_to_energy_index": [1 + b for b in range(n_bins)] if multi_bin else [0] * n_bins,
"site_integrated_index": 0,
"site_to_index": [1 + s for s in range(n_sites)] if multi_site else [0],
}
return out, axes_meta
[docs]
def save_master_pattern(mp: "MasterPattern", path: str | Path) -> Path:
"""Write a master pattern (with intermediates) to a compressed ``.npz``.
The file stores symmetry-reduced fundamental-sector intensities plus
embedded point-group operators so it can be expanded offline with
:mod:`ebsdsim.mploader`.
Parameters
----------
mp : MasterPattern
Result from :func:`~ebsdsim.api.master_pattern` or
:func:`~ebsdsim.api.master_pattern_from_cif`.
path : str or Path
Output path; ``.npz`` is appended if missing.
Returns
-------
Path
Resolved output path.
"""
out_path = Path(path)
if out_path.suffix.lower() != ".npz":
out_path = out_path.with_suffix(".npz")
out_path.parent.mkdir(parents=True, exist_ok=True)
if mp.pg_num is None or mp.kij is None or mp.khat is None:
raise ValueError(
"MasterPattern is missing fundamental-sector grid data; it must be "
"produced by ebsdsim.master_pattern[_from_cif]() to be saved."
)
n_k = int(mp.n_k)
n_sites = int(mp.n_sites)
integrated_fs = np.asarray(mp.integrated, dtype=np.float32).reshape(n_k, n_sites)
bin_fs = _stack_bins(list(mp.bin_patterns), n_k, n_sites)
site_weights = site_weights_from_meta_cell(mp.metadata.get("cell", {}))
if site_weights is None:
site_weights = np.full(n_sites, 1.0 / max(n_sites, 1), dtype=np.float32)
fundamental_sector, fs_axes = _consolidate_fundamental_sector(
integrated_fs, bin_fs, site_weights
)
kij = np.asarray(mp.kij, dtype=np.int32).reshape(n_k, 3)
khat = np.asarray(mp.khat, dtype=np.float32).reshape(n_k, 3)
symbol = pg_num_to_symbol(int(mp.pg_num))
ops = point_group_operators(symbol).reshape(-1, 3, 3).astype(np.float64)
normals = fs_normals(symbol).reshape(-1, 3).astype(np.float64)
bin_voltages_kv = np.asarray(mp.bin_voltages_kv, dtype=np.float32).reshape(-1)
bin_weights = np.asarray(mp.bin_weights, dtype=np.float32).reshape(-1)
meta = dict(mp.metadata)
meta.setdefault("format", "ebsdsim-master-pattern")
meta["format_version"] = 2
meta["array_layout"] = {
"fundamental_sector": ["energy", "site", "direction"],
"fundamental_kij": ["direction", "ij_sign"],
"fundamental_khat": ["direction", "xyz"],
"pg_operators": ["op", "row", "col"],
"fs_normals": ["normal", "xyz"],
}
meta["fundamental_sector_axes"] = fs_axes
meta_bytes = np.frombuffer(
json.dumps(meta, indent=2, sort_keys=False).encode("utf-8"), dtype=np.uint8
)
np.savez_compressed(
out_path,
fundamental_sector=fundamental_sector,
fundamental_kij=kij,
fundamental_khat=khat,
pg_operators=ops,
fs_normals=normals,
bin_voltages_kv=bin_voltages_kv,
bin_weights=bin_weights,
site_weights=np.asarray(site_weights, dtype=np.float32).reshape(-1),
meta_json=meta_bytes,
)
return out_path