Evaluating Synchronization Between Two Actuators
This tutorial shows how to evaluate the synchronization between two PULSAR HRI actuators by sending coordinated position commands and comparing their measured response times.
Unlike the basic multi-actuator control tutorial, this example is not about continuous operation. It is a measurement-oriented test that estimates how closely two actuators respond to the same motion command.
The example connects to two actuators, reads their initial positions, applies the same relative position step to both, captures feedback samples during the motion, and estimates synchronization error from the time each actuator reaches 50% of the commanded move.
Warning
Make sure both actuators can move freely and safely before starting the example.
Requirements
Before running the example, make sure:
- the C++ API is built,
- the adapter is connected and reachable,
- both actuators are powered and reachable,
- the user has permission to access the communication device,
- the commanded position step is safe for your setup.
Include files
The following headers are required for this example:
#include <pcp_api/pcp_api.hpp>
#include <chrono>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <limits>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
using namespace pcp_api;
using Clock = std::chrono::steady_clock;
Overview
This example performs the following steps:
- Connects to one adapter and two actuators.
- Reads the initial position of each actuator.
- Computes a relative target for each actuator.
- Configures position mode, feedback rate, and temporary motion limits.
- Arms both actuators and sends the target commands with minimal delay.
- Captures feedback samples during the motion.
- Sends return commands to the initial positions.
- Computes the 50% crossing time for each actuator.
- Estimates synchronization error from the difference between the two crossing times.
Define a sample structure
Each captured sample stores the measurement time and selected feedback values for both actuators.
struct Sample {
double t_s;
float pos1 = std::numeric_limits<float>::quiet_NaN();
float pos2 = std::numeric_limits<float>::quiet_NaN();
float spd1 = std::numeric_limits<float>::quiet_NaN();
float spd2 = std::numeric_limits<float>::quiet_NaN();
};
Helper function to wait until a time point
This helper is used to arm the test and fire both commands at a known time.
static void sleep_until(Clock::time_point tp)
{
while (Clock::now() < tp) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
Helper function to read feedback items safely
This helper extracts a feedback value from a map and returns a fallback value if the item is missing.
static float get_item(
const std::unordered_map<PulsarActuator::PCP_Items, float>& feedback,
PulsarActuator::PCP_Items item,
float fallback = std::numeric_limits<float>::quiet_NaN())
{
auto it = feedback.find(item);
if (it == feedback.end()) {
return fallback;
}
return it->second;
}
Estimate the 50% crossing time
This helper scans the captured samples and returns the first time at which the actuator position crosses 50% of the commanded step.
static std::optional<double> crossing_time_50pct(
const std::vector<Sample>& samples,
bool for_first_actuator,
float start_pos,
float target_pos)
{
const float threshold = start_pos + 0.5f * (target_pos - start_pos);
for (const auto& s : samples) {
const float pos = for_first_actuator ? s.pos1 : s.pos2;
if (std::isnan(pos)) {
continue;
}
if (target_pos >= start_pos) {
if (pos >= threshold) {
return s.t_s;
}
} else {
if (pos <= threshold) {
return s.t_s;
}
}
}
return std::nullopt;
}
Configure the adapter and actuator addresses
This example uses one direct USB actuator and one CAN actuator on the same adapter.
const std::string port = ""; // "" = auto-detect, or set an explicit port such as "/dev/ttyACM0"
const int addr1 = 0; // direct USB actuator
const int addr2 = 0x36; // CAN actuator
Configure the test parameters
These values define the step size and the timing of the measurement.
const float delta = 1.5f;
const auto pre_arm_delay = std::chrono::seconds(2);
const auto capture_time = std::chrono::seconds(3);
const auto poll_period = std::chrono::milliseconds(20);
Connect to the adapter and actuators
Create the adapter, verify the connection, and then attempt to connect to both actuators.
PCP_over_USB adapter(port, true);
if (!adapter.is_connected()) {
std::cerr << "ERROR: could not connect to adapter\n";
return 1;
}
PulsarActuator actuator1(adapter, addr1);
PulsarActuator actuator2(adapter, addr2);
bool actuator1_connected = actuator1.connect();
bool actuator2_connected = actuator2.connect();
Print basic information as soon as each connection succeeds.
if (actuator1_connected) {
std::cout << "A1 connected -> addr=0x" << std::hex << addr1 << std::dec
<< " model=" << actuator1.model()
<< " fw=" << actuator1.firmware_version() << "\n";
} else {
std::cerr << "WARNING: actuator 1 not responding (addr=0x"
<< std::hex << addr1 << std::dec << ")\n";
}
if (actuator2_connected) {
std::cout << "A2 connected -> addr=0x" << std::hex << addr2 << std::dec
<< " model=" << actuator2.model()
<< " fw=" << actuator2.firmware_version() << "\n";
} else {
std::cerr << "WARNING: actuator 2 not responding (addr=0x"
<< std::hex << addr2 << std::dec << ")\n";
}
Read the initial positions
Read the initial feedback of each actuator and compute the target position as a relative step from the starting position.
std::vector<PulsarActuator::PCP_Items> items = {
PulsarActuator::PCP_Items::POSITION_FB,
PulsarActuator::PCP_Items::SPEED_FB,
PulsarActuator::PCP_Items::TORQUE_FB
};
float pos1_0 = std::numeric_limits<float>::quiet_NaN();
float pos2_0 = std::numeric_limits<float>::quiet_NaN();
float target1 = std::numeric_limits<float>::quiet_NaN();
float target2 = std::numeric_limits<float>::quiet_NaN();
if (actuator1_connected) {
auto feedback1_0 = actuator1.getItemsBlocking(items, 1.0f);
pos1_0 = get_item(feedback1_0, PulsarActuator::PCP_Items::POSITION_FB);
if (std::isnan(pos1_0)) {
std::cerr << "WARNING: could not read initial position for A1, skipping A1 test\n";
actuator1.disconnect();
actuator1_connected = false;
} else {
target1 = pos1_0 + delta;
}
}
if (actuator2_connected) {
auto feedback2_0 = actuator2.getItemsBlocking(items, 1.0f);
pos2_0 = get_item(feedback2_0, PulsarActuator::PCP_Items::POSITION_FB);
if (std::isnan(pos2_0)) {
std::cerr << "WARNING: could not read initial position for A2, skipping A2 test\n";
actuator2.disconnect();
actuator2_connected = false;
} else {
target2 = pos2_0 + delta;
}
}
Configure position mode and temporary motion limits
Switch the connected actuators to POSITION mode, configure the feedback rate, and apply temporary position limits and profile settings for the test.
if (actuator1_connected) {
actuator1.change_mode(PulsarActuator::Mode::POSITION);
}
if (actuator2_connected) {
actuator2.change_mode(PulsarActuator::Mode::POSITION);
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (actuator1_connected) {
actuator1.setFeedbackItems(items);
actuator1.setFeedbackRate(PulsarActuator::Rates::RATE_50HZ);
actuator1.set_parameters({
{PulsarActuator::PCP_Parameters::LIM_POSITION_MIN, pos1_0 - 0.5f},
{PulsarActuator::PCP_Parameters::LIM_POSITION_MAX, pos1_0 + 0.5f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MAX, 0.8f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MIN, -0.8f}
});
}
if (actuator2_connected) {
actuator2.setFeedbackItems(items);
actuator2.setFeedbackRate(PulsarActuator::Rates::RATE_50HZ);
actuator2.set_parameters({
{PulsarActuator::PCP_Parameters::LIM_POSITION_MIN, pos2_0 - 0.5f},
{PulsarActuator::PCP_Parameters::LIM_POSITION_MAX, pos2_0 + 0.5f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MAX, 0.8f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MIN, -0.8f}
});
}
Arm the test and send the position commands
Start the actuators, wait until the armed fire time, and then send the target commands with minimal delay.
if (actuator1_connected) {
actuator1.start();
}
if (actuator2_connected) {
actuator2.start();
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));
const auto t0 = Clock::now();
const auto fire_time = t0 + pre_arm_delay;
std::cout << "Armed. Sending available setpoints in ~2 seconds...\n";
sleep_until(fire_time);
std::optional<Clock::time_point> send1;
std::optional<Clock::time_point> send2;
if (actuator1_connected) {
send1 = Clock::now();
actuator1.change_setpoint(target1);
}
if (actuator2_connected) {
send2 = Clock::now();
actuator2.change_setpoint(target2);
}
If both actuators are active, the difference between send2 and send1 provides a rough estimate of the command issue gap.
Capture feedback samples
Capture position and speed samples for both actuators during the motion window.
std::vector<Sample> samples;
const auto capture_start = Clock::now();
while (Clock::now() - capture_start < capture_time) {
std::unordered_map<PulsarActuator::PCP_Items, float> feedback1;
std::unordered_map<PulsarActuator::PCP_Items, float> feedback2;
if (actuator1_connected) {
feedback1 = actuator1.getItemsBlocking(items, 0.2f);
}
if (actuator2_connected) {
feedback2 = actuator2.getItemsBlocking(items, 0.2f);
}
Sample sample{};
sample.t_s = std::chrono::duration<double>(Clock::now() - fire_time).count();
if (actuator1_connected) {
sample.pos1 = get_item(feedback1, PulsarActuator::PCP_Items::POSITION_FB);
sample.spd1 = get_item(feedback1, PulsarActuator::PCP_Items::SPEED_FB);
}
if (actuator2_connected) {
sample.pos2 = get_item(feedback2, PulsarActuator::PCP_Items::POSITION_FB);
sample.spd2 = get_item(feedback2, PulsarActuator::PCP_Items::SPEED_FB);
}
samples.push_back(sample);
std::this_thread::sleep_for(poll_period);
}
Return the actuators to the starting positions
After the capture window, send the return commands and wait for the motion to finish.
if (actuator1_connected) {
actuator1.change_setpoint(pos1_0);
}
if (actuator2_connected) {
actuator2.change_setpoint(pos2_0);
}
std::this_thread::sleep_for(std::chrono::seconds(2));
if (actuator1_connected) {
actuator1.stop();
}
if (actuator2_connected) {
actuator2.stop();
}
Estimate synchronization error
Compute the 50% crossing time for each actuator and estimate synchronization error from their difference.
std::optional<double> t50_1;
std::optional<double> t50_2;
if (actuator1_connected) {
t50_1 = crossing_time_50pct(samples, true, pos1_0, target1);
}
if (actuator2_connected) {
t50_2 = crossing_time_50pct(samples, false, pos2_0, target2);
}
if (actuator1_connected && actuator2_connected && t50_1.has_value() && t50_2.has_value()) {
const double sync_error_ms = std::abs(*t50_1 - *t50_2) * 1000.0;
std::cout << "Estimated sync error (50% crossing): "
<< sync_error_ms << " ms\n";
}
Complete example
The following program is a complete example that can be copied into a .cpp file and compiled directly.
#include <pcp_api/pcp_api.hpp>
#include <chrono>
#include <cmath>
#include <iomanip>
#include <iostream>
#include <limits>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
using namespace pcp_api;
using Clock = std::chrono::steady_clock;
struct Sample {
double t_s;
float pos1 = std::numeric_limits<float>::quiet_NaN();
float pos2 = std::numeric_limits<float>::quiet_NaN();
float spd1 = std::numeric_limits<float>::quiet_NaN();
float spd2 = std::numeric_limits<float>::quiet_NaN();
};
static void sleep_until(Clock::time_point tp)
{
while (Clock::now() < tp) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
static float get_item(
const std::unordered_map<PulsarActuator::PCP_Items, float>& feedback,
PulsarActuator::PCP_Items item,
float fallback = std::numeric_limits<float>::quiet_NaN())
{
auto it = feedback.find(item);
if (it == feedback.end()) {
return fallback;
}
return it->second;
}
static std::optional<double> crossing_time_50pct(
const std::vector<Sample>& samples,
bool for_first_actuator,
float start_pos,
float target_pos)
{
const float threshold = start_pos + 0.5f * (target_pos - start_pos);
for (const auto& s : samples) {
const float pos = for_first_actuator ? s.pos1 : s.pos2;
if (std::isnan(pos)) {
continue;
}
if (target_pos >= start_pos) {
if (pos >= threshold) {
return s.t_s;
}
} else {
if (pos <= threshold) {
return s.t_s;
}
}
}
return std::nullopt;
}
int main()
{
const std::string port = ""; // "" = auto-detect, or set an explicit port such as "/dev/ttyACM0"
const int addr1 = 0; // direct USB actuator
const int addr2 = 0x36; // CAN actuator
const float delta = 1.5f;
const auto pre_arm_delay = std::chrono::seconds(2);
const auto capture_time = std::chrono::seconds(3);
const auto poll_period = std::chrono::milliseconds(20);
bool actuator1_connected = false;
bool actuator2_connected = false;
try {
PCP_over_USB adapter(port, true);
if (!adapter.is_connected()) {
std::cerr << "ERROR: could not connect to adapter\n";
return 1;
}
PulsarActuator actuator1(adapter, addr1);
PulsarActuator actuator2(adapter, addr2);
actuator1_connected = actuator1.connect();
if (actuator1_connected) {
std::cout << "A1 connected -> addr=0x" << std::hex << addr1 << std::dec
<< " model=" << actuator1.model()
<< " fw=" << actuator1.firmware_version() << "\n";
} else {
std::cerr << "WARNING: actuator 1 not responding (addr=0x"
<< std::hex << addr1 << std::dec << ")\n";
}
actuator2_connected = actuator2.connect();
if (actuator2_connected) {
std::cout << "A2 connected -> addr=0x" << std::hex << addr2 << std::dec
<< " model=" << actuator2.model()
<< " fw=" << actuator2.firmware_version() << "\n";
} else {
std::cerr << "WARNING: actuator 2 not responding (addr=0x"
<< std::hex << addr2 << std::dec << ")\n";
}
if (!actuator1_connected && !actuator2_connected) {
std::cerr << "ERROR: no actuator could be connected\n";
adapter.close();
return 1;
}
std::vector<PulsarActuator::PCP_Items> items = {
PulsarActuator::PCP_Items::POSITION_FB,
PulsarActuator::PCP_Items::SPEED_FB,
PulsarActuator::PCP_Items::TORQUE_FB
};
float pos1_0 = std::numeric_limits<float>::quiet_NaN();
float pos2_0 = std::numeric_limits<float>::quiet_NaN();
float target1 = std::numeric_limits<float>::quiet_NaN();
float target2 = std::numeric_limits<float>::quiet_NaN();
if (actuator1_connected) {
auto feedback1_0 = actuator1.getItemsBlocking(items, 1.0f);
pos1_0 = get_item(feedback1_0, PulsarActuator::PCP_Items::POSITION_FB);
if (std::isnan(pos1_0)) {
std::cerr << "WARNING: could not read initial position for A1, skipping A1 test\n";
actuator1.disconnect();
actuator1_connected = false;
} else {
target1 = pos1_0 + delta;
}
}
if (actuator2_connected) {
auto feedback2_0 = actuator2.getItemsBlocking(items, 1.0f);
pos2_0 = get_item(feedback2_0, PulsarActuator::PCP_Items::POSITION_FB);
if (std::isnan(pos2_0)) {
std::cerr << "WARNING: could not read initial position for A2, skipping A2 test\n";
actuator2.disconnect();
actuator2_connected = false;
} else {
target2 = pos2_0 + delta;
}
}
if (!actuator1_connected && !actuator2_connected) {
std::cerr << "ERROR: no actuator available after initial feedback check\n";
adapter.close();
return 2;
}
std::cout << std::fixed << std::setprecision(6);
if (actuator1_connected) {
std::cout << "Initial pos A1: " << pos1_0 << " rad\n";
std::cout << "Target pos A1: " << target1 << " rad\n";
}
if (actuator2_connected) {
std::cout << "Initial pos A2: " << pos2_0 << " rad\n";
std::cout << "Target pos A2: " << target2 << " rad\n";
}
if (actuator1_connected) {
actuator1.change_mode(PulsarActuator::Mode::POSITION);
}
if (actuator2_connected) {
actuator2.change_mode(PulsarActuator::Mode::POSITION);
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (actuator1_connected) {
actuator1.setFeedbackItems(items);
actuator1.setFeedbackRate(PulsarActuator::Rates::RATE_50HZ);
actuator1.set_parameters({
{PulsarActuator::PCP_Parameters::LIM_POSITION_MIN, pos1_0 - 0.5f},
{PulsarActuator::PCP_Parameters::LIM_POSITION_MAX, pos1_0 + 0.5f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MAX, 0.8f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MIN, -0.8f}
});
}
if (actuator2_connected) {
actuator2.setFeedbackItems(items);
actuator2.setFeedbackRate(PulsarActuator::Rates::RATE_50HZ);
actuator2.set_parameters({
{PulsarActuator::PCP_Parameters::LIM_POSITION_MIN, pos2_0 - 0.5f},
{PulsarActuator::PCP_Parameters::LIM_POSITION_MAX, pos2_0 + 0.5f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MAX, 0.8f},
{PulsarActuator::PCP_Parameters::PROFILE_POSITION_MIN, -0.8f}
});
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if (actuator1_connected) {
actuator1.start();
}
if (actuator2_connected) {
actuator2.start();
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));
const auto t0 = Clock::now();
const auto fire_time = t0 + pre_arm_delay;
std::cout << "Armed. Sending available setpoints in ~2 seconds...\n";
sleep_until(fire_time);
std::optional<Clock::time_point> send1;
std::optional<Clock::time_point> send2;
if (actuator1_connected) {
send1 = Clock::now();
actuator1.change_setpoint(target1);
}
if (actuator2_connected) {
send2 = Clock::now();
actuator2.change_setpoint(target2);
}
if (send1.has_value() && send2.has_value()) {
const double command_gap_ms =
std::chrono::duration<double, std::milli>(*send2 - *send1).count();
std::cout << "Command gap between A1 and A2 sends: "
<< command_gap_ms << " ms\n";
} else {
std::cout << "Only one actuator active: no inter-actuator command gap available\n";
}
std::vector<Sample> samples;
const auto capture_start = Clock::now();
while (Clock::now() - capture_start < capture_time) {
std::unordered_map<PulsarActuator::PCP_Items, float> feedback1;
std::unordered_map<PulsarActuator::PCP_Items, float> feedback2;
if (actuator1_connected) {
feedback1 = actuator1.getItemsBlocking(items, 0.2f);
}
if (actuator2_connected) {
feedback2 = actuator2.getItemsBlocking(items, 0.2f);
}
Sample sample{};
sample.t_s = std::chrono::duration<double>(Clock::now() - fire_time).count();
if (actuator1_connected) {
sample.pos1 = get_item(feedback1, PulsarActuator::PCP_Items::POSITION_FB);
sample.spd1 = get_item(feedback1, PulsarActuator::PCP_Items::SPEED_FB);
}
if (actuator2_connected) {
sample.pos2 = get_item(feedback2, PulsarActuator::PCP_Items::POSITION_FB);
sample.spd2 = get_item(feedback2, PulsarActuator::PCP_Items::SPEED_FB);
}
samples.push_back(sample);
std::this_thread::sleep_for(poll_period);
}
std::optional<Clock::time_point> back1;
std::optional<Clock::time_point> back2;
if (actuator1_connected) {
back1 = Clock::now();
actuator1.change_setpoint(pos1_0);
}
if (actuator2_connected) {
back2 = Clock::now();
actuator2.change_setpoint(pos2_0);
}
if (back1.has_value() && back2.has_value()) {
const double return_gap_ms =
std::chrono::duration<double, std::milli>(*back2 - *back1).count();
std::cout << "Return command gap between A1 and A2 sends: "
<< return_gap_ms << " ms\n";
} else {
std::cout << "Only one actuator active: no inter-actuator return gap available\n";
}
std::this_thread::sleep_for(std::chrono::seconds(2));
if (actuator1_connected) {
actuator1.stop();
}
if (actuator2_connected) {
actuator2.stop();
}
std::optional<double> t50_1;
std::optional<double> t50_2;
if (actuator1_connected) {
t50_1 = crossing_time_50pct(samples, true, pos1_0, target1);
}
if (actuator2_connected) {
t50_2 = crossing_time_50pct(samples, false, pos2_0, target2);
}
std::cout << "\n=== Summary ===\n";
if (actuator1_connected) {
if (t50_1.has_value()) {
std::cout << "A1 50% crossing time: " << *t50_1 << " s\n";
} else {
std::cout << "A1 50% crossing time: not reached\n";
}
} else {
std::cout << "A1: not connected / not tested\n";
}
if (actuator2_connected) {
if (t50_2.has_value()) {
std::cout << "A2 50% crossing time: " << *t50_2 << " s\n";
} else {
std::cout << "A2 50% crossing time: not reached\n";
}
} else {
std::cout << "A2: not connected / not tested\n";
}
if (actuator1_connected && actuator2_connected && t50_1.has_value() && t50_2.has_value()) {
const double sync_error_ms = std::abs(*t50_1 - *t50_2) * 1000.0;
std::cout << "Estimated sync error (50% crossing): "
<< sync_error_ms << " ms\n";
} else {
std::cout << "Estimated sync error: not available\n";
}
std::cout << "\nCaptured samples: " << samples.size() << "\n";
std::cout << "First 10 samples:\n";
for (size_t i = 0; i < samples.size() && i < 10; ++i) {
const auto& s = samples[i];
std::cout << "t=" << s.t_s
<< " | p1=" << s.pos1
<< " p2=" << s.pos2
<< " | v1=" << s.spd1
<< " v2=" << s.spd2 << "\n";
}
if (actuator1_connected) {
actuator1.disconnect();
}
if (actuator2_connected) {
actuator2.disconnect();
}
adapter.close();
return 0;
}
catch (const std::exception& e) {
std::cerr << "EXCEPTION: " << e.what() << "\n";
return 10;
}
catch (...) {
std::cerr << "UNKNOWN EXCEPTION\n";
return 11;
}
}
Example build command
Adjust the include path, library path, and library names to match your build output.