diff --git a/README.md b/README.md index 624e245..7bad160 100644 --- a/README.md +++ b/README.md @@ -197,34 +197,11 @@ connection details where possible. ## 4.7 Rotary Incremental Encoder -Classes for handling incremental rotary position encoders. Note Pyboard timers -can do this in hardware, as shown -[in this script](https://github.com/dhylands/upy-examples/blob/master/encoder.py) -from Dave Hylands. These samples cater for cases where that solution can't be -used. The [encoder_timed.py](./encoders/encoder_timed.py) sample provides rate -information by timing successive edges. In practice this is likely to need -filtering to reduce jitter caused by imperfections in the encoder geometry. - -There are other algorithms but this is the simplest and fastest I've -encountered. - -These were written for encoders producing logic outputs. For switches, adapt -the pull definition to provide a pull up or pull down as required. - -The [encoder_portable.py](./encoders/encoder_portable.py) version should work on -all MicroPython platforms. Tested on ESP8266. Note that interrupt latency on -the ESP8266 limits performance. ESP32 has similar limitations. - -See also [this asynchronous driver](https://github.com/peterhinch/micropython-async/blob/master/v3/docs/DRIVERS.md#6-quadrature-encoders) -intended for control knobs based on quadrature switches like -[this Adafruit product](https://www.adafruit.com/product/377). There are two -aspects to this. Firstly, the solutions in this repo run callbacks in an -interrupt context. This is necessary in applications like NC machines where -performance is paramount, but it can be problematic in applications for control -knobs where a user adjustment can trigger complex application behavior. The -asynchronous driver runs the callback in a `uasyncio` context. Secondly there -can be practical timing and sensitivity issues with control knobs which the -driver aims to address. +These devices produce digital signals from a shaft's rotary motion in such a +way that the absolute angle may be deduced. Specifically they measure +incremental change: it is up to the code to keep track of absolute position, a +task which has some pitfalls. [This doc](./encoders/ENCODER.md) discusses this +and points to some solutions in MicroPython code. ## 4.8 Pseudo random number generators diff --git a/encoders/ENCODERS.md b/encoders/ENCODERS.md new file mode 100644 index 0000000..aad15bf --- /dev/null +++ b/encoders/ENCODERS.md @@ -0,0 +1,131 @@ +# Incremental encoders + +There are three technologies that I am aware of: + 1. Optical. + 2. Magnetic. + 3. Mechanical (using switch contacts). + +All produce quadrature signals looking like this: +![Image](./quadrature.jpg) +consequently the same code may be used regardless of encoder type. + +They have two primary applications: control knobs for user input and shaft +position and speed measurements on machines. For user input a mechanical +device, being inexpensive, usually suffices. See +[this Adafruit product](https://www.adafruit.com/product/377). + +In applications such as NC machines longevity and reliability are paramount: +this normally rules out mechanical devices. Rotational speed is also likely to +be too high. In machine tools it is vital to maintain perfect accuracy over +very long periods. This may impact the electronic design of the interface +between the encoder and the host. High precision comes at no cost in code, but +there may be issues in devices with high interrupt latency such as ESP32, +especially with SPIRAM. + +The ideal host, especially for precison applications, is a Pyboard. This is +because Pyboard timers can decode in hardware, as shown +[in this script](https://github.com/dhylands/upy-examples/blob/master/encoder.py) +from Dave Hylands. Hardware decoding eliminates all concerns over interrupt +latency or input pulse rates. + +# Basic encoder script + +This comes from `encoder_portable.py` in this repo. It uses the simplest and +fastest algorithm I know. It should run on any MicrPython platform, but please +read the following notes as there are potential issues. + +```python +from machine import Pin + +class Encoder: + def __init__(self, pin_x, pin_y, scale=1): + self.scale = scale + self.forward = True + self.pin_x = pin_x + self.pin_y = pin_y + self._pos = 0 + try: + self.x_interrupt = pin_x.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.x_callback, hard=True) + self.y_interrupt = pin_y.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.y_callback, hard=True) + except TypeError: + self.x_interrupt = pin_x.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.x_callback) + self.y_interrupt = pin_y.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.y_callback) + + def x_callback(self, pin): + self.forward = pin() ^ self.pin_y() + self._pos += 1 if self.forward else -1 + + def y_callback(self, pin): + self.forward = self.pin_x() ^ pin() ^ 1 + self._pos += 1 if self.forward else -1 + + def position(self, value=None): + if value is not None: + self._pos = value // self.scale + return self._pos * self.scale +``` +If the direction is incorrect, transpose the X and Y pins in the constructor +call. + +# Problem 1: Interrupt latency + +By default, pin interrupts defined using the `machine` module are soft. This +introduces latency if a line changes state when a garbage collection is in +progress. The above script attempts to use hard IRQ's, but not all platforms +support them (notably ESP8266 and ESP32). + +Hard IRQ's present their own issues documented +[here](https://docs.micropython.org/en/latest/reference/isr_rules.html) but +the above script conforms with these rules. + +# Problem 2: Jitter + +The picture above is idealised. In practice it is possible to receive a +succession of edges on one input line, with no transitions on the other. On +mechanical encoders this may be caused by +[contact bounce](http://www.ganssle.com/debouncing.htm). On any type it can +result from vibration, where the encoder happens to stop at an angle exactly +matching an edge. Code must be designed to accommodate this. The above sample +does this. It is possible that the above latency issue may cause pulses to be +missed, notably on platforms which don't support hard IRQ's. In such cases +hardware may need to be adapted to limit the rate at which signals can change, +possibly with a CR low pass filter and a schmitt trigger. This clearly won't +work if the pulse rate from actual shaft rotation exceeds this limit. + +# Problem 3: Concurrency + +The presented code samples use interrupts in order to handle the potentially +high rate at which transitions can occur. The above script maintains a +position value `._pos` which can be queried at any time. This does not present +concurrency issues. However some applications, notably in user interface +designs, may require an encoder action to trigger complex behaviour. The +obvious solution would be to adapt the script to do this by having the two ISR +methods call a function. However the function would run in an interrupt context +which (even with soft IRQ's) presents concurrency issues where an application's +data can change at any point in the application's execution. Further, a complex +function would cause the ISR to block for a long period with the potential for +data loss. + +A solution to this is an interface between the ISR's and `uasyncio` whereby the +ISR's set a `ThreadSafeFlag`. This is awaited by a `uasyncio` `Task` which runs +a user supplied callback. The latter runs in a `uasyncio` context: the state of +any `Task` can only change at times when it has yielded to the scheduler in +accordance with `uasyncio` rules. This is implemented in +[this asynchronous driver](https://github.com/peterhinch/micropython-async/blob/master/v3/docs/DRIVERS.md#6-quadrature-encoders). +This also handles the case where a mechanical encoder has a large number of +states per revolution. The driver has the option to divide these down, reducing +the rate at which callbacks occur. + +# Code samples + + 1. `encoder_portable.py` Suitable for most purposes. + 2. `encoder_timed.py` Provides rate information by timing successive edges. In + practice this is likely to need filtering to reduce jitter caused by + imperfections in the encoder geometry. With a mechanical knob turned by an + anthropoid ape it's debatable whether it produces anything useful :) + 3. `encoder.py` An old Pyboard-specific version. + +These were written for encoders producing logic outputs. For switches, adapt +the pull definition to provide a pull up or pull down as required, or provide +physical resistors. This is my preferred solution as the internal resistors on +most platforms have a rather high value. diff --git a/encoders/encoder.py b/encoders/encoder.py index 8bca507..0843dd7 100644 --- a/encoders/encoder.py +++ b/encoders/encoder.py @@ -1,6 +1,11 @@ +# encoder.py + +# Copyright (c) 2016-2021 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file + import pyb -class Encoder(object): +class Encoder: def __init__(self, pin_x, pin_y, reverse, scale): self.reverse = reverse self.scale = scale diff --git a/encoders/encoder_portable.py b/encoders/encoder_portable.py index 87202d9..525ca42 100644 --- a/encoders/encoder_portable.py +++ b/encoders/encoder_portable.py @@ -1,32 +1,36 @@ +# encoder_portable.py + # Encoder Support: this version should be portable between MicroPython platforms -# Thanks to Evan Widloski for the adaptation to the machine module +# Thanks to Evan Widloski for the adaptation to use the machine module + +# Copyright (c) 2017-2021 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file from machine import Pin -class Encoder(object): - def __init__(self, pin_x, pin_y, reverse, scale): - self.reverse = reverse +class Encoder: + def __init__(self, pin_x, pin_y, scale=1): self.scale = scale self.forward = True self.pin_x = pin_x self.pin_y = pin_y self._pos = 0 - self.x_interrupt = pin_x.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.x_callback) - self.y_interrupt = pin_y.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.y_callback) + try: + self.x_interrupt = pin_x.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.x_callback, hard=True) + self.y_interrupt = pin_y.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.y_callback, hard=True) + except TypeError: + self.x_interrupt = pin_x.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.x_callback) + self.y_interrupt = pin_y.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.y_callback) - def x_callback(self, line): - self.forward = self.pin_x.value() ^ self.pin_y.value() ^ self.reverse + def x_callback(self, pin): + self.forward = pin() ^ self.pin_y() self._pos += 1 if self.forward else -1 - def y_callback(self, line): - self.forward = self.pin_x.value() ^ self.pin_y.value() ^ self.reverse ^ 1 + def y_callback(self, pin): + self.forward = self.pin_x() ^ pin() ^ 1 self._pos += 1 if self.forward else -1 - @property - def position(self): + def position(self, value=None): + if value is not None: + self._pos = value // self.scale return self._pos * self.scale - - @position.setter - def position(self, value): - self._pos = value // self.scale - diff --git a/encoders/encoder_timed.py b/encoders/encoder_timed.py index 77dfa78..8095ae3 100644 --- a/encoders/encoder_timed.py +++ b/encoders/encoder_timed.py @@ -1,57 +1,53 @@ -import pyb, utime +# encoder_timed.py -def tdiff(): - new_semantics = utime.ticks_diff(2, 1) == 1 - def func(old, new): - nonlocal new_semantics - if new_semantics: - return utime.ticks_diff(new, old) - return utime.ticks_diff(old, new) - return func +# Copyright (c) 2016-2021 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file -ticksdiff = tdiff() +import utime +from machine import Pin, disable_irq, enable_irq -class EncoderTimed(object): - def __init__(self, pin_x, pin_y, reverse, scale): - self.reverse = reverse - self.scale = scale +class EncoderTimed: + def __init__(self, pin_x, pin_y, scale=1): + self.scale = scale # Optionally scale encoder rate to distance/angle self.tprev = 0 self.tlast = 0 self.forward = True self.pin_x = pin_x self.pin_y = pin_y self._pos = 0 - self.x_interrupt = pyb.ExtInt(pin_x, pyb.ExtInt.IRQ_RISING_FALLING, pyb.Pin.PULL_NONE, self.x_callback) - self.y_interrupt = pyb.ExtInt(pin_y, pyb.ExtInt.IRQ_RISING_FALLING, pyb.Pin.PULL_NONE, self.y_callback) + try: + self.x_interrupt = pin_x.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.x_callback, hard=True) + self.y_interrupt = pin_y.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.y_callback, hard=True) + except TypeError: + self.x_interrupt = pin_x.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.x_callback) + self.y_interrupt = pin_y.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=self.y_callback) def x_callback(self, line): - self.forward = self.pin_x.value() ^ self.pin_y.value() ^ self.reverse + self.forward = self.pin_x.value() ^ self.pin_y.value() self._pos += 1 if self.forward else -1 self.tprev = self.tlast self.tlast = utime.ticks_us() def y_callback(self, line): - self.forward = self.pin_x.value() ^ self.pin_y.value() ^ self.reverse ^ 1 + self.forward = self.pin_x.value() ^ self.pin_y.value() ^ 1 self._pos += 1 if self.forward else -1 self.tprev = self.tlast self.tlast = utime.ticks_us() - @property - def rate(self): # Return rate in edges per second - self.x_interrupt.disable() - self.y_interrupt.disable() - if ticksdiff(self.tlast, utime.ticks_us) > 2000000: # It's stopped + def rate(self): # Return rate in signed distance/angle per second + state = disable_irq() + tlast = self.tlast # Cache current values + tprev = self.tprev + enable_irq(state) + if utime.ticks_diff(utime.ticks_us(), tlast) > 2_000_000: # It's stopped result = 0.0 else: - result = 1000000.0/(ticksdiff(self.tprev, self.tlast)) - self.x_interrupt.enable() - self.y_interrupt.enable() + result = 1000000.0/(utime.ticks_diff(tlast, tprev)) result *= self.scale return result if self.forward else -result - @property def position(self): - return self._pos*self.scale + return self._pos * self.scale def reset(self): self._pos = 0 diff --git a/encoders/quadrature.jpg b/encoders/quadrature.jpg new file mode 100644 index 0000000..fbe831e Binary files /dev/null and b/encoders/quadrature.jpg differ