Observables#

Definition#

Observables provide a unified way to access a large quantity of figures resulting from various computations on lattices. They may be used in parameter scans, matching, response matrices, plots…

Observables values are usually scalars or numpy arrays of any shape, but may have any type.

An Observable has a name, optional target, weight and bounds attributes for matching, a label for plot legends. After evaluation, it has the following main properties:

For evaluation, observables must be grouped in an ObservableList which combines the needs of all its members, avoiding redundant computations. ObservableList provides the evaluate() method, and the values, deviations, residuals and sum_residuals properties, among others.

Attention

Evaluation must only be done on ObservableLists, not directly on Observables. Evaluating directly an Observable will miss the data computed at the list level to avoid redundant computations.

Only instances of the base Observableclass may be directly evaluated since they don’t require any data

Observable values may depend on parameters being varied for different evaluations: for instance, optics results depend on the off-momentum dp, trajectory coordinates depend on the input coordinates r_in. These are called “evaluation parameters”. They can be defined, in increasing priority

  • at Observable instantation, to provide default values,

  • in the containing ObservableList, to give common parameters to all its members,

  • as arguments of the evaluate() method.

For optics specific Observables, the available evaluation parameters are documented with the Observable. For user-defined Observables, any evaluation parameter can be defined.

Attention

For optics-specific Observables, all optics computations are made at the ObservableList level for efficiency. So evaluation parameters defined at the Observable level will be ignored.

For instance the ring, dp parameters will be used in optics computations common to all the Observables. Such parameters defined in single Observables are ignored

AT provides a number of specific observables sharing a common interface, inherited from the Observable base class. They are:

Custom Observables may be created by providing the adequate evaluation function.

User-defined evaluation functions#

User-defined functions receive as keyword arguments all the evaluation parameters specified on their Observable instantiation, on the containing ObservableList and as arguments of the evaluate() method. They must therefore be ready to accept and ignore all keywords targeting other Observables.

With the base Observable class#

When using the base Observable class, the evaluation function is entirely responsible to provide the return value. It is called as value = fun(*eval_args, **eval_kw). eval_args is a tuple of positional evaluation arguments defined at the Observable instantiation, eval_kw is the dictionary of evaluation keywords.

The evaluation function returns the current date and time. We define the output format as an evaluation parameter named format:

def now(format="%j", **_):
    # ignore all other keywords
    return time.strftime(format)

We can now create two observables using this function, the second forcing the date format to %c

allobs = ObservableList([Observable(now), Observable(now, format="%c")])

Evaluation returns both different results:

allobs.evaluate()
for obs in allobs:
    print(f"{obs.name!r}: {obs.value}")
'now': 019
'now': Mon Jan 19 15:01:40 2026

But if we give a format to evaluate(), it will have priority in both cases:

allobs.evaluate(format="%A")
for obs in allobs:
    print(f"{obs.name!r}: {obs.value}")
'now': Monday
'now': Monday

With RingObservable#

The evaluation function receives the lattice attached to the RingObservable. It is called as value = fun(ring, **eval_kw). The lattice is given as a positional argument. All the evaluation keywords are also provided and can be ignored if irrelevant. By default the observable name is taken as the name of the evaluation function.

allobs = ObservableList()

Lattice circumference:

def circumference(ring, **_):
    # ignore all other keywords
    return ring.circumference

allobs.append(RingObservable(circumference))

Lattice momentum compaction:

As get_mcf() needs a 4D lattice, we need to make sure that the given lattice is 4D, whatever the lattice given to evaluate():

def momentum_compaction(ring, dp=0.0, **_):
    # ignore all other keywords
    return ring.get_mcf(dp=dp)

allobs.append(RingObservable(momentum_compaction, needs=Need.NEED_4D))

Evaluation:

allobs.evaluate(ring=hmba_lattice.enable_6d(copy=True), dp=0.01)
for obs in allobs:
    print(f"{obs.name!r}: {obs.value}")
'circumference': 843.9772144741422
'momentum_compaction': 8.820785280498264e-05

With LocalOpticsObservable#

The evaluation function receives the optical data computed at the specified locations. It is called as value = fun(elemdata, **eval_kw) where elemdata is the output of get_optics() for all the locations specified in refpts.

The return value must have as many lines as observation points (the length of elemdata) and any number of columns. The column may be selected in the output with the plane keyword.

allobs = ObservableList()

Phase advance between 2 points:

We ask for the phase at 2 points and take the difference

def phase_advance(elemdata, **_):
    mu = elemdata.mu
    return mu[-1] - mu[0]

We need to set all_points to avoid jumps in the phase advance, and to set summary to tell that the evaluation returns a single output instead of one output per observation point. We look at positions 33 and 101 and select vertical plane:

allobs.append(
    LocalOpticsObservable(
        [33, 101], phase_advance, plane="y", all_points=True, summary=True
    )
)

Beam size:

We can compute the beam envelope for arbitrary emittances \(\epsilon_{x|y}\) and momentum spread \(\sigma_\delta\) using \(\sigma_{x|y} = \sqrt{\beta_{x|y} \epsilon_{x|y} + (\eta_{x|y} \sigma_\delta)^2}\).

We will define the emittances as a emit evaluation keyword and the momentum spread as a sigma_e keyword:

def beam_size(elemdata, emit=None, sigma_e=None, **_):
    return np.sqrt(
        elemdata.beta * emit + (elemdata.dispersion[:, [0, 2]] * sigma_e) ** 2
    )

We set default values for the emittances at instantiation of the observable and look at all the monitors:

allobs.append(
    LocalOpticsObservable(
        at.Monitor, beam_size, emit=[130.0e-12, 10.0e-12], sigma_e=0.9e-3
    )
)

We can now evaluate the results for default options:

allobs.evaluate(ring=hmba_lattice)
for obs in allobs:
    print(f"{obs.name!r}: {obs.value}")
'phase_advance[y]': 2.9974197440054082
'beam_size': [[3.21226173e-05 7.28205547e-06]
 [8.04615146e-05 8.47119400e-06]
 [7.32761035e-05 8.09381727e-06]
 [1.95950639e-05 4.81099629e-06]
 [1.82841176e-05 5.83525760e-06]
 [1.82841255e-05 5.83526128e-06]
 [1.95950796e-05 4.81100216e-06]
 [7.32760882e-05 8.09380363e-06]
 [8.04614926e-05 8.47117301e-06]
 [3.21226186e-05 7.28203334e-06]]

But we can also evaluate with different parameters, for instance at 1% off-momentum and with different emittances:

allobs.evaluate(ring=hmba_lattice, dp=0.01, emit=[140.0e-12, 20.0e-12])
for obs in allobs:
    print(f"{obs.name!r}: {obs.value}")
'phase_advance[y]': 2.944614942817739
'beam_size': [[3.56856110e-05 1.02701261e-05]
 [7.99928036e-05 1.22514050e-05]
 [7.27788329e-05 1.17851226e-05]
 [1.89108125e-05 6.92431365e-06]
 [1.93833205e-05 8.05116711e-06]
 [1.93833291e-05 8.05117223e-06]
 [1.89108266e-05 6.92432199e-06]
 [7.27788189e-05 1.17851048e-05]
 [7.99927834e-05 1.22513769e-05]
 [3.56856115e-05 1.02700955e-05]]