axis.py 84.9 KB
Newer Older
1
2
3
4
# -*- coding: utf-8 -*-
#
# This file is part of the bliss project
#
Benoit Formet's avatar
Benoit Formet committed
5
# Copyright (c) 2015-2020 Beamline Control Unit, ESRF
6
7
# Distributed under the GNU LGPLv3. See LICENSE for more info.

8
9
"""
Axis related classes (:class:`~bliss.common.axis.Axis`, \
10
11
:class:`~bliss.common.axis.AxisState`, :class:`~bliss.common.axis.Motion`
and :class:`~bliss.common.axis.GroupMove`)
12
"""
13
from bliss import global_map
14
from bliss.common.hook import execute_pre_move_hooks
15
from bliss.common.protocols import Scannable
16
from bliss.common.cleanup import capture_exceptions
17
from bliss.common.motor_config import MotorConfig
18
from bliss.common.motor_settings import AxisSettings
19
from bliss.common import event
20
from bliss.common.greenlet_utils import protect_from_one_kill
21
from bliss.common.utils import with_custom_members, safe_get
22
from bliss.config.channels import Channel
23
from bliss.common.logtools import log_debug, user_print, log_warning
Piergiorgio Pancino's avatar
Piergiorgio Pancino committed
24
from bliss.common.utils import rounder
25
from bliss.common.utils import autocomplete_property
26
from bliss.comm.exceptions import CommunicationError
27

28
import enum
29
import gevent
30
import re
31
import sys
32
import math
33
import functools
34
import collections
35
import itertools
36
import numpy
37
from unittest import mock
38
import warnings
39
40

warnings.simplefilter("once", DeprecationWarning)
41

42

43
#: Default polling time
44
DEFAULT_POLLING_TIME = 0.02
Matias Guijarro's avatar
Matias Guijarro committed
45

46

47
48
49
50
class AxisOnLimitError(RuntimeError):
    pass


51
52
53
54
class AxisFaultError(RuntimeError):
    pass


55
56
57
58
59
60
61
62
63
64
def float_or_inf(value, inf_sign=1):
    if value is None:
        value = float("inf")
        sign = math.copysign(1, inf_sign)
    else:
        sign = 1
    value = float(value)  # accepts float or numpy array of 1 element
    return sign * value


65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def _prepare_one_controller_motions(controller, motions):
    try:
        controller.prepare_all(*motions)
    except NotImplementedError:
        for motion in motions:
            controller.prepare_move(motion)


def _start_one_controller_motions(controller, motions):
    try:
        controller.start_all(*motions)
    except NotImplementedError:
        for motion in motions:
            controller.start_one(motion)


def _stop_one_controller_motions(controller, motions):
    try:
        controller.stop_all(*motions)
    except NotImplementedError:
        for motion in motions:
            controller.stop(motion.axis)


89
class GroupMove:
90
    def __init__(self, parent=None):
91
92
        self.parent = parent
        self._move_task = None
93
94
        self._motions_dict = dict()
        self._stop_motion = None
95
96
        self._interrupted_move = False
        self._backlash_started_event = gevent.event.Event()
97
98
99
100
101

    # Public API

    @property
    def is_moving(self):
Matias Guijarro's avatar
Matias Guijarro committed
102
        # A greenlet evaluates to True when it is alive
103
104
        return bool(self._move_task)

105
106
107
    def move(
        self,
        motions_dict,
108
        prepare_motion,
109
110
111
112
        start_motion,
        stop_motion,
        move_func=None,
        wait=True,
113
        polling_time=None,
114
    ):
115
116
        self._motions_dict = motions_dict
        self._stop_motion = stop_motion
117
        self._interrupted_move = False
118

119
120
121
122
123
        # motions_dict is { controller: [motion, ...] }
        all_motions = list(itertools.chain(*motions_dict.values()))
        with execute_pre_move_hooks(all_motions):
            for axis in (m.axis for m in all_motions):
                axis._check_ready()
124
125
126
127

        for controller, motions in motions_dict.items():
            if prepare_motion is not None:
                prepare_motion(controller, motions)
128

129
            for motion_obj in motions:
130
131
132
133
                target_pos = motion_obj.user_target_pos
                if target_pos is not None and not isinstance(target_pos, str):
                    motion_obj.axis._set_position = target_pos

134
135
                msg = motion_obj.user_msg
                if msg:
136
                    user_print(msg)
137

138
        started = gevent.event.Event()
139

140
        self._move_task = gevent.spawn(
141
            self._move, motions_dict, start_motion, stop_motion, move_func, started
142
        )
143

144
145
146
        try:
            # Wait for the move to be started (or finished)
            gevent.wait([started, self._move_task], count=1)
147
        except BaseException:
148
149
            self.stop()
            raise
150
151
        # Wait if necessary and raise the move task exception if any
        if wait or self._move_task.ready():
152
            self.wait()
153
154
155

    def wait(self):
        if self._move_task is not None:
156
157
            try:
                self._move_task.get()
158
            except BaseException:
159
160
                self.stop()
                raise
161
162

    def stop(self, wait=True):
163
164
165
        with capture_exceptions(raise_index=0) as capture:
            if self._move_task is not None:
                with capture():
166
                    self._stop_move(self._motions_dict, self._stop_motion, wait=False)
167
168
                if wait:
                    self._move_task.get()
169
170
171

    # Internal methods

172
173
    def _monitor_move(self, motions_dict, move_func, stop_func):
        monitor_move_tasks = {}
Vincent Michel's avatar
Vincent Michel committed
174
        for controller, motions in motions_dict.items():
175
            for motion in motions:
176
                if move_func is None:
177
                    move_func = "_handle_move"
178
                task = gevent.spawn(getattr(motion.axis, move_func), motion)
179
180
                monitor_move_tasks[task] = motion

181
        try:
182
183
184
185
186
187
188
189
190
191
192
193
194
            gevent.joinall(monitor_move_tasks, raise_error=True)
        except BaseException:
            # in case of error, all moves are stopped
            # _stop_move is called with the same monitoring tasks:
            # the stop command will be sent, then the same monitoring continues
            # in '_stop_move'
            self._stop_move(motions_dict, stop_func, monitor_move_tasks)
            raise
        else:
            # everything went fine: update the last motor state ;
            # we know the tasks have all completed successfully
            for task, motion in monitor_move_tasks.items():
                motion.last_state = task.get()
195

196
197
    def _stop_move(self, motions_dict, stop_motion, stop_wait_tasks=None, wait=True):
        self._interrupted_move = True
198

199
        stop_tasks = []
Vincent Michel's avatar
Vincent Michel committed
200
        for controller, motions in motions_dict.items():
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
            stop_tasks.append(gevent.spawn(stop_motion, controller, motions))

        with capture_exceptions(raise_index=0) as capture:
            # wait for all stop commands to be sent
            with capture():
                gevent.joinall(stop_tasks, raise_error=True)
            if capture.failed:
                with capture():
                    gevent.joinall(stop_tasks)

            if wait:
                if stop_wait_tasks is None:
                    # create tasks to wait for end of motion
                    stop_wait_tasks = {}
                    for controller, motions in motions_dict.items():
                        for motion in motions:
                            stop_wait_tasks[
                                gevent.spawn(
                                    motion.axis._move_loop, motion.polling_time
                                )
                            ] = motion

                # wait for end of motion
                gevent.joinall(stop_wait_tasks)

                for task, motion in stop_wait_tasks.items():
                    motion.last_state = None
                    with capture():
                        motion.last_state = task.get()
230

231
    @protect_from_one_kill
232
    def _do_backlash_move(self, motions_dict):
233
        backlash_motions = collections.defaultdict(list)
Vincent Michel's avatar
Vincent Michel committed
234
        for controller, motions in motions_dict.items():
235
236
            for motion in motions:
                if motion.backlash:
237
238
                    if self._interrupted_move:
                        # have to recalculate target: do backlash move from where it stopped
239
240
241
                        motion.target_pos = (
                            motion.axis.dial * motion.axis.steps_per_unit
                        )
242
243
244
245
246
247
248
249
250
251
                        # Adjust the difference between encoder and motor controller indexer
                        if (
                            motion.axis._read_position_mode
                            == Axis.READ_POSITION_MODE.ENCODER
                        ):
                            controller_position = controller.read_position(motion.axis)
                            enc_position = motion.target_pos
                            delta_pos = controller_position - enc_position
                            motion.target_pos += delta_pos

252
253
254
255
256
                    backlash_motion = Motion(
                        motion.axis,
                        motion.target_pos + motion.backlash,
                        motion.backlash,
                    )
257
                    backlash_motions[controller].append(backlash_motion)
258

259
260
261
262
263
264
265
266
267
        if backlash_motions:
            backlash_mv_group = GroupMove()
            backlash_mv_group._do_move(
                backlash_motions,
                _start_one_controller_motions,
                _stop_one_controller_motions,
                None,
                self._backlash_started_event,
            )
268

269
270
271
272
    def _do_move(
        self, motions_dict, start_motion, stop_motion, move_func, started_event
    ):
        for controller, motions in motions_dict.items():
273
            for motion in motions:
274
                motion.last_state = None
Matias Guijarro's avatar
Matias Guijarro committed
275

276
        with capture_exceptions(raise_index=0) as capture:
277
278
279
280
281
            # Spawn start motion tasks for all controllers
            start = [
                gevent.spawn(start_motion, controller, motions)
                for controller, motions in motions_dict.items()
            ]
282

Matias Guijarro's avatar
Matias Guijarro committed
283
284
285
            # wait for start tasks to be all done ;
            # in case of error or if wait is interrupted (ctrl-c, kill...),
            # immediately stop and return
286
287
288
289
290
291
292
293
            with capture():
                gevent.joinall(start, raise_error=True)
            if capture.failed:
                # either a start task failed, or ctrl-c or kill happened.
                # First, let all start task to finish
                # /!\ it is important to join those, to ensure stop is called
                # after tasks are done otherwise there is a risk 'end' is
                # called before 'start' is all done
294
                with capture():
295
                    gevent.joinall(start)
296
297
298
299
                # then, stop all axes and wait end of motion
                self._stop_move(motions_dict, stop_motion)
                # exit
                return
300

301
302
            # All controllers are now started
            if started_event is not None:
303
304
                started_event.set()

305
306
            if self.parent:
                event.send(self.parent, "move_done", False)
307

308
309
310
311
312
313
314
315
316
317
            # Spawn the monitoring for all motions
            with capture():
                self._monitor_move(motions_dict, move_func, stop_motion)

    def _move(self, motions_dict, start_motion, stop_motion, move_func, started_event):
        # Set axis moving state
        for motions in motions_dict.values():
            for motion in motions:
                motion.axis._set_moving_state()

318
                motion.axis.settings.unregister_channels_callbacks()
319

320
        with capture_exceptions(raise_index=0) as capture:
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
            with capture():
                self._do_move(
                    motions_dict, start_motion, stop_motion, move_func, started_event
                )
            # Do backlash move, if needed
            with capture():
                self._do_backlash_move(motions_dict)

            reset_setpos = bool(capture.failed) or self._interrupted_move

            # cleanup
            # -------
            # update final state ; in case of exception
            # state is set to FAULT
            for motions in motions_dict.values():
                for motion in motions:
                    state = motion.last_state
                    if state is None:
                        # update state and update dial pos.
                        with capture():
                            motion.axis._update_settings()

            # update set position if motor has been stopped,
            # or if an exception happened or if motion type is
            # home search or hw limit search ;
            # as state update happened just before, this
            # is equivalent to sync_hard -> emit the signal
            # (useful for real motor positions update in case
            # of pseudo axis)
            # -- jog move is a special case
            if len(motions_dict) == 1:
                motion = motions_dict[list(motions_dict.keys()).pop()][0]
                if motion.type == "jog":
                    reset_setpos = False
                    motion.axis._jog_cleanup(
                        motion.saved_velocity, motion.reset_position
357
                    )
358
359
360
361
362
                elif motion.type == "homing":
                    reset_setpos = True
                elif motion.type == "limit_search":
                    reset_setpos = True
            if reset_setpos:
363
                with capture():
364
365
366
367
368
369
370
371
372
373
374
375
376
377
                    for motions in motions_dict.values():
                        for motion in motions:
                            motion.axis._set_position = motion.axis.position
                            event.send(motion.axis, "sync_hard")

            hooks = collections.defaultdict(list)
            for motions in motions_dict.values():
                for motion in motions:
                    axis = motion.axis

                    # group motion hooks
                    for hook in axis.motion_hooks:
                        hooks[hook].append(motion)

378
                    axis.settings.register_channels_callbacks()
379

380
                    # set move done
381
382
383
                    motion.axis._set_move_done()

            if self._interrupted_move:
384
                user_print("")
385
386
387
                for motion in motions:
                    _axis = motion.axis
                    _axis_pos = safe_get(_axis, "position", on_error="!ERR")
388
                    user_print(f"Axis {_axis.name} stopped at position {_axis_pos}")
389
390
391
392

            try:
                if self.parent:
                    event.send(self.parent, "move_done", True)
393
            finally:
394
                for hook, motions in reversed(list(hooks.items())):
395
                    with capture():
396
                        hook.post_move(motions)
397

Cyril Guilloud's avatar
Cyril Guilloud committed
398

399
class Modulo:
400
401
402
403
    def __init__(self, mod=360):
        self.modulo = mod

    def __call__(self, axis):
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
404
        dial_pos = axis.dial
405
        axis._Axis__do_set_dial(dial_pos % self.modulo)
406
407


408
class Motion:
409
410
411
412
413
414
415
416
    """Motion information

    Represents a specific motion. The following members are present:

    * *axis* (:class:`Axis`): the axis to which this motion corresponds to
    * *target_pos* (:obj:`float`): final motion position
    * *delta* (:obj:`float`): motion displacement
    * *backlash* (:obj:`float`): motion backlash
Vincent Michel's avatar
Vincent Michel committed
417

418
419
    Note: target_pos and delta can be None, in case of specific motion
    types like homing or limit search
420
    """
Matias Guijarro's avatar
Matias Guijarro committed
421

422
423
424
    def __init__(
        self, axis, target_pos, delta, motion_type="move", user_target_pos=None
    ):
Matias Guijarro's avatar
Matias Guijarro committed
425
        self.__axis = axis
426
        self.__type = motion_type
427
        self.user_target_pos = user_target_pos
Matias Guijarro's avatar
Matias Guijarro committed
428
429
430
        self.target_pos = target_pos
        self.delta = delta
        self.backlash = 0
431
        self.polling_time = DEFAULT_POLLING_TIME
Matias Guijarro's avatar
Matias Guijarro committed
432

Matias Guijarro's avatar
Matias Guijarro committed
433
434
    @property
    def axis(self):
435
        """Reference to :class:`Axis`"""
Matias Guijarro's avatar
Matias Guijarro committed
436
        return self.__axis
437

438
439
440
441
    @property
    def type(self):
        return self.__type

442
443
444
445
    @property
    def user_msg(self):
        start_ = rounder(self.axis.tolerance, self.axis.position)
        if self.type == "jog":
Cyril Guilloud's avatar
Cyril Guilloud committed
446
            msg = (
447
                f"Moving {self.axis.name} from {start_} until it is stopped, at constant velocity in {'positive' if self.delta > 0 else 'negative'} direction: {abs(self.target_pos/self.axis.steps_per_unit)}\n"
Cyril Guilloud's avatar
Cyril Guilloud committed
448
449
450
451
                f"To stop it: {self.axis.name}.stop()"
            )
            return msg

452
453
454
455
456
457
458
459
460
461
462
        else:
            if self.user_target_pos is None:
                return None
            else:
                if isinstance(self.user_target_pos, str):
                    # can be a string in case of special move like limit search, homing...
                    end_ = self.user_target_pos
                else:
                    end_ = rounder(self.axis.tolerance, self.user_target_pos)
                return f"Moving {self.axis.name} from {start_} to {end_}"

Matias Guijarro's avatar
Matias Guijarro committed
463

Matias Guijarro's avatar
linting    
Matias Guijarro committed
464
class Trajectory:
465
    """ Trajectory information
Matias Guijarro's avatar
Matias Guijarro committed
466

467
468
469
    Represents a specific trajectory motion.

    """
Matias Guijarro's avatar
Matias Guijarro committed
470

Matias Guijarro's avatar
Matias Guijarro committed
471
    def __init__(self, axis, pvt):
Matias Guijarro's avatar
Matias Guijarro committed
472
473
474
475
476
477
478
        """
        Args:
            axis -- axis to which this motion corresponds to
            pvt  -- numpy array with three fields ('position','velocity','time')
        """
        self.__axis = axis
        self.__pvt = pvt
479
480
481
482
        self._events_positions = numpy.empty(
            0, dtype=[("position", "f8"), ("velocity", "f8"), ("time", "f8")]
        )

483
484
485
486
487
488
489
    @property
    def axis(self):
        return self.__axis

    @property
    def pvt(self):
        return self.__pvt
Matias Guijarro's avatar
Matias Guijarro committed
490

491
492
    @property
    def events_positions(self):
493
        return self._events_positions
494

495
496
    @events_positions.setter
    def events_positions(self, events):
497
        self._events_positions = events
498

499
500
501
    def has_events(self):
        return self._events_positions.size

502
503
504
505
506
507
508
    def __len__(self):
        return len(self.pvt)

    def convert_to_dial(self):
        """
        Return a new trajectory with pvt position, velocity converted to dial units and steps per unit
        """
509
510
        user_pos = self.__pvt["position"]
        user_velocity = self.__pvt["velocity"]
511
        pvt = numpy.copy(self.__pvt)
512
513
        pvt["position"] = self.axis.user2dial(user_pos) * self.axis.steps_per_unit
        pvt["velocity"] *= self.axis.steps_per_unit
514
        new_obj = self.__class__(self.axis, pvt)
515
        pattern_evts = numpy.copy(self._events_positions)
516
517
        pattern_evts["position"] *= self.axis.steps_per_unit
        pattern_evts["velocity"] *= self.axis.steps_per_unit
518
        new_obj._events_positions = pattern_evts
519
        return new_obj
520
521


522
class CyclicTrajectory(Trajectory):
523
524
525
526
527
528
529
530
    def __init__(self, axis, pvt, nb_cycles=1, origin=0):
        """
        Args:
            axis -- axis to which this motion corresponds to
            pvt  -- numpy array with three fields ('position','velocity','time')
                    point coordinates are in relative space
        """
        super(CyclicTrajectory, self).__init__(axis, pvt)
531
532
        self.nb_cycles = nb_cycles
        self.origin = origin
533
534
535
536
537

    @property
    def pvt_pattern(self):
        return super(CyclicTrajectory, self).pvt

538
539
540
    @property
    def events_pattern_positions(self):
        return super(CyclicTrajectory, self).events_positions
541

542
543
    @events_pattern_positions.setter
    def events_pattern_positions(self, values):
544
545
        self._events_positions = values

546
547
548
549
    @property
    def is_closed(self):
        """True if the trajectory is closed (first point == last point)"""
        pvt = self.pvt_pattern
550
551
552
553
        return (
            pvt["time"][0] == 0
            and pvt["position"][0] == pvt["position"][len(self.pvt_pattern) - 1]
        )
554
555
556

    @property
    def pvt(self):
557
        """Return the full PVT table. Positions are absolute"""
558
559
560
561
562
563
564
565
566
567
568
569
570
571
        pvt_pattern = self.pvt_pattern
        if self.is_closed:
            # take first point out because it is equal to the last
            raw_pvt = pvt_pattern[1:]
            cycle_size = raw_pvt.shape[0]
            size = self.nb_cycles * cycle_size + 1
            offset = 1
        else:
            raw_pvt = pvt_pattern
            cycle_size = raw_pvt.shape[0]
            size = self.nb_cycles * cycle_size
            offset = 0
        pvt = numpy.empty(size, dtype=raw_pvt.dtype)
        last_time, last_position = 0, self.origin
572
        for cycle in range(self.nb_cycles):
573
            start = cycle_size * cycle + offset
574
575
            end = start + cycle_size
            pvt[start:end] = raw_pvt
576
577
578
579
            pvt["time"][start:end] += last_time
            last_time = pvt["time"][end - 1]
            pvt["position"][start:end] += last_position
            last_position = pvt["position"][end - 1]
580
581

        if self.is_closed:
582
583
            pvt["time"][0] = pvt_pattern["time"][0]
            pvt["position"][0] = pvt_pattern["position"][0] + self.origin
584
585
586

        return pvt

587
588
589
    @property
    def events_positions(self):
        pattern_evts = self.events_pattern_positions
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
590
        time_offset = 0.0
591
        last_time = self.pvt_pattern["time"][-1]
592
        nb_pattern_evts = len(pattern_evts)
593
594
595
        all_events = numpy.empty(
            self.nb_cycles * len(pattern_evts), dtype=pattern_evts.dtype
        )
596
        for i in range(self.nb_cycles):
597
598
599
            sub_evts = all_events[
                i * nb_pattern_evts : i * nb_pattern_evts + nb_pattern_evts
            ]
600
            sub_evts[:] = pattern_evts
601
            sub_evts["time"] += time_offset
602
603
604
            time_offset += last_time
        return all_events

605
606
607
608
    def convert_to_dial(self):
        """
        Return a new trajectory with pvt position, velocity converted to dial units and steps per unit
        """
609
610
611
612
        new_obj = super(CyclicTrajectory, self).convert_to_dial()
        new_obj.origin = self.axis.user2dial(self.origin) * self.axis.steps_per_unit
        new_obj.nb_cycles = self.nb_cycles
        return new_obj
613
614


615
616
617
def lazy_init(func):
    @functools.wraps(func)
    def func_wrapper(self, *args, **kwargs):
618
619
620
621
        if self.disabled:
            raise RuntimeError(f"Axis {self.name} is disabled")
        try:
            self.controller._initialize_axis(self)
622
623
624
625
        except Exception as e:
            if isinstance(e, CommunicationError):
                # also disable the controller
                self.controller._disabled = True
626
627
628
629
630
631
            self._disabled = True
            raise
        else:
            if not self.controller.axis_initialized(self):
                # failed to initialize
                self._disabled = True
632
        return func(self, *args, **kwargs)
633

634
    return func_wrapper
635

Matias Guijarro's avatar
Matias Guijarro committed
636

637
@with_custom_members
638
class Axis(Scannable):
639
    """
Cyril Guilloud's avatar
Cyril Guilloud committed
640
641
    This class is typically used by motor controllers in bliss to export
    axis with harmonised interface for users and configuration.
642
643
    """

644
645
    READ_POSITION_MODE = enum.Enum("Axis.READ_POSITION_MODE", "CONTROLLER ENCODER")

Matias Guijarro's avatar
Matias Guijarro committed
646
647
648
649
    def __init__(self, name, controller, config):
        self.__name = name
        self.__controller = controller
        self.__move_done = gevent.event.Event()
650
        self.__move_done_callback = gevent.event.Event()
Matias Guijarro's avatar
Matias Guijarro committed
651
        self.__move_done.set()
652
        self.__move_done_callback.set()
653
654
        self.__motion_hooks = []
        for hook in config.get("motion_hooks", []):
655
            hook._add_axis(self)
656
657
            self.__motion_hooks.append(hook)
        self.__encoder = config.get("encoder")
658
659
        if self.__encoder is not None:
            self.__encoder.axis = self
660
        self.__config = MotorConfig(config)
661
        self.__settings = AxisSettings(self)
662
        self._init_config_properties()
Matias Guijarro's avatar
linting    
Matias Guijarro committed
663
        self.__no_offset = False
664
        self._group_move = GroupMove()
665
        self._lock = gevent.lock.Semaphore()
666
        self.__positioner = True
667
        self._disabled = False
Matias Guijarro's avatar
Matias Guijarro committed
668

669
670
671
672
673
674
675
676
677
678
679
        try:
            config.parent
        # some Axis don't have a controller
        # like SoftAxis
        except AttributeError:
            disabled_cache = list()
        else:
            disabled_cache = config.parent.get(
                "disabled_cache", []
            )  # get it from controller (parent)
        disabled_cache.extend(config.get("disabled_cache", []))  # get it for this axis
Matias Guijarro's avatar
Matias Guijarro committed
680
681
        for setting_name in disabled_cache:
            self.settings.disable_cache(setting_name)
682
        self._unit = self.config.get("unit", str, None)
683
        self._polling_time = config.get("polling_time", DEFAULT_POLLING_TIME)
684
        global_map.register(self, parents_list=["axes", controller])
685

Matias Guijarro's avatar
linting    
Matias Guijarro committed
686
687
688
689
690
691
692
693
694
695
696
697
698
        # create Beacon channels
        self.settings.init_channels()
        self._move_stop_channel = Channel(
            f"axis.{self.name}.move_stop",
            default_value=False,
            callback=self._external_stop,
        )
        self._jog_velocity_channel = Channel(
            f"axis.{self.name}.change_jog_velocity",
            default_value=None,
            callback=self._set_jog_velocity,
        )

699
    def __close__(self):
700
701
702
703
704
705
706
        try:
            controller_close = self.__controller.close
        except AttributeError:
            pass
        else:
            controller_close()

707
708
709
710
711
712
713
714
    @property
    def no_offset(self):
        return self.__no_offset

    @no_offset.setter
    def no_offset(self, value):
        self.__no_offset = value

715
716
    @property
    def unit(self):
Cyril Guilloud's avatar
Cyril Guilloud committed
717
        """unit used for the Axis (mm, deg, um...)"""
718
719
        return self._unit

Matias Guijarro's avatar
Matias Guijarro committed
720
721
    @property
    def name(self):
Cyril Guilloud's avatar
Cyril Guilloud committed
722
        """name of the axis"""
Matias Guijarro's avatar
Matias Guijarro committed
723
724
        return self.__name

725
    @property
Jibril Mammeri's avatar
Jibril Mammeri committed
726
    def _positioner(self):
727
728
729
        """Axis positioner"""
        return self.__positioner

Jibril Mammeri's avatar
Jibril Mammeri committed
730
731
    @_positioner.setter
    def _positioner(self, new_p):
732
733
        self.__positioner = new_p

734
    @autocomplete_property
Matias Guijarro's avatar
Matias Guijarro committed
735
    def controller(self):
Cyril Guilloud's avatar
Cyril Guilloud committed
736
737
738
739
        """
        Motor controller of the axis
        Reference to :class:`~bliss.controllers.motor.Controller`
        """
Matias Guijarro's avatar
Matias Guijarro committed
740
741
742
743
        return self.__controller

    @property
    def config(self):
744
        """Reference to the :class:`~bliss.common.motor_config.MotorConfig`"""
Matias Guijarro's avatar
Matias Guijarro committed
745
746
747
748
        return self.__config

    @property
    def settings(self):
749
750
751
752
        """
        Reference to the
        :class:`~bliss.controllers.motor_settings.AxisSettings`
        """
Matias Guijarro's avatar
Matias Guijarro committed
753
754
755
756
        return self.__settings

    @property
    def is_moving(self):
757
758
759
        """
        Tells if the axis is moving (:obj:`bool`)
        """
Matias Guijarro's avatar
Matias Guijarro committed
760
761
        return not self.__move_done.is_set()

762
    def _init_config_properties(
Matias Guijarro's avatar
Matias Guijarro committed
763
764
        self, velocity=True, acceleration=True, limits=True, sign=True, backlash=True
    ):
765
766
        self.__steps_per_unit = self.config.get("steps_per_unit", float, 1)
        self.__tolerance = self.config.get("tolerance", float, 1e-4)
Matias Guijarro's avatar
Matias Guijarro committed
767
        if velocity:
768
            if "velocity" in self.settings.config_settings:
Matias Guijarro's avatar
Matias Guijarro committed
769
                self.__config_velocity = self.config.get("velocity", float)
770
            if "jog_velocity" in self.settings.config_settings:
771
772
773
                self.__config_jog_velocity = self.config.get(
                    "jog_velocity", float, self.__config_velocity
                )
774
775
776
777
778
779
            self.__config_velocity_low_limit = self.config.get(
                "velocity_low_limit", float, float("inf")
            )
            self.__config_velocity_high_limit = self.config.get(
                "velocity_high_limit", float, float("inf")
            )
Matias Guijarro's avatar
Matias Guijarro committed
780
        if acceleration:
781
            if "acceleration" in self.settings.config_settings:
Matias Guijarro's avatar
Matias Guijarro committed
782
783
784
785
786
787
788
789
                self.__config_acceleration = self.config.get("acceleration", float)
        if limits:
            self.__config_low_limit = self.config.get("low_limit", float, float("-inf"))
            self.__config_high_limit = self.config.get(
                "high_limit", float, float("+inf")
            )
        if backlash:
            self.__config_backlash = self.config.get("backlash", float, 0)
790

791
792
    @property
    def steps_per_unit(self):
793
        """Current steps per unit (:obj:`float`)"""
794
        return self.__steps_per_unit
795

796
    @property
Matias Guijarro's avatar
Matias Guijarro committed
797
798
799
800
801
802
    def config_backlash(self):
        """Current backlash in user units (:obj:`float`)"""
        return self.__config_backlash

    @property
    @lazy_init
803
804
    def backlash(self):
        """Current backlash in user units (:obj:`float`)"""
Matias Guijarro's avatar
Matias Guijarro committed
805
806
807
808
809
810
811
812
        backlash = self.settings.get("backlash")
        if backlash is None:
            return 0
        return backlash

    @backlash.setter
    def backlash(self, backlash):
        self.settings.set("backlash", backlash)
813

814
815
    @property
    def tolerance(self):
Cyril Guilloud's avatar
Cyril Guilloud committed
816
        """Current Axis tolerance in dial units (:obj:`float`)"""
817
        return self.__tolerance
818

Matias Guijarro's avatar
Matias Guijarro committed
819
820
    @property
    def encoder(self):
821
822
823
824
        """
        Reference to :class:`~bliss.common.encoder.Encoder` or None if no
        encoder is defined
        """
825
        return self.__encoder
826

827
828
    @property
    def motion_hooks(self):
Matias Guijarro's avatar
Matias Guijarro committed
829
830
        """Registered motion hooks (:obj:`MotionHook`)"""
        return self.__motion_hooks
831

832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
    @property
    @lazy_init
    def offset(self):
        """Current offset in user units (:obj:`float`)"""
        offset = self.settings.get("offset")
        if offset is None:
            return 0
        return offset

    @offset.setter
    def offset(self, new_offset):
        if self.no_offset:
            raise RuntimeError(
                f"{self.name}: cannot change offset, axis has 'no offset' flag"
            )
        self.__do_set_position(offset=new_offset)

    @property
    @lazy_init
    def sign(self):
        """Current motor sign (:obj:`int`) [-1, 1]"""
        sign = self.settings.get("sign")
        if sign is None:
            return 1
        return sign

    @sign.setter
859
    @lazy_init
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
    def sign(self, new_sign):
        new_sign = float(
            new_sign
        )  # works both with single float or numpy array of 1 element
        new_sign = math.copysign(1, new_sign)
        if new_sign != self.sign:
            if self.no_offset:
                raise RuntimeError(
                    f"{self.name}: cannot change sign, axis has 'no offset' flag"
                )
            self.settings.set("sign", new_sign)
            # update pos with new sign, offset stays the same
            # user pos is **not preserved** (like spec)
            self.position = self.dial2user(self.dial)

875
    def set_setting(self, *args):
876
        """Sets the given settings"""
877
878
879
        self.settings.set(*args)

    def get_setting(self, *args):
880
        """Return the values for the given settings"""
881
882
        return self.settings.get(*args)

Matias Guijarro's avatar
Matias Guijarro committed
883
    def has_tag(self, tag):
884
885
886
887
888
889
        """
        Tells if the axis has the given tag

        Args:
            tag (str): tag name

890
        Return:
891
892
            bool: True if the axis has the tag or False otherwise
        """
Vincent Michel's avatar
Vincent Michel committed
893
        for t, axis_list in self.__controller._tagged.items():
Matias Guijarro's avatar
Matias Guijarro committed
894
895
896
897
898
899
            if t != tag:
                continue
            if self.name in [axis.name for axis in axis_list]:
                return True
        return False

900
901
902
903
904
905
906
907
    @property
    def disabled(self):
        return self._disabled

    def enable(self):
        self._disabled = False
        self.hw_state  # force update

908
    @lazy_init
blissadm@ID16NI's avatar
blissadm@ID16NI committed
909
    def on(self):
910
        """Turns the axis on"""
911
912
913
        if self.is_moving:
            return

Matias Guijarro's avatar
Matias Guijarro committed
914
        self.__controller.set_on(self)
915
        state = self.__controller.state(self)
916
        self.settings.set("state", state)
blissadm@ID16NI's avatar
blissadm@ID16NI committed
917

918
    @lazy_init
blissadm@ID16NI's avatar
blissadm@ID16NI committed
919
    def off(self):
920
        """Turns the axis off"""
921
922
923
        if self.is_moving:
            raise RuntimeError("Can't set power off while axis is moving")

Matias Guijarro's avatar
Matias Guijarro committed
924
925
        self.__controller.set_off(self)
        state = self.__controller.state(self)
926
        self.settings.set("state", state)
blissadm@ID16NI's avatar
blissadm@ID16NI committed
927

Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
928
929
930
931
932
933
    @property
    @lazy_init
    def _set_position(self):
        sp = self.settings.get("_set_position")
        if sp is not None:
            return sp
934
935
936
937
938
939
        if self._read_position_mode == self.READ_POSITION_MODE.ENCODER:
            # no setting, first time pos is read, init with controller hw pos.
            # issue 2463
            position = self._do_read_hw_position()
        else:
            position = self.position
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
940
941
942
943
        self._set_position = position
        return position

    @_set_position.setter
944
    @lazy_init
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
945
    def _set_position(self, new_set_pos):
946
947
948
        new_set_pos = float(
            new_set_pos
        )  # accepts both float or numpy array of 1 element
949
        self.settings.set("_set_position", new_set_pos)
950

Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
951
    @property
952
    @lazy_init
Cyril Guilloud's avatar
Cyril Guilloud committed
953
954
    def measured_position(self):
        """
Cyril Guilloud's avatar
Cyril Guilloud committed
955
        Return measured position (ie: usually the encoder value).
956

Cyril Guilloud's avatar
Cyril Guilloud committed
957
        Returns:
958
            float: encoder value in user units
Cyril Guilloud's avatar
Cyril Guilloud committed
959
        """
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
960
        return self.dial2user(self.dial_measured_position)
961

Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
962
    @property
963
    @lazy_init
964
    def dial_measured_position(self):
965
        """
Cyril Guilloud's avatar
Cyril Guilloud committed
966
        Dial encoder position.
967

Cyril Guilloud's avatar
Cyril Guilloud committed
968
969
        Returns:
            float: Dial encoder position
970
        """
971
972
973
974
        if self.encoder is not None:
            return self.encoder.read()
        else:
            raise RuntimeError("Axis '%s` has no encoder." % self.name)
975

976
    def __do_set_dial(self, new_dial):
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
977
        user_pos = self.position
978
        old_dial = self.dial
979

980
981
982
983
984
985
986
987
988
        # Set the new dial on the encoder
        if self._read_position_mode == self.READ_POSITION_MODE.ENCODER:
            dial_pos = self.encoder.set(new_dial)
        else:
            # Send the new value in motor units to the controller
            # and read back the (atomically) reported position
            new_hw = new_dial * self.steps_per_unit
            hw_pos = self.__controller.set_position(self, new_hw)
            dial_pos = hw_pos / self.steps_per_unit
989
        self.settings.set("dial_position", dial_pos)
990

991
992
993
994
995
996
        if self.no_offset:
            self.__do_set_position(dial_pos, offset=0)
        else:
            # set user pos, will recalculate offset
            # according to new dial
            self.__do_set_position(user_pos)
997

998
999
        return dial_pos

Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
1000
    @property
1001
    @lazy_init
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
1002
    def dial(self):
1003
        """
1004
        Return current dial position, or set dial
1005

Cyril Guilloud's avatar
Cyril Guilloud committed
1006
        Returns:
1007
            float: current dial position (dimensionless)
1008
        """
Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
1009
1010
1011
1012
        dial_pos = self.settings.get("dial_position")
        if dial_pos is None:
            dial_pos = self._update_dial()
        return dial_pos
1013

Sebastien Petitdemange's avatar
Sebastien Petitdemange committed
1014
    @dial.setter