regulation.py 64.4 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
174
from bliss.common.protocols import CounterContainer

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

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

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

GUILLOU Perceval's avatar
GUILLOU Perceval committed
186
187
188
189
190
191
import functools


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

    return func_wrapper

197

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


205
206
207
208
209
210
211
212
213
class SCC(SamplingCounterController):
    def __init__(self, name, boss):
        super().__init__(name)
        self.boss = boss

    def read_all(self, *counters):
        return [self.boss.read()]


214
@with_custom_members
215
class Input(CounterContainer):
Valentin Valls's avatar
Valentin Valls committed
216
217
    """Implements the access to an input device which is accessed via the
    regulation controller (like a sensor plugged on a channel of the controller)
218
219
220
221
    """

    def __init__(self, controller, config):
        """ Constructor """
222
        self._name = config["name"]
223
224
        self._controller = controller
        self._config = config
225
        self._channel = self._config.get("channel")
226
227
228
229

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

230
231
232
    @property
    def name(self):
        return self._name
233

234
235
236
    @autocomplete_property
    def counters(self):
        return counter_namespace({self.name: self._controller.counters[self.name]})
237

238
239
    # ----------- BASE METHODS -----------------------------------------

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

243
244
        # below the parameters that may requires communication with the controller

245
246
247
248
        pass

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

        return self._controller

    @property
    def config(self):
255
        """ Return the Input config """
256
257
258

        return self._config

259
260
    @property
    def channel(self):
261
        return self._channel
262
263

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

265
266
267
268
269
270
271
272
273
274
275
276
277
    @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)

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

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

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

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

Perceval Guillou's avatar
Perceval Guillou committed
292
    def allow_regulation(self):
Valentin Valls's avatar
Valentin Valls committed
293
294
295
296
297
298
299
300
301
        """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
302
303
304
         """
        return True

305
306

class ExternalInput(Input):
Valentin Valls's avatar
Valentin Valls committed
307
308
309
310
311
312
    """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`
313
314
    """

315
316
    def __init__(self, config):
        super().__init__(None, config)
317

318
        self.device = config.get("device")
319
320
        self.load_base_config()

321
322
323
324
325
326
327
        self._controller = SCC(self.name, self)
        self._controller.create_counter(
            SamplingCounter,
            self.name,
            unit=self._config.get("unit"),
            mode=self._config.get("mode", "SINGLE"),
        )
328

329
330
331
    def __info__(self):
        lines = ["\n"]
        lines.append(f"=== ExternalInput: {self.name} ===")
Perceval Guillou's avatar
Perceval Guillou committed
332
333

        lines.append(f"device: {_get_external_device_name(self.device)}")
334
335
336
337
338
        lines.append(
            f"current value: {self.read():.3f} {self.config.get('unit', 'N/A')}"
        )
        return "\n".join(lines)

339
    def read(self):
340
        """ Return the input device value (in input unit) """
341
342
343
344
345
346

        log_debug(self, "ExternalInput:read")

        if isinstance(self.device, Axis):
            return self.device.position
        elif isinstance(self.device, SamplingCounter):
347
            return self.device._counter_controller.read_all(self.device)[0]
348
349
350
351
        else:
            raise TypeError(
                "the associated device must be an 'Axis' or a 'SamplingCounter'"
            )
352
353

    def state(self):
354
        """ Return the input device state """
355
356
357
358
359
360
361

        log_debug(self, "ExternalInput:state")

        if isinstance(self.device, Axis):
            return self.device.state
        elif isinstance(self.device, SamplingCounter):
            return "READY"
362
363
364
365
        else:
            raise TypeError(
                "the associated device must be an 'Axis' or a 'SamplingCounter'"
            )
366
367
368


@with_custom_members
369
class Output(CounterContainer):
370
371
372
    """ 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. 
373
        If ramprate != 0 then any new value sent to the output
374
375
376
377
378
379
380
        will use a ramp to reach that value (hardware ramping if available, else a software ramp).

    """

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

381
        self._name = config["name"]
382

383
384
        self._controller = controller
        self._config = config
385
        self._channel = self._config.get("channel")
386

387
388
        self._ramp = SoftRamp(self.read, self._set_value)
        self._use_soft_ramp = None
389

390
391
392
393
394
        self._limits = (
            self._config.get("low_limit", None),
            self._config.get("high_limit", None),
        )

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

398
399
400
    @property
    def name(self):
        return self._name
401

402
403
404
    @autocomplete_property
    def counters(self):
        return counter_namespace({self.name: self._controller.counters[self.name]})
405

406
407
    # ----------- BASE METHODS -----------------------------------------

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

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

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

    @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):
430
        """ Return the limits of the output device (in output unit)
431
432
433
434
        """

        return self._limits

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

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

        return self._ramp

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

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

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

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

457
        self._start_ramping(value)
458

459
460
461
462
463
    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))
464

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

467
468
469
470
471
472
473
474
475
476
477
    @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')}"
        )
478
        lines.append("\n=== Output.set_value ramping options ===")
Perceval Guillou's avatar
Perceval Guillou committed
479
        lines.append(f"ramprate: {self.ramprate}")
480
481
482
483
        lines.append(f"ramping: {self.is_ramping()}")
        lines.append(f"limits: {self._limits}")
        return "\n".join(lines)

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

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

491
    @lazy_init
492
493
494
495
496
497
498
    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
499
    @lazy_init
500
501
502
503
    def ramprate(self):
        """ Get ramprate (in output unit per second) """

        log_debug(self, "Output:get_ramprate")
504

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

    @ramprate.setter
511
    @lazy_init
512
513
514
515
516
517
518
519
520
521
    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
522

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

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

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

        elif self._use_soft_ramp:

            return self._ramp.is_ramping()

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

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

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

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

        self._controller.set_output_value(self, value)

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

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

562
563
564
565
566
567
568
569
570
        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)

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

        log_debug(self, "Output:_stop_ramping")

577
578
579
580
581
582
583
584
        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

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


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

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

    - Axis
598

Valentin Valls's avatar
Valentin Valls committed
599
600
601
602
    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).
603
604
    """

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

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

612
613
614
615
616
617
618
619
        self._controller = SCC(self.name, self)
        self._controller.create_counter(
            SamplingCounter,
            self.name,
            unit=self._config.get("unit"),
            mode=self._config.get("mode", "SINGLE"),
        )

620
    # ----------- BASE METHODS -----------------------------------------
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
651
652
653
654
655
656
657
658
659

    @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()
660

661
    # ----------- METHODS THAT A CHILD CLASS MAY CUSTOMIZE ------------------
662

663
664
665
    def __info__(self):
        lines = ["\n"]
        lines.append(f"=== ExternalOutput: {self.name} ===")
Perceval Guillou's avatar
Perceval Guillou committed
666
        lines.append(f"device: {_get_external_device_name(self.device)}")
667
668
669
        lines.append(
            f"current value: {self.read():.3f} {self.config.get('unit', 'N/A')}"
        )
670
        lines.append("\n=== Output.set_value ramping options ===")
Perceval Guillou's avatar
Perceval Guillou committed
671
        lines.append(f"ramprate: {self.ramprate}")
672
673
674
675
        lines.append(f"ramping: {self.is_ramping()}")
        lines.append(f"limits: {self._limits}")
        return "\n".join(lines)

676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
    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):
702
            with disable_user_output():
703
704
705
706
                if self.mode == "relative":
                    self.device.rmove(value)
                elif self.mode == "absolute":
                    self.device.move(value)
707
708
709
        else:
            raise TypeError("the associated device must be an 'Axis'")

710
711

@with_custom_members
712
class Loop(CounterContainer):
713
714
715
    """ Implements the access to the regulation loop 

        The regulation is the PID process that:
716
717
718
        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.
719
720
721
        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:
722
723
        -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).
724

725
        The regulation is automatically started by setting a new setpoint (Loop.setpoint = target_value).
726
727
        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.
728

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

732
        The loop output has a ramp object. If loop.output.ramprate != 0 then any new value sent to the output
733
734
735
736
        will use a ramp to reach that value (HW if available else a soft_ramp).

    """

737
738
739
740
741
    @enum.unique
    class WaitMode(enum.IntEnum):
        RAMP = 1
        DEADBAND = 2

742
743
744
    def __init__(self, controller, config):
        """ Constructor """

745
        self._name = config["name"]
746

747
748
        self._controller = controller
        self._config = config
749
        self._channel = self._config.get("channel")
750
751
752
        self._input = config.get("input")
        self._output = config.get("output")

753
754
        self._ramp = SoftRamp(self.input.read, self._set_setpoint)
        self._use_soft_ramp = None
755
        self._force_ramping_from_current_pv = config.get("ramp_from_pv", True)
756
757
758
759

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

760
        self._last_setpoint = None
761
        self._deadband = 0.1
762
763
764
765
766
767
768
        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

769
        self._wait_mode = self.WaitMode.DEADBAND  # RAMP
770

Perceval Guillou's avatar
Perceval Guillou committed
771
772
        self.reg_plot = None

773
774
        self._create_soft_axis()

775
776
777
778
779
780
781
782
783
784
    def __del__(self):
        self.close()

    def close(self):

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

        self._ramp.stop()

785
    # ----------- BASE METHODS -----------------------------------------
786

787
788
789
790
791
792
793
794
795
796
    @lazy_init
    def read(self):
        """ Return the current working setpoint """

        log_debug(self, "Loop:read")
        return self._get_working_setpoint()

    @property
    def name(self):
        return self._name
797

798
    ##--- CONFIG METHODS
799
    def load_base_config(self):
800
        """ Load from the config the values of the standard parameters """
801

802
803
        self.deadband = self._config.get("deadband", 0.1)
        self.deadband_time = self._config.get("deadband_time", 1.0)
804
805
806

        if self._config.get("wait_mode") is not None:
            self.wait_mode = self._config.get("wait_mode")
807
808
809

        # below the parameters that may requires communication with the controller

810
811
812
813
814
815
816
817
818
        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")
819

820
821
822
823
824
825
826
827
828
829
        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"),
            )
830

831
    ##--- MAIN ATTRIBUTES
832
833
834
835
836
837
838
839
840
841
842
843
    @autocomplete_property
    def controller(self):
        """ Return the associated regulation controller """

        return self._controller

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

        return self._config

844
845
    @property
    def channel(self):
846
        return self._channel
847

848
    @autocomplete_property
849
850
851
    def counters(self):
        """ Standard counter namespace """

852
853
854
        all_counters = (
            list(self.input.counters)
            + list(self.output.counters)
855
            + [self._controller.counters[self.name]]
856
857
        )

858
859
        return counter_namespace(all_counters)

860
861
862
863
864
865
866
867
868
869
870
871
    @autocomplete_property
    def input(self):
        """ Return the input object """

        return self._input

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

        return self._output

872
    @autocomplete_property
873
874
    def soft_ramp(self):
        """ Get the software ramp object """
875
876
877
878

        return self._ramp

    ##--- DEADBAND METHODS
879
    @property
880
881
882
883
    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.
884
885
        """

886
887
        log_debug(self, "Loop:get_deadband")
        return self._deadband
888

889
890
891
892
893
    @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.
894
895
        """

896
897
        log_debug(self, "Loop:set_deadband: %s" % (value))
        self._deadband = value
898
        self._soft_axis._Axis__tolerance = value
899
900

    @property
901
902
903
904
    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.
905
906
        """

907
908
        log_debug(self, "Loop:get_deadband_time")
        return self._deadband_time
909

910
911
912
913
914
    @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.
915
916
        """

917
918
        log_debug(self, "Loop:set_deadband_time: %s" % (value))
        self._deadband_time = value
919
920

    @property
921
922
923
924
    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.
925
926
        """

927
928
        log_debug(self, "Loop:get_deadband_idle_factor")
        return self._deadband_idle_factor * 100.
929

930
931
932
933
934
    @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.
935
936
937
938
939
940
        """

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

941
    def is_in_deadband(self):
942
        return self._x_is_in_deadband(self.input.read())
943

944
    def is_in_idleband(self):
945
        return self._x_is_in_idleband(self.input.read())
946

947
948
949
950
951
952
    def _get_last_input_value(self):
        return self.input.read()

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

953
954
955
    ##--- CTRL METHODS
    @property
    def setpoint(self):
956
        """
957
        Get the current setpoint (target value) (in input unit)
958
959
        """

960
961
        log_debug(self, "Loop:get_setpoint")
        return self._get_setpoint()
962

963
964
    @setpoint.setter
    def setpoint(self, value):
965
        """
966
        Set the new setpoint (target value, in input unit) and start regulation process (if not running already) (w/wo ramp) to reach this setpoint
967
968
        """

969
        log_debug(self, "Loop:set_setpoint: %s" % (value))
970

971
        self._in_deadband = False  # see self.axis_state()
972

973
974
        self._start_regulation()
        self._start_ramping(value)
975
        self._last_setpoint = value
976

977
    def stop(self):
978
        """ Stop the ramping """
979

980
        log_debug(self, "Loop:stop")
981

982
        self._stop_ramping()
983

984
    def abort(self):
985
        """ Stop the ramping (alias for stop) """
986

987
        log_debug(self, "Loop:abort")
988

989
        self._stop_ramping()
990

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

993
    @autocomplete_property
994
    def axis(self):
995
996
        """ Return a SoftAxis object that makes the Loop scanable """

Perceval Guillou's avatar
Perceval Guillou committed
997
        return self._soft_axis
998
999
1000

    def axis_position(self):
        """ Return the input device value as the axis position """
For faster browsing, not all history is shown. View entire blame