"""
Translation of a PALS lattice into a Bmad lattice file.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from ._common import (MULTIPOLE_RE, NAME_RE, ABRepresentation,
FullRepresentation, approx, ctrl_variables,
facility_props, fill_multipoles, fmt, fold,
name_value_pairs, tilt_rotation, times, try_float,
value_text)
from .node import YAMLNode
__all__ = ["BmadEleDef", "BmadBeamline", "BmadController", "BmadLattice",
"pals_to_bmad", "write_bmad_file"]
[docs]
@dataclass
class BmadEleDef:
"""A single Bmad element definition.
- ``name``: the element name.
- ``type``: the Bmad element-type name (e.g. ``Drift``, ``Quadrupole``).
- ``attrs``: already-translated attribute fragments, each an
``"attribute = value"`` string.
"""
name: str
type: str
attrs: List[str] = field(default_factory=list)
[docs]
@dataclass
class BmadBeamline:
"""A Bmad ``line`` definition: its ``name`` and the ordered list of member
element ``members`` (by name)."""
name: str
members: List[str] = field(default_factory=list)
[docs]
@dataclass
class BmadController:
"""A Bmad ``overlay`` or ``group`` element: what a PALS ``Controller``
becomes.
- ``name``: the controller name.
- ``type``: ``"overlay"`` for ``control_type: ABSOLUTE``, ``"group"`` for
``RELATIVE``. Bmad's overlay sets the slave parameter and its group adds to
it, which is the same split PALS makes.
- ``slaves``: the controlled parameters, each an
``"ele[attribute]: expression"`` string.
- ``vars``: the variable names, in definition order.
- ``inits``: the variables' initial values, each a ``"name = value"`` string.
"""
name: str
type: str
slaves: List[str] = field(default_factory=list)
vars: List[str] = field(default_factory=list)
inits: List[str] = field(default_factory=list)
[docs]
@dataclass
class BmadLattice:
"""An in-memory model of a Bmad lattice.
Produced by :func:`pals_to_bmad` and serialized to a file by
:func:`write_bmad_file`. The fields mirror the sections of a Bmad lattice
file:
- ``constants``: ``name = value`` definitions, in definition order.
- ``parameters``: global ``parameter[...] = ...`` settings (species, energy,
geometry).
- ``beginning``: ``beginning[...] = ...`` initial Twiss, coupling and
dispersion settings.
- ``particle_start``: ``particle_start[...] = ...`` initial-coordinate
settings.
- ``elements``: element definitions (:class:`BmadEleDef`).
- ``controllers``: ``overlay``/``group`` definitions
(:class:`BmadController`).
- ``beamlines``: ``line`` definitions (:class:`BmadBeamline`).
- ``use``: branch names for the final ``use, ...`` statement.
"""
constants: List[str] = field(default_factory=list)
parameters: List[str] = field(default_factory=list)
beginning: List[str] = field(default_factory=list)
particle_start: List[str] = field(default_factory=list)
elements: List[BmadEleDef] = field(default_factory=list)
controllers: List[BmadController] = field(default_factory=list)
beamlines: List[BmadBeamline] = field(default_factory=list)
use: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
[docs]
def pals_to_bmad(yaml: YAMLNode) -> BmadLattice:
"""Translate a parsed PALS lattice ``yaml`` (as returned by
:func:`~palsparserpy.parse_file`) into a :class:`BmadLattice`.
The returned structure is an in-memory model of the *Bmad* lattice (elements,
beamlines, parameters), not the input PALS tree. Translation is a three-step
process: parse the PALS file with ``parse_file``, build the target model with
``pals_to_bmad``, then emit the Bmad lattice file with
:func:`write_bmad_file`::
yaml = parse_file(file_dir)
write_bmad_file(pals_to_bmad(yaml), filename)
"""
pals = yaml["PALS"]
facility = pals["facility"]
lat = BmadLattice()
# Constants and variables may be defined directly under `PALS` as well as in
# the facility.
for key in ("constants", "variables"):
if key in pals:
lat.constants.extend(_bmad_constants(pals[key]))
# A FloorShift placed from the global origin is placed, in Bmad, from the
# beginning element, which is the same thing only while the lattice leaves
# that element at the origin.
beginning_at_origin = True
for ele in facility:
props = ele.child(0)
if ("kind" in props and props["kind"].value == "BeginningEle"
and "FloorP" in props):
floorP = props["FloorP"]
if any(k in floorP and floorP[k].as_float() != 0.0
for k in ("x", "y", "z", "theta", "phi", "psi")):
beginning_at_origin = False
drives = _multipole_drives(facility)
placeholders = set()
n_lattices = 0
for ele in facility:
props = ele.child(0)
# The compact `constants:`/`variables:` list is a facility entry in its
# own right, with no `kind` of its own; every other entry the translation
# looks at is a named element.
if props.node_key() in ("constants", "variables"):
lat.constants.extend(_bmad_constants(props))
continue
if "kind" not in props:
continue
pals_kind = props["kind"].value
if pals_kind == "BeginningEle":
params, beginning, particle = _ele_to_bmad_str(ele)
lat.parameters.extend(params)
lat.beginning.extend(beginning)
lat.particle_start.extend(particle)
elif pals_kind == "BeamLine":
lat.beamlines.append(_make_bmad_line(ele))
elif pals_kind == "Lattice":
n_lattices += 1
if n_lattices > 1:
raise ValueError(
"\nDifferent BeamLine complexes must be translated from "
"separate files.\nBmad only supports one branching lattice "
"per file.\nConsider using different Tao universes.\n")
_add_bmad_branches(lat, props["branches"])
elif pals_kind == "Controller":
lat.controllers.append(_make_bmad_controller(ele, facility))
elif pals_kind in ("constant", "variable"):
lat.constants.append(_bmad_constant(props, pals_kind))
elif pals_kind == "Placeholder":
# A Placeholder holds a place while a PALS lattice is built and is
# gone once it is.
placeholders.add(props.node_key())
else:
lat.elements.append(
_make_bmad_ele(ele, beginning_at_origin=beginning_at_origin,
drive=drives.get(props.node_key())))
for bl in lat.beamlines:
bl.members[:] = [m for m in bl.members if m not in placeholders]
if "girders" in pals:
lat.elements.extend(_make_bmad_girders(pals["girders"]))
return lat
# ---------------------------------------------------------------------------
def _add_bmad_branches(lat: BmadLattice, branches: YAMLNode) -> BmadLattice:
"""Translate a PALS ``Lattice``'s ``branches`` sequence into ``lat``.
Append each branch name to ``lat.use`` and its geometry to ``lat.parameters``
(``parameter[geometry]`` for a single branch, ``<name>[geometry]`` when
several branches are present).
"""
if len(branches) == 0:
return lat
single = len(branches) == 1
for bl in branches:
if bl.is_scalar():
name = bl.value
periodic = "open"
elif bl.is_map():
bl_props = bl.child(0)
name = bl_props.node_key()
# `periodic` is a YAML node, not a string, so it has to be rendered
# before it is compared.
periodic = "closed" if ("periodic" in bl_props and
bl_props["periodic"].value.lower() == "true") \
else "open"
elif bl.is_sequence():
raise ValueError("Expanding lattices is not done during PALS>Bmad "
"translation")
else:
raise ValueError(f"This object is neither a scalar, map, nor "
f"sequence: {bl!r}")
lat.use.append(name)
if single:
lat.parameters.append(f"parameter[geometry] = {periodic}")
else:
lat.parameters.append(f"{name}[geometry] = {periodic}")
return lat
# ---------------------------------------------------------------------------
[docs]
def write_bmad_file(lat: BmadLattice, filename) -> None:
"""Serialize the :class:`BmadLattice` ``lat`` to ``filename`` as a Bmad
lattice file.
Write the constant and variable definitions, the global, beginning-Twiss and
particle-start parameters, the element definitions, the ``overlay``/``group``
definitions, the beamline (``line``) definitions, and the branch (``use``)
statement, each in its own labelled section. The constants come first
because Bmad, unlike PALS, resolves a name against what the file has defined
*above* the point of use.
"""
with open(filename, "w") as out:
n_section = 0
# Head each section with the same rule and title, one blank line clear of
# the one before.
def section(title):
nonlocal n_section
n_section += 1
if n_section != 1:
out.write("\n")
out.write("!=====================================================" +
"=================\n" + f"! {title} \n\n")
if lat.constants:
section("Constant and variable definitions")
for constant in lat.constants:
out.write(constant + "\n")
section("Lattice parameters")
for parameter in lat.parameters:
out.write(parameter + "\n")
if lat.beginning:
out.write("\n")
for parameter in lat.beginning:
out.write(parameter + "\n")
if lat.particle_start:
out.write("\n")
for parameter in lat.particle_start:
out.write(parameter + "\n")
section("Element definitions")
for ele in lat.elements:
out.write(_format_bmad_ele(ele) + "\n")
# A controller spans several lines, so the definitions are set apart from
# one another.
if lat.controllers:
section("Controller definitions")
out.write("\n\n".join(_format_bmad_controller(c)
for c in lat.controllers) + "\n")
section("Beamline definitions")
if lat.beamlines:
out.write("\n\n".join(_format_bmad_line(b) for b in lat.beamlines) + "\n")
section("Branch structure")
if lat.use:
out.write("use, " + ", ".join(lat.use))
# ---------------------------------------------------------------------------
def _format_bmad_ele(ele: BmadEleDef) -> str:
"""Render a :class:`BmadEleDef` as a ``name: type, attr = val, ...`` Bmad
element definition, with each attribute on its own tab-indented continuation
line."""
text = f"{ele.name}: {ele.type}"
for attr in ele.attrs:
text += f",\n\t{attr}"
return text
# ---------------------------------------------------------------------------
def _format_bmad_controller(ctrl: BmadController) -> str:
"""Render a :class:`BmadController` as a
``name: overlay = {...}, var = {...}, v = init`` definition.
A control expression is a line's worth of text on its own, so each slave, the
variable list and each variable's initial value get a tab-indented
continuation line of their own. Every broken line ends in the comma that
continues it.
"""
text = f"{ctrl.name}: {ctrl.type} = {{" + ",\n\t\t".join(ctrl.slaves) + "}"
if ctrl.vars:
text += ",\n\tvar = {" + ", ".join(ctrl.vars) + "}"
for init in ctrl.inits:
text += ",\n\t" + init
return text
# ---------------------------------------------------------------------------
def _format_bmad_line(bl: BmadBeamline) -> str:
"""Render a :class:`BmadBeamline` as a Bmad ``name: line = (...)``
definition, wrapping the member list with tab-indented continuation lines to
keep rows under ~80 columns."""
line_str = ""
tmp = ""
l_tmp = len(bl.name) + 4
n = len(bl.members)
for i, member in enumerate(bl.members):
ele_str = member
if i < n - 1:
ele_str += ", "
l_ele_str = len(ele_str)
if l_tmp + l_ele_str < 80:
tmp += ele_str
l_tmp += l_ele_str
else:
line_str += tmp + "\n"
tmp = "\t" + ele_str
l_tmp = 7 + l_ele_str
line_str += tmp
wrapped = line_str if len(line_str) < 80 else ("\n\t" + line_str + "\n\t")
return f"{bl.name}: line = ({wrapped})"
# ---------------------------------------------------------------------------
def _ele_to_bmad_str(ele: YAMLNode) -> Tuple[List[str], List[str], List[str]]:
"""Translate a ``BeginningEle`` element into Bmad global-parameter settings.
Returns ``(params, beginning, particle_start)`` where ``params`` holds
``parameter[...]`` strings from the element's ``ReferenceP`` (species and
energy), ``beginning`` holds ``beginning[...]`` strings from its ``TwissP``
(initial Twiss, coupling and dispersion), and ``particle_start`` holds
``particle_start[...]`` strings from its ``ParticleP`` (initial phase-space
coordinates and spin).
"""
props = ele.child(0)
params: List[str] = []
beginning: List[str] = []
particle: List[str] = []
for key in props.keys():
if key == "TwissP":
twissP = props["TwissP"]
for k in twissP.keys():
# PALS and Bmad give these the same names, bar the coupling
# matrix's underscore and the derivatives, where Bmad puts dpz
# or ds before the plane or mode (PALS `dbeta_a_dpz` is Bmad
# `dbeta_dpz_a`, and `deta_x_ds` is `deta_ds_x`).
attribute = "cmat_" + k[4:] if k.startswith("cmat") else k
attribute = re.sub(r"^(dbeta|dalpha|deta|detap)_([abxy])_(dpz|ds)$",
r"\1_\3_\2", attribute)
beginning.append(f"beginning[{attribute}] = {twissP[k].value}")
elif key == "ReferenceP":
referenceP = props["ReferenceP"]
for k in referenceP.keys():
if k == "species_ref":
params.append(f"parameter[particle] = {referenceP[k].value}")
elif k == "pc_ref":
params.append(f"parameter[p0c] = {referenceP[k].value}")
elif k == "E_tot_ref":
params.append(f"parameter[E_tot] = {referenceP[k].value}")
elif k in ("time_ref", "location"):
print(f"{k} not supported yet")
elif key == "ParticleP":
particleP = props["ParticleP"]
for k in particleP.keys():
val = particleP[k].value
if k in ("x", "y", "z", "px", "py", "pz",
"spin_x", "spin_y", "spin_z"):
particle.append(f"particle_start[{k}] = {val}")
return params, beginning, particle
# ---------------------------------------------------------------------------
def _make_bmad_line(ele: YAMLNode) -> BmadBeamline:
"""Translate a ``BeamLine`` element into a :class:`BmadBeamline`.
Collect the member element names (dropping the leading reference entry,
``line[0]``, by design) into the returned beamline.
"""
props = ele.child(0)
name = props.node_key()
line = props["line"]
members: List[str] = []
for i in range(1, len(line)):
line_ele = line.child(i)
if line_ele.is_scalar():
members.append(line_ele.value)
elif line_ele.is_map() or line_ele.is_sequence():
members.append(line_ele.child(0).node_key())
else:
raise ValueError(f"BeamLine {name} element {i + 1} is not scalar or "
"sequence or map")
return BmadBeamline(name, members)
# ---------------------------------------------------------------------------
def _bmad_constants(node: YAMLNode) -> List[str]:
"""Translate a compact-form ``constants:``/``variables:`` list into Bmad
``name = value`` definitions.
Bmad draws no distinction between the two: both become a named value the rest
of the lattice file may use in an expression, so both lists translate the
same way.
"""
return [f"{name} = {value}" for name, value in name_value_pairs(node)]
# ---------------------------------------------------------------------------
def _bmad_constant(props: YAMLNode, pals_kind: str) -> str:
"""Translate a full-form (``kind: constant``, ``kind: variable``) definition
into a Bmad ``name = value`` definition.
A definition whose ``value`` is a structure rather than a single value has no
Bmad equivalent and raises an error; one with no ``value`` at all takes PALS'
default of zero.
"""
name = props.node_key()
if "value" not in props:
return f"{name} = 0"
value = props["value"]
if value.is_map() or value.is_sequence():
raise ValueError(f"{name}: the `value` of a `{pals_kind}` is not a "
"single value")
return f"{name} = {value_text(value)}"
# ---------------------------------------------------------------------------
def _bmad_control_target(cname: str, param: str,
facility: YAMLNode) -> Tuple[str, float, float]:
"""Translate a controller's ``parameter`` target into a Bmad slave reference.
Returns ``(target, factor, offset)`` where ``target`` is the
``"ele[attribute]"`` Bmad reference and the control expression must be
multiplied by ``factor`` and have ``offset`` subtracted from it to hold the
same physics. Neither is trivial in general because the element translation
does not carry PALS parameters across unchanged: a multipole that is not the
element's own becomes Bmad's normalized *integrated* strength ``An``/``Bn``,
so a controller driving a non-integrated one has to pick up the slave's
length (and the ``1/n!`` of the multipole convention) here. A multipole that
*is* the element's own strength becomes ``K1``, ``K2``, ``K3`` or a bend's
``DG`` (see :func:`_native_strength`), which is not length integrated, so
there an integrated PALS parameter is the one that needs the length; and
``DG``, alone among them, is measured from the reference bend rather than
from zero, which is the ``offset`` (see :func:`_bend_reference`). A bend's
added ``K1`` and ``K2`` are used only when that order has no skew part, so a
controller driving one of those has to look at the skew component to know
which attribute it will find.
A target may name its element by kind as well as by name, as
``{kind}::{name}``; the qualifier is checked against the element found and
then dropped, the Bmad file naming each element once.
Targets Bmad cannot express -- a pattern matching several elements, a ``>>``
or ``>>>`` qualifier naming the BeamLine or Lattice an element is reached
through, or a parameter with no Bmad attribute -- raise an error.
"""
# Bmad has a `branch>>ele` qualifier of its own, but a PALS BeamLine is not a
# Bmad branch -- it may be spliced into a longer line -- so the two do not
# correspond.
if ">>" in param:
raise ValueError(f"controller {cname}: `{param}` reaches its element "
"through a BeamLine or Lattice qualifier, which has no "
"Bmad equivalent")
parts = param.split(">")
if len(parts) != 2:
raise ValueError(f"controller {cname}: control parameter `{param}` is "
"not of the form `element>parameter`")
slave, path = parts
# An element may be named by its kind as well as by its name.
kind_wanted = None
if "::" in slave:
qualifier = slave.split("::")
if len(qualifier) != 2:
raise ValueError(f"controller {cname}: `{param}` does not name a "
"single element kind")
kind_wanted, slave = qualifier
if not NAME_RE.match(slave):
raise ValueError(f"controller {cname}: `{param}` selects slaves by "
"pattern, which a Bmad overlay cannot express")
props = facility_props(facility, slave)
if props is None:
raise ValueError(f"controller {cname}: `{param}` names no element of the "
"facility")
if kind_wanted is not None:
ele_kind = props["kind"].value if "kind" in props else ""
if kind_wanted != ele_kind:
raise ValueError(f"controller {cname}: `{param}` asks for a "
f"{kind_wanted} but {slave} is a {ele_kind}")
# A controller may drive another controller's variable, and so may a Bmad
# overlay.
if "kind" in props and props["kind"].value == "Controller":
if not NAME_RE.match(path):
raise ValueError(f"controller {cname}: `{param}` is not a variable "
f"of controller {slave}")
return f"{slave}[{path}]", 1.0, 0.0
if path == "length":
return f"{slave}[L]", 1.0, 0.0
m = MULTIPOLE_RE.match(path)
if m is not None:
order = int(m.group(3))
skew = m.group(2) == "s"
integrated = m.group(4) == "L"
ele_length = props["length"].as_float() if "length" in props else 1.0
# A tilted multipole rotates normal and skew into each other, so the one
# PALS parameter no longer maps onto the one Bmad attribute.
if "MagneticMultipoleP" in props and \
f"tilt{order}" in props["MagneticMultipoleP"]:
if not approx(props["MagneticMultipoleP"][f"tilt{order}"].as_float(), 0):
raise ValueError(f"controller {cname}: `{param}` drives a tilted "
"multipole, which has no single Bmad attribute")
# The normal component of the element's own multipole is its strength
# attribute, which the element translation writes without the length or
# the factorial.
ele_kind = props["kind"].value if "kind" in props else ""
native = _NATIVE_STRENGTH.get(ele_kind, {}).get(order)
if native is not None and not skew and not (integrated and ele_length == 0) \
and not (ele_kind == "Bend" and order > 0 and _has_skew(props, order)):
offset = _bend_reference(props, slave, m.group(1) == "K") \
if ele_kind == "Bend" and order == 0 else 0.0
attribute = native[0] if m.group(1) == "K" else native[1]
return (f"{slave}[{attribute}]",
1 / ele_length if integrated else 1.0, offset)
fact = math.factorial(order)
return (f"{slave}[{'A' if skew else 'B'}{order}]",
(1.0 if integrated else ele_length) / fact, 0.0)
raise ValueError(f"controller {cname}: control parameter `{param}` is not "
"yet translated to Bmad")
# ---------------------------------------------------------------------------
@dataclass
class _MultipoleDrive:
"""How the controllers of a lattice drive one element's multipoles.
- ``normalized``: the conventions the driven multipoles are stated in --
``True`` for a normalized ``K`` parameter, ``False`` for a field ``B`` one.
- ``multipole``: whether any of them lands in a Bmad ``An``/``Bn`` attribute
rather than in the element's own strength attribute.
"""
normalized: set = field(default_factory=set)
multipole: bool = False
def _multipole_drives(facility: YAMLNode) -> Dict[str, _MultipoleDrive]:
"""The multipoles each element of ``facility`` has driven by a controller,
keyed by element name.
The element translation needs this as well as the element's own parameters:
which convention Bmad reads a multipole in (``field_master``) and whether it
reads one as a fraction of the element's strength (``scale_multipoles``) are
settings of the element, and a controller-driven multipole needs them right
whatever value, zero included, the element starts out with -- or whether it
states the multipole at all.
"""
drives: Dict[str, _MultipoleDrive] = {}
for ele in facility:
props = ele.child(0)
if "kind" not in props or props["kind"].value != "Controller" or \
"controls" not in props:
continue
cname = props.node_key()
for control in props["controls"]:
if "parameter" not in control:
continue
param = control["parameter"].value
m = MULTIPOLE_RE.match(param.rsplit(">", 1)[-1])
if m is None:
continue
target, _, _ = _bmad_control_target(cname, param, facility)
slave, attribute = target[:-1].split("[")
drive = drives.setdefault(slave, _MultipoleDrive())
drive.normalized.add(m.group(1) == "K")
if re.fullmatch(r"[AB][0-9]+", attribute):
drive.multipole = True
return drives
# ---------------------------------------------------------------------------
def _make_bmad_controller(ele: YAMLNode, facility: YAMLNode) -> BmadController:
"""Translate a ``Controller`` element into a :class:`BmadController`.
``facility`` is needed to reach the slave elements: what a control expression
must be scaled by depends on the element it drives (see
:func:`_bmad_control_target`).
"""
props = ele.child(0)
name = props.node_key()
control_type = props["control_type"].value if "control_type" in props \
else "ABSOLUTE"
if control_type == "ABSOLUTE":
bmad_type = "overlay"
elif control_type == "RELATIVE":
bmad_type = "group"
else:
raise ValueError(f"{name}: control_type must be ABSOLUTE or RELATIVE, "
f"not {control_type}")
variables: List[str] = []
inits: List[str] = []
for var, value in ctrl_variables(props):
variables.append(var)
inits.append(f"{var} = {value}")
slaves: List[str] = []
if "controls" in props:
for control in props["controls"]:
if "parameter" not in control or "expression" not in control:
raise ValueError(f"{name}: a controls entry needs both a "
"`parameter` and an `expression`")
target, factor, offset = _bmad_control_target(
name, control["parameter"].value, facility)
expression = control["expression"].value
if not approx(factor, 1):
expression = f"{fmt(factor)}*({expression})"
# An `overlay` sets the attribute, so an attribute Bmad measures from
# something other than zero needs that something taken off. A `group`
# varies the attribute instead, and what it is measured from is the
# same before and after, so there the offset cancels.
if bmad_type == "overlay" and not approx(offset, 0):
expression = f"{expression} - ({fmt(offset)})"
slaves.append(f"{target}: {expression}")
return BmadController(name, bmad_type, slaves, variables, inits)
# ---------------------------------------------------------------------------
def _bmad_kind(ele_kind: str) -> str:
"""The Bmad element-type name for the PALS ``ele_kind``.
Kinds with no Bmad equivalent (e.g. ``UnionEle``, ``Feedback``) raise an
error.
"""
# Magnets and RF Cavities
#
# PALS has the one `Bend`, whose reference geometry is a sector; the pole
# face rotations that make a bend rectangular are parameters of it
# (`e1_rect`, `e2_rect`), not a second kind. Bmad splits the two, so `Bend`
# maps to Bmad's sector bend and Bmad's `RBend` has no PALS kind to map from.
renamed = {"ACKicker": "AC_Kicker", "Bend": "SBend",
"CrabCavity": "Crab_Cavity", "Multipole": "AB_Multipole",
# Bookkeeping Elements
"BeginningEle": "Beginning_Ele", "FloorShift": "Floor_Shift",
"ReferenceChange": "Patch"}
unchanged = {
# Magnets and RF Cavities
"Drift", "Kicker", "Octupole", "Quadrupole", "RFCavity", "Sextupole",
"Solenoid", "Wiggler",
# Beam and Plasma Elements
"BeamBeam",
# Sources and Collimation
"Converter", "Foil", "Mask",
# Instrumentation and Diagnostics
"Instrument",
# Map Elements
"Match", "Taylor",
# Bookkeeping Elements
"Fiducial", "Fork", "Marker", "Patch"}
if ele_kind in renamed:
return renamed[ele_kind]
if ele_kind in unchanged:
return ele_kind
if ele_kind == "EGun":
return "E_Gun"
# Structural and Grouping Elements
if ele_kind == "Girder":
raise ValueError("A Girder is defined under `PALS: girders`, not among "
"the lattice elements")
if ele_kind == "UnionEle":
raise ValueError("No UnionEle in Bmad")
# External Circuits
if ele_kind == "Feedback":
raise ValueError("No Feedback elements in Bmad")
raise ValueError(f"Element kind {ele_kind} is not translated to Bmad")
# ---------------------------------------------------------------------------
def _kind_map(ele_kind: str):
"""The multipole representation type used for a given element kind.
Elements that carry field multipoles map to
:class:`~palsparserpy._common.ABRepresentation`; kinds that have no multipole
attributes, or are unrecognized, raise an error.
"""
if ele_kind in ("Bend", "Quadrupole", "Sextupole", "Octupole", "Multipole",
"Solenoid", "Kicker", "Wiggler", "RFCavity", "CrabCavity"):
return ABRepresentation
if ele_kind in ("EGun", "Mask", "Converter", "Instrument"):
raise ValueError(f"Bmad {ele_kind} has no multipole attributes")
raise ValueError(f"Element type {ele_kind} is unrecognized")
# ---------------------------------------------------------------------------
#: The multipole orders an element kind holds as its own strength, and the Bmad
#: attributes that hold them.
#:
#: Each entry maps a PALS element kind to a map from multipole order to
#: ``(normalized_attribute, field_attribute)``: a quadrupole's order-1 field is
#: Bmad's ``K1`` (or ``B1_GRADIENT``), not a ``B1`` multipole. A bend carries a
#: quadrupole and a sextupole component of its own as well as its bending field,
#: so it has three. A bend's order-0 field is ``DG`` (or ``DB_FIELD``), which
#: Bmad measures from the reference bend rather than from zero, so that one is
#: written with an offset (see :func:`_bend_reference`). Kinds whose strength does
#: not line up one-to-one with a PALS multipole -- a kicker's ``HKICK``, a
#: solenoid's ``KS`` -- are deliberately absent, and keep the multipole form.
#:
#: A bend has no attribute above order 2, so its higher multipoles keep the
#: ``An``/``Bn`` form.
_NATIVE_STRENGTH: Dict[str, Dict[int, Tuple[str, str]]] = {
"Bend": {0: ("DG", "DB_FIELD"), 1: ("K1", "B1_GRADIENT"),
2: ("K2", "B2_GRADIENT")},
"Quadrupole": {1: ("K1", "B1_GRADIENT")},
"Sextupole": {2: ("K2", "B2_GRADIENT")},
"Octupole": {3: ("K3", "B3_GRADIENT")},
}
# ---------------------------------------------------------------------------
def _native_strength(full: FullRepresentation, ele_kind: str,
offset: float = 0.0) -> List[str]:
"""Take the multipoles that are an element's own strength out of ``full`` and
return their Bmad attribute fragments.
The strength of a Bmad quadrupole is its ``K1``, so that is where a PALS
``Kn1`` belongs: leaving it in a ``B1`` multipole would give an element whose
nominal strength is zero and whose field comes entirely from a multipole slot.
A bend has a ``K1`` and a ``K2`` of its own on top of its bending field, so a
bend's ``Kn1`` and ``Kn2`` land there in the same way. A native attribute is
not length integrated, so an integrated PALS value is divided by the element
length; a tilted one is rotated first, and whatever lands in the skew part is
left behind in ``full`` as an ordinary multipole. That rotation is why the
tilt does not simply become the Bmad element ``tilt``, which is already spoken
for by ``BodyShiftP.z_rot``.
``offset`` is subtracted from the order-0 value written, for the one native
attribute Bmad does not measure from zero: a bend's ``DG`` is the departure of
the field from the reference bend (see :func:`_bend_reference`).
An order is left in ``full`` untouched, to be written in the multipole form,
when it has no native attribute for this kind; when an integrated multipole
sits on a zero-length element, which no non-integrated attribute can express;
and, for a bend's added ``K1`` and ``K2``, when the field has a skew part. As
elsewhere in this conversion, an element with no ``length`` is taken to be one
metre long.
"""
if ele_kind not in _NATIVE_STRENGTH:
return []
native = _NATIVE_STRENGTH[ele_kind]
attrs: List[str] = []
for order in sorted(full.magnitude):
if order not in native:
continue
length = full.L if full.integrated[order] else 1.0
if length == 0:
continue
strength = (complex(*full.magnitude[order])
* tilt_rotation(order, full.tilt.get(order, 0.0)) / length)
# A bend's `K1` and `K2` are components added to a field the element
# already has, not the strength that makes it the element it is, and Bmad
# has no skew attribute to go with them. So an order with a skew part is
# left whole in the `An`/`Bn` form, which holds both parts in the one
# convention, rather than split between a native attribute and a
# multipole slot.
if ele_kind == "Bend" and order > 0 and not approx(strength.imag, 0):
continue
# What is left is a skew multipole of the same order, in the same units
# the native attribute was just read in: no longer integrated, and with
# the tilt already applied.
full.magnitude[order] = [0.0, strength.imag]
full.integrated[order] = False
full.tilt.pop(order, None)
# Order 0 is compared against the offset rather than against zero: a bend
# whose field is the reference bend has no departure from it to write,
# and a PALS file states the two to the same handful of digits, which is
# not enough to subtract exactly. For every other order -- and for an
# offset of zero -- this is the same exact test as before.
off = offset if order == 0 else 0.0
if approx(strength.real, off):
continue
attribute = native[order][0] if full.normalized[order] else native[order][1]
attrs.append(f"{attribute} = {fmt(strength.real - off)}")
return attrs
# ---------------------------------------------------------------------------
def _bend_reference(props: YAMLNode, name: str, normalized: bool) -> float:
"""The reference bend strength a ``Bend``'s order-0 normal multipole is
measured against.
PALS states the field of a bend outright, as ``MagneticMultipoleP.Kn0`` (or
``Bn0``). Bmad states it as ``DG`` (or ``DB_FIELD``), the departure of the
field from the reference bend the element geometry is built on, so the
reference has to come off the PALS value: ``dg = Kn0 - g_ref``. The reference
is ``BendP.g_ref`` -- or the curvature ``1/radius_ref`` of that same bend --
for a ``normalized`` multipole, and ``BendP.Bn0_ref`` for an unnormalized one.
A bend with no reference of its own does not bend, and the offset is zero.
The two flavors cannot be mixed: going from one to the other takes the
reference momentum, which belongs to the branch and not to the element, so a
normalized field measured against an unnormalized reference (or the reverse)
raises an error.
"""
if "BendP" not in props:
return 0.0
bendP = props["BendP"]
has_g = "g_ref" in bendP or "radius_ref" in bendP
has_B = "Bn0_ref" in bendP
if normalized:
if has_B and not has_g:
raise ValueError(f"{name}: the bend field (Kn0) and its reference "
"bend (Bn0_ref) are not both normalized")
if "g_ref" in bendP:
return bendP["g_ref"].as_float()
if "radius_ref" in bendP:
return 1 / bendP["radius_ref"].as_float()
else:
if has_g and not has_B:
raise ValueError(f"{name}: the bend field (Bn0) and its reference "
"bend (g_ref) are not both normalized")
if has_B:
return bendP["Bn0_ref"].as_float()
return 0.0
# ---------------------------------------------------------------------------
def _has_skew(props: YAMLNode, order: int) -> bool:
"""Whether the element has a nonzero skew multipole of the given ``order``.
Which Bmad attribute an order lands in can depend on it: a bend's ``K1`` and
``K2`` are used only for a field with no skew part (see
:func:`_native_strength`), so a controller driving one has to ask. Any of the
four spellings of the component -- normalized or not, integrated or not --
counts.
"""
if "MagneticMultipoleP" not in props:
return False
mmP = props["MagneticMultipoleP"]
for key in (f"Ks{order}", f"Ks{order}L", f"Bs{order}", f"Bs{order}L"):
if key in mmP and not approx(mmP[key].as_float(), 0):
return True
return False
# ---------------------------------------------------------------------------
def _mp_key(rep: ABRepresentation) -> List[str]:
"""The Bmad attribute fragments for A/B field-integral multipoles.
Emits an ``An = ...`` / ``Bn = ...`` fragment for each nonzero coefficient in
``rep``.
"""
out: List[str] = []
for order in sorted(rep.A):
if not approx(rep.A[order], 0):
out.append(f"A{order} = {fmt(rep.A[order])}")
if not approx(rep.B[order], 0):
out.append(f"B{order} = {fmt(rep.B[order])}")
return out
# ---------------------------------------------------------------------------
def _bmad_quote(text: str) -> Optional[str]:
"""``text`` as a quoted Bmad string constant, or ``None`` if it cannot be
quoted.
Bmad accepts either quote character but has no escape for one inside a string,
so a ``text`` holding a double quote is wrapped in single quotes. One holding
both is unrepresentable.
"""
if '"' not in text:
return f'"{text}"'
if "'" in text:
return None
return f"'{text}'"
# ---------------------------------------------------------------------------
def _make_bmad_ele(ele: YAMLNode, beginning_at_origin: bool = True,
drive: Optional[_MultipoleDrive] = None) -> BmadEleDef:
"""Translate a single PALS element into a :class:`BmadEleDef`.
Dispatch on the element ``kind`` and its parameter groups (aperture, bend,
body shift, multipoles, patch, RF, solenoid, reference change, ...) to build
the Bmad element type and its attribute fragments. Unsupported parameter
groups emit a message or raise an error.
A ``FloorShift`` or ``Fiducial`` is placed by its ``CoordinateSetP`` and
``BodyShiftP`` together (see :func:`_bmad_placement`);
``beginning_at_origin`` says whether the lattice leaves its beginning element
at the global origin, which is what a FloorShift placed from the global
origin is placed from in Bmad.
``drive`` is how controllers drive the element's multipoles (see
:func:`_multipole_drives`), which decides the multipole settings along with
the element's own ``MagneticMultipoleP``.
"""
props = ele.child(0)
name = props.node_key()
ele_kind = props["kind"].value
ele_kind_bmad = _bmad_kind(ele_kind)
attrs: List[str] = []
places = ele_kind in ("FloorShift", "Fiducial")
if places:
attrs.extend(_bmad_placement(props, ele_kind, name, beginning_at_origin))
# Strip a trailing comma (and surrounding whitespace) from a fragment before
# storing it.
def push_attr(text):
text = text.rstrip()
if text.endswith(","):
text = text[:-1].rstrip()
if text:
attrs.append(text)
# Bmad reads all of an element's multipoles in the one convention, so the
# element's own and those its controllers drive have to agree.
driven = drive.normalized if drive is not None else set()
def push_field_master(normalized):
conventions = set(normalized) | driven
if len(conventions) > 1:
raise ValueError(f"{name}: the multipoles of an element, and those "
"controllers drive, must be all normalized or all "
"unnormalized")
# field_master = F is the default.
if conventions == {False} and ele_kind != "RFCavity":
push_attr("field_master = T")
# Bmad reads An/Bn on an ordinary element as fractions of that element's own
# strength, scaling them by K1*L for a quadrupole, K2*L for a sextupole, and so
# on. PALS multipoles are the field integrals themselves, so the scaling has to
# be turned off -- for one a controller drives as much as for one the element
# states, even when the element's own value is zero and so is not written. The
# kinds that hold nothing but multipoles do not scale, and have no such
# attribute to set.
def push_scale_multipoles(mp_attrs):
if (any(re.match(r"[AB][0-9]", a) for a in mp_attrs) or
(drive is not None and drive.multipole)) and \
ele_kind_bmad not in ("AB_Multipole", "Multipole", "SAD_Mult"):
push_attr("scale_multipoles = F")
for key in props.keys():
if key == "length":
push_attr(f"L = {props['length'].value}")
elif key == "ACKickerP":
raise ValueError("ACKickerP not yet supported")
elif key == "ApertureP":
apertureP = props["ApertureP"]
has_xmin = "x_min" in apertureP
has_xmax = "x_max" in apertureP
has_xwidth = "x_width" in apertureP
has_xcen = "x_center" in apertureP
has_ymin = "y_min" in apertureP
has_ymax = "y_max" in apertureP
has_ywidth = "y_width" in apertureP
has_ycen = "y_center" in apertureP
# Shape, location and the rest describe an aperture; they do not put
# one there. Writing them out for a group that sets no limit would
# hand Bmad an aperture the PALS lattice does not have. A `vertices`
# aperture is bounded too, by its vertex list.
has_xaperture = has_xmin or has_xmax or has_xwidth or has_xcen
has_yaperture = has_ymin or has_ymax or has_ywidth or has_ycen
if not (has_xaperture or has_yaperture or "vertices" in apertureP):
continue
tmp = ""
if (has_xmin or has_xmax) and (has_xwidth or has_xcen):
print(f"\n Ignoring ApertureP of element {name}."
"\n Either x_min and max should be defined "
"or width and center, not both.\n ")
# Bmad states a limit as a distance from the axis, not as a
# coordinate: it loses a particle at `x < -x1_limit`, so the low-side
# limit is the negated PALS `x_min`.
elif has_xwidth:
width = apertureP["x_width"].as_float()
center = apertureP["x_center"].as_float() if has_xcen else 0.0
tmp += f"x1_limit = {fmt(width / 2 - center)}, "
tmp += f"x2_limit = {fmt(width / 2 + center)},"
elif has_xmin and has_xmax:
tmp += f"x1_limit = {fmt(-apertureP['x_min'].as_float())}, "
tmp += f"x2_limit = {apertureP['x_max'].value},"
push_attr(tmp)
tmp = ""
if (has_ymin or has_ymax) and (has_ywidth or has_ycen):
print(f"\n Ignoring ApertureP of element {name}."
"\n Either y_min and max should be defined "
"or width and center, not both.\n ")
elif has_ywidth:
width = apertureP["y_width"].as_float()
center = apertureP["y_center"].as_float() if has_ycen else 0.0
tmp += f"y1_limit = {fmt(width / 2 - center)}, "
tmp += f"y2_limit = {fmt(width / 2 + center)},"
elif has_ymin and has_ymax:
tmp += f"y1_limit = {fmt(-apertureP['y_min'].as_float())}, "
tmp += f"y2_limit = {apertureP['y_max'].value},"
push_attr(tmp)
for akey in apertureP.keys():
tmp = ""
if akey == "shape":
shape = apertureP["shape"].value
if shape == "ELLIPTICAL":
tmp += "aperture_type = elliptical,"
elif shape == "RECTANGULAR":
tmp += "aperture_type = rectangular,"
else:
raise ValueError(f"shape {shape} is not supported")
elif akey == "location":
location = apertureP["location"].value
if location == "ENTRANCE_END":
tmp += "aperture_at = entrance_end,"
elif location == "EXIT_END":
tmp += "aperture_at = exit_end,"
elif location in ("BOTH_ENDS", "CENTER"):
if location == "CENTER":
print("location=CENTER not supported, set to "
"aperture_at=both_ends")
tmp += "aperture_at = both_ends,"
elif location == "EVERYWHERE":
tmp += "aperture_at = continuous,"
elif location == "NOWHERE":
tmp += "aperture_at = no_aperture,"
elif akey == "aperture_shifts_with_body":
shifts = apertureP["aperture_shifts_with_body"].value.lower()
tmp += f"offset_moves_aperture = {'T' if shifts == 'true' else 'F'},"
elif akey == "aperture_active":
active = apertureP["aperture_active"].value.lower()
tmp += f"is_on = {'T' if active == 'true' else 'F'},"
elif akey == "vertices":
print("vertices not yet supported")
elif akey == "material":
print("material not yet supported")
elif akey == "thickness":
print("thickness not yet supported")
push_attr(tmp)
elif key == "BeamBeamP":
raise ValueError(f"{name}: BeamBeamP not translated yet")
elif key == "BendP":
bendP = props["BendP"]
for bkey in bendP.keys():
tmp = ""
if bkey == "angle_ref":
tmp += f"angle = {bendP['angle_ref'].value},"
elif bkey == "radius_ref":
tmp += f"rho = {bendP['radius_ref'].value},"
elif bkey == "Bn0_ref":
tmp += f"B_field = {bendP['Bn0_ref'].value},"
elif bkey in ("e1", "e1_rect", "e2", "e2_rect"):
tmp += _bmad_bend_face(props, bkey, name) + ","
elif bkey == "edge1_int":
val = bendP["edge1_int"].as_float()
if not approx(val, 0):
tmp += "fint = 0.5, "
tmp += f"hgap = {fmt(2 * val)},"
elif bkey == "edge2_int":
val = bendP["edge2_int"].as_float()
if not approx(val, 0):
tmp += "fintx = 0.5, "
tmp += f"hgapx = {fmt(2 * val)},"
elif bkey == "g_ref":
tmp += f"g = {bendP['g_ref'].value},"
elif bkey == "h1":
tmp += f"h1 = {bendP['h1'].value},"
elif bkey == "h2":
tmp += f"h2 = {bendP['h2'].value},"
elif bkey == "L_chord":
raise ValueError(f"{name}: L_chord is a derived quantity for "
"SBend elements")
elif bkey == "L_sagitta":
raise ValueError(f"{name}: L_sagitta is a derived quantity "
"for SBend/RBend elements")
elif bkey == "tilt_ref":
tmp += f"ref_tilt = {bendP['tilt_ref'].value},"
push_attr(tmp)
elif key == "BodyShiftP" and not places:
bodyshiftP = props["BodyShiftP"]
for bskey in bodyshiftP.keys():
tmp = ""
if bskey == "x_offset":
tmp = f"x_offset = {bodyshiftP['x_offset'].value},"
elif bskey == "y_offset":
tmp = f"y_offset = {bodyshiftP['y_offset'].value},"
elif bskey == "z_offset":
tmp = f"z_offset = {bodyshiftP['z_offset'].value},"
elif bskey == "x_rot":
tmp = f"y_pitch = {_bmad_negate(bodyshiftP['x_rot'].value)},"
elif bskey == "y_rot":
tmp = f"x_pitch = {bodyshiftP['y_rot'].value},"
elif bskey == "z_rot":
tmp = f"tilt = {bodyshiftP['z_rot'].value},"
push_attr(tmp)
elif key == "ElectricMultipoleP":
raise ValueError("ElectricMultipoleP not yet supported")
elif key == "FloorP":
raise ValueError("FloorP not yet supported")
elif key == "ForkP":
raise ValueError("ForkP not yet supported")
elif key == "MagneticMultipoleP":
mmP = props["MagneticMultipoleP"]
full = FullRepresentation()
full.L = props["length"].as_float() if "length" in props else 1.0
fill_multipoles(full, mmP, name)
push_field_master(full.normalized.values())
# The multipoles that are the element's own strength become Bmad
# strength attributes; the rest stay multipoles. A bend's order-0
# attribute is `DG`, which Bmad measures from the reference bend
# rather than from zero.
offset = _bend_reference(props, name, full.normalized[0]) \
if ele_kind == "Bend" and 0 in full.normalized else 0.0
attrs.extend(_native_strength(full, ele_kind, offset))
# Pick the element-specific representation, then down-convert.
rep = _kind_map(ele_kind)(full)
mp_attrs = _mp_key(rep)
attrs.extend(mp_attrs)
push_scale_multipoles(mp_attrs)
elif key == "MetaP":
metaP = props["MetaP"]
for mkey in metaP.keys():
# Bmad keeps three metadata strings. The rest of MetaP (ID,
# location, history and any custom nodes) has nowhere to go in
# Bmad, so it is dropped.
bkey = {"alias": "alias", "label": "type",
"description": "descrip"}.get(mkey, "")
if bkey == "":
print(f"{name}: MetaP.{mkey} has no Bmad equivalent, not "
"translated")
continue
# `description` (and any component, in principle) may be a
# structure rather than a string, which a Bmad attribute cannot
# hold.
val = metaP[mkey]
if val.is_map() or val.is_sequence():
print(f"{name}: MetaP.{mkey} is not a simple string, not "
"translated")
continue
text = _bmad_quote(val.value)
if text is None:
print(f"{name}: MetaP.{mkey} holds both quote characters, "
"not translated")
continue
push_attr(f"{bkey} = {text}")
elif key == "PatchP":
patchP = props["PatchP"]
for pkey in patchP.keys():
tmp = ""
if pkey == "x_offset":
tmp = f"x_offset = {patchP['x_offset'].value},"
elif pkey == "y_offset":
tmp = f"y_offset = {patchP['y_offset'].value},"
elif pkey == "z_offset":
tmp = f"z_offset = {patchP['z_offset'].value},"
elif pkey == "t_offset":
tmp = f"t_offset = {patchP['t_offset'].value},"
elif pkey == "x_rot":
tmp = f"y_pitch = {_bmad_negate(patchP['x_rot'].value)},"
elif pkey == "y_rot":
tmp = f"x_pitch = {patchP['y_rot'].value},"
elif pkey == "z_rot":
tmp = f"tilt = {patchP['z_rot'].value},"
elif pkey == "flexible":
flex = patchP["flexible"].value.lower()
tmp = f"flexible = {'T' if flex == 'true' else 'F'},"
elif pkey == "ref_coords":
ref = patchP["ref_coords"].value
if ref == "ENTRANCE_END":
tmp = "ref_coords = entrance_end,"
elif ref == "EXIT_END":
tmp = "ref_coords = exit_end,"
elif pkey == "user_sets_length":
usl = patchP["user_sets_length"].value.lower()
tmp = f"user_sets_length = {'T' if usl == 'true' else 'F'},"
push_attr(tmp)
elif key == "RFP":
rfP = props["RFP"]
if props["kind"].value == "CrabCavity":
raise ValueError(f"{name}: CrabCavity not yet translated")
for rfkey in rfP.keys():
tmp = ""
if rfkey == "frequency":
tmp += f"rf_frequency = {rfP['frequency'].value}, "
tmp += "harmon_master = false,"
elif rfkey == "harmonic_number":
tmp += f"harmon = {rfP['harmonic_number'].value}, "
tmp += "harmon_master = true,"
elif rfkey == "voltage":
tmp += f"voltage = {rfP['voltage'].value},"
# Bmad's `gradient` is a dependent attribute, so the voltage is
# stated instead.
elif rfkey == "gradient":
if "L_active" in rfP:
length = rfP["L_active"].value
elif "length" in props:
length = props["length"].value
else:
raise ValueError(f"{name}: `gradient` needs a length to become "
"the voltage Bmad states")
tmp += f"voltage = {times(rfP['gradient'].value, length)},"
# PALS states RF phases in radians, Bmad in turns.
elif rfkey == "phase":
tmp += f"phi0 = {_bmad_turns(rfP['phase'].value)},"
elif rfkey == "multipass_phase":
tmp += f"phi0_multipass = {_bmad_turns(rfP['multipass_phase'].value)},"
elif rfkey == "cavity_type":
tmp += f"cavity_type = {rfP['cavity_type'].value},"
elif rfkey == "num_cells":
tmp += f"n_cell = {rfP['num_cells'].value},"
elif rfkey == "zero_phase":
zp = rfP["zero_phase"].value
if zp == "ACCELERATING":
raise ValueError(f"{name}: `Accelerating` phase is not "
"supported with phi0_autoscale in Bmad")
elif zp == "BELOW_TRANSITION":
tmp += "rf_phase_below_transition_ref = T,"
elif zp == "ABOVE_TRANSITION":
tmp += "rf_phase_below_transition_ref = F,"
else:
print(f"{name}: unknown zero_phase type")
elif rfkey == "L_active":
raise ValueError(f"{name}: `L_active` is a dependent "
"parameter in Bmad")
elif rfkey == "dE_ref":
raise ValueError(f"{name}: needs translation to LCavity for "
"`dE_ref`")
push_attr(tmp)
if "frequency" in rfP and "harmonic_number" in rfP:
raise ValueError(f"{name}: can only define `frequency` or "
"`harmonic_number` but not both")
elif key == "SolenoidP":
solP = props["SolenoidP"]
if solP.keys():
if "Ksol" in solP:
push_attr(f"ks = {solP['Ksol'].value}")
elif "Bsol" in solP:
push_attr("field_master = T")
push_attr(f"bs_field = {solP['Bsol'].value}")
else:
print(f"{name} - unknown key(s): {solP.keys()}")
elif key == "TrackingP":
trackingP = props["TrackingP"]
for tkey in trackingP.keys():
if tkey == "Bmad":
pass
elif key == "ReferenceChangeP":
if ele_kind_bmad != "Patch":
raise ValueError(
f"{name}: Bmad reference changes only allowed in Patch "
"elements (PALS: Patch / RefereneChange)")
refchangeP = props["ReferenceChangeP"]
for rkey in refchangeP.keys():
if rkey == "dtime_ref":
push_attr(f"t_offset = {refchangeP['dtime_ref'].value}")
elif rkey == "dE_ref":
push_attr(f"E_tot_offset = {refchangeP['dE_ref'].value}")
elif rkey == "dpc_ref":
raise ValueError(f"{name}: dpc_ref (p0c_offset) not "
"supported by Bmad, only E_tot_offset")
elif rkey == "time_ref":
raise ValueError(f"{name}: setting time_ref is not supported "
"by Bmad")
elif rkey == "E_tot_ref":
push_attr(f"E_tot_set = {refchangeP['E_tot_ref'].value}")
elif rkey == "pc_ref":
push_attr(f"p0c_set = {refchangeP['pc_ref'].value}")
elif rkey == "species_ref":
raise ValueError(f"{name}: changing species in-beamline is "
"not supported by Bmad")
# An element whose multipoles only its controllers state still needs them read
# the way the controllers mean them.
if "MagneticMultipoleP" not in props:
push_field_master(())
push_scale_multipoles(())
return BmadEleDef(name, ele_kind_bmad, attrs)
# ---------------------------------------------------------------------------
def _bmad_negate(text: str) -> str:
"""The negative of the value ``text``: a number comes out a number, an
expression comes out an expression."""
value = try_float(text)
return f"-({text})" if value is None else fmt(-value)
# ---------------------------------------------------------------------------
def _bmad_turns(text: str) -> str:
"""The angle ``text``, in radians, in turns: a number comes out a number, an
expression comes out an expression divided by Bmad's ``twopi``."""
value = try_float(text)
return f"({text}) / twopi" if value is None else fmt(value / (2 * math.pi))
# ---------------------------------------------------------------------------
_SHIFT_KEYS = ("x_offset", "y_offset", "z_offset", "x_rot", "y_rot", "z_rot")
def _rot_y(a):
return [[math.cos(a), 0, math.sin(a)], [0, 1, 0], [-math.sin(a), 0, math.cos(a)]]
def _rot_x(a):
return [[1, 0, 0], [0, math.cos(a), -math.sin(a)], [0, math.sin(a), math.cos(a)]]
def _rot_z(a):
return [[math.cos(a), -math.sin(a), 0], [math.sin(a), math.cos(a), 0], [0, 0, 1]]
def _matmul(a, b):
return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)]
for i in range(3)]
def _matvec(a, v):
return [sum(a[i][k] * v[k] for k in range(3)) for i in range(3)]
def _combined_shift(props: YAMLNode, name: str) -> Dict[str, str]:
"""The shift a ``FloorShift`` or ``Fiducial`` is placed by, as a component
name to value-text map.
The placement is the ``CoordinateSetP`` shift followed by the ``BodyShiftP``
one. Bmad has a single shift for it, so when both groups give one the two are
composed: ``L = L_c + S_c L_b``, ``S = S_c S_b``, with
``S = R_y(y_rot) R_x(x_rot) R_z(z_rot)``. With no rotation in the
``CoordinateSetP`` shift the offsets simply add; otherwise the composition
needs numbers, so an expression in either group is then an error.
"""
def part(group):
if group not in props:
return {}
g = props[group]
return {k: g[k].value for k in _SHIFT_KEYS if k in g}
cs, bs = part("CoordinateSetP"), part("BodyShiftP")
if not bs:
return cs
if not cs:
return bs
if not any(k in cs for k in ("x_rot", "y_rot", "z_rot")):
out = dict(bs)
for k in ("x_offset", "y_offset", "z_offset"):
if k not in cs:
continue
if k not in bs:
out[k] = cs[k]
continue
a, b = try_float(cs[k]), try_float(bs[k])
out[k] = fmt(a + b) if a is not None and b is not None \
else f"{cs[k]} + {bs[k]}"
return out
def num(d, k):
if k not in d:
return 0.0
v = try_float(d[k])
if v is None:
raise ValueError(f"{name}: CoordinateSetP and BodyShiftP both shift the "
"placement, and composing the two into Bmad's one shift "
f"needs numbers, not `{d[k]}`")
return v
def rot(d):
return _matmul(_rot_y(num(d, "y_rot")),
_matmul(_rot_x(num(d, "x_rot")), _rot_z(num(d, "z_rot"))))
def off(d):
return [num(d, "x_offset"), num(d, "y_offset"), num(d, "z_offset")]
S = _matmul(rot(cs), rot(bs))
Lb = _matvec(rot(cs), off(bs))
L = [c + b for c, b in zip(off(cs), Lb)]
# S = R_y(a) R_x(b) R_z(c): S[0][2] = sin a cos b, S[2][2] = cos a cos b,
# S[1][2] = -sin b, S[1][0] = cos b sin c, S[1][1] = cos b cos c.
a = math.atan2(S[0][2], S[2][2])
b = math.atan2(-S[1][2], math.hypot(S[0][2], S[2][2]))
c = math.atan2(S[1][0], S[1][1])
return {"x_offset": fmt(L[0]), "y_offset": fmt(L[1]), "z_offset": fmt(L[2]),
"x_rot": fmt(b), "y_rot": fmt(a), "z_rot": fmt(c)}
def _bmad_origin(props: YAMLNode, name: str, bmad_kind: str,
beginning_at_origin: bool) -> List[str]:
"""Translate the origin a ``CoordinateSetP`` places a ``floor_shift``,
``fiducial`` or ``girder`` from into Bmad ``origin_ele`` attributes.
PALS defaults to the global origin and to the exit end of a named origin
element; Bmad's defaults differ by kind (the previous element for a
``floor_shift``, the global origin for a ``fiducial``, the middle of the
supported elements for a ``girder``, and the centre of a named element), so
the origin is always written out. A ``floor_shift`` has no global-origin
setting, and is placed from Bmad's beginning element instead, which only
works while the lattice leaves that element at the origin.
``PREVIOUS_ELEMENT`` is a ``floor_shift``'s own default and means nothing to
the other two.
"""
cs = props["CoordinateSetP"] if "CoordinateSetP" in props else None
origin = cs["origin_ele"].value if cs is not None and "origin_ele" in cs \
else "GLOBAL_ORIGIN"
ref_pt = cs["origin_ele_ref_pt"].value \
if cs is not None and "origin_ele_ref_pt" in cs else "EXIT_END"
if origin == "PREVIOUS_ELEMENT":
if bmad_kind == "floor_shift":
return []
raise ValueError(f"{name}: a Bmad {bmad_kind} cannot be placed from the "
"previous element")
if origin == "GLOBAL_ORIGIN":
if bmad_kind == "girder":
return ["origin_ele = global_coordinates"]
if bmad_kind == "fiducial":
return []
if not beginning_at_origin:
raise ValueError(f"{name}: a Bmad floor_shift cannot be placed from the "
"global origin when the beginning element is not at it")
return ["origin_ele = beginning", "origin_ele_ref_pt = exit_end"]
if not NAME_RE.match(origin):
raise ValueError(f"{name}: origin_ele `{origin}` is not a single element "
"name, which Bmad needs")
return [f"origin_ele = {origin}", f"origin_ele_ref_pt = {ref_pt.lower()}"]
# ---------------------------------------------------------------------------
_PLACEMENT_NAMES = {
"floor_shift": {"x_offset": "x_offset", "y_offset": "y_offset",
"z_offset": "z_offset", "y_rot": "x_pitch", "x_rot": "y_pitch",
"z_rot": "tilt"},
"origin": {"x_offset": "dx_origin", "y_offset": "dy_origin",
"z_offset": "dz_origin", "y_rot": "dtheta_origin",
"x_rot": "dphi_origin", "z_rot": "dpsi_origin"},
}
def _shift_attrs(shift: Dict[str, str], names: Dict[str, str]) -> List[str]:
"""The Bmad attributes of a shift; both Bmad conventions rotate by
``R_y R_{-x} R_z``, so the pitch about x changes sign."""
out = []
for k in _SHIFT_KEYS:
if k in shift:
val = _bmad_negate(shift[k]) if k == "x_rot" else shift[k]
out.append(f"{names[k]} = {val}")
return out
def _bmad_placement(props: YAMLNode, ele_kind: str, name: str,
beginning_at_origin: bool) -> List[str]:
"""Translate the placement of a ``FloorShift`` or ``Fiducial`` -- its
``CoordinateSetP`` origin and shift, then its ``BodyShiftP`` -- into Bmad
``floor_shift`` or ``fiducial`` attributes. A ``floor_shift`` states its
shift with the patch attributes and a ``fiducial`` with the ``d*_origin``
ones."""
bmad_kind = "floor_shift" if ele_kind == "FloorShift" else "fiducial"
attrs = _bmad_origin(props, name, bmad_kind, beginning_at_origin)
names = _PLACEMENT_NAMES["floor_shift" if bmad_kind == "floor_shift" else "origin"]
return attrs + _shift_attrs(_combined_shift(props, name), names)
# ---------------------------------------------------------------------------
def _bmad_girder_slaves(spec: str, name: str) -> str:
"""Translate a ``GirderP.elements`` element set into a Bmad girder slave
list. Bmad takes either a list of element names or a single ``start:end``
range, so the PALS set has to be one of those two."""
terms = [t.strip() for t in spec.split(",")]
if len(terms) == 1 and terms[0].count(":") == 1:
a, b = (x.strip() for x in terms[0].split(":"))
if NAME_RE.match(a) and NAME_RE.match(b):
return f"{{{a}:{b}}}"
elif all(NAME_RE.match(t) for t in terms):
return "{" + ", ".join(terms) + "}"
raise ValueError(f"Girder {name}: GirderP.elements `{spec}` is neither a list "
"of names nor a single range, which is all a Bmad girder takes")
def _make_bmad_girders(girders: YAMLNode) -> List[BmadEleDef]:
"""Translate the ``PALS: girders`` dict into Bmad ``girder`` elements.
Bmad needs a girder defined after any girder it supports, so a supported
girder is written before the one supporting it. The girder's origin and
offset come from its ``CoordinateSetP`` (see :func:`_bmad_origin`) and its
misalignment from its ``BodyShiftP``.
"""
defs = {k: girders[k] for k in girders.keys()}
order: List[str] = []
state: Dict[str, int] = {}
def visit(gname):
if state.get(gname) == 2:
return
if state.get(gname) == 1:
raise ValueError(f"Girder {gname} is part of a circular chain of support")
state[gname] = 1
props = defs[gname]
if "GirderP" in props and "elements" in props["GirderP"]:
for t in re.split(r"[,:]", props["GirderP"]["elements"].value):
if t.strip() in defs:
visit(t.strip())
state[gname] = 2
order.append(gname)
for gname in sorted(defs):
visit(gname)
out = []
for gname in order:
props = defs[gname]
if not ("GirderP" in props and "elements" in props["GirderP"]):
raise ValueError(f"Girder {gname}: GirderP.elements names nothing to "
"support")
slaves = _bmad_girder_slaves(props["GirderP"]["elements"].value, gname)
attrs = _bmad_origin(props, gname, "girder", True)
if "CoordinateSetP" in props:
cs = props["CoordinateSetP"]
attrs += _shift_attrs({k: cs[k].value for k in _SHIFT_KEYS if k in cs},
_PLACEMENT_NAMES["origin"])
if "BodyShiftP" in props:
bs = props["BodyShiftP"]
attrs += _shift_attrs({k: bs[k].value for k in _SHIFT_KEYS if k in bs},
_PLACEMENT_NAMES["floor_shift"])
if "MetaP" in props:
metaP = props["MetaP"]
for mkey, bkey in (("alias", "alias"), ("label", "type"),
("description", "descrip")):
if mkey in metaP and not (metaP[mkey].is_map()
or metaP[mkey].is_sequence()):
text = _bmad_quote(metaP[mkey].value)
if text is not None:
attrs.append(f"{bkey} = {text}")
out.append(BmadEleDef(gname, f"girder = {slaves}", attrs))
return out
# ---------------------------------------------------------------------------
def _bmad_bend_angle(props: YAMLNode, name: str) -> Optional[str]:
"""The reference bend angle of a ``Bend`` as Bmad text, or ``None`` if the
element states too little to work it out.
PALS states the bend geometry with any two of a curvature (``g_ref``,
``radius_ref``), a length (``length``, ``L_chord``, ``L_rectangle``) and the
angle (``angle_ref``). A curvature given as the field ``Bn0_ref`` needs the
reference rigidity and is not used here.
"""
bendP = props["BendP"]
if "angle_ref" in bendP:
return bendP["angle_ref"].value
if "g_ref" in bendP:
g = bendP["g_ref"].value
elif "radius_ref" in bendP:
r = bendP["radius_ref"].value
g = fold(f"1 / ({r})", lambda x: 1 / x, r)
else:
return None
if "length" in props:
return times(g, props["length"].value)
if "L_chord" in bendP:
length = bendP["L_chord"].value
return fold(f"2 * asin(({g}) * ({length}) / 2)",
lambda a, b: 2 * math.asin(a * b / 2), g, length)
if "L_rectangle" in bendP:
length = bendP["L_rectangle"].value
return fold(f"asin(({g}) * ({length}))",
lambda a, b: math.asin(a * b), g, length)
return None
# ---------------------------------------------------------------------------
def _bmad_bend_face(props: YAMLNode, key: str, name: str) -> str:
"""The Bmad ``e1``/``e2`` fragment for the PALS pole-face parameter ``key``
of a ``Bend``.
A Bmad ``sbend`` measures its face angles against the sector geometry, as
PALS' own ``e1`` and ``e2`` are, so those come straight across. PALS'
``e1_rect`` and ``e2_rect`` are measured against fiducial lines parallel to
each other, and the bend angle separates the two pairs by a share that
depends on ``ref_geometry`` (bend.md)::
ARC, CHORD e1 = e1_rect + angle/2, e2 = e2_rect + angle/2
ENTRANCE_COORDS e1 = e1_rect, e2 = e2_rect + angle
EXIT_COORDS e1 = e1_rect + angle, e2 = e2_rect
A face given both ways is contradictory, and one given the rectangular way on
a bend whose angle cannot be worked out cannot be converted; both raise an
error.
"""
bendP = props["BendP"]
face = key[:2]
if face in bendP and face + "_rect" in bendP:
raise ValueError(f"{name}: should not have both {face} and {face}_rect")
if not key.endswith("_rect"):
return f"{face} = {bendP[key].value}"
geometry = bendP["ref_geometry"].value if "ref_geometry" in bendP else "ARC"
if geometry == "ENTRANCE_COORDS":
share = 0.0 if face == "e1" else 1.0
elif geometry == "EXIT_COORDS":
share = 1.0 if face == "e1" else 0.0
else:
share = 0.5
rect = bendP[key].value
if share == 0:
return f"{face} = {rect}"
angle = _bmad_bend_angle(props, name)
if angle is None:
raise ValueError(f"{name}: {key} is measured against the bend angle, which "
"is not given")
return f"{face} = " + fold(f"{rect} + {share} * ({angle})",
lambda r, a: r + share * a, rect, angle)