Files
eco/docs/concepts.md
T
2026-08-18 21:56:17 +02:00

298 lines
10 KiB
Markdown

# Concepts
<!-- Diagrams are plain, hand-editable SVGs in docs/images/. -->
**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.
![Why eco: it sits below the SwissFEL systems and ties them together](images/why_eco.svg)
**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:
1. a **software library of basic elements** used to structure devices and their
communication;
2. a way to **combine control communication with functional algorithms**; and
3. 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.
![Basic elements: Adjustable and Detector as leaves, plus DAQ and pipeline clients](images/basic_elements.svg)
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:
```python
import time
time.time() # -> 1774719387.2182806
```
eco's {py:class}`~eco.elements.detector.DetectorGet` turns such a function into
a proper Detector:
```python
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:
```python
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:
```python
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:
```python
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:
```python
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.
![An Assembly bundles elements and reaches one device through EPICS, data and files](images/assembly.svg)
You build an Assembly by *appending* sub-objects to it, each with a name and a
couple of flags:
```python
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, use
`is_display='recursive'`.)
Because assemblies nest, a whole instrument is just a tree of these objects, and
every node shares the same handful of methods:
```python
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.
```python
from eco.bernina import bernina
```
Inspect what is available:
```python
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:
```python
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:
```python
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:
```python
# "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.