mirror of
https://github.com/odriverobotics/ODrive.git
synced 2026-09-23 00:40:12 +08:00
Merge branch 'RazorsFrozenTesting' into RazorsEdge
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,6 +47,8 @@ public:
|
||||
float Tf_;
|
||||
|
||||
float yAccel_;
|
||||
|
||||
float t_;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
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
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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://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
@@ -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 +0,0 @@
|
||||
test
|
||||
@@ -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())
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
])
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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())
|
||||
+579
-120
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user