Merge branch 'RazorsFrozenTesting' into RazorsEdge

This commit is contained in:
Paul Guenette
2020-04-23 16:36:01 -04:00
31 changed files with 1533 additions and 4371 deletions
+2
View File
@@ -32,6 +32,8 @@ Please add a note of your changes below this heading if you make a Pull Request.
* Some Encoder settings have been made read-only
* Cleaned up VSCode C/C++ Configuration settings on Windows with recursive includePath
* Now compiling with C++17
* Fixed a firmware hang that could occur from unlikely but possible user input
# Releases
## [0.4.11] - 2019-07-25
### Added
+2 -1
View File
@@ -338,7 +338,7 @@ bool Axis::run_closed_loop_control_loop() {
return true;
});
set_step_dir_active(false);
set_step_dir_active(config_.enable_step_dir && config_.step_dir_always_on);
return check_for_errors();
}
@@ -428,6 +428,7 @@ bool Axis::run_idle_loop() {
// run_control_loop ignores missed modulation timing updates
// if and only if we're in AXIS_STATE_IDLE
safety_critical_disarm_motor_pwm(motor_);
set_step_dir_active(config_.enable_step_dir && config_.step_dir_always_on);
run_control_loop([this]() {
return true;
});
+7 -1
View File
@@ -68,8 +68,14 @@ public:
bool startup_closed_loop_control = false; //<! enable closed loop control after calibration/startup
bool startup_sensorless_control = false; //<! enable sensorless control after calibration/startup
bool startup_homing = false; //<! enable homing after calibration/startup
bool enable_step_dir = false; //<! enable step/dir input after calibration
// For M0 this has no effect if enable_uart is true
bool step_dir_always_on = false; //<! Keep step/dir enabled while the motor is disabled.
//<! This is ignored if enable_step_dir is false.
//<! This setting only takes effect on a state transition
//<! into idle or out of closed loop control.
float counts_per_step = 2.0f;
float watchdog_timeout = 0.0f; // [s] (0 disables watchdog)
@@ -120,7 +126,6 @@ public:
void step_cb();
void set_step_dir_active(bool enable);
void decode_step_dir_pins();
void update_watchdog_settings();
static void load_default_step_dir_pin_config(
const AxisHardwareConfig_t& hw_config, Config_t* config);
@@ -276,6 +281,7 @@ public:
make_protocol_property("startup_sensorless_control", &config_.startup_sensorless_control),
make_protocol_property("startup_homing", &config_.startup_homing),
make_protocol_property("enable_step_dir", &config_.enable_step_dir),
make_protocol_property("step_dir_always_on", &config_.step_dir_always_on),
make_protocol_property("counts_per_step", &config_.counts_per_step),
make_protocol_property("watchdog_timeout", &config_.watchdog_timeout),
make_protocol_property("enable_watchdog", &config_.enable_watchdog),
+6 -7
View File
@@ -54,7 +54,7 @@ void Controller::move_to_pos(float goal_point) {
axis_->trap_.config_.vel_limit,
axis_->trap_.config_.accel_limit,
axis_->trap_.config_.decel_limit);
traj_start_loop_count_ = axis_->loop_counter_;
axis_->trap_.t_ = 0.0f;
trajectory_done_ = false;
}
@@ -160,7 +160,7 @@ bool Controller::update(float* current_setpoint_output) {
float step = std::clamp(full_step, -max_step_size, max_step_size);
vel_setpoint_ += step;
current_setpoint_ = step / current_meas_period * config_.inertia;
current_setpoint_ = (step / current_meas_period) * config_.inertia;
} break;
case INPUT_MODE_CURRENT_RAMP: {
float max_step_size = std::abs(current_meas_period * config_.current_ramp_rate);
@@ -198,10 +198,8 @@ bool Controller::update(float* current_setpoint_output) {
// Avoid updating uninitialized trajectory
if (trajectory_done_)
break;
// Note: uint32_t loop count delta is OK across overflow
// Beware of negative deltas, as they will not be well behaved due to uint!
float t = (axis_->loop_counter_ - traj_start_loop_count_) * current_meas_period;
if (t > axis_->trap_.Tf_) {
if (axis_->trap_.t_ > axis_->trap_.Tf_) {
// Drop into position control mode when done to avoid problems on loop counter delta overflow
config_.control_mode = CTRL_MODE_POSITION_CONTROL;
pos_setpoint_ = input_pos_;
@@ -209,10 +207,11 @@ bool Controller::update(float* current_setpoint_output) {
current_setpoint_ = 0.0f;
trajectory_done_ = true;
} else {
TrapezoidalTrajectory::Step_t traj_step = axis_->trap_.eval(t);
TrapezoidalTrajectory::Step_t traj_step = axis_->trap_.eval(axis_->trap_.t_);
pos_setpoint_ = traj_step.Y;
vel_setpoint_ = traj_step.Yd;
current_setpoint_ = traj_step.Ydd * config_.inertia;
axis_->trap_.t_ += current_meas_period;
}
anticogging_pos = pos_setpoint_; // FF the position setpoint instead of the pos_estimate
} break;
-1
View File
@@ -117,7 +117,6 @@ public:
bool input_pos_updated_ = false;
uint32_t traj_start_loop_count_ = 0;
bool trajectory_done_ = true;
bool anticogging_valid_ = false;
+2
View File
@@ -47,6 +47,8 @@ public:
float Tf_;
float yAccel_;
float t_;
};
#endif
+14 -12
View File
@@ -69,18 +69,6 @@ static const float one_by_sqrt3 = 0.57735026919f;
static const float two_by_sqrt3 = 1.15470053838f;
static const float sqrt3_by_2 = 0.86602540378f;
//beware of inserting large values!
static inline float wrap_pm(float x, float pm_range) {
while (x >= pm_range) x -= (2.0f * pm_range);
while (x < -pm_range) x += (2.0f * pm_range);
return x;
}
//beware of inserting large angles!
static inline float wrap_pm_pi(float theta) {
return wrap_pm(theta, M_PI);
}
// like fmodf, but always positive
static inline float fmodf_pos(float x, float y) {
float out = fmodf(x, y);
@@ -89,6 +77,20 @@ static inline float fmodf_pos(float x, float y) {
return out;
}
/**
* @brief Similar to modulo operator, except that the output range is centered
* around zero.
* The returned value is always in the range [-pm_range, pm_range).
*/
static inline float wrap_pm(float x, float pm_range) {
return fmodf_pos(x + pm_range, 2.0f * pm_range) - pm_range;
}
//beware of inserting large angles!
static inline float wrap_pm_pi(float theta) {
return wrap_pm(theta, M_PI);
}
// Compute rising edge timings (0.0 - 1.0) as a function of alpha-beta
// as per the magnitude invariant clarke transform
// The magnitude of the alpha-beta vector may not be larger than sqrt(3)/2
+1 -1
View File
@@ -131,7 +131,7 @@ Not all parameters can be accessed via the ASCII protocol but at least all param
```
* `property` name of the property, as seen in ODrive Tool
* `value` text representation of the value to be written
* Example: `w axis0.controller.pos_setpoint -123.456`
* Example: `w axis0.controller.input_pos -123.456`
#### System commands:
* `ss` - Save config
+17 -2
View File
@@ -60,6 +60,21 @@ Possible values are:
* `CTRL_MODE_CURRENT_CONTROL`
* `CTRL_MODE_VOLTAGE_CONTROL` - this one is not normally used.
### Input Mode
The default input mode is `INPUT_MODE_PASSTHROUGH`.
Modes can be selected by changing `<axis>.controller.config.input_mode`.
Possible values are:
* `INPUT_MODE_INACTIVE`
* `INPUT_MODE_PASSTHROUGH`
* `INPUT_MODE_VEL_RAMP`
* `INPUT_MODE_POS_FILTER`
* `INPUT_MODE_MIX_CHANNELS`
* `INPUT_MODE_TRAP_TRAJ`
* `INPUT_MODE_CURRENT_RAMP`
* `INPUT_MODE_MIRROR`
For more information, see [input_modes](input_modes.md).
# Control Commands
* `<axis>.controller.input_pos = <encoder_counts>`
* `<axis>.controller.input_vel = <encoder_counts/s>`
@@ -96,7 +111,7 @@ All variables that are part of a `[...].config` object can be saved to non-volat
The ODrive can run without encoder/hall feedback, but there is a minimum speed, usually around a few hunderd RPM.
However the units of this mode is different from when using an encoder. Velocities are not measured in counts/s, instead it is electrical rad/s. This also applies to the gains. For example, `vel_gain` is in units of `A / (rad/s)` instead of `A / (count/s)`.
To give an example, suppose you have a motor with 7 pole pairs, and you want to spin it at 3000 RPM. Then you would set the `vel_setpoint` to `3000 * 2*pi/60 * 7 = 2199 rad/s electrical`.
To give an example, suppose you have a motor with 7 pole pairs, and you want to spin it at 3000 RPM. Then you would set the `input_vel` to `3000 * 2*pi/60 * 7 = 2199 rad/s electrical`.
Below are some suggested starting parameters that you can use. Note that you _must_ set the `pm_flux_linkage` correctly for sensorless mode to work.
@@ -104,7 +119,7 @@ Below are some suggested starting parameters that you can use. Note that you _mu
odrv0.axis0.controller.config.vel_gain = 0.01
odrv0.axis0.controller.config.vel_integrator_gain = 0.05
odrv0.axis0.controller.config.control_mode = 2
odrv0.axis0.controller.vel_setpoint = 400
odrv0.axis0.controller.input_vel = 400
odrv0.axis0.motor.config.direction = 1
odrv0.axis0.sensorless_estimator.config.pm_flux_linkage = 5.51328895422 / (<pole pairs> * <motor kv>)
```
+1 -1
View File
@@ -2,7 +2,7 @@
The motor controller is a cascaded style position, velocity and current control loop, as per the diagram below. When the control mode is set to position control, the whole loop runs. When running in velocity control mode, the position control part is removed and the velocity command is fed directly in to the second stage input. In current control mode, only the current controller is used.
![Cascaded pos vel I loops](https://github.com/madcowswe/ODrive/blob/master/docs/controller_with_ff.png?raw=true)
![Cascaded pos vel I loops](controller_with_ff.png)
Each stage of the control loop is a variation on a [PID controller](https://en.wikipedia.org/wiki/PID_controller). A PID controller is a mathematical model that can be adapted to control a wide variety of systems. This flexibility is essential as it allows the ODrive to be used to control all kinds of mechanical systems.
+1 -1
View File
@@ -70,7 +70,7 @@ If calibration works, congratulations.
Now try:
* `<axis>.requested_state = AXIS_STATE_CLOSED_LOOP_CONTROL`
* `<axis>.controller.set_vel_setpoint(3000,0) `
* `<axis>.controller.input_vel = 3000`
let it loop a few times and then set:
* `<axis>.requested_state = AXIS_STATE_IDLE`
+5 -5
View File
@@ -247,7 +247,7 @@ Let's get motor 0 up and running. The procedure for motor 1 is exactly the same,
</div></details>
2. Type `odrv0.axis0.requested_state = AXIS_STATE_CLOSED_LOOP_CONTROL` <kbd>Enter</kbd>. From now on the ODrive will try to hold the motor's position. If you try to turn it by hand, it will fight you gently. That is unless you bump up `odrv0.axis0.motor.config.current_lim`, in which case it will fight you more fiercely. If the motor begins to vibrate either immediately or after being disturbed you will need to [lower the controller gains](control.md).
3. Send the motor a new position setpoint. `odrv0.axis0.controller.pos_setpoint = 10000` <kbd>Enter</kbd>. The units are in encoder counts.
3. Send the motor a new position setpoint. `odrv0.axis0.controller.input_pos = 10000` <kbd>Enter</kbd>. The units are in encoder counts.
4. At this point you will probably want to [Properly tune](control.md) the motor controller in order to maximize system performance.
## Other control modes
@@ -328,16 +328,16 @@ You can also execute a move with the [appropriate ascii command](ascii-protocol.
To enable Circular position control, set `axis.controller.config.setpoints_in_cpr = True`
This mode is useful for continuos incremental position movement. For example a robot rolling indefinitely, or an extruder motor or conveyor belt moving with controlled increments indefinitely.
In the regular position mode, the `pos_setpoint` would grow to a very large value and would lose precision due to floating point rounding.
In the regular position mode, the `input_pos` would grow to a very large value and would lose precision due to floating point rounding.
In this mode, the controller will try to track the position within only one turn of the motor. Specifically, `pos_setpoint` is expected in the range `[0, cpr-1]`, where `cpr` is the number of encoder counts in one revolution. If the `pos_setpoint` is incremented to outside this range (say via step/dir input), it is automatically wrapped around into the correct value.
In this mode, the controller will try to track the position within only one turn of the motor. Specifically, `input_pos` is expected in the range `[0, cpr-1]`, where `cpr` is the number of encoder counts in one revolution. If the `input_pos` is incremented to outside this range (say via step/dir input), it is automatically wrapped around into the correct value.
Note that in this mode `encoder.pos_cpr` is used for feedback in stead of `encoder.pos_estimate`.
If you try to increment the axis with a large step in one go that exceeds `cpr/2` steps, the motor will go to the same angle around the wrong way. This is also the case if there is a large disturbance. If you have an application where you would like to handle larger steps, you can use a virtual CPR that is an integer times larger than your encoder's actual CPR. Set `encoder.config.cpr = N * your_enc_cpr`, where N is some integer. Choose N to give you an appropriate circular space for your application.
### Velocity control
Set `axis.controller.config.control_mode = CTRL_MODE_VELOCITY_CONTROL`.<br>
You can now control the velocity with `axis.controller.vel_setpoint = 5000` [count/s].
You can now control the velocity with `axis.controller.input_vel = 5000` [count/s].
### Ramped velocity control
Set `axis.controller.config.control_mode = CTRL_MODE_VELOCITY_CONTROL`.<br>
@@ -347,7 +347,7 @@ You can now control the velocity with `axis.controller.input_vel = 5000` [count/
### Current control
Set `axis.controller.config.control_mode = CTRL_MODE_CURRENT_CONTROL`.<br>
You can now control the current with `axis.controller.current_setpoint = 3` [A].
You can now control the current with `axis.controller.input_current = 3` [A].
Note: If you exceed `vel_limit` in current control mode, the current is reduced. To disable this, set `axis.controller.enable_current_vel_limit = False`.
+10 -10
View File
@@ -112,9 +112,9 @@ The ODrive starts in idle (we will look at changing this later) so we can enable
odrv0.save_configuration()
odrv0.reboot()
odrv0.axis0.requested_state = AXIS_STATE_CLOSED_LOOP_CONTROL
odrv0.axis0.controller.vel_setpoint = 120
odrv0.axis0.controller.input_vel = 120
# Your motor should spin here
odrv0.axis0.controller.vel_setpoint = 0
odrv0.axis0.controller.input_vel = 0
odrv0.axis0.requested_state = AXIS_STATE_IDLE
```
@@ -128,31 +128,31 @@ We also have to reboot to activate the PWM input.
```txt
odrv0.config.gpio3_pwm_mapping.min = -200
odrv0.config.gpio3_pwm_mapping.max = 200
odrv0.config.gpio3_pwm_mapping.endpoint = odrv0.axis0.controller._remote_attributes['vel_setpoint']
odrv0.config.gpio3_pwm_mapping.endpoint = odrv0.axis0.controller._remote_attributes['input_vel']
odrv0.config.gpio4_pwm_mapping.min = -200
odrv0.config.gpio4_pwm_mapping.max = 200
odrv0.config.gpio4_pwm_mapping.endpoint = odrv0.axis1.controller._remote_attributes['vel_setpoint']
odrv0.config.gpio4_pwm_mapping.endpoint = odrv0.axis1.controller._remote_attributes['input_vel']
odrv0.save_configuration()
odrv0.reboot()
```
Now we can check that the sticks are writing to the velocity setpoint. Move the stick, print `vel_setpoint`, move to a different position, check again.
Now we can check that the sticks are writing to the velocity setpoint. Move the stick, print `input_vel`, move to a different position, check again.
```txt
In [1]: odrv0.axis1.controller.vel_setpoint
In [1]: odrv0.axis1.controller.input_vel
Out[1]: 0.1904754638671875
In [2]: odrv0.axis1.controller.vel_setpoint
In [2]: odrv0.axis1.controller.input_vel
Out[2]: 0.1904754638671875
In [3]: odrv0.axis1.controller.vel_setpoint
In [3]: odrv0.axis1.controller.input_vel
Out[3]: 28.152389526367188
In [4]: odrv0.axis1.controller.vel_setpoint
In [4]: odrv0.axis1.controller.input_vel
Out[4]: 61.21905517578125
In [5]: odrv0.axis1.controller.vel_setpoint
In [5]: odrv0.axis1.controller.input_vel
Out[5]: -52.990474700927734
```
+113
View File
@@ -0,0 +1,113 @@
# Input Modes
As of version ###, ODrive now intercepts the incoming commands and can apply filters to them. The old protocol values `pos_setpoint`, `vel_setpoint`, and `current_setpoint` are still used internally by the closed-loop cascade control, but the user cannot write to them directly. This allows us to condense the number of ways the ODrive accepts motion commands. The new commands are:
* `<axis>.controller.config.input_mode`
* `<axis>.controller.input_pos`
* `<axis>.controller.input_vel`
* `<axis>.controller.input_current`
The Input Modes currently valid are:
* `INPUT_MODE_INACTIVE`
* `INPUT_MODE_PASSTHROUGH`
* `INPUT_MODE_VEL_RAMP`
* `INPUT_MODE_POS_FILTER`
* `INPUT_MODE_MIX_CHANNELS`
* `INPUT_MODE_TRAP_TRAJ`
* `INPUT_MODE_CURRENT_RAMP`
* `INPUT_MODE_MIRROR`
---
## INPUT_MODE_INACTIVE
Disable inputs. Setpoints retain their last value.
## INPUT_MODE_PASSTHROUGH
Pass `input_xxx` through to `xxx_setpoint` directly.
### Valid Inputs:
* `input_pos`
* `input_vel`
* `input_current`
### Valid Control modes:
* `CTRL_MODE_VOLTAGE_CONTROL`
* `CTRL_MODE_CURRENT_CONTROL`
* `CTRL_MODE_VELOCITY_CONTROL`
* `CTRL_MODE_POSITION_CONTROL`
## INPUT_MODE_VEL_RAMP
Ramps a velocity command from the current value to the target value.
### Configuration Values:
* `<axis>.controller.config.vel_ramp_rate` [cpr/sec]
* `<axis>.controller.config.inertia` [A/(count/s^2))]
### Valid inputs:
* `input_vel`
### Valid Control Modes:
* `CTRL_MODE_VELOCITY_CONTROL`
## INPUT_MODE_POS_FILTER
Implements a 2nd order position tracking filter. Inteded for use with step/dir interface, but can also be used with position-only commands.
![POS Filter Response](secondOrderResponse.png)
Result of a step command from 1000 to 0
### Configuration Values:
* `<axis>.controller.config.input_filter_bandwidth`
* `<axis>.controller.config.inertia`
### Valid inputs:
* `input_pos`
### Valid Control modes:
* `CTRL_MODE_POSITION_CONTROL`
## INPUT_MODE_MIX_CHANNELS
Not Implemented.
## INPUT_MODE_TRAP_TRAJ
Implementes an online trapezoidal trajectory planner.
![Trapezoidal Planner Response](TrapTrajPosVel.png)
### Configuration Values:
* `<axis>.trap_traj.config.vel_limit`
* `<axis>.trap_traj.config.accel_limit`
* `<axis>.trap_traj.config.decel_limit`
* `<axis>.controller.config.inertia`
### Valid Inputs:
* `input_pos`
### Valid Control Modes:
* `CTRL_MODE_POSITION_CONTROL`
## INPUT_MODE_CURRENT_RAMP
Ramp a current command from the current value to the target value.
### Configuration Values:
* `<axis>.controller.config.current_ramp_rate`
### Valid Inputs:
* `input_current`
### Valid Control Modes:
* `CTRL_MODE_CURRENT_CONTROL`
## INPUT_MODE_MIRROR
Implements "electronic mirroring". This is like electronic camming, but you can only mirror exactly the movements of the other motor, according to a fixed ratio
[![](http://img.youtube.com/vi/D4_vBtyVVzM/0.jpg)](http://www.youtube.com/watch?v=D4_vBtyVVzM "Example Mirroring Video")
### Configuration Values
* `<axis>.controller.config.axis_to_mirror`
* `<axis>.controller.config.mirror_ratio`
### Valid Inputs
* None. Inputs are taken directly from the other axis encoder estimates
### Valid Control modes
* `CTRL_MODE_POSITION_CONTROL`
+2 -2
View File
@@ -115,13 +115,13 @@ Some GPIO pins can be used for PWM input, if they are not allocated to other fun
Any of the numerical parameters that are writable from the ODrive Tool can be hooked up to a PWM input.
As an example, we'll configure GPIO4 to control the angle of axis 0. We want the axis to move within a range of -1500 to 1500 encoder counts.
1. Make sure you're able control the axis 0 angle by writing to `odrv0.axis0.controller.pos_setpoint`. If you need help with this follow the [getting started guide](getting-started.md).
1. Make sure you're able control the axis 0 angle by writing to `odrv0.axis0.controller.input_pos`. If you need help with this follow the [getting started guide](getting-started.md).
2. If you want to control your ODrive with the PWM input without using anything else to activate the ODrive, you can configure the ODrive such that axis 0 automatically goes operational at startup. See [here](commands.md#startup-procedure) for more information.
3. In ODrive Tool, configure the PWM input mapping
```
In [1]: odrv0.config.gpio4_pwm_mapping.min = -1500
In [2]: odrv0.config.gpio4_pwm_mapping.max = 1500
In [3]: odrv0.config.gpio4_pwm_mapping.endpoint = odrv0.axis0.controller._remote_attributes['pos_setpoint']
In [3]: odrv0.config.gpio4_pwm_mapping.endpoint = odrv0.axis0.controller._remote_attributes['input_pos']
```
Note: you can disable the input by setting `odrv0.config.gpio4_pwm_mapping.endpoint = None`
4. Save the configuration and reboot
-1
View File
@@ -1 +0,0 @@
test
+123
View File
@@ -0,0 +1,123 @@
import test_runner
import time
import math
import os
import numpy as np
from odrive.enums import errors
from test_runner import *
teensy_code_template = """
void setup() {
analogWriteResolution(10);
// base clock of the PWM timer is 150MHz (on Teensy 4.0)
int freq = 150000000/1024; // ~146.5kHz PWM frequency
analogWriteFrequency({analog_out}, freq);
// for filtering, assuming we have a 150 Ohm resistor, we need a capacitor of
// 1/(150000000/1024)*2*pi/150 = 2.85954744646751e-07 F, that's ~0.33uF
//pinMode({lpf_enable}, OUTPUT);
}
int i = 0;
void loop() {
i++;
i = i & 0x3ff;
if (digitalRead({analog_reset}))
i = 0;
analogWrite({analog_out}, i);
delay(1);
}
"""
class TestAnalogInput():
"""
Verifies the Analog input.
The Teensy generates a PWM signal with a duty cycle that follows a sawtooth signal
with a period of 1 second. The signal should be connected to the ODrive's
analog input through a low-pass-filter.
___ ___
Teensy PWM ----|___|-------o---------|___|----- ODrive Analog Input
150 Ohm | 150 Ohm
===
| 330nF
|
GND
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for odrive_gpio_num, odrive_gpio in [(2, odrive.gpio3), (3, odrive.gpio4)]:
analog_out_options = []
lpf_gpio = [gpio for lpf in testrig.get_connected_components(odrive_gpio, LowPassFilterComponent)
for gpio in testrig.get_connected_components(lpf.en, LinuxGpioComponent)]
for teensy_gpio in testrig.get_connected_components(odrive_gpio, TeensyGpio):
teensy = teensy_gpio.parent
analog_reset_options = []
for gpio in teensy.gpios:
for local_gpio in testrig.get_connected_components(gpio, LinuxGpioComponent):
analog_reset_options.append((gpio, local_gpio))
analog_out_options.append((teensy, teensy_gpio, analog_reset_options))
yield (odrive, lpf_gpio, odrive_gpio_num, analog_out_options)
def run_test(self, odrive: ODriveComponent, lpf_enable: LinuxGpioComponent, analog_in_num: int, teensy: TeensyComponent, teensy_analog_out: Component, teensy_analog_reset: Component, analog_reset_gpio: LinuxGpioComponent, logger: Logger):
code = teensy_code_template.replace("{analog_out}", str(teensy_analog_out.num)).replace("{analog_reset}", str(teensy_analog_reset.num)) #.replace("lpf_enable", str(lpf_enable.num))
teensy.compile_and_program(code)
analog_reset_gpio.config(output=True)
analog_reset_gpio.write(True)
lpf_enable.config(output=True)
lpf_enable.write(False)
logger.debug("Set up analog input...")
min_val = -20000
max_val = 20000
period = 1.025 # period in teensy code is 1s, but due to tiny overhead it's a bit longer
analog_mapping = [
None, #odrive.handle.config.gpio1_analog_mapping,
None, #odrive.handle.config.gpio2_analog_mapping,
odrive.handle.config.gpio3_analog_mapping,
odrive.handle.config.gpio4_analog_mapping,
None, #odrive.handle.config.gpio5_analog_mapping,
][analog_in_num]
odrive.unuse_gpios()
analog_mapping.endpoint = odrive.handle.axis0.controller._remote_attributes['input_pos']
analog_mapping.min = min_val
analog_mapping.max = max_val
odrive.save_config_and_reboot()
logger.debug("Recording log...")
data = []
start = time.monotonic()
analog_reset_gpio.write(False)
while time.monotonic() - start < 5.0:
data.append((
time.monotonic() - start,
odrive.handle.axis0.controller.input_pos
))
data = np.array(data)
# Expect mean error to be at most 2% (of the full scale).
# Expect there to be less than 2% outliers, where an outlier is anything that is more than 5% (of full scale) away from the expected value.
full_range = abs(max_val - min_val)
slope, offset, fitted_curve = fit_sawtooth(data, min_val, max_val)
test_assert_eq(slope, (max_val - min_val) / period, accuracy=0.005)
test_curve_fit(data, fitted_curve, max_mean_err = full_range * 0.02, inlier_range = full_range * 0.05, max_outliers = len(data[:,0]) * 0.02)
if __name__ == '__main__':
test_runner.run(TestAnalogInput())
+74 -45
View File
@@ -6,12 +6,9 @@ from math import pi
import os
from fibre.utils import Logger
from test_runner import AxisTestContext, MotorTestContext, EncoderTestContext, test_assert_eq, test_assert_no_error, request_state, program_teensy
from test_runner import *
from odrive.enums import *
def modpm(val, range):
return ((val + (range / 2)) % range) - (range / 2)
class TestMotorCalibration():
"""
@@ -19,14 +16,19 @@ class TestMotorCalibration():
and checks if the measurements match the expectation.
"""
def is_compatible(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext):
return axis_ctx.yaml == motor_ctx.yaml['name'] # check if connected
def get_test_cases(self, testrig: TestRig):
"""Returns all axes that are connected to a motor, along with the corresponding motor(s)"""
for odrive in testrig.get_components(ODriveComponent):
for axis in odrive.axes:
for motor in testrig.get_connected_components(axis, MotorComponent):
yield (axis, motor)
def run_test(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext, logger: Logger):
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, logger: Logger):
# reset old calibration values
axis_ctx.handle.motor.config.phase_resistance = 0.0
axis_ctx.handle.motor.config.phase_inductance = 0.0
axis_ctx.handle.motor.config.pre_calibrated = False
axis_ctx.handle.config.enable_watchdog = False
axis_ctx.handle.clear_errors()
@@ -47,10 +49,14 @@ class TestDisconnectedMotorCalibration():
Tests if the motor calibration fails as expected if the phases are floating.
"""
def is_compatible(self, axis_ctx: AxisTestContext):
return axis_ctx.yaml == 'floating'
def get_test_cases(self, testrig: TestRig):
"""Returns all axes that are disconnected"""
for odrive in testrig.get_components(ODriveComponent):
for axis in odrive.axes:
if axis.yaml == 'floating':
yield (axis,)
def run_test(self, axis_ctx: AxisTestContext, logger: Logger):
def run_test(self, axis_ctx: ODriveAxisComponent, logger: Logger):
axis = axis_ctx.handle
# reset old calibration values
@@ -73,14 +79,21 @@ class TestEncoderDirFind():
Runs the encoder index search.
"""
def is_compatible(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext, enc_ctx: EncoderTestContext):
return (axis_ctx.yaml == motor_ctx.yaml['name']) and (axis_ctx.num == enc_ctx.num) # check if connected
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
def run_test(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext, enc_ctx: EncoderTestContext, logger: Logger):
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
axis = axis_ctx.handle
# TODO: read teensy config from YAML file
hexfile = 'encoder_pass_through.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
time.sleep(1.0) # wait for PLLs to stabilize
# Set motor calibration values
@@ -110,14 +123,21 @@ class TestEncoderOffsetCalibration():
Runs the encoder index search.
"""
def is_compatible(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext, enc_ctx: EncoderTestContext):
return (axis_ctx.yaml == motor_ctx.yaml['name']) and (axis_ctx.num == enc_ctx.num) # check if connected
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
def run_test(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext, enc_ctx: EncoderTestContext, logger: Logger):
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, logger: Logger):
axis = axis_ctx.handle
# TODO: read teensy config from YAML file
hexfile = 'encoder_pass_through.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
time.sleep(1.0) # wait for PLLs to stabilize
# Set motor calibration values
@@ -127,9 +147,9 @@ class TestEncoderOffsetCalibration():
# Set calibration settings
axis_ctx.handle.motor.config.direction = 0
enc_ctx.handle.config.use_index = False
enc_ctx.handle.config.calib_scan_omega = 12.566 # 2 electrical revolutions per second
enc_ctx.handle.config.calib_scan_distance = 50.265 # 8 revolutions
axis_ctx.handle.encoder.config.use_index = False
axis_ctx.handle.encoder.config.calib_scan_omega = 12.566 # 2 electrical revolutions per second
axis_ctx.handle.encoder.config.calib_scan_distance = 50.265 # 8 revolutions
axis_ctx.handle.clear_errors()
@@ -141,7 +161,7 @@ class TestEncoderOffsetCalibration():
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
test_assert_eq(enc_ctx.handle.is_ready, True)
test_assert_eq(axis_ctx.handle.encoder.is_ready, True)
test_assert_eq(axis_ctx.handle.motor.config.direction in [-1, 1], True)
@@ -152,14 +172,27 @@ class TestEncoderIndexSearch():
host's GPIO.
"""
def is_compatible(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext, enc_ctx: EncoderTestContext):
return (axis_ctx.yaml == motor_ctx.yaml['name']) and (axis_ctx.num == enc_ctx.num) # check if connected
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for num in range(2):
encoders = testrig.get_connected_components({
'a': (odrive.encoders[num].a, False),
'b': (odrive.encoders[num].b, False)
}, EncoderComponent)
motors = testrig.get_connected_components(odrive.axes[num], MotorComponent)
z_gpio = list(testrig.get_connected_components((odrive.encoders[num].z, False), LinuxGpioComponent))
def run_test(self, axis_ctx: AxisTestContext, motor_ctx: MotorTestContext, enc_ctx: EncoderTestContext, logger: Logger):
for motor, encoder in itertools.product(motors, encoders):
if encoder.impl in testrig.get_connected_components(motor):
yield (odrive.axes[num], motor, encoder, z_gpio)
def run_test(self, axis_ctx: ODriveAxisComponent, motor_ctx: MotorComponent, enc_ctx: EncoderComponent, z_gpio: LinuxGpioComponent, logger: Logger):
axis = axis_ctx.handle
# TODO: read teensy config from YAML file
hexfile = 'encoder_pass_through.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
cpr = int(enc_ctx.yaml['cpr'])
z_gpio.config(output=True)
z_gpio.write(False)
time.sleep(1.0) # wait for PLLs to stabilize
# Set motor calibration values
@@ -177,24 +210,20 @@ class TestEncoderIndexSearch():
time.sleep(3)
test_assert_eq(enc_ctx.handle.index_found, False)
with open("/sys/class/gpio/gpio{}/direction".format(20), "w") as fp:
fp.write("out")
with open("/sys/class/gpio/gpio{}/value".format(20), "w") as gpio:
gpio.write("0")
test_assert_eq(axis_ctx.handle.encoder.index_found, False)
time.sleep(0.1)
with open("/sys/class/gpio/gpio{}/value".format(20), "w") as gpio:
gpio.write("1")
test_assert_eq(enc_ctx.handle.index_found, True)
z_gpio.write(True)
test_assert_eq(axis_ctx.handle.encoder.index_found, True)
z_gpio.write(False)
test_assert_eq(axis_ctx.handle.current_state, AXIS_STATE_IDLE)
test_assert_no_error(axis_ctx)
test_assert_eq(enc_ctx.handle.shadow_count, 0.0, range=20)
test_assert_eq(enc_ctx.handle.count_in_cpr, 0.0, range=20)
test_assert_eq(enc_ctx.handle.pos_estimate, 0.0, range=20)
test_assert_eq(enc_ctx.handle.pos_cpr, 0.0, range=20)
test_assert_eq(enc_ctx.handle.pos_abs, 0.0, range=20)
test_assert_eq(axis_ctx.handle.encoder.shadow_count, 0.0, range=20)
test_assert_eq(modpm(axis_ctx.handle.encoder.count_in_cpr, cpr), 0.0, range=20)
test_assert_eq(axis_ctx.handle.encoder.pos_estimate, 0.0, range=20)
test_assert_eq(axis_ctx.handle.encoder.pos_cpr, 0.0, range=20)
test_assert_eq(axis_ctx.handle.encoder.pos_abs, 0.0, range=20)
if __name__ == '__main__':
+9 -5
View File
@@ -7,8 +7,8 @@ import asyncio
import time
from fibre.utils import Logger
from odrive.enums import errors
from test_runner import CANTestContext, ODriveTestContext, test_assert_eq
from odrive.enums import *
from test_runner import *
# Each argument is described as tuple (name, format, scale).
# Struct format codes: https://docs.python.org/2/library/struct.html
@@ -81,10 +81,12 @@ async def request(bus, node_id, cmd_name, timeout = 1.0):
class TestSimpleCAN():
def is_compatible(self, canbus: CANTestContext, odrive: ODriveTestContext):
return canbus.yaml['bus'] == odrive.yaml['can'] # check if connected
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
can_interfaces = testrig.get_connected_components(odrive.can, CanInterfaceComponent)
yield (odrive, list(can_interfaces))
def run_test(self, canbus: CANTestContext, odrive: ODriveTestContext, logger: Logger):
def run_test(self, odrive: ODriveComponent, canbus: CanInterfaceComponent, logger: Logger):
node_id = 0
axis = odrive.handle.axis0
axis.config.can_node_id = node_id
@@ -139,11 +141,13 @@ class TestSimpleCAN():
test_assert_eq(axis.controller.input_vel, 2.0, range=0.01)
test_assert_eq(axis.controller.input_current, 3.0, range=0.001)
axis.controller.config.control_mode = CTRL_MODE_VELOCITY_CONTROL
my_cmd('set_input_vel', input_vel=-10.0, cur_ff=30.1234)
fence()
test_assert_eq(axis.controller.input_vel, -10.0, range=0.01)
test_assert_eq(axis.controller.input_current, 30.1234, range=0.01)
axis.controller.config.control_mode = CTRL_MODE_CURRENT_CONTROL
my_cmd('set_input_current', input_current=3.1415)
fence()
test_assert_eq(axis.controller.input_current, 3.1415, range=0.01)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+258 -59
View File
@@ -6,79 +6,278 @@ from math import pi
import os
from fibre.utils import Logger
from test_runner import EncoderTestContext, test_assert_eq, program_teensy
from odrive.enums import *
from test_runner import *
def modpm(val, range):
return ((val + (range / 2)) % range) - (range / 2)
class TestIncrementalEncoder():
teensy_code_template = """
void setup() {
pinMode({enc_a}, OUTPUT);
pinMode({enc_b}, OUTPUT);
}
def is_compatible(self, enc_ctx: EncoderTestContext):
return True
int cpr = 8192;
int rpm = 30;
def run_delta_test(self, encoder, true_cps, with_cpr):
encoder.config.cpr = with_cpr
// the loop routine runs over and over again forever:
void loop() {
int microseconds_per_count = (1000000 * 60 / cpr / rpm);
for i in range(100):
now = time.monotonic()
new_shadow_count = encoder.shadow_count
new_count_in_cpr = encoder.count_in_cpr
new_phase = encoder.phase
new_pos_estimate = encoder.pos_estimate
new_pos_cpr = encoder.pos_cpr
for (;;) {
digitalWrite({enc_a}, HIGH);
delayMicroseconds(microseconds_per_count);
digitalWrite({enc_b}, HIGH);
delayMicroseconds(microseconds_per_count);
digitalWrite({enc_a}, LOW);
delayMicroseconds(microseconds_per_count);
digitalWrite({enc_b}, LOW);
delayMicroseconds(microseconds_per_count);
}
}
"""
if i > 0:
dt = now - before
test_assert_eq((new_shadow_count - last_shadow_count) / dt, true_cps, accuracy = 0.05)
test_assert_eq(modpm(new_count_in_cpr - last_count_in_cpr, with_cpr) / dt, true_cps, accuracy = 0.3)
#test_assert_eq(modpm(new_phase - last_phase, 2*pi) / dt, 2*pi*true_rps, accuracy = 0.1)
test_assert_eq((new_pos_estimate - last_pos_estimate) / dt, true_cps, accuracy = 0.3)
test_assert_eq(modpm(new_pos_cpr - last_pos_cpr, with_cpr) / dt, true_cps, accuracy = 0.3)
test_assert_eq(encoder.vel_estimate, true_cps, accuracy = 0.05)
before = now
last_shadow_count = new_shadow_count
last_count_in_cpr = new_count_in_cpr
last_phase = new_phase
last_pos_estimate = new_pos_estimate
last_pos_cpr = new_pos_cpr
time.sleep(0.01)
teensy_code_template2 = """
void setup() {
analogWriteResolution(10);
int freq = 150000000/1024; // ~146.5kHz PWM frequency
analogWriteFrequency({enc_sin}, freq);
analogWriteFrequency({enc_cos}, freq);
}
def run_test(self, enc_ctx: EncoderTestContext, logger: Logger):
int rpm = 60;
float pos = 0;
void loop() {
pos += 0.001f * ((float)rpm / 60.0f);
if (pos > 1.0f)
pos -= 1.0f;
analogWrite({enc_sin}, (int)(512.0f + 512.0f * sin(2.0f * M_PI * pos)));
analogWrite({enc_cos}, (int)(512.0f + 512.0f * cos(2.0f * M_PI * pos)));
delay(1);
}
"""
teensy_code_template3 = """
void setup() {
pinMode({hall_a}, OUTPUT);
pinMode({hall_b}, OUTPUT);
pinMode({hall_c}, OUTPUT);
digitalWrite({hall_a}, HIGH);
}
int cpr = 90; // 15 pole-pairs. Value suggested in hoverboard.md
int rpm = 60;
int microseconds_per_count = (1000000 * 60 / cpr / rpm);
void loop() {
digitalWrite({hall_b}, HIGH);
delayMicroseconds(microseconds_per_count);
digitalWrite({hall_a}, LOW);
delayMicroseconds(microseconds_per_count);
digitalWrite({hall_c}, HIGH);
delayMicroseconds(microseconds_per_count);
digitalWrite({hall_b}, LOW);
delayMicroseconds(microseconds_per_count);
digitalWrite({hall_a}, HIGH);
delayMicroseconds(microseconds_per_count);
digitalWrite({hall_c}, LOW);
delayMicroseconds(microseconds_per_count);
}
"""
class TestEncoderBase():
"""
Base class for encoder tests.
TODO: incremental encoder doesn't use this yet.
All encoder tests expect the encoder to run at a constant velocity.
This can be achieved by generating an encoder signal with a Teensy.
During 5 seconds, several variables are recorded and then compared against
the expected waveform. This is either a straight line, a sawtooth function
or a constant.
"""
def run_generic_encoder_test(self, encoder, true_cpr, true_rps):
encoder.config.cpr = true_cpr
true_cps = true_cpr * true_rps
logger.debug("Recording log...")
data = []
start = time.monotonic()
encoder.set_linear_count(0) # prevent numerical errors
while time.monotonic() - start < 5.0:
data.append((
time.monotonic() - start,
encoder.shadow_count,
encoder.count_in_cpr,
encoder.phase,
encoder.pos_estimate,
encoder.pos_cpr,
encoder.vel_estimate,
))
data = np.array(data)
short_period = (abs(1 / true_rps) < 5.0)
reverse = (true_rps < 0)
# encoder.shadow_count
slope, offset, fitted_curve = fit_line(data[:,(0,1)])
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,1)], fitted_curve, max_mean_err = true_cpr * 0.01, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.count_in_cpr
slope, offset, fitted_curve = fit_sawtooth(data[:,(0,2)], true_cpr if reverse else 0, 0 if reverse else true_cpr)
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,2)], fitted_curve, max_mean_err = true_cpr * 0.01, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.phase
slope, offset, fitted_curve = fit_sawtooth(data[:,(0,3)], pi if reverse else -pi, -pi if reverse else pi, sigma=5)
test_assert_eq(slope / 7, 2*pi*true_rps, accuracy=0.01)
test_curve_fit(data[:,(0,3)], fitted_curve, max_mean_err = true_cpr * 0.01, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.pos_estimate
slope, offset, fitted_curve = fit_line(data[:,(0,4)])
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,4)], fitted_curve, max_mean_err = true_cpr * 0.01, inlier_range = true_cpr * 0.02, max_outliers = len(data[:,0]) * 0.02)
# encoder.pos_cpr
slope, offset, fitted_curve = fit_sawtooth(data[:,(0,5)], true_cpr if reverse else 0, 0 if reverse else true_cpr)
test_assert_eq(slope, true_cps, accuracy=0.005)
test_curve_fit(data[:,(0,5)], fitted_curve, max_mean_err = true_cpr * 0.05, inlier_range = true_cpr * 0.05, max_outliers = len(data[:,0]) * 0.02)
# encoder.vel_estimate
slope, offset, fitted_curve = fit_line(data[:,(0,6)])
test_assert_eq(slope, 0.0, range = true_cpr * abs(true_rps) * 0.01)
test_assert_eq(offset, true_cpr * true_rps, accuracy = 0.01)
test_curve_fit(data[:,(0,6)], fitted_curve, max_mean_err = true_cpr * 0.05, inlier_range = true_cpr * 0.05, max_outliers = len(data[:,0]) * 0.02)
class TestIncrementalEncoder(TestEncoderBase):
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for encoder in odrive.encoders:
# Find the Teensy that is connected to the encoder pins and the corresponding Teensy GPIOs
gpio_conns = [
testrig.get_directly_connected_components(encoder.a),
testrig.get_directly_connected_components(encoder.b),
]
valid_combinations = [
(combination[0].parent,) + tuple(combination)
for combination in itertools.product(*gpio_conns)
if ((len(set(c.parent for c in combination)) == 1) and isinstance(combination[0].parent, TeensyComponent))
]
yield (encoder, valid_combinations)
def run_test(self, enc: EncoderComponent, teensy: TeensyComponent, teensy_gpio_a: int, teensy_gpio_b: int, logger: Logger):
true_cps = 8192*-0.5 # counts per second generated by the virtual encoder
# TODO: read teensy config from YAML file
if enc_ctx.num == 0:
hexfile = 'enc0_sim_-4096cps.ino.hex'
code = teensy_code_template.replace("{enc_a}", str(teensy_gpio_a.num)).replace("{enc_b}", str(teensy_gpio_b.num))
teensy.compile_and_program(code)
if enc.handle.config.mode != ENCODER_MODE_INCREMENTAL:
enc.handle.config.mode = ENCODER_MODE_INCREMENTAL
enc.parent.save_config_and_reboot()
else:
hexfile = 'enc1_sim_-4096cps.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
time.sleep(1.0) # wait for PLLs to stabilize
time.sleep(1.0) # wait for PLLs to stabilize
encoder = enc_ctx.handle
enc.handle.config.bandwidth = 1000
# The true encoder count and PLL output should be roughly the same.
# At 8192 CPR and 0.5 RPM, the delta because of sequential reading is
# around 3.25 counts. The exact value depends on the connection.
# The tracking error of the PLL is below 1 count.
logger.debug("testing with 8192 CPR...")
self.run_generic_encoder_test(enc.handle, 8192, true_cps / 8192)
logger.debug("testing with 65536 CPR...")
self.run_generic_encoder_test(enc.handle, 65536, true_cps / 65536)
enc.handle.config.cpr = 8192
#logger.debug("check if count_in_cpr == pos_cpr")
#configured_cpr = 8192
#encoder.config.cpr = configured_cpr
#expected_delta = true_cps/1200
#for _ in range(1000):
# first = enc_ctx.handle.axis0.encoder.count_in_cpr
# second = enc_ctx.handle.axis0.encoder.pos_cpr
# test_assert_eq(modpm(second - first, configured_cpr), expected_delta, range=abs(true_cps/500))
# time.sleep(0.001)
logger.debug("check if variables move at the correct velocity (8192 CPR)...")
self.run_delta_test(encoder, true_cps, 8192)
logger.debug("check if variables move at the correct velocity (65536 CPR)...")
self.run_delta_test(encoder, true_cps, 65536)
encoder.config.cpr = 8192
class TestSinCosEncoder(TestEncoderBase):
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
gpio_conns = [
testrig.get_directly_connected_components(odrive.gpio3),
testrig.get_directly_connected_components(odrive.gpio4),
]
valid_combinations = [
(combination[0].parent,) + tuple(combination)
for combination in itertools.product(*gpio_conns)
if ((len(set(c.parent for c in combination)) == 1) and isinstance(combination[0].parent, TeensyComponent))
]
yield (odrive.encoders[0], valid_combinations)
def run_test(self, enc: EncoderComponent, teensy: TeensyComponent, teensy_gpio_sin: TeensyGpio, teensy_gpio_cos: TeensyGpio, logger: Logger):
code = teensy_code_template2.replace("{enc_sin}", str(teensy_gpio_sin.num)).replace("{enc_cos}", str(teensy_gpio_cos.num))
teensy.compile_and_program(code)
if enc.handle.config.mode != ENCODER_MODE_SINCOS:
enc.parent.unuse_gpios()
enc.handle.config.mode = ENCODER_MODE_SINCOS
enc.parent.save_config_and_reboot()
else:
time.sleep(1.0) # wait for PLLs to stabilize
enc.handle.config.bandwidth = 100
self.run_generic_encoder_test(enc.handle, 6283, 1.0)
class TestHallEffectEncoder(TestEncoderBase):
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
for encoder in odrive.encoders:
# Find the Teensy that is connected to the encoder pins and the corresponding Teensy GPIOs
gpio_conns = [
testrig.get_directly_connected_components(encoder.a),
testrig.get_directly_connected_components(encoder.b),
testrig.get_directly_connected_components(encoder.z),
]
valid_combinations = [
(combination[0].parent,) + tuple(combination)
for combination in itertools.product(*gpio_conns)
if ((len(set(c.parent for c in combination)) == 1) and isinstance(combination[0].parent, TeensyComponent))
]
yield (encoder, valid_combinations)
def run_test(self, enc: EncoderComponent, teensy: TeensyComponent, teensy_gpio_a: int, teensy_gpio_b: int, teensy_gpio_c: int, logger: Logger):
true_cpr = 90
true_rps = -1.0
code = teensy_code_template3.replace("{hall_a}", str(teensy_gpio_a.num)).replace("{hall_b}", str(teensy_gpio_b.num)).replace("{hall_c}", str(teensy_gpio_c.num))
teensy.compile_and_program(code)
if enc.handle.config.mode != ENCODER_MODE_HALL:
enc.handle.config.mode = ENCODER_MODE_HALL
enc.parent.save_config_and_reboot()
else:
time.sleep(1.0) # wait for PLLs to stabilize
enc.handle.config.bandwidth = 100
self.run_generic_encoder_test(enc.handle, true_cpr, true_rps)
enc.handle.config.cpr = 8192
if __name__ == '__main__':
test_runner.run(TestIncrementalEncoder())
test_runner.run([
TestIncrementalEncoder(),
TestSinCosEncoder(),
TestHallEffectEncoder(),
])
+10 -9
View File
@@ -7,17 +7,18 @@ import os
import fibre
from fibre.utils import Logger
from test_runner import ODriveTestContext, test_assert_eq
from test_runner import *
class TestStoreAndReboot():
"""
Stores the current configuration to NVM and reboots.
"""
def is_compatible(self, odrive: ODriveTestContext):
return True
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
yield (odrive,)
def run_with_values(self, values, odrive: ODriveTestContext, logger: Logger):
def run_with_values(self, odrive: ODriveComponent, values: list, logger: Logger):
logger.debug("storing configuration and rebooting...")
for value in values:
@@ -31,15 +32,15 @@ class TestStoreAndReboot():
odrive.handle = None
time.sleep(2)
odrive.make_available(logger)
odrive.prepare(logger)
logger.debug("verifying configuration after reboot...")
test_assert_eq(odrive.handle.config.brake_resistance, values[-1], accuracy=0.01)
def run_test(self, odrive: ODriveTestContext, logger: Logger):
self.run_with_values([0.5, 1.0, 1.5], odrive, logger)
self.run_with_values([2.5, 3.7], odrive, logger)
self.run_with_values([0.47], odrive, logger)
def run_test(self, odrive: ODriveComponent, logger: Logger):
self.run_with_values(odrive, [0.5, 1.0, 1.5], logger)
self.run_with_values(odrive, [2.5, 3.7], logger)
self.run_with_values(odrive, [0.47], logger)
if __name__ == '__main__':
test_runner.run(TestStoreAndReboot())
+56 -21
View File
@@ -5,16 +5,37 @@ import time
import math
import os
import fibre
from fibre.utils import Logger
from odrive.enums import errors
from test_runner import ODriveTestContext, test_assert_eq, program_teensy
from test_runner import *
#def modpm(val, lower_bound, upper_bound):
# return ((val - lower_bound) % (upper_bound - lower_bound)) - lower_bound
def modpm(val, range):
return ((val + (range / 2)) % range) - (range / 2)
teensy_code_template = """
float position = 0; // between 0 and 1
float velocity = 1; // [position per second]
void setup() {
{setup_code}
}
// the loop routine runs over and over again forever:
void loop() {
int high_microseconds = 1000 + (int)(position * 1000.0f);
{set_high_code}
delayMicroseconds(high_microseconds);
{set_low_code}
// Wait for a total of 20ms.
// delayMicroseconds() only works well for values <= 16383
delayMicroseconds(10000 - high_microseconds);
delayMicroseconds(10000);
position += velocity * 0.02;
while (position > 1.0)
position -= 1.0;
}
"""
class TestPwmInput():
"""
@@ -28,8 +49,24 @@ class TestPwmInput():
Note: this test is currently only written for ODrive 3.6 (or similar GPIO layout).
"""
def is_compatible(self, odrive: ODriveTestContext):
return True
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
# Find the Teensy that is connected to gpios 1-4 of the ODrive and the corresponding Teensy GPIOs
gpio_conns = [
testrig.get_directly_connected_components(odrive.gpio1),
testrig.get_directly_connected_components(odrive.gpio2),
testrig.get_directly_connected_components(odrive.gpio3),
testrig.get_directly_connected_components(odrive.gpio4)
]
valid_combinations = [
(combination[0].parent,) + tuple(combination)
for combination in itertools.product(*gpio_conns)
if ((len(set(c.parent for c in combination)) == 1) and isinstance(combination[0].parent, TeensyComponent))
]
yield (odrive, valid_combinations)
def run_delta_test(self, attr, with_min, with_max, timeout = 5.0):
rounds_per_s = 1.0
@@ -84,10 +121,16 @@ class TestPwmInput():
test_assert_eq(min_val, with_min, range = step_size)
test_assert_eq(max_val, with_max, range = step_size)
def run_test(self, odrive: ODriveTestContext, logger: Logger):
def run_test(self, odrive: ODriveComponent, teensy: TeensyComponent, teensy_gpio1: Component, teensy_gpio2: Component, teensy_gpio3: Component, teensy_gpio4: Component, logger: Logger):
# TODO: test each GPIO separately
hexfile = 'pwm_sim.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
setup_code = "\n".join(" pinMode(" + str(gpio.num) + ", OUTPUT);" for gpio in [teensy_gpio1, teensy_gpio2, teensy_gpio3, teensy_gpio4])
set_high_code = "\n".join(" digitalWrite(" + str(gpio.num) + ", HIGH);" for gpio in [teensy_gpio1, teensy_gpio2, teensy_gpio3, teensy_gpio4])
set_low_code = "\n".join(" digitalWrite(" + str(gpio.num) + ", LOW);" for gpio in [teensy_gpio1, teensy_gpio2, teensy_gpio3, teensy_gpio4])
code = teensy_code_template.replace("{setup_code}", setup_code).replace("{set_high_code}", set_high_code).replace("{set_low_code}", set_low_code)
teensy.compile_and_program(code)
time.sleep(1.0) # wait for PLLs to stabilize
logger.debug("Set up PWM input...")
@@ -106,15 +149,7 @@ class TestPwmInput():
odrive.handle.config.gpio4_pwm_mapping.min = -20000
odrive.handle.config.gpio4_pwm_mapping.max = 20000
# Save and reboot
odrive.handle.save_configuration()
try:
odrive.handle.reboot()
except fibre.ChannelBrokenException:
pass # this is expected
odrive.handle = None
time.sleep(2)
odrive.make_available(logger)
odrive.save_config_and_reboot()
logger.debug("Check if PWM on GPIO1 works...")
self.run_delta_test(odrive.handle.axis0.controller._remote_attributes['input_pos'], -50, 200)
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
import test_runner
import struct
import asyncio
import time
from fibre.utils import Logger
from odrive.enums import *
from test_runner import *
class TestStepDir():
"""
Tests Step/Dir input.
Not all possible combinations are tested, but each axis and each GPIO
participates in at least one test case.
The tests are conducted while the axis is in idle.
"""
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
gpio_conns = [
list(testrig.get_connected_components((odrive.gpio1, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio2, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio3, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio4, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio5, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio6, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio7, False), LinuxGpioComponent)),
list(testrig.get_connected_components((odrive.gpio8, False), LinuxGpioComponent)),
]
yield (odrive.axes[0], 1, gpio_conns[0], 2, gpio_conns[1])
yield (odrive.axes[0], 3, gpio_conns[2], 4, gpio_conns[3])
yield (odrive.axes[0], 5, gpio_conns[4], 6, gpio_conns[5]) # broken
yield (odrive.axes[0], 7, gpio_conns[6], 8, gpio_conns[7]) # broken
yield (odrive.axes[1], 7, gpio_conns[6], 8, gpio_conns[7])
def run_test(self, axis: ODriveAxisComponent, step_gpio_num: int, step_gpio: LinuxGpioComponent, dir_gpio_num: int, dir_gpio: LinuxGpioComponent, logger: Logger):
step_gpio.config(output=True)
step_gpio.write(False)
dir_gpio.config(output=True)
dir_gpio.write(True)
if axis.num == 0:
axis.parent.handle.config.enable_uart = False
axis.handle.config.enable_step_dir = True
axis.handle.config.step_dir_always_on = True # needed for testing
axis.handle.config.step_gpio_pin = step_gpio_num
axis.handle.config.dir_gpio_pin = dir_gpio_num
request_state(axis, AXIS_STATE_IDLE) # apply step_dir_always_on config
ref = axis.handle.controller.input_pos
axis.handle.config.counts_per_step = counts_per_step = 10
# On the RPi 4 a ~5kHz GPIO signal can be generated from Python
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref + (i + 1) * counts_per_step, range = 0.4 * counts_per_step)
ref = axis.handle.controller.input_pos
dir_gpio.write(False)
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref - (i + 1) * counts_per_step, range = 0.4 * counts_per_step)
ref = axis.handle.controller.input_pos
dir_gpio.write(True)
axis.handle.config.counts_per_step = counts_per_step = 1
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref + (i + 1) * counts_per_step, range = 0.4 * counts_per_step)
ref = axis.handle.controller.input_pos
axis.handle.config.counts_per_step = counts_per_step = -1
for i in range(100):
step_gpio.write(True)
step_gpio.write(False)
test_assert_eq(axis.handle.controller.input_pos, ref + (i + 1) * counts_per_step, range = 0.4 * abs(counts_per_step))
if __name__ == '__main__':
test_runner.run(TestStepDir())
File diff suppressed because it is too large Load Diff
+97 -85
View File
@@ -5,13 +5,12 @@ import struct
import time
import os
import io
import serial
import functools
import operator
from fibre.utils import Logger
from odrive.enums import *
from test_runner import ODriveTestContext, test_assert_eq, test_assert_no_error, program_teensy
from test_runner import *
def append_checksum(command):
@@ -32,28 +31,29 @@ def reset_state(ser):
ser.flushInput() # discard response
class TestUartAscii():
def is_compatible(self, odrive: ODriveTestContext):
return True
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
ports = list(testrig.get_connected_components({
'rx': (odrive.gpio1, True),
'tx': (odrive.gpio2, False)
}, SerialPortComponent))
yield (odrive, ports)
def run_test(self, odrive: ODriveTestContext, logger: Logger):
def run_test(self, odrive: ODriveComponent, port: SerialPortComponent, logger: Logger):
"""
Tests the most important functions of the ASCII protocol.
"""
# Disable noise
with open("/sys/class/gpio/gpio{}/direction".format(20), "w") as fp:
fp.write("out")
with open("/sys/class/gpio/gpio{}/value".format(20), "w") as gpio:
gpio.write("0")
hexfile = 'uart_pass_through.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
time.sleep(1.0)
if (odrive.handle.config.gpio1_pwm_mapping.endpoint != (0,0)) or (odrive.handle.config.gpio2_pwm_mapping.endpoint != (0,0)):
logger.debug('UART pins in use. Reconfiguring...')
odrive.handle.config.gpio1_pwm_mapping.endpoint = None
odrive.handle.config.gpio2_pwm_mapping.endpoint = None
odrive.save_config_and_reboot()
odrive.handle.axis0.config.enable_step_dir = False
odrive.handle.config.enable_uart = True
with serial.Serial('/dev/ttyS0', 115200, timeout=1) as ser:
with port.open(115200) as ser:
# reset port to known state
reset_state(ser)
@@ -159,84 +159,24 @@ class TestUartAscii():
# TODO: test cases for 't', 'ss', 'se', 'sr' commands
class TestUartNoise():
def is_compatible(self, odrive: ODriveTestContext):
return True
def run_test(self, odrive: ODriveTestContext, logger: Logger):
"""
Tests if the UART can handle invalid signals.
"""
# Disable noise
with open("/sys/class/gpio/gpio{}/direction".format(20), "w") as fp:
fp.write("out")
with open("/sys/class/gpio/gpio{}/value".format(20), "w") as gpio:
gpio.write("0")
hexfile = 'uart_pass_through.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
time.sleep(1.0)
odrive.handle.axis0.config.enable_step_dir = False
odrive.handle.config.enable_uart = True
with serial.Serial('/dev/ttyS0', 115200, timeout=1) as ser:
# reset port to known state
reset_state(ser)
# Enable square wave of ~1.6MHz on the ODrive's RX line
with open("/sys/class/gpio/gpio{}/value".format(20), "w") as gpio:
gpio.write("1")
time.sleep(0.1)
reset_state(ser)
# Read an attribute (should fail because the command is not passed through)
ser.write(b'r vbus_voltage\n')
test_assert_eq(ser.readline(), b'')
# Disable square wave
with open("/sys/class/gpio/gpio{}/value".format(20), "w") as gpio:
gpio.write("0")
# Give receiver some time to recover
time.sleep(0.1)
# reset port to known state
reset_state(ser)
# Try again
ser.write(b'r vbus_voltage\n')
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
class TestUartBurnIn():
def is_compatible(self, odrive: ODriveTestContext):
return True
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
ports = list(testrig.get_connected_components({
'rx': (odrive.gpio1, True),
'tx': (odrive.gpio2, False)
}, SerialPortComponent))
yield (odrive, ports)
def run_test(self, odrive: ODriveTestContext, logger: Logger):
def run_test(self, odrive: ODriveComponent, port: SerialPortComponent, logger: Logger):
"""
Tests if the ASCII protocol can handle 64kB of random data being thrown at it.
"""
# Disable noise
with open("/sys/class/gpio/gpio{}/direction".format(20), "w") as fp:
fp.write("out")
with open("/sys/class/gpio/gpio{}/value".format(20), "w") as gpio:
gpio.write("0")
hexfile = 'uart_pass_through.ino.hex'
program_teensy(os.path.join(os.path.dirname(__file__), hexfile), 26, logger)
time.sleep(1.0)
odrive.handle.axis0.config.enable_step_dir = False
odrive.handle.config.enable_uart = True
with serial.Serial('/dev/ttyS0', 115200, timeout=1) as ser:
with port.open(115200) as ser:
with open('/dev/random', 'rb') as rand:
buf = rand.read(65536)
ser.write(buf)
@@ -250,9 +190,81 @@ class TestUartBurnIn():
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
class TestUartNoise():
def get_test_cases(self, testrig: TestRig):
for odrive in testrig.get_components(ODriveComponent):
# For every ODrive, find a connected serial port which has a teensy
# in between, so that we can inject noise,
ports = list(testrig.get_connected_components({
'rx': (odrive.gpio1, True),
'tx': (odrive.gpio2, False)
}, SerialPortComponent))
# Hack the bus objects to enable noise_enable functionality on the TX line.
def get_noise_gpio(bus):
teensy = bus.gpio_tuples[1][0]
for teensy_gpio in teensy.gpios:
for other_gpio in testrig.get_directly_connected_components(teensy_gpio):
if isinstance(other_gpio, LinuxGpioComponent):
return teensy_gpio, other_gpio
return None
for idx, bus in enumerate(ports):
noise_gpio_on_teensy, noise_gpio_on_rpi = get_noise_gpio(bus)
assert(noise_gpio_on_rpi)
t, i, o, _ = bus.gpio_tuples[1]
bus.gpio_tuples[1] = (t, i, o, noise_gpio_on_teensy)
ports[idx] = (bus, noise_gpio_on_rpi)
yield (odrive, ports)
def run_test(self, odrive: ODriveComponent, port: SerialPortComponent, noise_enable: LinuxGpioComponent, logger: Logger):
"""
Tests if the UART can handle invalid signals.
"""
noise_enable.config(output=True)
noise_enable.write(False)
time.sleep(0.1)
odrive.handle.axis0.config.enable_step_dir = False
odrive.handle.config.enable_uart = True
with port.open(115200) as ser:
# reset port to known state
reset_state(ser)
# Enable square wave of ~1.6MHz on the ODrive's RX line
noise_enable.write(True)
time.sleep(0.1)
reset_state(ser)
time.sleep(1.0)
# Read an attribute (should fail because the command is not passed through)
ser.write(b'r vbus_voltage\n')
test_assert_eq(ser.readline(), b'')
# Disable square wave
noise_enable.write(False)
# Give receiver some time to recover
time.sleep(0.1)
# reset port to known state
reset_state(ser)
# Try again
ser.write(b'r vbus_voltage\n')
response = float(ser.readline().strip())
test_assert_eq(response, odrive.handle.vbus_voltage, accuracy=0.1)
if __name__ == '__main__':
test_runner.run([
TestUartAscii(),
TestUartNoise(),
TestUartBurnIn(),
TestUartNoise(),
])
File diff suppressed because it is too large Load Diff
+52 -13
View File
@@ -8,15 +8,27 @@ components:
name: rpi
ssh: odrv
net: homenet
can0: main_canbus
uart0: /dev/serial/by-id/[not-yet-used]
components:
- type: uart
name: uart0
port: /dev/ttyS0
connected-to: main_uart
- type: can
name: can0
interface: can0
connected-to: odrive.can
# need to specify GPIOs explicitly for the generalpurpose type
- {type: gpio, num: 16}
- {type: gpio, num: 19}
- {type: gpio, num: 20}
- {type: gpio, num: 26}
- type: programmer
name: The Blue STLink/v2
id: '\x53\x3f\x75\x06\x49\x3f\x49\x51\x44\x54\x19\x3f'
# - type: programmer
# name: The Blue STLink/v2
# id: '\x53\x3f\x75\x06\x49\x3f\x49\x51\x44\x54\x19\x3f'
- type: odrive
name: ODrive
name: odrive
board-version: v3.6-58V
serial-number: "20703595524B"
brake-resistance: 0.47
@@ -40,15 +52,42 @@ components:
max-voltage: 40
- type: encoder
name: real_encoder_0
cpr: 8192
max-rpm: 7000
- type: encoder
name: real_encoder_1
name: real_encoder
cpr: 8192
max-rpm: 7000
- type: teensy
name: teensy
- {type: lpf, name: lpf0}
- {type: lpf, name: lpf1}
connections:
- ['odrive.can', 'rpi.can0']
- ['teensy.program', 'rpi.gpio26']
- ['teensy.gpio11', 'rpi.uart0.tx']
- ['teensy.gpio12', 'rpi.uart0.rx']
- ['teensy.gpio10', 'odrive.gpio1']
- ['teensy.gpio9', 'odrive.gpio2']
- ['teensy.gpio8', 'odrive.gpio3']
- ['teensy.gpio7', 'odrive.gpio4']
- ['teensy.gpio14', 'odrive.gpio5']
- ['teensy.gpio15', 'odrive.gpio6']
- ['teensy.gpio16', 'odrive.gpio7']
- ['teensy.gpio17', 'odrive.gpio8']
- ['teensy.gpio4', 'rpi.gpio20']
- ['teensy.gpio5', 'rpi.gpio19']
- ['teensy.gpio23', 'odrive.encoder0.z']
- ['teensy.gpio22', 'odrive.encoder0.a']
- ['teensy.gpio21', 'odrive.encoder0.b']
- ['teensy.gpio20', 'odrive.encoder1.z']
- ['teensy.gpio19', 'odrive.encoder1.a']
- ['teensy.gpio18', 'odrive.encoder1.b']
- ['teensy.gpio0', 'real_encoder.z']
- ['teensy.gpio1', 'real_encoder.a']
- ['teensy.gpio2', 'real_encoder.b']
- ['odrive.axis0', 'D5065-270KV_0']
- ['D5065-270KV_0', 'real_encoder']
- ['odrive.gpio3', 'lpf0']
- ['odrive.gpio4', 'lpf1']
- ['lpf0.en', 'lpf1.en', 'rpi.gpio16']