"""
Load lattices from Matlab files.
"""
from __future__ import annotations
__all__ = ["load_mat", "save_mat", "load_m", "save_m", "load_var"]
import sys
import os
from os.path import abspath, basename, splitext
from typing import Any
from collections.abc import Sequence, Generator
from math import isfinite
from warnings import warn
import numpy as np
import scipy.io
# imports necessary in 'globals()' for 'eval'
from numpy import array, uint8, nan as NaN # noqa: F401
from .allfiles import register_format
from .utils import split_ignoring_parentheses, RingParam, keep_elements
from .utils import _drop_attrs, _CLASS_MAP
from ..lattice import Element, Lattice, Particle, Filter
from ..lattice import elements, AtWarning, params_filter, AtError
# 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 = {v: k for k, v in _m2p.items() if v is not None}
# Attribute to drop when writing a file
_p2m.update(_drop_attrs)
# Python to Matlab type translation
_mattype_map = {
int: float,
list: lambda attr: np.array(attr, dtype=object),
tuple: lambda attr: np.array(attr, dtype=object),
Particle: lambda attr: attr.to_dict(),
}
# 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"""
return _mattype_map.get(type(v), lambda attr: attr)(v)
def _matfile_generator(
params: dict[str, Any], mat_file: str
) -> Generator[Element, None, None]:
"""Run through Matlab cells and generate AT elements"""
def mclean(data):
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:
v = data[0, 0]
if issubclass(v.dtype.type, np.void):
# Object => Return a dict
return {f: mclean(v[f]) for f in v.dtype.fields}
else:
# Return a scalar
return v
else:
# Remove any surplus dimensions in arrays.
return np.squeeze(data)
# noinspection PyUnresolvedReferences
m = scipy.io.loadmat(params.setdefault("in_file", mat_file))
matvars = [varname for varname in m if not varname.startswith("__")]
default_key = matvars[0] if (len(matvars) == 1) else "RING"
key = params.setdefault("use", default_key)
if key not in m.keys():
kok = [k for k in m.keys() if "__" not in k]
raise AtError(
f"Selected '{key}' variable does not exist, please select in: {kok}"
)
check = params.pop("check", True)
quiet = params.pop("quiet", False)
cell_array = m[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)
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 elem.items():
k2 = _m2p.get(k, k)
if k2 is not None:
if 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, **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.
"""
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,
abspath(filename),
iterator=params_filter,
**kwargs,
)
def _element_from_m(line: str) -> 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):
"""Build directory from Matlab struct arguments"""
def pairs(it):
while True:
try:
a = next(it)
except StopIteration:
break
yield eval(a), convert(next(it))
return dict(pairs(iter(mat_struct)))
def makearray(mat_arr):
"""Build numpy array for Matlab array syntax"""
def arraystr(arr):
lns = arr.split(";")
rr = [arraystr(v) for v in lns] if len(lns) > 1 else lns[0].split()
return f"[{', '.join(rr)}]"
return eval(f"array({arraystr(mat_arr)})")
def convert(value):
"""convert Matlab syntax to numpy syntax"""
if value.startswith("["):
result = makearray(value[1:-1])
elif value.startswith("struct"):
result = makedir(argsplit(value[7:-1]))
else:
result = eval(value)
return result
left = line.index("(")
right = line.rindex(")")
matcls = line[:left].strip()[2:]
cls = _CLASS_MAP[matcls]
arguments = argsplit(line[left + 1 : right])
ll = len(cls._BUILD_ATTRIBUTES)
if ll < len(arguments) and arguments[ll].endswith("Pass'"):
arguments.insert(ll, "'PassMethod'")
args = [convert(v) for v in arguments[:ll]]
kwargs = makedir(arguments[ll:])
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 cls(*args, **kwargs)
[docs]
def load_m(filename: str, **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: str) -> Generator[Element, None, None]:
"""Run through the lines of a Matlab m-file and generate AT elements"""
with open(params.setdefault("in_file", m_file)) 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)
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
return Lattice(
ringparam_filter,
mfile_generator,
abspath(filename),
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 elem in latt:
yield Element.from_dict(elem)
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:
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, **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_dict(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):
if arr.ndim > 1:
return np.array2string(arg).replace("\n", ";")
elif arr.ndim > 0:
return np.array2string(arg)
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, tuple):
return convert_list(arg)
elif isinstance(arg, list):
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())))
attrs = dict(elem.items())
# noinspection PyProtectedMember
args = [attrs.pop(k, getattr(elem, k)) for k in elem._BUILD_ATTRIBUTES]
defelem = elem.__class__(*args)
kwds = {
k: v
for k, v in attrs.items()
if not np.array_equal(v, getattr(defelem, k, None))
}
argstrs = [convert(arg) for arg in args]
if "PassMethod" in kwds:
argstrs.append(convert(kwds.pop("PassMethod")))
argstrs += [", ".join((repr(k), convert(v))) for k, v in kwds.items()]
return "{:>15}({});...".format(m_name(elem.__class__), ", ".join(argstrs))
[docs]
def save_m(ring: Lattice, filename: str | 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:
with open(filename, "w") as mfile:
[funcname, _] = splitext(basename(filename))
print(f"function ring = {funcname}()", 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:
raise AttributeError("'Lattice' object has no attribute 'mat_file'") from None
if isinstance(in_file, str):
_, ext = os.path.splitext(in_file)
if ext != ".mat":
raise AttributeError("'Lattice' object has no attribute 'mat_file'")
else:
raise AttributeError("'Lattice' object has no attribute 'mat_file'")
return in_file
def _mat_key(ring):
"""selected Matlab variable. Deprecated, use *use* instead."""
try:
mat_key = ring.use
except AttributeError:
raise AttributeError("'Lattice' object has no attribute 'mat_key'") 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)