pimoroni / pimoroni-pico

Libraries and examples to support Pimoroni Pico add-ons in C++ and MicroPython.
https://shop.pimoroni.com/collections/pico
MIT License
1.31k stars 495 forks source link

Servo doesn't respect limits from calibration #885

Closed ClintH closed 9 months ago

ClintH commented 10 months ago

Context

Pimoroni Servo 2040 board with Pimoroni's v1.21.0 MicroPython and some MG90S servos.

I'm using ServoCluster of four servos, passing in a default calibration. Printing out the calibration later (via servos.calibration(index)) shows that the calibration was stored properly, and also that that the limits are enabled.

Eg, setting a angular range of -10 to 10, I get:

Calibration(size = 3, pairs = {{500.0, -10.0}, {1500.0, 0.0}, {2500.0, 10.0}}, limit_lower = True, limit_upper = True)

...which seems to be correct.

I'm new to the library, so it could be that I'm not using the calibration feature properly.

Problem

all_to_min,all_to_max, all_to_percent, to_percent do not seem to respect the limit, and instead moves at its full range. Trying different angular limits makes no apparent difference to range of movement.

If the limits are meant to be hard limits to prevent damage, they ought to respect the limits.

What else has been tried

Rather than pass in a calibration when initialising the ServoCluster, I also tried looping over the pins, getting the calibration, calling first_value & last_value with the min and max angles before setting the calibration back again. This did not seem to make a difference, despite the calibration seeming to be saved correctly.

Code

import gc
from servo import ServoCluster, servo2040, Calibration, ANGULAR

gc.collect()

START_PIN = servo2040.SERVO_1
END_PIN = servo2040.SERVO_4

pins = list(range(START_PIN, END_PIN + 1))

EXTENT = 10
defaultCalibration = Calibration()
defaultCalibration.apply_default_pairs(ANGULAR)
defaultCalibration.first_value(-EXTENT)
defaultCalibration.last_value(EXTENT)

servos = ServoCluster(pio=0, sm=0, pins=pins, calibration=defaultCalibration)

# Confirm calibration
for j in pins:
    print(f'Servo: {j} Min: {servos.min_value(j)} Max: {servos.max_value(j)}')
    print(servos.calibration(j))
    print('')

# Example output:
# Servo: 0 Min: -10.0 Max: 10.0
# Calibration(size = 3, pairs = {{500.0, -10.0}, {1500.0, 0.0}, {2500.0, 10.0}}, limit_lower = True, limit_upper = True)

servos.enable_all()
time.sleep(2)

# Go to min (this does not respect calibration)
servos.all_to_min() 
time.sleep(2)

# Go to max (this does not respect calibration)
servos.all_to_max()
time.sleep(2)

# Same outcome as all_to_min, does not respect calibration
servos.all_to_percent(0)
time.sleep(2)

# Disable the servos
servos.disable_all()
ZodiusInfuser commented 9 months ago

Hi @ClintH ,

Apologies for the delay in replying. I've been on holiday break (technically still am) and have only had chance check issues relevant to my specific libraries now.

Thanks for the thorough issue report. Looking at what you have tried, It looks to me like confusion in how to set up the limits, rather than a code bug itself. Let me elaborate.

With standard servos a pulse width of 500uS corresponds to -90, and 2500uS corresponds to +90. When you changed your calibration to this:

Calibration(size = 3, pairs = {{500.0, -10.0}, {1500.0, 0.0}, {2500.0, 10.0}}, limit_lower = True, limit_upper = True)

you were only telling the Servo class to think those pulse widths are -10 and +10 degrees. The servo will still move to -90 and +90. This is why all_to_min() and all_to_max() appear to go beyond the limits.

To fix this, in addition to setting the first and last values as you did, you also need to set the first and last pulses. These can either be pulses you have arrived at experimentally (aka, what pulse width gives me the output angle I want), or calculate (((500 - 1500) / 90) * X) + 1500 for the low end, and (((2500 - 1500) / 90) * X) + 1500 for the high end (where X is your new angle). In the case of 10 degrees, these would be 1389 and 1611. This should result in a calibration printout like:

Calibration(size = 3, pairs = {{1389, -10.0}, {1500.0, 0.0}, {1611, 10.0}}, limit_lower = True, limit_upper = True)

I suspect I did not do a great job of explaining the calibration feature in our docs (and most people don't use it anyway), so if there is anything you can suggest to improve it, let me know.

ClintH commented 9 months ago

No worries, and thanks for getting back to me on your holidays :)

I should have more clearly articulated my underlying need as well. I am controlling 16 servos, and ideally, when I set them all to a relative value of 0...1 they move to the same position. Mechanically, they are all set up the same, but it does require some per-servo calibration so they are in fact lining up.

The 0...1 scale I'd like to be on a custom angle range, say -10 to 10 degrees. This is so a) the rest of the code doesn't need to know about the correct range or per-servo calibrations and b) because the full range of movement would cause mechanical damage.

In the docs it talks about restricting angular movement ('Below is an example that creates an angular servo that can only travel from -45 degrees to 45 degrees ... if the servo turning beyond those values would cause damage to whatever mechanism it is driving') and later it seems to imply a link between functions like all_to_min() and the calibration. In addition, the calibration class has min_value and max_value parameters which further supported this link.

The way I (wrongly) understood the calibration pulse parameters was it was determining the relation of angular degrees to pulse. i.e allowing you to dial in 'move to 45 deg' actually goes to 45 deg, but had nothing to do with limits. I understood the angular calibration parameters as being limits which were 'on top of' the pulse parameters, restricting the allowed range of calibrated movement (in degrees). It seemed logical this would follow through to all_to_min() et al. That's why I thought I could leave the pulse parameters alone and just set the angular limits with first_value and last_value.

If I understand your reply, if I want to achieve my aim of restricted physical travel and per-servo adjustment for slight differences, I ought to set the pulse count and angle limit together, where the pulse counts represent the same angle limits I'm using. And I should just ignore the relative movement stuff since that doesn't follow the limits?

Thanks!

ZodiusInfuser commented 9 months ago

No worries, and thanks for getting back to me on your holidays :)

You're welcome

I should have more clearly articulated my underlying need as well. I am controlling 16 servos, and ideally, when I set them all to a relative value of 0...1 they move to the same position. Mechanically, they are all set up the same, but it does require some per-servo calibration so they are in fact lining up.

That makes sense. Sadly no two servos will go to the exact same mechanical angle when given the same pulse width. I've had to calibrate the servos on walking robots before to get the correct angles, which is why I made calibration a key feature of this library.

The 0...1 scale I'd like to be on a custom angle range, say -10 to 10 degrees. This is so a) the rest of the code doesn't need to know about the correct range or per-servo calibrations and b) because the full range of movement would cause mechanical damage.

For that the to_percent function is likely the best way to go.

In the docs it talks about restricting angular movement ('Below is an example that creates an angular servo that can only travel from -45 degrees to 45 degrees ... if the servo turning beyond those values would cause damage to whatever mechanism it is driving') and later it seems to imply a link between functions like all_to_min() and the calibration. In addition, the calibration class has min_value and max_value parameters which further supported this link.

Yes, and reading that again, it is all still valid. You may not have noticed though in that -45 to +45 degree example that the pulse values were also changed. min_value will return the minimum angular calibration parameter, and all_to_min() will move the servos to their min_value. Similarly for max_value.

The way I (wrongly) understood the calibration pulse parameters was it was determining the relation of angular degrees to pulse. i.e allowing you to dial in 'move to 45 deg' actually goes to 45 deg,

Your understanding is correct, though I think treating the parameters as separate rather than linked pairs is part of the confusion. The calibration maps an arbitrary number of angular calibration parameters to calibration pulse parameters, forming a calibration pair. So for 45 degrees you would have a pair like {{2000, 45.0}. This is saying "45 degrees is a pulse width of 2000uS" as well as the reverse, as the calibration mapping works both ways.

but had nothing to do with limits. I understood the angular calibration parameters as being limits which were 'on top of' the pulse parameters, restricting the allowed range of calibrated movement (in degrees). It seemed logical this would follow through to all_to_min() et al. That's why I thought I could leave the pulse parameters alone and just set the angular limits with first_value and last_value.

The limits are whatever your minimum and maximum calibration pairs are. If it was just the angular calibration parameter, that would make no sense on any middle calibration pairs

If I understand your reply, if I want to achieve my aim of restricted physical travel and per-servo adjustment for slight differences, I ought to set the pulse count and angle limit together, where the pulse counts represent the same angle limits I'm using.

That is correct. Calibrate your servos for the mechanical range they can safely perform. So with your -10 to +10 degrees, work out what pulse parameters each servo needs to get to those angles, then pipe them into your calibration.

And I should just ignore the relative movement stuff since that doesn't follow the limits?

That is incorrect. Every servo operation obeys the limits whilst limit_lower and upper_limit are true.

To achieve your relative 0 to 1 movement, use the .to_percent() function once the servos are calibrated. 0 corresponds to the min_value of your calibration, 1 corresponds to the max_value of your calibration. If you need to do a range less than that, there are additional parameters that function can take https://github.com/pimoroni/pimoroni-pico/tree/main/micropython/modules/servo#control-by-percent-1. For example if you calibrated your servo from -45 to +45, but wanted -10 to +10, you could call all_to_percent(percent, value_min=-10, value_max=+10). You could even change your input range to be anything other than 0 to 1, using the parameters in_min and in_max.

I hope that clears things up, but let me know if not. In any case I am confident the library can do what you wish :)

ZodiusInfuser commented 9 months ago

To achieve your relative 0 to 1 movement, use the .to_percent() function once the servos are calibrated. 0 corresponds to the min_value of your calibration, 1 corresponds to the max_value of your calibration. If you need to do a range less than that, there are additional parameters that function can take https://github.com/pimoroni/pimoroni-pico/tree/main/micropython/modules/servo#control-by-percent-1. For example if you calibrated your servo from -45 to +45, but wanted -10 to +10, you could call all_to_percent(percent, value_min=-10, value_max=+10). You could even change your input range to be anything other than 0 to 1, using the parameters in_min and in_max.

Alternatively, cut out the middle step and make your calibration be from 0 to 1. E.g.

Calibration(size = 2, pairs = {{1389, 0.0}, {1611, 1.0}}, limit_lower = True, limit_upper = True)

This is why the code refers to angle as value, because there are types of servos (continuous rotation, linear actuator) where an angle makes no sense.

ClintH commented 9 months ago

Wonderful, thank you very much! That last tip is real useful!