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

186 lines
7.9 KiB
Markdown

# Direct motor drivers (Newport XPS · Schneider MCode)
Almost every motor in eco is reached through EPICS: a PV name in, a channel-access
call out. That is the right default on the beamline, but it assumes an IOC already
sits in front of the hardware. Sometimes there isn't one — a controller on the
bench, a spare stage on a laptop, a unit you are commissioning before its IOC
exists. For those cases eco ships two small **EPICS-independent** drivers that talk
to the hardware directly:
| Module | Hardware | Transport |
|--------|----------|-----------|
| {py:mod}`eco.devices_general.newport_xps` | Newport **XPS** (C/D/Q/RL) controllers | TCP socket, port 5001 |
| {py:mod}`eco.devices_general.schneider_mcode` | Schneider / IMS **MDrive · MForce · Lexium** steppers | Serial, MCode ASCII |
:::{note}
These are **helper libraries, not eco components.** They are deliberately not
re-exported from `eco.devices_general` and they are not
{py:class}`~eco.elements.adjustable.AdjustableGetSet` /
{py:class}`~eco.elements.assembly.Assembly` objects — you cannot drop an
`XPSAxis` into a scan the way you would a normal eco motor. Think of them as a
clean way to *reach a controller directly* when EPICS is not in the picture
(bench work, commissioning, diagnostics). Import them by their full package path.
:::
:::{warning}
The Schneider driver needs [`pyserial`](https://pypi.org/project/pyserial/)
(`pip install pyserial`); the Newport driver is standard-library only. Because
`pyserial` is optional, importing `eco.devices_general` does **not** pull either
driver in — you import the submodule you want explicitly.
:::
## Newport XPS
### The controller's protocol, in one paragraph
An XPS answers an ASCII socket API: you send a C-style function call
(`FunctionName(arg1,arg2,...)`) and it replies with
`<errorCode>,<value1>,...,EndOfAPI`, where `errorCode == 0` means success. Output
parameters appear in the request as the literal placeholders `double *` / `int *`
/ `char *`. Axes are grouped, and a positioner is addressed as
`GroupName.PositionerName`. The driver inserts the placeholders, checks the error
code (raising {py:class}`~eco.devices_general.newport_xps.XPSCommandError` with the
controller's own error text on failure) and parses the reply for you.
### Minimal runnable example
Open a session, identify the controller, bring a group up and move it. The
context manager owns the TCP socket, so the connection is always closed:
```python
from eco.devices_general.newport_xps import NewportXPS
with NewportXPS("192.168.0.254") as xps: # controller IP / hostname
print(xps.firmware_version())
xps.initialize("Group1") # GroupInitialize
xps.home("Group1") # GroupHomeSearch (blocks on the XPS)
xps.move_absolute_and_wait("Group1", 10.0) # mm or deg, per stage config
print(xps.position("Group1")) # -> [10.0]
```
Positions are always returned as a `list[float]` — a group can hold several
positioners — and are in whatever engineering units the stage is configured for
(mm for linear, deg for rotary). No scaling is imposed by the driver.
### Reading like a plain motor: `XPSAxis`
A single-axis group is the common case, and typing the group name into every call
gets old. {py:meth}`~eco.devices_general.newport_xps.NewportXPS.axis` binds one
`Group.Positioner` into an
{py:class}`~eco.devices_general.newport_xps.XPSAxis` that reads like a simple
motor:
```python
with NewportXPS("192.168.0.254") as xps:
stage = xps.axis("Group1", "Pos") # Group1.Pos
stage.enable()
stage.move_relative(1.5, wait=True) # block until the move finishes
print(stage.position, stage.limits) # scalar float, (min, max)
v, a, jmin, jmax = stage.get_velocity() # S-curve profile
```
### Polling and the raw escape hatch
`is_moving` / `is_ready` classify the numeric group status for you; if you need a
call the convenience layer doesn't wrap,
{py:meth}`~eco.devices_general.newport_xps.NewportXPS.send` gives you the raw API
with the same error checking:
```python
with NewportXPS("192.168.0.254") as xps:
while xps.is_moving("Group1"):
...
xps.send("GroupStatusStringGet(11,char *)") # -> ['Ready state from homing']
```
## Schneider / IMS MCode
### One drive on its own port
The stepper drives speak **MCode** ASCII over a serial line. For a single drive on
a dedicated port, {py:meth}`~eco.devices_general.schneider_mcode.SchneiderMotor.open_single`
opens the port and hands you the motor; the `with` block closes it again.
Positions and velocities are in the drive's native (micro)step units:
```python
from eco.devices_general.schneider_mcode import SchneiderMotor
with SchneiderMotor.open_single("/dev/ttyUSB0", baudrate=9600) as m:
print(m.firmware_version()) # PR VR
m.run_current = 50 # RC, % of drive maximum
m.max_velocity = 200_000 # VM, steps/s
m.move_relative_and_wait(1000) # MR 1000, then poll MV until stopped
print(m.position) # PR P
```
The named properties (`run_current`, `max_velocity`, `acceleration`, `position`,
…) are thin wrappers over the corresponding MCode variables — assigning to them
issues the `set`, reading them issues `PR`.
### Several drives on one RS-485 bus (party mode)
Party mode multi-drops several drives on one bus, each with a one-character device
name. {py:class}`~eco.devices_general.schneider_mcode.SchneiderBus` owns the shared
port; every {py:class}`~eco.devices_general.schneider_mcode.SchneiderMotor` it
hands out prefixes its commands with its address and the I/O is serialised behind
a lock, so the same bus is safe to drive from several threads:
```python
from eco.devices_general.schneider_mcode import SchneiderBus
with SchneiderBus("/dev/ttyUSB0", baudrate=115200, party=True) as bus:
x, y = bus.motor("1"), bus.motor("2")
x.move_absolute(10_000)
y.move_absolute(-5_000)
bus.wait_all(x, y) # block until both have stopped
```
### Reaching the rest of MCode
The convenience API covers the common moves and parameters; the *entire* MCode
variable and flag space stays reachable through the generic accessors, so an
unusual unit or an I/O point never leaves you stuck:
```python
m.set("S3", 16) # configure I/O point 3
m.get("S3") # -> "16"
m.command("PR C1") # any raw command line, returns the reply text
```
:::{note}
MCode mnemonics differ slightly across MForce / MDrive / Lexium firmware. The
typed methods use the set common to those product lines; if a particular unit
disagrees, reach it with `get` / `set` / `command` and, if it recurs, add a thin
property alongside the existing ones.
:::
## From the command line
Both modules expose a `python -m` probe for bench testing without writing a
script — handy for a first contact with a new controller:
```bash
# Newport: firmware + a group's status/position, or a raw API call
python -m eco.devices_general.newport_xps 192.168.0.254 -g Group1
python -m eco.devices_general.newport_xps 192.168.0.254 "FirmwareVersionGet(char *)"
# Schneider: single drive, or a party-mode address, running an MCode command
python -m eco.devices_general.schneider_mcode /dev/ttyUSB0 -b 9600 PR VR
python -m eco.devices_general.schneider_mcode /dev/ttyUSB0 -b 115200 -a 1 PR P
```
## A note on verification
Both drivers are functionally tested against a **fake XPS TCP server** and a
**pseudo-terminal MForce responder** — echo stripping, party addressing, move and
status parsing, and error propagation are exercised, but no real controller was in
the loop. Point them at your actual hardware to confirm the exact command set of
your units before trusting an unattended move, and prefer the `_and_wait` helpers
(or an explicit `wait`) so a script never races ahead of the mechanics.
## Related
- {doc}`deltatau_config` — read/apply/verify PowerBrick (Delta Tau) configs.
- {doc}`../concepts` — the eco `Adjustable`/`Assembly` model these drivers sit
*outside* of.