10 KiB
Concepts
eco is a communication and control tool: the same code that drives a device interactively also runs unattended in automated routines. This page builds up the small vocabulary of elements eco is made of — each one introduced through the smallest example you can actually run — before combining them into real devices.
Why an experiment control interface?
A single experiment touches many systems that speak in different ways: not only EPICS channel access, but a DAQ, an analysis pipeline, the electronic logbook, data catalogue, archivers and a configuration store. An experiment has to set devices, collect data feedback, and run algorithms across all of them.
Example — SwissFEL. The diagram below is SwissFEL's instance of this problem; the systems named are specific to this facility, but the shape of the problem is general.
Objectives for a tool that solves this:
- talk to all the devices, whatever protocol they use;
- employ existing algorithms (the scientific Python ecosystem); and
- be used and automated by the majority of staff, not just experts.
eco is that tool.
Definition
eco is three things at once:
- a software library of basic elements used to structure devices and their communication;
- a way to combine control communication with functional algorithms; and
- a collection of convenience tools for interactive usage.
eco is implemented in Python: a language that is simple to learn, ubiquitous in the scientific community, backed by a large base of interfaceable libraries, and (mostly) free of licence costs.
The basic elements
eco is built from a small vocabulary. There are just two local element types — the leaves you read and drive — plus client objects that stand in for the external systems eco talks to.
- Adjustable
- something you can set (and read back) — e.g. a motor.
- Detector
- something you can only read — e.g. an intensity diode.
The two external systems are represented locally as clients, not as first-class elements:
- DAQ client
- eco's client application to the external DAQ.
- Pipeline client
- eco's client to the pipeline server, for online analysis. This client is
still under development — see {doc}
examples/pipeline_offload.
There is deliberately no separate "scan engine" element. A scan is not a distinct kind of object in eco; it is an acquisition routine, written in ordinary Python, that drives Adjustables and reads Detectors in a loop. The basic elements above are all a scan needs.
This composability rests on structural typing (informally, "duck typing"):
an object is an Adjustable or a Detector purely by having the right
method, not by inheriting from a particular base class. eco expresses this
formally as a typing.Protocol
({py:class}~eco.elements.protocols.Adjustable /
{py:class}~eco.elements.protocols.Detector), so membership can even be
checked with a plain isinstance() — no inheritance required, as the examples
below show.
A Detector
A Detector is anything with a get_current_value(). So any zero-argument
"get me the current value of something" function already behaves like one — for
instance the built-in clock:
import time
time.time() # -> 1774719387.2182806
eco's {py:class}~eco.elements.detector.DetectorGet turns such a function into
a proper Detector:
from eco.elements.detector import DetectorGet
d = DetectorGet(time.time)
d.get_current_value() # -> the current time, now via the eco Detector interface
That is the whole idea: DetectorGet wraps a get function.
A class can just as well provide get_current_value itself, with no wrapping
at all:
from eco.elements.protocols import Detector
class MyDetector:
def get_current_value(self):
return 42
isinstance(MyDetector(), Detector) # -> True
MyDetector never imports eco and does not inherit from Detector — yet
isinstance() confirms it already satisfies the protocol. This is what
"having the right method" means, concretely.
An Adjustable
An Adjustable is a Detector that can additionally be set: it has both
get_current_value() and set_target_value(). So you supply two functions —
one to read, one to write:
state = 5
def get_state():
return state
def set_state(value):
global state
state = value
from eco.elements.adjustable import AdjustableGetSet
a = AdjustableGetSet(get_state, set_state)
a.get_current_value() # -> 5
a.set_target_value(99)
a.get_current_value() # -> 99
Real devices replace get_state/set_state with EPICS reads/writes, motion
commands, file access, and so on — but the interface the rest of eco sees
stays exactly this small.
The same works with a class that defines both methods directly:
from eco.elements.protocols import Adjustable
class MyAdjustable:
def get_current_value(self):
return self._value
def set_target_value(self, value):
self._value = value
isinstance(MyAdjustable(), Adjustable) # -> True
:::{admonition} A cleaner variant, without the module global
:class: tip
The AdjustableGetSet example above uses a module-level global state, which
is easy to follow but is the kind of pattern real code usually avoids. An
equivalent that keeps its state tidily encapsulated:
class _Store:
value = 5
a = AdjustableGetSet(lambda: _Store.value,
lambda v: setattr(_Store, "value", v))
Both are legitimate — the closure/attribute form just avoids a module global. :::
Representing real devices — the Assembly
A real device is rarely a single value. A diffractometer has many motors; a detector arm mixes motion, readback and files. eco represents such a device as an Assembly: one named object that bundles Adjustables, Detectors (and other Assemblies) together, reaching the hardware through whatever mix of interfaces it needs — EPICS, a live data stream, the file system.
You build an Assembly by appending sub-objects to it, each with a name and a couple of flags:
from eco.elements.assembly import Assembly
class SampleStage(Assembly):
def __init__(self, name=None):
super().__init__(name=name)
self._append(Motor, "SARES:MOT_X", name="x", is_setting=True, is_status=True)
self._append(Motor, "SARES:MOT_Y", name="y", is_setting=True, is_status=True)
is_setting=True- the child is a setting of the assembly — it shows up in
.settings()and is captured when settings are saved. is_status=True- the child contributes to the assembly's
.status()and its shell representation. (For a sub-assembly whose settings should be shown, useis_display='recursive'.)
Because assemblies nest, a whole instrument is just a tree of these objects, and every node shares the same handful of methods:
stage.x.mv(2.5) # move the x motor to 2.5 and wait
stage.x.wm() # where is it now?
stage.status() # formatted status of the assembly
stage.get_tree() # print the object tree
Bernina: a whole instrument as a lazy Namespace
The Bernina instrument is itself an Assembly — specifically a Namespace, a lazy Assembly whose components are only initialised when you actually call/touch them. That keeps startup fast and tolerates components that happen to be broken or absent on a given day.
from eco.bernina import bernina
Inspect what is available:
bernina.namespace.all_names # every known component
bernina.namespace.lazy_names # known but not yet initialised
bernina.namespace.required_names # the ones marked as needed
Every Assembly — Bernina or any sub-component — answers the same general commands:
bernina.get_tree(level=-1) # full component tree
bernina.status() # formatted status
Because some components are optional (and sometimes not working), you select the subset you actually need with:
bernina.namespace.select_required_names()
(pipeline-offload)=
Offloading calculations to the pipeline server
The pipeline server can run user-supplied Python code on a stream close to
the source — so heavy per-shot calculations on beam-synchronous (BS) data can be
offloaded off the analysis client and onto the server. The cam_server
pipeline client supports this: you upload a small processing script and attach it
to a running pipeline instance, which then hot-reloads and runs your function on
every message.
A processing script implements one of two contracts, depending on the pipeline type:
# "processing" pipeline (camera image + optional BS data):
def process_image(image, pulse_id, timestamp, x_axis, y_axis, parameters, bsdata=None):
return {"my_result": float(image.sum())}
# "custom" pipeline (arbitrary streaming source, e.g. BS channels):
def process(parameters, init=False):
stream_data = {"my_result": 42.0}
return stream_data, timestamp, pulse_id, data_size
eco is growing a dedicated, still-separate module to make this ergonomic — uploading a local Python function straight from the shell, managing server-side scripts, and wiring them onto pipeline instances. This is early/experimental; the module lives apart from the main device tree for now.
Where to go next
- {doc}
examples/listening_monitor— subscribe to a channel and collect its values in the background (a push stream, complementing the poll-style Detector above). - {doc}
examples/archiver_stripchart— pull historical data from the archiver and open a live strip chart. - {doc}
examples/pipeline_offload— upload your own code to the pipeline server, continuing the section above.