|
| 1 | +# PWM testing |
| 2 | + |
| 3 | +This directory contains tools for testing PWM behavior in CircuitPython. |
| 4 | + |
| 5 | +1. [CircuitPython PWM test code](code.py) is run on the board to be tested. |
| 6 | +2. As the code runs, the state of the PWM signal is logged by a logic analyzer (here Saleae Logic Pro 8). |
| 7 | +3. Data collected by the logic analyzer is analyzed and plotted into a PNG image by [CPython code](duty.py). |
| 8 | + |
| 9 | +Here is a sample plot with key features annotated: |
| 10 | + |
| 11 | +<img src="pwm_plot.explainer.png"> |
| 12 | + |
| 13 | + |
| 14 | + |
| 15 | +<details open> |
| 16 | +<summary> |
| 17 | + |
| 18 | +# PWM expected behavior |
| 19 | + |
| 20 | +</summary> |
| 21 | + |
| 22 | +(1) A PWM signal has a period (defined as the time between rising edges) and a duty cycle (defined as the ratio of the time between a rising edge and the next falling edge, divided by the period). In a typical application the PWM period will be constant while the duty cycle changes frequently. |
| 23 | + |
| 24 | +(2) An exception to (1) is that CircuitPython explicitly supports duty cycles of 0% and 100%, where the signal stays constant at a low/high level. In the CP API duty cycle is always specified as a 16-bit value, where 0 corresponds to 0%, 0xFFFF corresponds to 100%, and values in between scale accordingly. |
| 25 | + |
| 26 | +(3) While in the PWM API the duty cycle is specified as a 16-bit value and the period is specified by a 32-bit frequency value, a given processor may not be able to provide a signal with that precision, but will do its best to approximate what is asked for to the best of its ability. The actual PWM duty and frequency settings resulting from the requested parameters can be obtained from the API. |
| 27 | + |
| 28 | +(4) The user can set the duty cycle and frequency (if initialized with `variable_frequency=True`) at any time. Changes in duty cycle and frequency should appear in the PWM signal as soon as possible after the set function is invoked. |
| 29 | + |
| 30 | +(5) Changes in duty cycle should ideally never result in output glitches - that is, the duty cycle of output PWM should never take on a value which has not been set by the user. |
| 31 | + |
| 32 | +(6) Changes in frequency may (and will usually) result in a transient glitch in duty cycle. PWM hardware is generally not designed for glitch-free frequency transitions. |
| 33 | + |
| 34 | +(7) PWM frequency and duty cycle should be jitter-free. |
| 35 | + |
| 36 | +(8) Setting of PWM frequency and duty cycle should be non-blocking operations which return control to the caller as soon as possible. |
| 37 | + |
| 38 | +(9) As a corollary to (2), PWM settings of 0 and 0xFFFF should be the ONLY settings which result in always low/always high PWM. |
| 39 | + Other settings should always provide an oscillating signal. |
| 40 | +</details> |
| 41 | + |
| 42 | +# Test method |
| 43 | + |
| 44 | +To test all of the elements of expected behavior listed above, I exercised the PWM API and captured the resulting output signal on a logic analyzer (Seleae Logic Pro 8). Logic analyzer data was then analyzed and plotted. |
| 45 | + |
| 46 | +Here is the CP code. It loops through a list of PWM frequencies, staying at each frequency for a specified interval, typically 1 second. At each frequency it repeatedly cycles through a list of duty cycles in a tight loop, updating the duty cycle as frequently as it is able. |
| 47 | +For the tests I show here, I used a set of 13 frequencies logarithmically spaced between 1000 Hz and 10 MHz, and duty cycles of 1/9, 2/9, 3/9, 4/9, 5/9, 6/9, 7/9, 8/9, selected to stay away from "special numbers" like 0, 65535, and powers of two. |
| 48 | +<details> |
| 49 | +<summary> CircuitPython code</summary> |
| 50 | + |
| 51 | +```python |
| 52 | +import board |
| 53 | +import pwmio |
| 54 | +import random |
| 55 | +import time |
| 56 | +import microcontroller |
| 57 | +import os |
| 58 | +import sys |
| 59 | + |
| 60 | +cr10 = 10**(1/3) |
| 61 | + |
| 62 | +freqs = [int(f) for f in [1e3, 1e3*cr10, 1e3*cr10*cr10, |
| 63 | + 1e4, 1e4*cr10, |
| 64 | + 1e4*cr10*cr10, |
| 65 | + 1e5, 1e5*cr10, 1e5*cr10*cr10, |
| 66 | + 1e6, 1e6*cr10, 1e6*cr10*cr10, |
| 67 | + 10000000]] |
| 68 | + |
| 69 | +top = 65536 |
| 70 | +duties = [int(top * frac) for frac in [ 1/9, 8/9, 2/9, 7/9, 3/9, 6/9, 4/9, 5/9 ]] |
| 71 | +# duties = [int(top * frac) for frac in [ 1/9 ]] |
| 72 | + |
| 73 | +freq_duration = 1.0 |
| 74 | +duty_duration = 0.00000001 |
| 75 | +start_duty = int(65535*0.1) |
| 76 | + |
| 77 | +print('\n\n') |
| 78 | +board_name = sys.implementation[2] |
| 79 | + |
| 80 | +pins = {"RP2040-Zero": (("GP15", ""),), |
| 81 | + "Grand Central": (("D51", "TCC"), ("A15", "TC")), |
| 82 | + "Metro M0": (("A2", "TC"), ("A3", "TCC")), |
| 83 | + "ESP32-S3-DevKit": (("IO6", ""),), # marked D5 on board for XIAO-ESP32-s3 |
| 84 | + "XIAO nRF52840": (("D9", ""),), |
| 85 | + } |
| 86 | + |
| 87 | +for board_key in pins: |
| 88 | + if board_key in board_name: |
| 89 | + pins_to_use = pins[board_key] |
| 90 | + break |
| 91 | + |
| 92 | +while True: |
| 93 | + for pin_name, pin_type in pins_to_use: |
| 94 | + pin = getattr(board, pin_name) |
| 95 | + print('title="', end="") |
| 96 | + print(f"{board_name} at {microcontroller.cpu.frequency} Hz, pin {pin_name}", end="") |
| 97 | + if len(pin_type) > 0: |
| 98 | + print(f" ({pin_type})", end="") |
| 99 | + print('",') |
| 100 | + print(f'subtitle="{freq_duration:0.1f}s per frequency",') |
| 101 | + print(f'version="{sys.version}",') |
| 102 | + print('freq_calls=', end="") |
| 103 | + pwm = pwmio.PWMOut(pin, variable_frequency=True) |
| 104 | + t0 = time.monotonic() |
| 105 | + duty_time = t0 + duty_duration |
| 106 | + print('(', end='') |
| 107 | + for freq in freqs: |
| 108 | + i = 0 |
| 109 | + try: |
| 110 | + pwm.frequency = freq |
| 111 | + except ValueError: |
| 112 | + break; |
| 113 | + pwm.duty_cycle = start_duty |
| 114 | + freq_time = t0 + freq_duration |
| 115 | + duty_time = t0 + duty_duration |
| 116 | + while time.monotonic() < freq_time: |
| 117 | + for duty in duties: |
| 118 | + while time.monotonic() < duty_time and time.monotonic() < freq_time: |
| 119 | + pass |
| 120 | + pwm.duty_cycle = duty |
| 121 | + duty_time += duty_duration |
| 122 | + i += 1 |
| 123 | + if time.monotonic() > freq_time: |
| 124 | + break |
| 125 | + t0 = freq_time |
| 126 | + print(f'({freq}, {i/freq_duration:.0f}), ', end='') |
| 127 | + print(')') |
| 128 | + print('done.') |
| 129 | + pwm.deinit() |
| 130 | + time.sleep(5) |
| 131 | +``` |
| 132 | +</details> |
| 133 | + |
| 134 | +Here is the Python code used to analyze the captured logic analyzer data (typically ~300MB). The plots generated display the frequency and duty cycle of every captured PWM cycle (typically 16M points). |
| 135 | + |
| 136 | +<details> |
| 137 | +<summary>Data analysis code</summary> |
| 138 | + |
| 139 | +```python |
| 140 | +import math |
| 141 | +import numpy as np |
| 142 | +from PIL import Image |
| 143 | +import matplotlib.pyplot as plt |
| 144 | +from PIL import Image |
| 145 | +from PIL import ImageFont |
| 146 | +from PIL import ImageDraw |
| 147 | + |
| 148 | +def read(filename, image_filename=None, height=500, width=500, f_min=100, f_max=1e8, title='', subtitle='', version='', |
| 149 | + duty_labels=(0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9), freq_calls=tuple(), margin=0.01, |
| 150 | + duty_color = (255, 0, 0), freq_color = (0, 255, 0), calls_color = (0, 255, 255), title_color = (255, 255, 255)): |
| 151 | + """Read a one channel logic analyzer raw csv data file and generate a plot visualizing the PWM signal |
| 152 | + captured in the file. Each line of the file is a <time, level> pair indicating the times (in seconds) |
| 153 | + at which the signal transitioned to that level. For example: |
| 154 | + 1.726313020,0 |
| 155 | + 1.726313052,1 |
| 156 | + 1.726313068,0 |
| 157 | + 1.727328804,1 |
| 158 | + """ |
| 159 | + rising_edge = None |
| 160 | + falling_edge = None |
| 161 | + pixels = np.zeros((height, width, 3), dtype=np.uint8) * 255 |
| 162 | + t0 = None |
| 163 | + t1 = None |
| 164 | + with open(filename, 'r') as f: |
| 165 | + for line in f: |
| 166 | + t, val = line.split(',') |
| 167 | + try: |
| 168 | + t = float(t) |
| 169 | + val = int(val) |
| 170 | + except ValueError: |
| 171 | + val=None |
| 172 | + if val is not None: |
| 173 | + if t0 is None or t<t0: |
| 174 | + t0 = t |
| 175 | + if t1 is None or t>t1: |
| 176 | + t1 = t |
| 177 | + with open(filename, 'r') as f: |
| 178 | + pts = 0 |
| 179 | + f_log_max = int(math.log10(f_max)) |
| 180 | + f_log_min = int(math.log10(f_min)) |
| 181 | + f_log_span = f_log_max - f_log_min |
| 182 | + for line in f: |
| 183 | + t, val = line.split(',') |
| 184 | + try: |
| 185 | + t = float(t) |
| 186 | + val = int(val) |
| 187 | + except ValueError: |
| 188 | + val=None |
| 189 | + if val==1: |
| 190 | + if falling_edge is not None and rising_edge is not None: |
| 191 | + period = t - rising_edge |
| 192 | + frequency = 1/period |
| 193 | + duty_cycle = (falling_edge - rising_edge) / period |
| 194 | + x = int(width * (t - t0)/(t1 - t0)) |
| 195 | + y_duty = int((1 - duty_cycle) * height) |
| 196 | + y_freq = int(height * (1 - (math.log10(frequency) - f_log_min) / f_log_span)) |
| 197 | + x = max(0, min(x, width - 1)) |
| 198 | + y_duty = max(0, min(y_duty, height - 1)) |
| 199 | + y_freq = max(0, min(y_freq, height - 1)) |
| 200 | + pixels[y_duty, x] = duty_color |
| 201 | + pixels[y_freq, x] = freq_color |
| 202 | + pts += 1 |
| 203 | + rising_edge = t |
| 204 | + elif val==0: |
| 205 | + falling_edge = t |
| 206 | + image = Image.fromarray(pixels) |
| 207 | + draw = ImageDraw.Draw(image) |
| 208 | + draw.text((width*margin, height * (1 - margin)), 'Duty cycle', duty_color, anchor='lb') |
| 209 | + draw.text((width * 0.5, height * (1 - margin)), 'Call throughput', calls_color, anchor='mb') |
| 210 | + draw.text((width*(1-margin), height * (1 - margin)), 'PWM frequency', freq_color, anchor='rb') |
| 211 | + |
| 212 | + for duty in duty_labels: |
| 213 | + draw.text((width*margin, height * (1 - duty)), f'{int(100*duty):d}%', duty_color, anchor='lm') |
| 214 | + for exponent in range(f_log_min+1, f_log_max): |
| 215 | + draw.text((width*(1-margin), height * (1 - (exponent - f_log_min) / (f_log_span))), |
| 216 | + str(10**exponent) + ' Hz', freq_color, anchor='rm') |
| 217 | + for freq, count in freq_calls: |
| 218 | + draw.text((width * 0.5, height * (1 - (math.log10(freq) - f_log_min) / (f_log_span))), |
| 219 | + f'{count} Hz', calls_color, anchor='mm') |
| 220 | + subtitle += f', showing {pts} PWM cycles' |
| 221 | + draw.text((width*0.5, height * margin), title, title_color, anchor='mm') |
| 222 | + draw.text((width*0.5, height * 4 * margin), version, title_color, anchor='mm') |
| 223 | + draw.text((width*0.5, height * 8 * margin), subtitle, title_color, anchor='mm') |
| 224 | + image.show() |
| 225 | + if image_filename is not None: |
| 226 | + image.save(image_filename) |
| 227 | + return image |
| 228 | +``` |
| 229 | +</details> |
| 230 | + |
| 231 | +The plots this generates are pretty dense. Here's one, measured on pin 51 of a Metro M4 Grand Central board, annotated to show what is going on: |
| 232 | + |
| 233 | +<img src="https://github.com/timchinowsky/circuitpython/assets/5445541/a374d6a9-77ea-4541-933b-f775b5fa0f6a" /> |
| 234 | + |
| 235 | +The PWM performance here looks pretty great except for the thing that got me started on this in the first place - the call to set the PWM duty cycle blocks for a time equal to the PWM period. |
| 236 | + |
| 237 | +# Measurements on samd21/51 port |
| 238 | + |
| 239 | +Investigation of PWM on the CP samd21/51 port is complicated by the port's inclusion of two different processor families, each of which have two different types of PWM peripherals (TC and TCC). Moreover, the TC peripheral for the samd21 is not identical to that of the samd51, as is hinted at by these datasheet excerpts: |
| 240 | + |
| 241 | +<details> |
| 242 | +<summary>Datasheet summaries of samd21 and samd51 TC and TCC peripherals</summary> |
| 243 | +<img src="https://github.com/timchinowsky/circuitpython/assets/5445541/ba9b38a0-72b1-47b5-a457-8cd8325b1c7a" /> |
| 244 | +</details> |
| 245 | + |
| 246 | +The description of TC for the samd51 notably adds mention of double-buffering, while the samd21/51 descriptions for TCC are the same. |
| 247 | + |
| 248 | +For samd21 and samd51 boards, TC and TCC were chosen by selecting pins which support only one or the other. Here the pins are outlined in red: |
| 249 | + |
| 250 | +<details> |
| 251 | +<summary>Pins implementing TC and TCC on samd21/51</summary> |
| 252 | +<img src="https://github.com/timchinowsky/circuitpython/assets/5445541/ca7baa7d-d1be-4b72-922e-4d5ce918be15" /> |
| 253 | +</details> |
| 254 | + |
| 255 | +Here is an analysis of data gathered from each of the four types of PWM peripheral using the software described above: |
| 256 | + |
| 257 | +| | samd21 | samd51 | |
| 258 | +| ------ | ----------- | ---------- | |
| 259 | +| TC |  |  | |
| 260 | +| TCC |  |  | |
| 261 | + |
| 262 | +Key findings: |
| 263 | + |
| 264 | +* Data from both samd51 peripherals looks good, except for the blocking issue (variation in execution time with PWM frequency) |
| 265 | +* Data from samd21 TCC looks good, except for errant frequency measurements at the top two frequencies (pointed out with white arrow). Unlike samd51, execution time does not depend on PWM frequency. |
| 266 | +* Data from samd21 TC has a lot of weirdness at all frequencies. |
| 267 | + |
| 268 | +# Code analysis |
| 269 | + |
| 270 | + |
| 271 | + |
| 272 | + |
| 273 | + |
| 274 | + |
| 275 | + |
| 276 | + |
| 277 | +, https://github.com/adafruit/circuitpython/issues/1644 and to port `audiopwmio` to samd21/51. |
| 278 | + |
| 279 | + |
| 280 | + |
| 281 | +<details> |
| 282 | +<summary>Datasheet excerpts</summary> |
| 283 | +<img src="https://github.com/timchinowsky/circuitpython/assets/5445541/50651bef-e2a2-4a6b-8982-f5adad296f5b" /> |
| 284 | +</details> |
0 commit comments