Skip to content

Commit cbd5131

Browse files
authored
Merge pull request #9343 from timchinowsky/fix-samd-pwm
Fix delays and rounding in samd PWM
2 parents 3645e81 + d35c2e3 commit cbd5131

File tree

6 files changed

+283
-14
lines changed

6 files changed

+283
-14
lines changed

ports/atmel-samd/common-hal/pwmio/PWMOut.c

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -249,41 +249,34 @@ extern void common_hal_pwmio_pwmout_set_duty_cycle(pwmio_pwmout_obj_t *self, uin
249249
// Track it here so that if frequency is changed we can use this value to recalculate the
250250
// proper duty cycle.
251251
// See https://github.com/adafruit/circuitpython/issues/2086 for more details
252-
self->duty_cycle = duty;
253252

253+
self->duty_cycle = duty;
254254
const pin_timer_t *t = self->timer;
255255
if (t->is_tc) {
256256
uint16_t adjusted_duty = tc_periods[t->index] * duty / 0xffff;
257+
if (adjusted_duty == 0 && duty != 0) {
258+
adjusted_duty = 1; // prevent rounding down to 0
259+
}
257260
#ifdef SAMD21
258261
tc_insts[t->index]->COUNT16.CC[t->wave_output].reg = adjusted_duty;
259262
#endif
260263
#ifdef SAM_D5X_E5X
261264
Tc *tc = tc_insts[t->index];
262-
while (tc->COUNT16.SYNCBUSY.bit.CC1 != 0) {
263-
}
264265
tc->COUNT16.CCBUF[1].reg = adjusted_duty;
265266
#endif
266267
} else {
267268
uint32_t adjusted_duty = ((uint64_t)tcc_periods[t->index]) * duty / 0xffff;
269+
if (adjusted_duty == 0 && duty != 0) {
270+
adjusted_duty = 1; // prevent rounding down to 0
271+
}
268272
uint8_t channel = tcc_channel(t);
269273
Tcc *tcc = tcc_insts[t->index];
270-
271-
// Write into the CC buffer register, which will be transferred to the
272-
// CC register on an UPDATE (when period is finished).
273-
// Do clock domain syncing as necessary.
274-
275-
while (tcc->SYNCBUSY.reg != 0) {
276-
}
277-
278-
// Lock out double-buffering while updating the CCB value.
279-
tcc->CTRLBSET.bit.LUPD = 1;
280274
#ifdef SAMD21
281275
tcc->CCB[channel].reg = adjusted_duty;
282276
#endif
283277
#ifdef SAM_D5X_E5X
284278
tcc->CCBUF[channel].reg = adjusted_duty;
285279
#endif
286-
tcc->CTRLBCLR.bit.LUPD = 1;
287280
}
288281
}
289282

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# PWM testing
2+
3+
This directory contains tools for testing CircuitPython's PWM API. Running the tests involves three steps:
4+
5+
1. [CircuitPython PWM test code `code.py`](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 (I used a 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`](duty.py).
8+
9+
Here is a sample plot with key features annotated:
10+
11+
<img src="pwm_plot_explainer.png">
12+
13+
The CircuitPython code loops through a list of PWM frequencies ranging from 100 Hz to 10 MHz, staying at each frequency for one second. At each frequency it repeatedly and randomly cycles through a list of duty cycles in a tight loop, updating the duty cycle as frequently as it is able. The captured waveform is analyzed by `duty.py`, which calculates the duration and duty cycle of every observed PWM cycle and plots a point for each.
14+
15+
## PWM expected behavior
16+
17+
These tests can be used to assess how well the PWM API delivers expected behavior, as outlined below:
18+
19+
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.
20+
21+
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.
22+
23+
3. As a corollary to (2), PWM settings of 0 and 0xFFFF should be the ONLY settings which result in always low/always high PWM. Other settings should always result in an oscillating signal.
24+
25+
4. 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. The actual PWM duty and frequency settings resulting from the requested parameters can be obtained from the API.
26+
27+
5. 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 setting function is invoked. The execution time of API calls for setting PWM frequency and duty cycle should be as short as possible and should not depend on the frequency and duty cycle parameters.
28+
29+
6. Changes in duty cycle should ideally never result in output glitches -- that is, the duty cycle of the PWM signal should never take on a value which has not been set by the user.
30+
31+
7. Changes in frequency may (and will usually) result in a transient glitch in frequency and duty cycle. PWM hardware is generally not designed for glitch-free frequency transitions.
32+
33+
8. PWM frequency and duty cycle should be jitter-free.
34+
35+
## Examples of PWM flaws
36+
37+
The plot at the top of this page depicts data PWM gathered from a device with an API that displays all of the expected behavior listed above. The plots below show how the tools reveal flaws in the behavior of PWM APIs that are not as complete.
38+
39+
<img src="pwm_flaw_explainer.png">
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import board
2+
import pwmio
3+
import random
4+
import time
5+
import microcontroller
6+
import os
7+
import sys
8+
import random
9+
10+
exponents = [
11+
2,
12+
2.333,
13+
2.667,
14+
3,
15+
3.333,
16+
3.667,
17+
4,
18+
4.333,
19+
4.667,
20+
5,
21+
5.333,
22+
5.667,
23+
6,
24+
6.333,
25+
6.667,
26+
7,
27+
]
28+
29+
freqs = [int(10**f) for f in exponents]
30+
31+
top = 65536
32+
den = 10
33+
duties = [int(top * num / den) for num in range(1, den)]
34+
duties = [1, 65534, 1] + duties
35+
freq_duration = 1.0
36+
duty_duration = 0.000000001
37+
38+
print("\n\n")
39+
board_name = sys.implementation[2]
40+
41+
pins = {
42+
"RP2040-Zero": (("GP15", ""),),
43+
"Grand Central": (("D51", "TCC"), ("A15", "TC")),
44+
"Metro M0": (("A2", "TC"), ("A3", "TCC")),
45+
"ESP32-S3-DevKit": (("IO6", ""),), # marked D5 on board for XIAO-ESP32-s3
46+
"Feather ESP32-S2": (("D9", ""),),
47+
"XIAO nRF52840": (("D9", ""),),
48+
}
49+
50+
for board_key in pins:
51+
if board_key in board_name:
52+
pins_to_use = pins[board_key]
53+
break
54+
55+
while True:
56+
for pin_name, pin_type in pins_to_use:
57+
pin = getattr(board, pin_name)
58+
print('title="', end="")
59+
print(f"{board_name} at {microcontroller.cpu.frequency} Hz, pin {pin_name}", end="")
60+
if len(pin_type) > 0:
61+
print(f" ({pin_type})", end="")
62+
print('",')
63+
print(f'subtitle="{freq_duration:0.1f}s per frequency",')
64+
print(f'version="{sys.version}",')
65+
print("freq_calls=", end="")
66+
pwm = pwmio.PWMOut(pin, variable_frequency=True)
67+
t0 = time.monotonic()
68+
duty_time = t0 + duty_duration
69+
print("(", end="")
70+
offset = 0
71+
increment = 1
72+
for freq in freqs:
73+
i = 0
74+
try:
75+
pwm.frequency = freq
76+
except ValueError:
77+
break
78+
freq_time = t0 + freq_duration
79+
duty_time = t0 + duty_duration
80+
while time.monotonic() < freq_time:
81+
j = random.randrange(0, len(duties))
82+
duty = duties[j]
83+
if j > 1:
84+
duty = duties[j] + offset
85+
if duty > 65533:
86+
duty -= 65533
87+
pwm.duty_cycle = duty
88+
offset += increment
89+
if offset > 65533:
90+
offset = 0
91+
while time.monotonic() < duty_time and time.monotonic() < freq_time:
92+
pass
93+
duty_time += duty_duration
94+
i += 1
95+
if time.monotonic() > freq_time:
96+
break
97+
t0 = freq_time
98+
print(f"({freq}, {i/freq_duration:.0f}), ", end="")
99+
print(")")
100+
print("done.")
101+
pwm.deinit()
102+
time.sleep(5)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import math
2+
import matplotlib.pyplot as plt
3+
import numpy as np
4+
from PIL import Image
5+
from PIL import Image
6+
from PIL import ImageFont
7+
from PIL import ImageDraw
8+
9+
10+
def read(
11+
filename,
12+
image_filename=None,
13+
height=480,
14+
width=640,
15+
f_min=10,
16+
f_max=1e8,
17+
title="",
18+
subtitle="",
19+
version="",
20+
duty_labels=(0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9),
21+
freq_calls=tuple(),
22+
margin=0.01,
23+
duty_color=(255, 0, 0),
24+
freq_color=(0, 255, 0),
25+
calls_color=(0, 255, 255),
26+
title_color=(255, 255, 255),
27+
):
28+
"""Read a one channel logic analyzer raw csv data file and generate a plot visualizing the PWM signal
29+
captured in the file. Each line of the file is a <time, level> pair indicating the times (in seconds)
30+
at which the signal transitioned to that level. For example:
31+
1.726313020,0
32+
1.726313052,1
33+
1.726313068,0
34+
1.727328804,1
35+
"""
36+
left = 80
37+
right = 80
38+
bottom = 20
39+
top = 60
40+
x0 = left
41+
y0 = top
42+
y1 = height - bottom
43+
x1 = width - right
44+
rising_edge = None
45+
falling_edge = None
46+
pixels = np.zeros((height, width, 3), dtype=np.uint8) * 255
47+
t0 = None
48+
t1 = None
49+
val = None
50+
with open(filename, "r") as f:
51+
first = True
52+
for line in f: # find min and max t, excluding first and last values
53+
if val is not None:
54+
if not first:
55+
if t0 is None or t < t0:
56+
t0 = t
57+
if t1 is None or t > t1:
58+
t1 = t
59+
else:
60+
first = False
61+
t, val = line.split(",")
62+
try:
63+
t = float(t)
64+
val = int(val)
65+
except ValueError:
66+
val = None
67+
print("plotting", t1 - t0, "seconds")
68+
69+
with open(filename, "r") as f:
70+
pts = 0
71+
f_log_max = int(math.log10(f_max))
72+
f_log_min = int(math.log10(f_min))
73+
f_log_span = f_log_max - f_log_min
74+
for line in f:
75+
t, val = line.split(",")
76+
try:
77+
t = float(t)
78+
val = int(val)
79+
except ValueError:
80+
val = None
81+
if val == 1:
82+
if falling_edge is not None and rising_edge is not None:
83+
period = t - rising_edge
84+
frequency = 1 / period
85+
duty_cycle = (falling_edge - rising_edge) / period
86+
x = int((x1 - x0) * (t - t0) / (t1 - t0)) + x0
87+
y_duty = int((1 - duty_cycle) * (y1 - y0)) + y0
88+
y_freq = (
89+
int((y1 - y0) * (1 - (math.log10(frequency) - f_log_min) / f_log_span))
90+
+ y0
91+
)
92+
x = max(x0, min(x, x1 - 1))
93+
y_duty = max(y0, min(y_duty, y1 - 1))
94+
y_freq = max(y0, min(y_freq, y1 - 1))
95+
pixels[y_duty, x] = duty_color
96+
pixels[y_freq, x] = freq_color
97+
pts += 1
98+
rising_edge = t
99+
elif val == 0:
100+
falling_edge = t
101+
image = Image.fromarray(pixels)
102+
draw = ImageDraw.Draw(image)
103+
draw.text((left - 10, top), "Duty", duty_color, anchor="rt")
104+
draw.text((0, top), "Calls", calls_color, anchor="lt")
105+
draw.text((width - right / 2, top), "Freq", freq_color, anchor="mt")
106+
107+
for duty in duty_labels:
108+
draw.text(
109+
(left - 10, y0 + (y1 - y0) * (1 - duty)),
110+
f"{int(100*duty):d}%",
111+
duty_color,
112+
anchor="rm",
113+
)
114+
for exponent in range(f_log_min + 1, f_log_max):
115+
draw.text(
116+
(width - right / 2, y0 + (y1 - y0) * (1 - (exponent - f_log_min) / (f_log_span))),
117+
str(10**exponent) + " Hz",
118+
freq_color,
119+
anchor="mm",
120+
)
121+
for freq, count in freq_calls:
122+
draw.text(
123+
(0, y0 + (y1 - y0) * (1 - (math.log10(freq) - f_log_min) / (f_log_span))),
124+
f"{count} Hz",
125+
calls_color,
126+
anchor="lm",
127+
)
128+
subtitle += f", showing {pts} PWM cycles"
129+
draw.text((width * 0.5, height * margin), title, title_color, anchor="mm")
130+
draw.text((width * 0.5, height * 4 * margin), version, title_color, anchor="mm")
131+
draw.text((width * 0.5, height * 8 * margin), subtitle, title_color, anchor="mm")
132+
image.show()
133+
if image_filename is not None:
134+
image.save(image_filename)
135+
return image
79.5 KB
Loading
230 KB
Loading

0 commit comments

Comments
 (0)