Source code for at.load.matfile

"""
Load lattices from Matlab files.
"""

from __future__ import annotations

__all__ = ["load_m", "load_mat", "load_var", "save_m", "save_mat"]

import sys
from collections.abc import Generator, Sequence, Mapping, Iterator, Iterable
from math import isfinite
from pathlib import Path
from typing import Any
from warnings import warn

import h5py
import numpy as np
import scipy.io

# imports necessary in 'globals()' for 'eval'
from numpy import array  # noqa: F401
from numpy import nan as NaN  # noqa: F401
from numpy import uint8  # noqa: F401

from ..lattice import (
    AtError,
    AtWarning,
    Element,
    Filter,
    Lattice,
    Particle,
    elements,
    params_filter,
)
from .allfiles import register_format
from .utils import (
    _CLASS_MAP,
    RingParam,
    _drop_attrs,
    keep_elements,
    split_ignoring_parentheses,
    element_from_dict,
)

# Translation of RingParam attributes
_m2p = {
    "FamName": "name",
    "Energy": "energy",
    "Periodicity": "periodicity",
    "Particle": "particle",
    "cell_harmnumber": "cell_harmnumber",  # necessary: property
    "beam_current": "beam_current",  # necessary: property
    "PassMethod": None,  # Useless Matlab attributes
    "Length": None,
    "cavpts": None,
    "Mat_File": None,  # These are erroneous attributes saved in
    "Mat_Key": None,  # RingParam by old versions
    "Beam_Current": None,
    "Nbunch": None,
}
_p2m: dict[str, str | None] = {v: k for k, v in _m2p.items() if v is not None}
# Attribute to drop when writing a file
_p2m.update(_drop_attrs)

# Matlab constructor function
# Default: "".join(("at", element_class.__name__.lower()))
_mat_constructor = {
    "Dipole": "atsbend",
    "M66": "atM66",
}


def _mat_encoder(v):
    """type encoding for .mat files."""
    if isinstance(v, int):
        return float(v)
    if isinstance(v, Particle):
        return v.to_dict()
    if isinstance(v, (list, tuple)):
        return np.array(v, dtype=object)
    return v


def _matfile_generator(
    params: dict[str, Any], mat_file: Path
) -> Generator[Element, None, None]:
    """Run through Matlab cells and generate AT elements.

    Arguments:
        params: parameter dictionary
        mat_file: matlab file name

    Yields:
        pyat Element from dictionary
    """

    def mclean(data: Any) -> Any:
        if data.dtype.type is np.str_:
            # Convert strings in arrays back to strings.
            return str(data[0]) if data.size > 0 else ""
        elif data.size == 1:
            vdata = data[0, 0]
            if issubclass(vdata.dtype.type, np.void):
                # Object => Return a dict
                return {f: mclean(vdata[f]) for f in vdata.dtype.fields}
            else:
                # Return a scalar
                return vdata
        else:
            # Remove any surplus dimensions in arrays.
            return np.squeeze(data)

    def mcleanhdf5(data: Any) -> Any:
        matlab_class = data.attrs["MATLAB_class"]
        if matlab_class == b"struct":
            # Return a dict from recursion
            return {f: mcleanhdf5(data[f]) for f in data}
        elif matlab_class == b"char":
            # Convert to string
            return "".join(chr(i) for i in np.asarray(data).flatten())
        else:
            # e.g. matlab_class == b"double":
            # Remove any surplus dimensions in arrays.
            return np.squeeze(np.asarray(data))

    def define_default_key(
        params: dict[str, Any], mat_input: Mapping, ignore_chars: str = ""
    ) -> tuple:
        matvars = [
            varname for varname in mat_input if not varname.startswith(ignore_chars)
        ]
        default_key = matvars[0] if (len(matvars) == 1) else "RING"
        key = params.setdefault("use", default_key)
        if key not in mat_input:
            kok = [k for k in mat_input if "__" not in k]
            msg = f"Selected '{key}' variable does not exist, please select in: {kok}"
            raise AtError(msg)
        return params, key

    # noinspection PyUnresolvedReferences
    check = params.pop("check", True)
    quiet = params.pop("quiet", False)
    params.setdefault("in_file", str(mat_file))
    matlabfile_ver = scipy.io.matlab.matfile_version(mat_file)
    if matlabfile_ver < (2, 0):
        mat_input = scipy.io.loadmat(mat_file)
        params, key = define_default_key(params, mat_input, ignore_chars="__")
        cell_array = mat_input[key].flat
        for index, mat_elem in enumerate(cell_array):
            elem = mat_elem[0, 0]
            kwargs = {f: mclean(elem[f]) for f in elem.dtype.fields}
            yield element_from_dict(kwargs, index=index, check=check, quiet=quiet)
    else:
        mat_input = h5py.File(mat_file)
        params, key = define_default_key(params, mat_input, ignore_chars="#")
        cell_array = mat_input[key][0]
        for index, ref_elem in enumerate(cell_array):
            elem = mat_input[ref_elem]
            kwargs = {f: mcleanhdf5(elem[f]) for f in elem}
            yield element_from_dict(kwargs, index=index, check=check, quiet=quiet)


def ringparam_filter(
    params: dict[str, Any], elem_iterator: Filter, *args
) -> Generator[Element, None, None]:
    """Run through all elements, process and optionally removes
    RingParam elements.

    Parameters:
        params:         Lattice building parameters (see :py:class:`.Lattice`)
        elem_iterator:  Iterator over the lattice Elements

    Yields:
        elem (Element): new Elements

    The following keys in ``params`` are used:

    ============    ===================
    **keep_all**    keep RingParam elem_iterator as Markers
    ============    ===================

    The following keys in ``params`` are set:

    * ``name``
    * ``energy``
    * ``periodicity``
    * ``_harmnumber`` or
    * ``harmonic_number``
    * ``_particle``
    * ``_radiation``
    """
    keep_all = params.pop("keep_all", False)
    ringparams = []
    radiate = False
    for elem in elem_iterator(params, *args):
        if elem.PassMethod.endswith("RadPass") or elem.PassMethod.endswith(
            "CavityPass"
        ):
            radiate = True
        if isinstance(elem, RingParam):
            ringparams.append(elem)
            for k, v in vars(elem).items():
                k2 = _m2p.get(k, k)
                if k2 is not None and (k2 != "cell_harmnumber" or isfinite(v)):
                    params.setdefault(k2, v)
            if keep_all:
                pars = vars(elem).copy()
                name = pars.pop("FamName")
                yield elements.Marker(name, tag="RingParam", **pars)
        else:
            yield elem
    params["_radiation"] = radiate
    params.setdefault("periodicity", 0)

    if len(ringparams) > 1:
        warn(
            AtWarning("More than 1 RingParam element, the 1st one is used"),
            stacklevel=2,
        )


[docs] def load_mat(filename: str | Path, **kwargs) -> Lattice: """Create a :py:class:`.Lattice` from a Matlab mat-file. Parameters: filename: Name of a '.mat' file Keyword Args: use (str): Name of the Matlab variable containing the lattice. Default: it there is a single variable, use it, otherwise select 'RING' mat_key (str): deprecated alias for *use* check (bool): Run the coherence tests. Default: :py:obj:`True` quiet (bool): Suppress the warning for non-standard classes. Default: :py:obj:`False` keep_all (bool): Keep RingParam elements as Markers. Default: :py:obj:`False` name (str): Name of the lattice. Default: taken from the lattice, or ``''`` energy (float): Energy of the lattice [eV]. Default: taken from the lattice elements periodicity(int): Number of periods. Default: taken from the elements, or 1 *: All other keywords will be set as Lattice attributes Returns: lattice (Lattice): New :py:class:`.Lattice` object See Also: :py:func:`.load_lattice` for a generic lattice-loading function. """ filepath = Path(filename) if "key" in kwargs: # process the deprecated 'key' keyword kwargs.setdefault("use", kwargs.pop("key")) if "mat_key" in kwargs: # process the deprecated 'mat_key' keyword kwargs.setdefault("use", kwargs.pop("mat_key")) return Lattice( ringparam_filter, _matfile_generator, filepath.resolve(), iterator=params_filter, **kwargs, )
def _element_from_m(line: str, index: int | None = None) -> Element: """Builds an :py:class:`.Element` from a line in an m-file. Parameters: line: Matlab string representation of an :py:class:`.Element` Returns: elem (Element): new :py:class:`.Element` """ def argsplit(value): return [a.strip() for a in split_ignoring_parentheses(value)] def makedir(mat_struct: Iterable[str]) -> Iterator[tuple[str, Any]]: """Build directory from Matlab struct arguments.""" it = iter(mat_struct) while True: try: a = next(it) except StopIteration: break yield eval(a), convert(next(it)) def makearray(mat_arr: str) -> np.ndarray: """Build numpy array from Matlab array as strings. Accepts matlab 1D or 2D array repr [...], [...;...], or [[...];[...]]. Arguments: mat_arr: matlab style array. Returns: numpy style ndarray. """ lns = mat_arr.replace("[", "").replace("]", "").split(";") return np.squeeze(np.array([row.split() for row in lns], dtype=np.float64)) def convert(value): """convert Matlab syntax to numpy syntax.""" if value.startswith("["): result = makearray(value) elif value.startswith("struct"): result = dict(makedir(argsplit(value[7:-1]))) else: result = eval(value) return result left = line.index("(") right = line.rindex(")") matcls = line[:left].strip()[2:] build_attrs = _CLASS_MAP[matcls]._BUILD_ATTRIBUTES arguments = argsplit(line[left + 1 : right]) ll = len(build_attrs) if ll < len(arguments) and arguments[ll].endswith("Pass'"): arguments.insert(ll, "'PassMethod'") args = [convert(v) for v in arguments[:ll]] kwargs = dict(zip(build_attrs, args, strict=True)) kwargs.update(makedir(arguments[ll:])) kwargs["Class"] = matcls if matcls == "rbend": # the Matlab 'rbend' has no equivalent in PyAT. This adds parameters # necessary for using the python sector bend halfangle = 0.5 * args[2] kwargs.setdefault("EntranceAngle", halfangle) kwargs.setdefault("ExitAngle", halfangle) return element_from_dict(kwargs, index=index)
[docs] def load_m(filename: str | Path, **kwargs) -> Lattice: """Create a :py:class:`.Lattice` from a Matlab m-file. Parameters: filename: Name of a '.m' file Keyword Args: keep_all (bool): Keep RingParam elements as Markers. Default: :py:obj:`False` name (str): Name of the lattice. Default: taken from the lattice, or ``''`` energy (float): Energy of the lattice [eV]. Default: taken from the lattice elements periodicity(int): Number of periods. Default: taken from the elements, or 1 *: All other keywords will be set as Lattice attributes Returns: lattice (Lattice): New :py:class:`.Lattice` object See Also: :py:func:`.load_lattice` for a generic lattice-loading function. """ def mfile_generator(params: dict, m_file: Path) -> Generator[Element, None, None]: """Run through the lines of a Matlab m-file and generate AT elements.""" params.setdefault("in_file", str(m_file)) with m_file.open() as file: _ = next(file) # Matlab function definition _ = next(file) # Cell array opening for lineno, line in enumerate(file): if line.startswith("};"): break try: elem = _element_from_m(line, index=lineno) except ValueError: warn(AtWarning(f"Invalid line {lineno} skipped."), stacklevel=2) continue except KeyError: warn(AtWarning(f"Line {lineno}: Unknown class."), stacklevel=2) continue else: yield elem filepath = Path(filename) return Lattice( ringparam_filter, mfile_generator, filepath.resolve(), iterator=params_filter, **kwargs, )
[docs] def load_var(matlat: Sequence[dict], **kwargs) -> Lattice: """Create a :py:class:`.Lattice` from a Matlab cell array. Parameters: matlat: Matlab lattice Keyword Args: keep_all (bool): Keep RingParam elements as Markers. Default: :py:obj:`False` name (str): Name of the lattice. Default: taken from the lattice, or ``''`` energy (float): Energy of the lattice [eV]. Default: taken from the lattice elements periodicity(int): Number of periods. Default: taken from the elements, or 1 *: All other keywords will be set as Lattice attributes Returns: lattice (Lattice): New :py:class:`.Lattice` object """ # noinspection PyUnusedLocal def var_generator(params, latt): for index, elem in enumerate(latt): yield element_from_dict(elem, index=index) return Lattice( ringparam_filter, var_generator, matlat, iterator=params_filter, **kwargs )
def matlab_ring(ring: Lattice) -> Generator[Element, None, None]: """Prepend a RingParam element to a lattice.""" def required(rng): # Public lattice attributes params = {k: v for k, v in vars(rng).items() if not k.startswith("_")} # Output the required attributes/properties for kp, km in _p2m.items(): try: v = getattr(rng, kp) except AttributeError: # noqa: PERF203 pass else: params.pop(kp, None) if km is not None: yield km, v # Output the remaining attributes yield from params.items() dct = dict(required(ring)) yield RingParam(**dct) yield from keep_elements(ring)
[docs] def save_mat(ring: Lattice, filename: str | Path, **kwargs) -> None: """Save a :py:class:`.Lattice` as a Matlab mat-file. Parameters: ring: Lattice description filename: Name of the '.mat' file Keyword Args: use (str): Name of the Matlab variable containing the lattice, Default: "RING" mat_key (str): Deprecated, alias for *use* See Also: :py:func:`.save_lattice` for a generic lattice-saving function. """ # Ensure the lattice is a Matlab column vector: list(list) use = kwargs.pop("mat_key", "RING") # For backward compatibility use = kwargs.pop("use", use) lring = [el.to_file(encoder=_mat_encoder) for el in matlab_ring(ring)] scipy.io.savemat(filename, {use: lring}, long_field_names=True)
def _element_to_m(elem: Element) -> str: """Builds the Matlab-evaluable string for an :py:class:`.Element`. Parameters: elem: :py:class:`.Element` Returns: mstr (str): Matlab string representation of the :py:class:`.Element` attributes """ def convert(arg): def convert_dict(pdir): def scan(d): for k, v in d.items(): yield convert(k) yield convert(v) return "struct({})".format(", ".join(scan(pdir))) def convert_array(arr): max_array = max(1000, np.prod(arr.shape)) + 1 mod_opt = {"threshold": max_array, "max_line_width": np.inf} if arr.ndim > 1: # replace endline character by ; to indicate the end of a 1D array # replace [SPACE by [ to remove extra space from +sign of # first array element return ( np.array2string(arg, **mod_opt) .replace("\n", ";") .replace("[ ", "[") ) elif arr.ndim > 0: return np.array2string(arg, **mod_opt).replace("[ ", "[") else: return str(arr) def convert_list(lst): return f"{{{{{str(lst)[1:-1]}}}}}" if isinstance(arg, np.ndarray): return convert_array(arg) elif isinstance(arg, np.number): return str(arg) elif isinstance(arg, dict): return convert_dict(arg) elif isinstance(arg, (list, tuple)): return convert_list(arg) elif isinstance(arg, Particle): return convert_dict(arg.to_dict()) else: return repr(arg) def m_name(elclass): classname = elclass.__name__ return _mat_constructor.get(classname, "".join(("at", classname.lower()))) _, args, kwds = elem.definition argstrs = [convert(arg) for arg in args] if "PassMethod" in kwds: argstrs.append(convert(kwds.pop("PassMethod"))) argstrs.extend( ", ".join((repr(elem._convert_attr.get(k, k)), convert(v))) for k, v in kwds.items() if k not in elem._drop_attr ) return "{:>15}({});...".format(m_name(elem.__class__), ", ".join(argstrs))
[docs] def save_m(ring: Lattice, filename: str | Path | None = None) -> None: """Save a :py:class:`.Lattice` as a Matlab m-file. Parameters: ring: Lattice description filename: Name of the '.m' file. Default: outputs on :py:obj:`sys.stdout` See Also: :py:func:`.save_lattice` for a generic lattice-saving function. """ def save(file): with np.printoptions(linewidth=1000, floatmode="unique"): print("ring = {...", file=file) for elem in matlab_ring(ring): print(_element_to_m(elem), file=file) print("};", file=file) if filename is None: save(sys.stdout) else: filename = Path(filename) with filename.open("w") as mfile: print(f"function ring = {filename.stem}()", file=mfile) save(mfile) print(" function v=False()\n v=false;\n end", file=mfile) print(" function v=True()\n v=true;\n end", file=mfile) print("end", file=mfile)
# Simulates the deprecated "mat_file" and "mat_key" attributes def _mat_file(ring): """.mat input file. Deprecated, use *in_file* instead.""" try: in_file = ring.in_file except AttributeError: msg = "'Lattice' object has no attribute 'mat_file'" raise AttributeError(msg) from None if isinstance(in_file, str): ext = Path(in_file).suffix if ext != ".mat": msg = "'Lattice' object has no attribute 'mat_file'" raise AttributeError(msg) else: msg = "'Lattice' object has no attribute 'mat_file'" raise AttributeError(msg) return in_file def _mat_key(ring): """selected Matlab variable. Deprecated, use *use* instead.""" try: mat_key = ring.use except AttributeError: msg = "'Lattice' object has no attribute 'mat_key'" raise AttributeError(msg) from None return mat_key # noinspection PyUnusedLocal def _ignore(ring, value): pass register_format( ".mat", load_mat, save_mat, descr="Matlab binary mat-file. See :py:func:`.load_mat`.", ) register_format( ".m", load_m, save_m, descr="Matlab text m-file. See :py:func:`.load_m`.", ) Lattice.mat_file = property(_mat_file, _ignore, None) Lattice.mat_key = property(_mat_key, _ignore, None)