pyAT-specific implementation of LUME model classes for virtual accelerators.
lume-pyat sits between lume-base
and pyAT. You build the lattice; this package
wraps it in a PyATSimulator, binds free-form variable names to element
attributes in native pyAT units, and exposes the result as a lume-base
ActionModel. It is a sibling of
lume-cheetah and follows the
same conventions.
The package is facility-agnostic. It ships no lattice data and knows nothing
about any particular machine's naming, units, or calibration. Mapping
control-system addresses and engineering units onto simulator parameters is the
job of the layer above — and PyATWritableScalarVariable is the seam where
that layer plugs in.
pip install lume-pyatUntil the first release reaches PyPI, install from source instead:
pip install git+https://github.com/als-apg/lume-pyat.
Requires Python 3.10 or newer.
- Atomic multi-set. A batch of writes either lands completely or not at all. Every element the batch touches is snapshotted first, the whole batch is applied, the orbit is solved once, and only then is anything committed. A failure restores every snapshot, so a rejected write is a complete no-op.
- Bindings are checked where you declare them. A variable naming an element the lattice does not have, a name two elements share, or an attribute that does not exist is rejected when the model is built — not at whichever later write happens to touch it.
- Instability is an exception, not a NaN. pyAT reports an unstable ring by
returning non-finite values rather than raising.
solve_orbitchecks the one-turn matrix and closed orbit by value and raisesOrbitSolveError, so a caller cannot serve garbage as a monitor reading. - One persistent lattice. It is mutated in place, never copied or rebuilt. Writes compose the way their physical counterparts do, and seeded misalignments survive every later write.
- Cheap imports.
import lume_pyatpulls in neither pyAT nor lume-base nor HDF5 — resolution is lazy, so error paths and light entry points stay light.
import at
import numpy as np
from lume_pyat import (
LUMEPyATModel,
PyATReadOnlyScalarVariable,
PyATSimulator,
PyATWritableScalarVariable,
)
# 1. Build a lattice. lume-pyat never builds one for you -- this is a small
# FODO ring, but any 4D-canonical at.Lattice works.
N_CELLS = 8
elements = []
for cell in range(1, N_CELLS + 1):
tag = f"{cell:02d}"
elements += [
at.Monitor(f"BPM_{tag}"),
at.Drift("DRIFT", 0.4),
at.Quadrupole(f"QUAD_F_{tag}", 0.3, 1.0),
at.Drift("DRIFT", 0.4),
at.Corrector(f"COR_H_{tag}", 0.0, [0.0, 0.0]),
at.Dipole(f"BEND_{tag}", 1.0, 2 * np.pi / N_CELLS),
at.Drift("DRIFT", 0.4),
at.Quadrupole(f"QUAD_D_{tag}", 0.3, -1.0),
at.Drift("DRIFT", 0.4),
]
ring = at.Lattice(elements, name="EXAMPLE", energy=1.0e9, periodicity=1)
ring.disable_6d()
# 2. Hand the lattice to a simulator. It is mutated in place, never copied.
simulator = PyATSimulator(ring)
# 3. Bind names to element attributes. Names are free-form -- use whatever your
# own control system calls these. Values are in native pyAT units.
variables = [
PyATWritableScalarVariable(
name="corrector_kick",
element_name="COR_H_01",
attribute="KickAngle",
index=0,
default_value=0.0,
unit="rad",
),
*(
PyATReadOnlyScalarVariable(
name=f"bpm_{cell:02d}_x", element_name=f"BPM_{cell:02d}", axis="x", unit="m"
)
for cell in range(1, N_CELLS + 1)
),
]
# 4. The model solves the boot orbit once and caches it.
model = LUMEPyATModel(simulator=simulator, action_variables=variables)
bpm_names = [f"bpm_{cell:02d}_x" for cell in range(1, N_CELLS + 1)]
print(f"{len(model.supported_variables)} variables: 1 input, {len(bpm_names)} outputs")
# 5. Read: the orbit is flat and the corrector sits at its default.
print(f"before: corrector = {model.get('corrector_kick')} rad")
print(f" max |x| = {max(abs(v) for v in model.get(bpm_names).values()):.3e} m")
# 6. Write: one batch, one solve, all or nothing.
model.set({"corrector_kick": 1.0e-4})
print(f"after: corrector = {model.get('corrector_kick')} rad <- retained exactly")
print(f" max |x| = {max(abs(v) for v in model.get(bpm_names).values()):.3e} m")
# 7. A write that leaves the ring without a stable orbit is a complete no-op.
try:
model.set({"corrector_kick": 1.0})
except Exception as exc:
print(f"rejected: {type(exc).__name__} -- corrector still {model.get('corrector_kick')}")
# 8. reset() puts every input back to its default, in one atomic batch.
model.reset()
print(f"reset: corrector = {model.get('corrector_kick')} rad")
print(f" max |x| = {max(abs(v) for v in model.get(bpm_names).values()):.3e} m")
assert model.get("corrector_kick") == 0.0
assert max(abs(v) for v in model.get(bpm_names).values()) < 1e-12Which prints:
9 variables: 1 input, 8 outputs
before: corrector = 0.0 rad
max |x| = 0.000e+00 m
after: corrector = 0.0001 rad <- retained exactly
max |x| = 8.155e-04 m
rejected: OrbitSolveError -- corrector still 0.0001
reset: corrector = 0.0 rad
max |x| = 0.000e+00 m
The example code above is extracted and executed on every CI run, so it cannot drift from the package. (The sample output is illustrative — the last digits depend on the platform's linear algebra.)
PyATWritableScalarVariable writes native pyAT quantities. To control an
element in your own units, subclass it and put the conversion in _set and
_get:
class CurrentVariable(PyATWritableScalarVariable):
amps_per_unit: float
def _set(self, simulator, value):
super()._set(simulator, value / self.amps_per_unit)
def _get(self, simulator):
return super()._get(simulator) * self.amps_per_unitThe conversion belongs to your layer, not to this package. Everything else — the atomicity, the solve, the caching — keeps working unchanged.
| Name | What it is |
|---|---|
PyATSimulator |
Owns one at.Lattice; solve(), element(), element_index(), unique_element_index(), lattice, last_solution, and snapshot_solution()/restore_solution() for rolling a solve back |
LUMEPyATModel |
ActionModel over a simulator; atomic set(), cached get(), batched reset() |
PyATWritableScalarVariable |
Binds a name to one element attribute; the extension point |
PyATReadOnlyScalarVariable |
One transverse coordinate of a monitor's reading |
solve_orbit, monitor_xy |
Guarded 4D closed-orbit solve and its monitor readout |
apply_misalignment |
dx/dy/roll element misalignment, ported from pySC |
OrbitSolveError, UnknownElementError, AmbiguousElementError |
The exception contract |
Everything that can be settled when a variable is declared is settled then, so the write path raises only about the physics.
| Exception | Raised when |
|---|---|
UnknownElementError |
A name does not reach an element of the lattice — a variable binding, a lookup, or a misalignment key |
AmbiguousElementError |
A name reaches more than one, so it addresses neither: two monitors sharing a FamName (at PyATSimulator) or a variable binding a repeated name (at LUMEPyATModel). A subclass of UnknownElementError |
AttributeError |
A variable declares an attribute its element does not have, caught when LUMEPyATModel adopts the variable |
TypeError |
A variable names no lattice element at all — it carries no element_name, so it is not a pyAT action variable — caught when LUMEPyATModel adopts it |
ValueError |
A writable variable is declared without the default_value that reset() writes back to the lattice. Pydantic surfaces it as a ValidationError, which is a ValueError |
ReadOnlyError |
set() is called on a read-only variable — an orbit reading is solved for, never written — or one is declared with read_only=False. lume-base raises it; it is a subclass of TypeError |
OrbitSolveError |
A solve cannot be trusted: a non-finite one-turn matrix, |trace| >= 2 in either plane, or a non-finite closed orbit |
Duplicate names are only a problem for names you address. A lattice whose drifts all share one name — most of them — is fine.
uv sync --extra dev
uv run pytest lume_pyat/tests
uv run pre-commit run --all-filesApache-2.0. See LICENSE.