regulation.py 64 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
8
# Distributed under the GNU LGPLv3. See LICENSE for more info.

"""
9
This module implements the classes allowing the control of regulation processes and associated hardware
10
11

    The regulation is a process that:
Valentin Valls's avatar
Valentin Valls committed
12

13
    1) reads a value from an input device 
Valentin Valls's avatar
Valentin Valls committed
14
    2) takes a target value (`setpoint`) and compare it to the current input value (processed value)
15
16
17
18
19
    3) computes an output value sent to an output device which has an effect on the processed value
    4) back to step 1) and loop forever so that the processed value reaches the target value and stays stable around that target value.  

    The regulation Loop has:

Valentin Valls's avatar
Valentin Valls committed
20
21
22
23
    -One input: an Input object to read the processed value (ex: temperature sensor)
    -One output: an Output object which has an effect on the processed value (ex: cooling device)

    The regulation is automatically started by setting a new `setpoint` (`Loop.setpoint = target_value`).
24
25
    The Loop object implements methods to manage the PID algorithm that performs the regulation.
    A Loop object is associated to one Input and one Output.
26

Valentin Valls's avatar
Valentin Valls committed
27
28
    The Loop object has a ramp object. If loop.ramprate != 0 then any new `setpoint` cmd (using `Loop.setpoint`)
    will use a ramp to reach that value (HW ramp if available else a `SoftRamp`).
29

30
    The Output object has a ramp object. If loop.output.ramprate != 0 then any new value sent to the output
Valentin Valls's avatar
Valentin Valls committed
31
    will use a ramp to reach that value (HW ramp if available else a `SoftRamp`).
32
33
34
35
36
37
    
    Depending on the hardware capabilities we can distinguish two main cases.

    1) Hardware regulation:

        A physical controller exists and the input and output devices are connected to the controller.
Valentin Valls's avatar
Valentin Valls committed
38
39
40
41
        In that case, a regulation Controller object must be implemented by inheriting from the `Controller` base class (`bliss.controllers.regulator`).
        The inputs and outputs attached to that controller are defined through the YML configuration file.

    .. code-block::
42

43
44
45
            ---------------------------------------------- YML file example ------------------------------------------------------------------------    

            -
46
                class: Mockup                  # <-- the controller class inheriting from 'bliss.controllers.regulator.Controller'
47
                module: temperature.mockup
48
49
                host: lid42
                inputs:
Valentin Valls's avatar
Valentin Valls committed
50
                    -
51
52
53
54
                        name: thermo_sample
                        channel: A
                        unit: deg

Valentin Valls's avatar
Valentin Valls committed
55
                    -
56
57
                        name: sensor
                        channel: B
Valentin Valls's avatar
Valentin Valls committed
58

59
60
61
62
63
                outputs: 
                    -
                        name: heater
                        channel: A 
                        unit: Volt
64
65
66
                        low_limit:  0.0          # <-- minimum device value [unit] 
                        high_limit: 100.0        # <-- maximum device value [unit]
                        ramprate: 0.0            # <-- ramprate to reach the output value [unit/s]
Valentin Valls's avatar
Valentin Valls committed
67

68
69
70
71
72
73
74
75
                ctrl_loops:
                    -
                        name: sample_regulation
                        input: $thermo_sample
                        output: $heater
                        P: 0.5
                        I: 0.2
                        D: 0.0
76
77
                        low_limit: 0.0           # <-- low limit of the PID output value. Usually equal to 0 or -1.
                        high_limit: 1.0          # <-- high limit of the PID output value. Usually equal to 1.
78
79
80
                        frequency: 10.0
                        deadband: 0.05
                        deadband_time: 1.5
81
                        ramprate: 1.0            # <-- ramprate to reach the setpoint value [input_unit/s]
82
                        wait_mode: deadband
Valentin Valls's avatar
Valentin Valls committed
83

84
85
86
87
88
89
90
            ----------------------------------------------------------------------------------------------------------------------------------------

    2) Software regulation

        Input and Output devices are not always connected to a regulation controller.
        For example, it may be necessary to regulate a temperature by moving a cryostream on a stage (axis).

Valentin Valls's avatar
Valentin Valls committed
91
        Any `SamplingCounter` can be interfaced as an input (`ExternalInput`) and any 'Axis' as an input or output (`ExternalOutput`).
92
93
94
        Devices which are not standard Bliss objects can be interfaced by implementing a custom input or output class inheriting from the Input/Output classes.

        To perform the regulation with this kind of inputs/outputs not attached to an hardware regulation controller, users must define a SoftLoop.
Valentin Valls's avatar
Valentin Valls committed
95
96
97
        The `SoftLoop` object inherits from the Loop class and implements its own PID algorithm (using the `simple_pid` Python module).

    .. code-block::
98
99

            ---------------------------------------------- YML file example ------------------------------------------------------------------------
Valentin Valls's avatar
Valentin Valls committed
100
            -
101
102
103
104
                class: MyDevice     # <== any kind of object (usually declared in another YML file)
                package: bliss.controllers.regulation.temperature.mockup
                plugin: bliss
                name: my_device
105

Valentin Valls's avatar
Valentin Valls committed
106
            -
107
                class: MyCustomInput     # <-- a custom input defined by the user and inheriting from the ExternalInput class
108
                package: bliss.controllers.regulation.temperature.mockup  # <-- the module where the custom class is defined
109
110
                plugin: bliss
                name: custom_input
111
                device: $my_device       # <-- any kind of object reference (pointing to an object declared somewhere else in a YML config file)
112
                unit: deg
Valentin Valls's avatar
Valentin Valls committed
113
114
115


            -
116
                class: MyCustomOutput    # <-- a custom output defined by the user and inheriting from the ExternalOutput class
117
                package: bliss.controllers.regulation.temperature.mockup  # <-- the module where the custom class is defined
118
119
                plugin: bliss
                name: custom_output
120
                device: $my_device       # <-- any kind of object reference (pointing to an object declared somewhere else in a YML config file)
121
                unit: W
122
123
124
                low_limit: 0.0           # <-- minimum device value [unit]
                high_limit: 100.0        # <-- maximum device value [unit]
                ramprate: 0.0            # <-- ramprate to reach the output value [unit/s]
Valentin Valls's avatar
Valentin Valls committed
125
126


127
            - 
128
                class: ExternalInput     # <-- declare an 'ExternalInput' object
129
                name: diode_input          
130
                device: $diode           # <-- a SamplingCounter object reference (pointing to a counter declared somewhere else in a YML config file )
131
                unit: N/A
Valentin Valls's avatar
Valentin Valls committed
132
133


134
            -
135
                class: ExternalOutput    # <-- declare an 'ExternalOutput' object
136
                name: robz_output        
137
                device: $robz            # <-- an axis object reference (pointing to an axis declared somewhere else in a YML config file )
138
                unit: mm
139
140
                low_limit: -1.0          # <-- minimum device value [unit]
                high_limit: 1.0          # <-- maximum device value [unit]
141
                ramprate: 0.0            # <-- ramprate to reach the output value [unit/s]
142
                mode: relative           # <-- the axis will perform relative moves (use 'absolute' for absolute moves)
Valentin Valls's avatar
Valentin Valls committed
143
144
145


            -
146
                class: SoftLoop          # <== declare a 'SoftLoop' object
147
148
                name: soft_regul
                input: $custom_input
149
150
151
                output: $custom_output
                P: 0.05
                I: 0.1
152
                D: 0.0
Valentin Valls's avatar
Valentin Valls committed
153
154
                low_limit: 0.0            # <-- low limit of the PID output value. Usually equal to 0 or -1.
                high_limit: 1.0           # <-- high limit of the PID output value. Usually equal to 1.
155
                frequency: 10.0
156
157
                deadband: 0.1
                deadband_time: 3.0
Perceval Guillou's avatar
Perceval Guillou committed
158
159
                ramprate: 1.0    
                wait_mode: deadband   
160
161
162
163
164

                ------------------------------------------------------------------------------------------------------------------------------------

        Note: a SoftLoop can use an Input or Output defined in a regulation controller section.
        For example the 'soft_regul' loop could define 'thermo_sample' as its input.  
165
166
167
168
169
170
    
"""

import time
import gevent
import gevent.event
171
import enum
172

173
from bliss.common.logtools import log_debug, disable_user_output
174
from bliss.common.utils import with_custom_members, autocomplete_property
175
from bliss.common.counter import SamplingCounter
176
from bliss.controllers.counter import SamplingCounterController, counter_namespace
177
178
179
180

from bliss.common.soft_axis import SoftAxis
from bliss.common.axis import Axis, AxisState

181
from simple_pid import PID
Perceval Guillou's avatar
Perceval Guillou committed
182
from bliss.common.plot import get_flint
183

GUILLOU Perceval's avatar
GUILLOU Perceval committed
184
185
186
187
188
189
import functools


def lazy_init(func):
    @functools.wraps(func)
    def func_wrapper(self, *args, **kwargs):
190
        self.controller.init_obj(self)
GUILLOU Perceval's avatar
GUILLOU Perceval committed
191
192
193
194
        return func(self, *args, **kwargs)

    return func_wrapper

195

Perceval Guillou's avatar
Perceval Guillou committed
196
197
198
199
200
201
202
def _get_external_device_name(device):
    try:
        return f"{device.name} {device}"
    except AttributeError:
        return device


203
@with_custom_members
204
class Input(SamplingCounterController):
Valentin Valls's avatar
Valentin Valls committed
205
206
    """Implements the access to an input device which is accessed via the
    regulation controller (like a sensor plugged on a channel of the controller)
207
208
209
210
211
    """

    def __init__(self, controller, config):
        """ Constructor """

212
213
        super().__init__(name=config["name"])

214
215
        self._controller = controller
        self._config = config
216
        self._channel = self._config.get("channel")
217

218
219
        self.max_sampling_frequency = config.get("max_sampling_frequency", 5)

220
221
222
        # useful attribute for a temperature controller writer
        self._attr_dict = {}

223
224
225
        self._build_counters()

    def _build_counters(self):
Perceval Guillou's avatar
Perceval Guillou committed
226
227
228
        self.create_counter(
            SamplingCounter,
            self.name + "_counter",
229
230
            unit=self._config.get("unit", "N/A"),
            mode=self._config.get("sampling-counter-mode", "SINGLE"),
Perceval Guillou's avatar
Perceval Guillou committed
231
        )
232

233
234
235
    def read_all(self, *counters):
        return [self.read()]

236
237
    # ----------- BASE METHODS -----------------------------------------

238
    def load_base_config(self):
239
        """ Load from the config the values of the standard parameters """
240

241
242
        # below the parameters that may requires communication with the controller

243
244
245
246
        pass

    @property
    def controller(self):
247
        """ Return the associated regulation controller """
248
249
250
251
252

        return self._controller

    @property
    def config(self):
253
        """ Return the Input config """
254
255
256

        return self._config

257
258
    @property
    def channel(self):
259
        return self._channel
260
261

    # ----------- METHODS THAT A CHILD CLASS MAY CUSTOMIZE ------------------
262

263
264
265
266
267
268
269
270
271
272
273
274
275
    @lazy_init
    def __info__(self):
        lines = ["\n"]
        lines.append(f"=== Input: {self.name} ===")
        lines.append(
            f"controller: {self.controller.name if self.controller.name is not None else self.controller.__class__.__name__}"
        )
        lines.append(f"channel: {self.channel}")
        lines.append(
            f"current value: {self.read():.3f} {self.config.get('unit', 'N/A')}"
        )
        return "\n".join(lines)

276
    @lazy_init
277
    def read(self):
278
        """ Return the input device value (in input unit) """
279
280
281
282

        log_debug(self, "Input:read")
        return self._controller.read_input(self)

283
    @lazy_init
284
    def state(self):
285
        """ Return the input device state """
286
287
288
289

        log_debug(self, "Input:state")
        return self._controller.state_input(self)

Perceval Guillou's avatar
Perceval Guillou committed
290
    def allow_regulation(self):
Valentin Valls's avatar
Valentin Valls committed
291
292
293
294
295
296
297
298
299
        """This method is called by the SoftLoop to check if the regulation
        should be suspended.

        If this method returns False, the SoftLoop will pause the PID algorithm
        that computes the output value. As soon as this method returns True, the
        PID algorithm is resumed.

        While returning False, you must ensure that the read method of the Input
        still returns a numerical value (like the last readable value).
Perceval Guillou's avatar
Perceval Guillou committed
300
301
302
         """
        return True

303
304

class ExternalInput(Input):
Valentin Valls's avatar
Valentin Valls committed
305
306
307
308
309
310
    """Implements the access to an external input device (i.e. not accessed via
    the regulation controller itself, like an axis or a counter)

    Managed devices are objects of the type:
    - `Axis`
    - `SamplingCounter`
311
312
    """

313
314
    def __init__(self, config):
        super().__init__(None, config)
315

316
        self.device = config.get("device")
317
318
        self.load_base_config()

319
    # ----------- METHODS THAT A CHILD CLASS MAY CUSTOMIZE ------------------
320

321
322
323
    def __info__(self):
        lines = ["\n"]
        lines.append(f"=== ExternalInput: {self.name} ===")
Perceval Guillou's avatar
Perceval Guillou committed
324
325

        lines.append(f"device: {_get_external_device_name(self.device)}")
326
327
328
329
330
        lines.append(
            f"current value: {self.read():.3f} {self.config.get('unit', 'N/A')}"
        )
        return "\n".join(lines)

331
    def read(self):
332
        """ Return the input device value (in input unit) """
333
334
335
336
337
338

        log_debug(self, "ExternalInput:read")

        if isinstance(self.device, Axis):
            return self.device.position
        elif isinstance(self.device, SamplingCounter):
339
            return self.device._counter_controller.read_all(self.device)[0]
340
341
342
343
        else:
            raise TypeError(
                "the associated device must be an 'Axis' or a 'SamplingCounter'"
            )
344
345

    def state(self):
346
        """ Return the input device state """
347
348
349
350
351
352
353

        log_debug(self, "ExternalInput:state")

        if isinstance(self.device, Axis):
            return self.device.state
        elif isinstance(self.device, SamplingCounter):
            return "READY"
354
355
356
357
        else:
            raise TypeError(
                "the associated device must be an 'Axis' or a 'SamplingCounter'"
            )
358
359
360


@with_custom_members
361
class Output(SamplingCounterController):
362
363
364
    """ Implements the access to an output device which is accessed via the regulation controller (like an heater plugged on a channel of the controller)
    
        The Output has a ramp object. 
365
        If ramprate != 0 then any new value sent to the output
366
367
368
369
370
371
372
        will use a ramp to reach that value (hardware ramping if available, else a software ramp).

    """

    def __init__(self, controller, config):
        """ Constructor """

373
374
        super().__init__(name=config["name"])

375
376
        self._controller = controller
        self._config = config
377
        self._channel = self._config.get("channel")
378

379
380
        self._ramp = SoftRamp(self.read, self._set_value)
        self._use_soft_ramp = None
381

382
383
384
385
386
        self._limits = (
            self._config.get("low_limit", None),
            self._config.get("high_limit", None),
        )

387
388
389
        # useful attribute for a temperature controller writer
        self._attr_dict = {}

390
391
        self.max_sampling_frequency = config.get("max_sampling_frequency", 5)

392
393
394
        self._build_counters()

    def _build_counters(self):
Perceval Guillou's avatar
Perceval Guillou committed
395
396
397
        self.create_counter(
            SamplingCounter,
            self.name + "_counter",
398
399
            unit=self._config.get("unit", "N/A"),
            mode=self._config.get("sampling-counter-mode", "SINGLE"),
Perceval Guillou's avatar
Perceval Guillou committed
400
        )
401

402
403
404
    def read_all(self, *counters):
        return [self.read()]

405
406
    # ----------- BASE METHODS -----------------------------------------

407
    def load_base_config(self):
408
        """ Load from the config the value of the standard parameters """
409

410
        # below the parameters that may requires communication with the controller
411

412
413
        if self._config.get("ramprate") is not None:
            self.ramprate = self._config.get("ramprate")
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428

    @property
    def controller(self):
        """ Return the associated regulation controller """

        return self._controller

    @property
    def config(self):
        """ Return the Output config """

        return self._config

    @property
    def limits(self):
429
        """ Return the limits of the output device (in output unit)
430
431
432
433
        """

        return self._limits

434
435
    @property
    def channel(self):
436
        return self._channel
437

438
    @autocomplete_property
439
440
    def soft_ramp(self):
        """ Get the software ramp object """
441
442
443

        return self._ramp

444
445
    def set_value(self, value):
        """ Set 'value' as new target and start ramping to this target (no ramping if ramprate==0).
446
447
        """

448
        log_debug(self, "Output:set_value %s" % value)
449

450
        if self._limits[0] is not None:
451
            value = max(value, self._limits[0])
452
453

        if self._limits[1] is not None:
454
455
            value = min(value, self._limits[1])

456
        self._start_ramping(value)
457

458
459
460
461
462
    def _add_custom_method(self, method, name, types_info=(None, None)):
        """ Necessary to add custom methods to this class """

        setattr(self, name, method)
        self.__custom_methods_list.append((name, types_info))
463

464
    # ----------- METHODS THAT A CHILD CLASS MAY CUSTOMIZE ------------------
465

466
467
468
469
470
471
472
473
474
475
476
    @lazy_init
    def __info__(self):
        lines = ["\n"]
        lines.append(f"=== Output: {self.name} ===")
        lines.append(
            f"controller: {self.controller.name if self.controller.name is not None else self.controller.__class__.__name__}"
        )
        lines.append(f"channel: {self.channel}")
        lines.append(
            f"current value: {self.read():.3f} {self.config.get('unit', 'N/A')}"
        )
477
        lines.append("\n=== Output.set_value ramping options ===")
Perceval Guillou's avatar
Perceval Guillou committed
478
        lines.append(f"ramprate: {self.ramprate}")
479
480
481
482
        lines.append(f"ramping: {self.is_ramping()}")
        lines.append(f"limits: {self._limits}")
        return "\n".join(lines)

483
    @lazy_init
484
485
486
487
488
489
    def state(self):
        """ Return the state of the output device"""

        log_debug(self, "Output:state")
        return self._controller.state_output(self)

490
    @lazy_init
491
492
493
494
495
496
497
    def read(self):
        """ Return the current value of the output device (in output unit) """

        log_debug(self, "Output:read")
        return self._controller.read_output(self)

    @property
498
    @lazy_init
499
500
501
502
    def ramprate(self):
        """ Get ramprate (in output unit per second) """

        log_debug(self, "Output:get_ramprate")
503

504
505
506
507
508
509
        try:
            return self._controller.get_output_ramprate(self)
        except NotImplementedError:
            return self._ramp.rate

    @ramprate.setter
510
    @lazy_init
511
512
513
514
515
516
517
518
519
520
    def ramprate(self, value):
        """ Set ramprate (in output unit per second) """

        log_debug(self, "Output:set_ramprate: %s" % (value))

        self._ramp.rate = value
        try:
            self._controller.set_output_ramprate(self, value)
        except NotImplementedError:
            pass
521

522
    @lazy_init
523
524
525
526
527
528
    def is_ramping(self):
        """
        Get the ramping status.
        """

        log_debug(self, "Output:is_ramping")
529
530
531
532

        if (
            self._use_soft_ramp is None
        ):  # case where '_start_ramping' was never called previously.
533
534
535
536
            try:
                return self._controller.is_output_ramping(self)
            except NotImplementedError:
                return False
537
538
539
540
541
542

        elif self._use_soft_ramp:

            return self._ramp.is_ramping()

        else:
543
            return self._controller.is_output_ramping(self)
544

545
    @lazy_init
546
547
548
    def _set_value(self, value):
        """ Set the value for the output. Value is expressed in output unit """

549
        # lazy_init not required here because this method is called by a method with the @lazy_init
550

551
552
553
554
        log_debug(self, "Output:_set_value %s" % value)

        self._controller.set_output_value(self, value)

555
    @lazy_init
556
557
558
    def _start_ramping(self, value):
        """ Start the ramping process to target_value """

559
        # lazy_init not required here because this method is called by a method with the @lazy_init
560

561
562
563
564
565
566
567
568
569
        log_debug(self, "Output:_start_ramping %s" % value)

        try:
            self._use_soft_ramp = False
            self._controller.start_output_ramp(self, value)
        except NotImplementedError:
            self._use_soft_ramp = True
            self._ramp.start(value)

570
    @lazy_init
571
572
573
574
575
    def _stop_ramping(self):
        """ Stop the ramping process """

        log_debug(self, "Output:_stop_ramping")

576
577
578
579
580
581
582
583
        if (
            self._use_soft_ramp is None
        ):  # case where '_start_ramping' was never called previously.
            try:
                self._controller.stop_output_ramp(self)
            except NotImplementedError:
                pass

584
585
586
587
        elif self._use_soft_ramp:
            self._ramp.stop()
        else:
            self._controller.stop_output_ramp(self)
588
589
590


class ExternalOutput(Output):
Valentin Valls's avatar
Valentin Valls committed
591
592
    """Implements the access to an external output device (i.e. not accessed via
    the regulation controller itself, like an axis)
593

Valentin Valls's avatar
Valentin Valls committed
594
595
596
    Managed devices are objects of the type:

    - Axis
597

Valentin Valls's avatar
Valentin Valls committed
598
599
600
601
    The Output has a ramp object.
    If `ramprate != 0` then any new value sent to the output
    will use a ramp to reach that value (hardware ramping if available, else a
    software ramp).
602
603
    """

604
605
    def __init__(self, config):
        super().__init__(None, config)
606

607
        self.device = config.get("device")
608
        self.mode = config.get("mode", "relative")
609
610
        self.load_base_config()

611
    # ----------- BASE METHODS -----------------------------------------
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650

    @property
    def ramprate(self):
        """ Get ramprate (in output unit per second) """

        log_debug(self, "ExternalOutput:get_ramprate")

        return self._ramp.rate

    @ramprate.setter
    def ramprate(self, value):
        """ Set ramprate (in output unit per second) """

        log_debug(self, "ExternalOutput:set_ramprate: %s" % (value))

        self._ramp.rate = value

    def is_ramping(self):
        """
        Get the ramping status.
        """

        log_debug(self, "ExternalOutput:is_ramping")

        return self._ramp.is_ramping()

    def _start_ramping(self, value):
        """ Start the ramping process to target_value """

        log_debug(self, "ExternalOutput:_start_ramping %s" % value)

        self._ramp.start(value)

    def _stop_ramping(self):
        """ Stop the ramping process """

        log_debug(self, "ExternalOutput:_stop_ramping")

        self._ramp.stop()
651

652
    # ----------- METHODS THAT A CHILD CLASS MAY CUSTOMIZE ------------------
653

654
655
656
    def __info__(self):
        lines = ["\n"]
        lines.append(f"=== ExternalOutput: {self.name} ===")
Perceval Guillou's avatar
Perceval Guillou committed
657
        lines.append(f"device: {_get_external_device_name(self.device)}")
658
659
660
        lines.append(
            f"current value: {self.read():.3f} {self.config.get('unit', 'N/A')}"
        )
661
        lines.append("\n=== Output.set_value ramping options ===")
Perceval Guillou's avatar
Perceval Guillou committed
662
        lines.append(f"ramprate: {self.ramprate}")
663
664
665
666
        lines.append(f"ramping: {self.is_ramping()}")
        lines.append(f"limits: {self._limits}")
        return "\n".join(lines)

667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
    def state(self):
        """ Return the state of the output device"""

        log_debug(self, "ExternalOutput:state")

        if isinstance(self.device, Axis):
            return self.device.state
        else:
            raise TypeError("the associated device must be an 'Axis'")

    def read(self):
        """ Return the current value of the output device (in output unit) """

        log_debug(self, "ExternalOutput:read")

        if isinstance(self.device, Axis):
            return self.device.position
        else:
            raise TypeError("the associated device must be an 'Axis'")

    def _set_value(self, value):
        """ Set the value for the output. Value is expressed in output unit """

        log_debug(self, "ExternalOutput:_set_value %s" % value)

        if isinstance(self.device, Axis):
693
            with disable_user_output():
694
695
696
697
                if self.mode == "relative":
                    self.device.rmove(value)
                elif self.mode == "absolute":
                    self.device.move(value)
698
699
700
        else:
            raise TypeError("the associated device must be an 'Axis'")

701
702

@with_custom_members
703
class Loop(SamplingCounterController):
704
705
706
    """ Implements the access to the regulation loop 

        The regulation is the PID process that:
707
708
709
        1) reads a value from an input device.
        2) takes a target value (setpoint) and compare it to the current input value (processed value).
        3) computes an output value  and send it to an output device which has an effect on the processed value.
710
711
712
        4) back to step 1) and loop forever so that the processed value reaches the target value and stays stable around that target value.  

        The Loop has:
713
714
        -one input: an Input object to read the processed value (ex: temperature sensor).
        -one output: an Output object which has an effect on the processed value (ex: cooling device).
715

716
        The regulation is automatically started by setting a new setpoint (Loop.setpoint = target_value).
717
718
        The Loop object implements methods to manage the PID algorithm that performs the regulation.
        A Loop object is associated to one Input and one Output.
719

720
        The Loop has a ramp object. If loop.ramprate != 0 then any new setpoint cmd
721
722
        will use a ramp to reach that value (HW if available else a soft_ramp).

723
        The loop output has a ramp object. If loop.output.ramprate != 0 then any new value sent to the output
724
725
726
727
        will use a ramp to reach that value (HW if available else a soft_ramp).

    """

728
729
730
731
732
    @enum.unique
    class WaitMode(enum.IntEnum):
        RAMP = 1
        DEADBAND = 2

733
734
735
    def __init__(self, controller, config):
        """ Constructor """

736
737
        super().__init__(name=config["name"])

738
739
        self._controller = controller
        self._config = config
740
        self._channel = self._config.get("channel")
741
742
743
        self._input = config.get("input")
        self._output = config.get("output")

744
745
        self._ramp = SoftRamp(self.input.read, self._set_setpoint)
        self._use_soft_ramp = None
746
        self._force_ramping_from_current_pv = config.get("ramp_from_pv", True)
747
748
749
750

        # useful attribute for a temperature controller writer
        self._attr_dict = {}

751
        self._last_setpoint = None
752
        self._deadband = 0.1
753
754
755
756
757
758
759
        self._deadband_time = 1.0
        self._deadband_idle_factor = 0.5
        self._in_deadband = False
        self._time_enter_deadband = None

        self._first_scan_move = True

760
        self._wait_mode = self.WaitMode.DEADBAND  # RAMP
761

Perceval Guillou's avatar
Perceval Guillou committed
762
763
        self.reg_plot = None

764
765
766
767
768
769
770
        self.max_sampling_frequency = config.get("max_sampling_frequency", 5)

        self._build_counters()

        self._create_soft_axis()

    def _build_counters(self):
Perceval Guillou's avatar
Perceval Guillou committed
771
772
        self.create_counter(
            SamplingCounter,
773
            self.name + "_setpoint",
774
            unit=self.input.config.get("unit", "N/A"),
Perceval Guillou's avatar
Perceval Guillou committed
775
776
777
            mode="SINGLE",
        )

778
779
780
781
782
783
784
785
786
787
    def __del__(self):
        self.close()

    def close(self):

        if self.reg_plot:
            self.reg_plot.stop()

        self._ramp.stop()

788
    # ----------- BASE METHODS -----------------------------------------
789

790
    def read_all(self, *counters):
791
        return [self._get_working_setpoint()]
792

793
    ##--- CONFIG METHODS
794
    def load_base_config(self):
795
        """ Load from the config the values of the standard parameters """
796

797
798
        self.deadband = self._config.get("deadband", 0.1)
        self.deadband_time = self._config.get("deadband_time", 1.0)
799
800
801

        if self._config.get("wait_mode") is not None:
            self.wait_mode = self._config.get("wait_mode")
802
803
804

        # below the parameters that may requires communication with the controller

805
806
807
808
809
810
811
812
813
        if self._config.get("P") is not None:
            self.kp = self._config.get("P")
        if self._config.get("I") is not None:
            self.ki = self._config.get("I")
        if self._config.get("D") is not None:
            self.kd = self._config.get("D")

        if self._config.get("frequency") is not None:
            self.sampling_frequency = self._config.get("frequency")
814

815
816
817
818
819
820
821
822
823
824
        if self._config.get("ramprate") is not None:
            self.ramprate = self._config.get("ramprate")

        if (self._config.get("low_limit") is not None) and (
            self._config.get("low_limit") is not None
        ):
            self.pid_range = (
                self._config.get("low_limit"),
                self._config.get("high_limit"),
            )
825

826
    ##--- MAIN ATTRIBUTES
827
828
829
830
831
832
833
834
835
836
837
838
    @autocomplete_property
    def controller(self):
        """ Return the associated regulation controller """

        return self._controller

    @property
    def config(self):
        """ Return the loop config """

        return self._config

839
840
    @property
    def channel(self):
841
        return self._channel
842

843
    @autocomplete_property
844
845
846
    def counters(self):
        """ Standard counter namespace """

847
848
849
        all_counters = (
            list(self.input.counters)
            + list(self.output.counters)
850
            + list(self._counters.values())
851
852
        )

853
854
        return counter_namespace(all_counters)

855
856
857
858
859
860
861
862
863
864
865
866
    @autocomplete_property
    def input(self):
        """ Return the input object """

        return self._input

    @autocomplete_property
    def output(self):
        """ Return the output object """

        return self._output

867
    @autocomplete_property
868
869
    def soft_ramp(self):
        """ Get the software ramp object """
870
871
872
873

        return self._ramp

    ##--- DEADBAND METHODS
874
    @property
875
876
877
878
    def deadband(self):
        """ Get the deadband value (in input unit). 
            The regulation is considered stable if input value is in the range: setpoint +/- deadband
            for a time >= deadband_time.
879
880
        """

881
882
        log_debug(self, "Loop:get_deadband")
        return self._deadband
883

884
885
886
887
888
    @deadband.setter
    def deadband(self, value):
        """ Set the deadband value (in input unit). 
            The regulation is considered stable if input value is in the range: setpoint +/- deadband
            for a time >= deadband_time.
889
890
        """

891
892
        log_debug(self, "Loop:set_deadband: %s" % (value))
        self._deadband = value
893
        self._soft_axis._Axis__tolerance = value
894
895

    @property
896
897
898
899
    def deadband_time(self):
        """ Get the deadband_time value (s). 
            The regulation is considered stable if input value is in the range: setpoint +/- deadband 
            for a time >= deadband_time.
900
901
        """

902
903
        log_debug(self, "Loop:get_deadband_time")
        return self._deadband_time
904

905
906
907
908
909
    @deadband_time.setter
    def deadband_time(self, value):
        """ Set the deadband_time value (s). 
            The regulation is considered stable if input value is in the range: setpoint +/- deadband
            for a time >= deadband_time.
910
911
        """

912
913
        log_debug(self, "Loop:set_deadband_time: %s" % (value))
        self._deadband_time = value
914
915

    @property
916
917
918
919
    def deadband_idle_factor(self):
        """ Get the deadband_idle_factor value (%). 
            The regulation (PID process) won't send a command to the Output if the 
            processed value is in the range: setpoint +/- deadband_idle_factor*deadband.
920
921
        """

922
923
        log_debug(self, "Loop:get_deadband_idle_factor")
        return self._deadband_idle_factor * 100.
924

925
926
927
928
929
    @deadband_idle_factor.setter
    def deadband_idle_factor(self, value):
        """ Set the deadband_idle_factor value (%) 
            The regulation (PID process) won't send a command to the Output if the 
            processed value is in the range: setpoint +/- deadband_idle_factor*deadband.
930
931
932
933
934
935
        """

        log_debug(self, "Loop:set_deadband_idle_factor: %s" % (value))
        value = max(min(value, 100), 0)
        self._deadband_idle_factor = value / 100.

936
    def is_in_deadband(self):
937
        return self._x_is_in_deadband(self.input.read())
938

939
    def is_in_idleband(self):
940
        return self._x_is_in_idleband(self.input.read())
941

942
943
944
945
946
947
    def _get_last_input_value(self):
        return self.input.read()

    def _get_last_output_value(self):
        return self.output.read()

948
949
950
    ##--- CTRL METHODS
    @property
    def setpoint(self):
951
        """
952
        Get the current setpoint (target value) (in input unit)
953
954
        """

955
956
        log_debug(self, "Loop:get_setpoint")
        return self._get_setpoint()
957

958
959
    @setpoint.setter
    def setpoint(self, value):
960
        """
961
        Set the new setpoint (target value, in input unit) and start regulation process (if not running already) (w/wo ramp) to reach this setpoint
962
963
        """

964
        log_debug(self, "Loop:set_setpoint: %s" % (value))
965

966
        self._in_deadband = False  # see self.axis_state()
967

968
969
        self._start_regulation()
        self._start_ramping(value)
970
        self._last_setpoint = value
971

972
    def stop(self):
973
        """ Stop the ramping """
974

975
        log_debug(self, "Loop:stop")
976

977
        self._stop_ramping()
978

979
    def abort(self):
980
        """ Stop the ramping (alias for stop) """
981

982
        log_debug(self, "Loop:abort")
983

984
        self._stop_ramping()
985

986
    ##--- SOFT AXIS METHODS: makes the Loop object scannable (ex: ascan(loop, ...) )
987

988
    @autocomplete_property
989
    def axis(self):
990
991
        """ Return a SoftAxis object that makes the Loop scanable """

Perceval Guillou's avatar
Perceval Guillou committed
992
        return self._soft_axis
993
994
995
996
997
998
999
1000

    def axis_position(self):
        """ Return the input device value as the axis position """

        return self.input.read()

    def axis_move(self, pos):
        """ Set the Loop setpoint value as if moving an axis to a position """