Skip to content

Commit 5434d1a

Browse files
committed
Added Sequencer trigger logic from ScanSpec
1 parent 7d6070b commit 5434d1a

File tree

5 files changed

+145
-1
lines changed

5 files changed

+145
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ dev = [
6161
"pytest-rerunfailures",
6262
"pytest-timeout",
6363
"ruff",
64+
"scanspec==0.7.2",
6465
"sphinx<7.4.0", # https://github.com/bluesky/ophyd-async/issues/459
6566
"sphinx-autobuild",
6667
"autodoc-pydantic",

src/ophyd_async/epics/motor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def __init__(self, prefix: str, name="") -> None:
7878
self.high_limit_travel = epics_signal_rw(float, prefix + ".HLM")
7979

8080
self.motor_stop = epics_signal_x(prefix + ".STOP")
81+
self.encoder_res = epics_signal_rw(float, prefix + ".ERES")
8182
# Whether set() should complete successfully or not
8283
self._set_success = True
8384

src/ophyd_async/fastcs/panda/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
)
2020
from ._trigger import (
2121
PcompInfo,
22+
ScanSpecInfo,
23+
ScanSpecSeqTableTriggerLogic,
2224
SeqTableInfo,
2325
StaticPcompTriggerLogic,
2426
StaticSeqTableTriggerLogic,
@@ -34,6 +36,8 @@
3436
"PcompBlock",
3537
"PcompDirection",
3638
"PulseBlock",
39+
"ScanSpecInfo",
40+
"ScanSpecSeqTableTriggerLogic",
3741
"SeqBlock",
3842
"TimeUnits",
3943
"HDFPanda",

src/ophyd_async/fastcs/panda/_trigger.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import asyncio
2+
from typing import Literal
23

4+
import numpy as np
35
from pydantic import BaseModel, Field
6+
from scanspec.specs import Frames, Path, Spec
47

58
from ophyd_async.core import FlyerController, wait_for_value
9+
from ophyd_async.epics import motor
610

711
from ._block import BitMux, PcompBlock, PcompDirection, SeqBlock, TimeUnits
8-
from ._table import SeqTable
12+
from ._table import SeqTable, SeqTrigger
913

1014

1115
class SeqTableInfo(BaseModel):
@@ -14,6 +18,11 @@ class SeqTableInfo(BaseModel):
1418
prescale_as_us: float = Field(default=1, ge=0) # microseconds
1519

1620

21+
class ScanSpecInfo(BaseModel):
22+
spec: Spec[motor.Motor | Literal["DURATION"]] = Field(default=None)
23+
deadtime: float = Field()
24+
25+
1726
class StaticSeqTableTriggerLogic(FlyerController[SeqTableInfo]):
1827
def __init__(self, seq: SeqBlock) -> None:
1928
self.seq = seq
@@ -41,6 +50,84 @@ async def stop(self):
4150
await wait_for_value(self.seq.active, False, timeout=1)
4251

4352

53+
class ScanSpecSeqTableTriggerLogic(FlyerController[ScanSpecInfo]):
54+
def __init__(self, seq: SeqBlock, name="") -> None:
55+
self.seq = seq
56+
self.name = name
57+
58+
async def prepare(self, value: ScanSpecInfo):
59+
await self.seq.prescale_units.set(TimeUnits.us)
60+
await self.seq.enable.set(BitMux.zero)
61+
path = Path(value.spec.calculate())
62+
chunk = path.consume()
63+
gaps = self._calculate_gaps(chunk)
64+
if gaps[0] == 0:
65+
gaps = np.delete(gaps, 0)
66+
scan_size = len(chunk)
67+
68+
fast_axis = chunk.axes()[len(chunk.axes()) - 2]
69+
gaps = np.append(gaps, scan_size)
70+
start = 0
71+
# Wait for GPIO to go low
72+
rows = SeqTable.row(trigger=SeqTrigger.BITA_0)
73+
for gap in gaps:
74+
# Wait for GPIO to go high
75+
rows += SeqTable.row(trigger=SeqTrigger.BITA_1)
76+
# Wait for position
77+
if chunk.midpoints[fast_axis][gap - 1] > chunk.midpoints[fast_axis][start]:
78+
trig = SeqTrigger.POSA_GT
79+
dir = False
80+
81+
else:
82+
trig = SeqTrigger.POSA_LT
83+
dir = True
84+
rows += SeqTable.row(
85+
trigger=trig,
86+
position=chunk.lower[fast_axis][start]
87+
/ await fast_axis.encoder_res.get_value(),
88+
)
89+
90+
# Time based triggers
91+
rows += SeqTable.row(
92+
repeats=gap - start,
93+
trigger=SeqTrigger.IMMEDIATE,
94+
time1=(chunk.midpoints["DURATION"][0] - value.deadtime) * 10**6,
95+
time2=int(value.deadtime * 10**6),
96+
outa1=True,
97+
outb1=dir,
98+
outa2=False,
99+
outb2=False,
100+
)
101+
102+
# Wait for GPIO to go low
103+
rows += SeqTable.row(trigger=SeqTrigger.BITA_0)
104+
105+
start = gap
106+
await asyncio.gather(
107+
self.seq.prescale.set(0),
108+
self.seq.repeats.set(1),
109+
self.seq.table.set(rows),
110+
)
111+
112+
async def kickoff(self) -> None:
113+
await self.seq.enable.set(BitMux.one)
114+
await wait_for_value(self.seq.active, True, timeout=1)
115+
116+
async def complete(self) -> None:
117+
await wait_for_value(self.seq.active, False, timeout=None)
118+
119+
async def stop(self):
120+
await self.seq.enable.set(BitMux.zero)
121+
await wait_for_value(self.seq.active, False, timeout=1)
122+
123+
def _calculate_gaps(self, chunk: Frames[motor.Motor]):
124+
inds = np.argwhere(chunk.gap)
125+
if len(inds) == 0:
126+
return [len(chunk)]
127+
else:
128+
return inds
129+
130+
44131
class PcompInfo(BaseModel):
45132
start_postion: int = Field(description="start position in counts")
46133
pulse_width: int = Field(description="width of a single pulse in counts", gt=0)

tests/fastcs/panda/test_trigger.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
import numpy as np
44
import pytest
55
from pydantic import ValidationError
6+
from scanspec.specs import Line, fly
67

78
from ophyd_async.core import DeviceCollector, set_mock_value
9+
from ophyd_async.epics import motor
810
from ophyd_async.fastcs.core import fastcs_connector
911
from ophyd_async.fastcs.panda import (
1012
CommonPandaBlocks,
1113
PcompDirection,
1214
PcompInfo,
15+
ScanSpecInfo,
16+
ScanSpecSeqTableTriggerLogic,
1317
SeqTable,
1418
SeqTableInfo,
1519
SeqTrigger,
@@ -69,6 +73,53 @@ async def set_active(value: bool):
6973
await asyncio.gather(trigger_logic.complete(), set_active(False))
7074

7175

76+
@pytest.fixture
77+
async def sim_x_motor():
78+
async with DeviceCollector(mock=True):
79+
sim_motor = motor.Motor("BLxxI-MO-STAGE-01:X", name="sim_x_motor")
80+
81+
set_mock_value(sim_motor.encoder_res, 0.2)
82+
83+
yield sim_motor
84+
85+
86+
@pytest.fixture
87+
async def sim_y_motor():
88+
async with DeviceCollector(mock=True):
89+
sim_motor = motor.Motor("BLxxI-MO-STAGE-01:Y", name="sim_x_motor")
90+
91+
set_mock_value(sim_motor.encoder_res, 0.2)
92+
93+
yield sim_motor
94+
95+
96+
async def test_seq_scanspec_trigger_logic(mock_panda, sim_x_motor, sim_y_motor) -> None:
97+
spec = fly(Line(sim_y_motor, 1, 2, 3) * ~Line(sim_x_motor, 1, 5, 5), 1)
98+
info = ScanSpecInfo(spec=spec, deadtime=0.1)
99+
trigger_logic = ScanSpecSeqTableTriggerLogic(mock_panda.seq[1])
100+
await trigger_logic.prepare(info)
101+
out = await trigger_logic.seq.table.get_value()
102+
assert (out.repeats == [1, 1, 1, 5, 1, 1, 1, 5, 1, 1, 1, 5, 1]).all()
103+
assert out.trigger == [
104+
SeqTrigger.BITA_0,
105+
SeqTrigger.BITA_1,
106+
SeqTrigger.POSA_GT,
107+
SeqTrigger.IMMEDIATE,
108+
SeqTrigger.BITA_0,
109+
SeqTrigger.BITA_1,
110+
SeqTrigger.POSA_LT,
111+
SeqTrigger.IMMEDIATE,
112+
SeqTrigger.BITA_0,
113+
SeqTrigger.BITA_1,
114+
SeqTrigger.POSA_GT,
115+
SeqTrigger.IMMEDIATE,
116+
SeqTrigger.BITA_0,
117+
]
118+
assert (out.position == [0, 0, 2, 0, 0, 0, 27, 0, 0, 0, 2, 0, 0]).all()
119+
assert (out.time1 == [0, 0, 0, 900000, 0, 0, 0, 900000, 0, 0, 0, 900000, 0]).all()
120+
assert (out.time2 == [0, 0, 0, 100000, 0, 0, 0, 100000, 0, 0, 0, 100000, 0]).all()
121+
122+
72123
@pytest.mark.parametrize(
73124
["kwargs", "error_msg"],
74125
[

0 commit comments

Comments
 (0)