User Guide

This guide is indented as an informal introduction into basic concepts and features of apace. For a detailed reference of its classes and functions, see API Reference.

All data describing the structure and properties of the accelerator is represented by different objects. This sections gives an brief overview of the most important classes.

Element Classes

All basic components of the magnetic lattice like drift spaces, bending magnets and quadrupole are a subclass of an abstract base class called Element. All elements have a name (which should be unique), a length and an optional description.

You can create a new Drift space with:

drift = ap.Drift(name='Drift', length=2)

Check that the drift is actually a subclass of Element:

>>> isinstance(drift, ap.Element)
True

To create the more interresting Quadrupole object use:

quad = ap.Quadrupole(name='Quadrupole', length=1, k1=1)

The attributes of elements can also be changed after they a created:

>>> quad.k1
1
>>> quad.k1 = 0.8
>>> quad.k1
0.8

Note

You can also set up an event listener to whenever an element gets changed, for more information see Signals and Events.

When using Python interactively you can get further information on a specific element with the builtin print() function:

>>> print(quad)
name          : Quadrupole
description   :
parent_lattices  : set()
k1            : 0.8
length        : 1
length_changed: Signal
value_changed : Signal

As you can see, the Quadrupole object has by default also the parent_lattices attribute, which we will discuss in the next subsection.

Lattice class

The magnetic lattice of modern Particle accelerators is typically more complex than a single quadrupole. Therefore multiple elements can be arranged into a more complex structure using the Lattice class.

Creating a Double Dipole Achromat

As we already created a FODO structure in Quickstart, let’s create a Double Dipole Achromat Lattice this time. In addition to our drift and quad elements, we need a new Dipole object:

bend = ap.Dipole('Dipole', length=1, angle=math.pi / 16)

Now we can create a DBA lattice:

dba_cell = ap.Lattice('DBA_CELL', [drift, bend, drift, quad, drift, bend, drift])

As you can see, it is possible for elements to occur multiple times within the same lattice. Elements can even be in different lattices at the same time. What is important to note is, that elements which appear within a lattice multiple times (e.g. all instances of drift within the dba_cell) correspond to the same underlying object.

You can easily check this by changing the length of the drift and displaying the length of the dba_cell before and afterwards:

>>> dba_cell.length
11
>>> drift.length += 0.25
>>> dba_cell.length
12

As the drift space appears four times within the dba_cell its length increased four-fold.

Parent lattices

You may have also noticed that length of the dba_cell was updated automatically without you having to call any update function. This works because apace keeps track of all parent lattices through the parent_lattices attribute and informs all parents whenever the length of an element changes.

Note

Apace only notifies the lattice that it has to update its length value. The calculation of new length only happens when the attribute is accessed. This may be not that advantageous for a simple length calculation, but (apace uses this system for all its data) makes a difference for more computational expensive properties. For more see Lazy Evaluation.

Try to print the contents of parent_lattices for the quad object:

>>> quad.parent_lattices
{Lattice}

In contrast to the end of Element Classes section, where it was empty, quad.parent_latticess now has one entry. Note that this is a Python set, so it cannot to contain duplicates in case that an element appears multiple times within the same lattice. The set gets updated whenever an element gets added or removed from a Lattice.

It is also possible to create a lattice out of lattices. For example you could create a DBA ring using the already existing dba_cell:

dba_ring = ap.Lattice('DBA_RING', [dba_cell] * 16)

The dba_ring should now be listed as a parent of the dba_cell:

>>> dba_cell.parent_lattices
{DBA_RING}

Its length should be 16 times the length of the dba_cell:

>>> dba_ring.length
192.0

Direct children

The structure which defines the order of elements in our DBA ring can be thought of as a Tree, where dba_ring is the root, the dba_cell objects are the nodes and the bend, drift and quad elements are the leafes. The attribute which stores the order of objects within a lattice is called chilldren. Try to pritn the children for the dba_ring and dba_cell objects:

>>> dba_ring.children
[DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL, DBA_CELL]
>>> dba_cell.children
[Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift]

This can be also visualized by calling the Lattice.print_tree() method:

>>> dba_ring.print_tree()
DBA_RING
├─── DBA_CELL
│   ├─── Drift
│   ├─── Dipole
│   ├─── Drift
│   ├─── Quadrupole
│   ├─── Drift
│   ├─── Dipole
│   └─── Drift
# ... 14 times more ...
└─── DBA_CELL
    ├─── Drift
    ├─── Dipole
    ├─── Drift
    ├─── Quadrupole
    ├─── Drift
    ├─── Dipole
    └─── Drift

As a nested structure is not always convenient to work with, there are three other representations of the nested children attribute:

  1. The sequence attribute

    To loop over the exact sequence of objects there is the Lattice.sequence attribute, which is a list of Element objects. It can be thought of a flattened version of children. The sequence attribute can be used in regular Python for ... in loops:

    >>> sum(element.length for element in dba_ring.sequence)
    192
    

    As the dba_cell does not contain any other lattices, the sequence and children attributes should be equal:

    >>> dba_cell.children == dba_cell.sequence
    True
    

    On the other hand, the sequence attribute of the dba_ring should look different then its children:

    >>> dba_ring.sequence
    [Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift, Drift, Dipole, Drift, Quadrupole, Drift, Dipole, Drift]
    
  2. The elements attribute

    If the elements are loaded from a lattice file, you do no have individual Python variables to access them. For this purpose you can use the Lattice.elements attribute, which is a key value pair of Element.name and Element objects. You can access a specific element of a lattices with:

    >>> drift = dba_ring['Drift']
    >>> drift.length
    2.25
    

    To loop over all elements in no specific order use Lattice.elements.values()

    >>> for element in dba_ring.elements.values():
    >>>   print(element.name, element.length)
    Drift 2.25
    Dipole 1
    Quadrupole 1
    

    Note

    In contrary to lattice elements to not appear multiple times in elements.values(). It can be thought of as a set of lattice.

  3. The sub_lattices attribute

    This attribute is equivalent to the elements attribute but for lattices. It contains all sub-lattices within a given lattice, including grandchildren, great grandchildren, etc. The sub_lattices attribute should be empty for the dba_cell as it does not contain any other lattices:

    >>> dba_cell.sub_lattices
    {}
    

Adding and Removing Objects

As adding and removing objects from the children significantly increased the code complextetiy, so it was decided that children cannont be altered after a Lattice instance was created. If you needed to add/remove an object just create a new Lattice instance or add an Element with length zero, which can be altered when needed.

Load and Save Lattice Files

lattices can also be imported from a lattice file. This can be done using the Lattice.from_file() method:

lattice = ap.Lattice.from_file('/path/to/file')

Individual elements and sub-lattices can be accessed through the elements and sub_lattices, respectively:

bend = lattice.elements['bend']
sub_cell = lattice.sub_lattices['sub_cell']

A given lattice can be saved to a lattice file using the save_lattice() function:

ap.Lattice.from_file(lattice, '/path/to/file')

The Twiss class

The Twiss class acts as container object for the Twiss parameter. Create a new Twiss object for our DBA lattice:

twiss = ap.Twiss(dba_ring)

The orbit position, horizontal beta and dispersion functions can be accessed through the s, beta_x and eta_x attributes, respectively:

beta_x = twiss.beta_x
s = twiss.s
eta_x = twiss.beta_x

These are simple numpy.ndarray objects and can simply plotted using matplotlib:

import matplotlib.pyplot as plt
plt.plot(s, beta_x, s, eta_x)

The tunes and betatron phase are available via tune_x and psi_x. To view the complete list of all attributes click 👉 Twiss 👈.

The Tracking class

Similar to the Twiss class the Tracking class acts as container for the tracking data. Before creating a new Tracking object we need to create an initial particle distribution:

n_particles = 100
dist = create_particle_dist(n_particles, x_dist='uniform', x_width=0.001)

Now a Tracking object for dba_ring can be created with:

track = ap.Tracking(dba_ring, dist, n_turns=2)

Now one could either plot horizontal particle trajectory:

plt.plot(track.s, track.x)

Or, picture the particle movement within the horizontal phase space:

plt.plot(track.x, track.x_dds)

Lattice File Format

The layout and order of elements within an accelerator is usually stored in a so-called “lattice file”. There are a variety of different lattice files and different attempts to unify them:

  • MAD and elegant have relatively human readable lattice files but are difficult to parse and also not commonly used in other areas.

  • The Accelerator Markup Language (AML) is based on XML, which is practical to describe the hierarchical data structure elements within an accelerator lattice, and can be parsed by different languages. XML’s main drawback is that it is fairly verbose, hence less human readable and has become less common recently.

apace tries to get the best out of both worlds and uses a JSON based lattice file. JSON is able to describe complex data structures, has a simple syntax and is available in all common programming language.

apace lattice file for a simple fodo cell:

{
  "version": "2.0",
  "title": "FODO Lattice",
  "info": "This is the simplest possible strong focusing lattice. (from Klaus Wille Chapter 3.13.3)",
  "root": "FODO",
  "elements": {
    "D1": ["Drift", {"length": 0.55}],
    "Q1": ["Quadrupole", {"length": 0.2, "k1": 1.2}],
    "Q2": ["Quadrupole", {"length": 0.4, "k1": -1.2}],
    "B1": ["Dipole", {"length": 1.5, "angle": 0.392701, "e1": 0.1963505, "e2": 0.1963505}]
  },
  "lattices": {
    "FODO": ["Q1", "D1", "B1", "D1", "Q2", "D1", "B1", "D1", "Q1"]
  }
}

Implementation Details

Signals and Events

As we have already seen in the Parent lattices section, the length of of a Lattice gets updated whenever the length of one of its Element objects changes. The same happens for the transfer matrices of the Twiss object. This is not only convenient - as one does not have to call an update() function every time an attribute changes - but is also more efficient, because apace has internal knowledge about which elements have changed and can accordingly only update the transfer matrices which have actually changed.

This is achieved by a so called Observer Pattern, where an subject emits an event to all its observers whenever its state changes.

These events are implemented by the Signal class. A callback can be connected to a given Signal through the connect() method. Calling an instance of the Signal will have the same effect as calling all connected callbacks.

Example: Each Element has a length_changed signal, which gets emitted whenever the length of the element changes. You can check this yourself by connecting your own callback to the length_changed signal:

>>> callback = lambda: print("This is a callback")
>>> drift = ap.Drift('Drift', length=2)
>>> drift.length_changed.connect(callback)
>>> drift.length += 1
This is a callback

This may not seem useful at first, but can be handy for different optimization tasks. Also apace internally heavily relies on this event system.

Lazy Evaluation

In addition to the event system apace also makes use of Lazy Evaluation. This means that whenever an object changes its state, it will only notify its dependents that an updated is needed. The recalculation of the dependents’s new attribute will be delayed until the next time it is accessed.

This lazy evaluation scheme is especially important in combination with the signal system as it can prevent unnecessary calculations: Without the lazy evaluation scheme computational expensive properties will get recalculated whenever one of its dependents changes. With the lazy evaluation scheme they are only calculated if they are actually accessed.

To check if a property needs to be updated one can log the private variable _needs_update variables:

>>> drift = ap.Drift("Drift", length=2)
>>> lattice = ap.Lattice('Lattice', drift)
>>> drift.length = 1
>>> lattice._length_needs_update
True

Warning

The _needs_update variables are meant for internal use only!