Data and Event Recorders

Strongback lets you record various kinds of data to a log file on the RoboRIO. You can then post-process that file and extract time histories of multiple channels and events, and use tools like Excel or Tableau to visualize those histories.

The following is a graph produced with Tableau that shows on the top the y-axis acceleration (in g’s), in the middle that periods of time that the acceleration exceeded 0.9 g’s, and at the bottom the state of a manual button.

Strongback supports recording two kinds of data: events and data. Data are essentially continuous values, and Stronback’s DataRecorder is used to capture at regular time steps values for all registered continuous channels. Events, on the other hand, are far less frequent occurrences of some type of action; recording them as a channel is inefficient (since there is no value at most time steps) and often makes little sense, so instead the EventRecorder allows components to record these spurious and infrequent events. Both the data and event records can then be combined to view a complete timeline with all available information.

Strongback’s command scheduler can be configured to automatically record the state transitions of Commands as they are executed. Of course, custom robot code can also record any other kinds of events.

DataRecorder

Strongback’s data recorder takes measurements every cycle of the executor, so its job is to frequently take measurements and record them. The org.strongback.DataRecorder interface is used to register the functions that supply the measurable values:

public interface DataRecorder {
    /**
     * Registers by name a function that will be periodically polled to obtain
     * and record an integer value. This method will remove any previously-registered
     * supplier, switch, or motor with the same name.
     *
     * @param name the name of this data supplier
     * @param supplier the {@link IntSupplier} of the value to be logged
     * @return this instance so methods can be chained together; never null
     * @throws IllegalArgumentException if the {@code supplier} parameter is null
     */
    public DataRecorder register(String name, IntSupplier supplier);

    /**
     * Registers by name a function that will be periodically polled to obtain
     * a double value and scale it to an integer value that can be recorded.
     * This method will remove any previously-registered supplier, switch, or
     * motor with the same name.
     *
     * @param name the name of this data supplier
     * @param scale the scale factor to multiply the supplier's double values
     *        before casting to an integer value
     * @param supplier the {@link IntSupplier} of the value to be logged
     * @return this instance so methods can be chained together; never null
     * @throws IllegalArgumentException if the {@code supplier} parameter is null
     */
    default public DataRecorder register(String name, double scale,
                                         DoubleSupplier supplier) {...}

    /**
     * Registers by name a switch that will be periodically polled to obtain
     * and record the switch state. This method will remove any previously-registered
     * supplier, switch, or motor with the same name.
     *
     * @param name the name of the {@link Switch}
     * @param swtch the {@link Switch} to be logged
     * @return this instance so methods can be chained together; never null
     * @throws IllegalArgumentException if the {@code swtch} parameter is null
     */
    public DataRecorder register(String name, Switch swtch);

    /**
     * Registers by name a speed sensor that will be periodically polled to obtain
     * and record the current speed. This method will remove any previously-registered
     * supplier, switch, or motor with the same name.
     *
     * @param name the name of the {@link SpeedSensor}
     * @param sensor the {@link SpeedSensor} to be logged
     * @return this instance so methods can be chained together; never null
     * @throws IllegalArgumentException if the {@code sensor} parameter is null
     */
    public DataRecorder register(String name, SpeedSensor sensor);
}

Strongback comes with a DataRecorder instance that is accessed via a static method:

DataRecorder recorder = Strongback.dataRecorder();

This is always done within the robotInit() method, before Strongback is actually started. But notice how all of the register(…​) methods return the DataRecorder; its more ideomatic to chain multiple register(…​) methods together. Here’s a sample robot with a few sensors and components, and how they are used to record their states:

public class SimpleTankDriveRobot extends IterativeRobot {

    ....

    private TankDrive drive;
    private ContinuousRange driveSpeed;
    private ContinuousRange turnSpeed;

    @Override
    public void robotInit() {
        ...
        Motor left = Motor.compose(Hardware.Motors.talon(1), Hardware.Motors.talon(2));
        Motor right = Motor.compose(Hardware.Motors.talon(3), Hardware.Motors.talon(4)).invert();
        drive = new TankDrive(left, right);

        // Set up the human input controls for teleoperated mode. We want to use the
        // Logitech Attack 3D's throttle as a "sensitivity" input to scale the drive
        // speed and throttle, so we'll map it from it's native [-1,1] to a simple scale
        // factor of [0,1] ...
        FlightStick joystick = Hardware.HumanInterfaceDevices.logitechAttack3D(1);
        ContinuousRange throttle = joystick.getThrottle();
        ContinuousRange sensitivity = throttle.map(t -> (t + 1.0) / 2.0);
        driveSpeed = joystick.getPitch().scale(sensitivity::read); // scaled
        turnSpeed = joystick.getRoll().scale(sensitivity::read).invert(); // scaled and inverted
        Switch trigger = joystick.getTrigger();

        // Get the RoboRIO's accelerometer ...
        ThreeAxisAccelerometer accel = Hardware.Accelerometers.builtIn();
        Accelerometer xAccel = accel.getXDirection();
        Accelerometer yAccel = accel.getYDirection();
        Accelerometer zAccel = accel.getZDirection();

        VoltageSensor battery = Hardware.powerPanel().getVoltageSensor();
        CurrentSensor current = Hardware.powerPanel().getCurrentSensor();

        Strongback.dataRecorder()
                  .register("Battery Volts",1000, battery)
                  .register("Current load", 1000, current)
                  .register("Left Motors",  left)
                  .register("Right Motors", right)
                  .register("Trigger",      trigger)
                  .register("Throttle",     1000, throttle::read)
                  .register("Drive Speed",  1000, driveSpeed::read)
                  .register("Turn Speed",   1000, turnSpeed::read)
                  .register("X-Accel",      1000, xAccel::getAcceleration)
                  .register("Y-Accel",      1000, yAccel::getAcceleration)
                  .register("Z-Accel",      ()->zAccel.getAcceleration()*1000));
        ...

    }
    ...
}

All of the sensors' double values are scaled by 1000 since they are converted to an integer when recorded. This truncates the values and reduces the overall of data recorded in the file, and can easily be converted back to the correct value during post-processing.

The "Z-Accel" channel is registered using a lambda rather than a method reference, showing how one can compute a value in a registered function.

Look at the "Trigger" channel, which records the state of the trigger at every cycle. Since the trigger’s state changes quite infrequently (at least compared to the 50-200 times per second that the data recorder captures measurements), its much more efficient to use the event recorder discussed in the next section.

EventRecorder

Strongback’s event recorder captures non-continous or infrequent events that your code supplies directly via the org.strongback.EventRecorder interface:

@ThreadSafe
public interface EventRecorder extends Executable {

    /**
     * Record an event with the given identifier and event information.
     *
     * @param eventType the type of event; may not be null
     * @param value the event details as a string value; may be null
     */
    public void record(String eventType, String value);

    /**
     * Record an event with the given identifier and event information.
     *
     * @param eventType the type of event; may not be null
     * @param value the event detail as an integer value; may be null
     */
    public void record(String eventType, int value);

    /**
     * Record an event with the given identifier and event information.
     *
     * @param eventType the type of event; may not be null
     * @param value the event detail as an integer value; may be null
     */
    default public void record(String eventType, boolean value) {...}

    /**
     * Return an {@link EventRecorder} implementation that does nothing.
     *
     * @return the no-operation event recorder; never null
     */
    public static EventRecorder noOp() {...}
}

The eventType parameter provides a logical name that is meaningful to you, and thus is very similar to the channel name used when registering functions with the DataRecorder. Unlike the DataRecorder that is always set up in the robotInit() method, the EventRecorder is used when the robot is operating and code wants to capture some "event".

We ended the previous section saying that capturing the state of a trigger is better done with the event recorder rather than the data recorder. Here’s the same example we used in that section, except the trigger state change is captured as an event. We use the Switch Reactor to asynchronously detect the state change:

public class SimpleTankDriveRobot extends IterativeRobot {

    ....

    private TankDrive drive;
    private ContinuousRange driveSpeed;
    private ContinuousRange turnSpeed;

    @Override
    public void robotInit() {
        ...
        Motor left = Motor.compose(Hardware.Motors.talon(1), Hardware.Motors.talon(2));
        Motor right = Motor.compose(Hardware.Motors.talon(3), Hardware.Motors.talon(4)).invert();
        drive = new TankDrive(left, right);

        // Set up the human input controls for teleoperated mode. We want to use the
        // Logitech Attack 3D's throttle as a "sensitivity" input to scale the drive
        // speed and throttle, so we'll map it from it's native [-1,1] to a simple scale
        // factor of [0,1] ...
        FlightStick joystick = Hardware.HumanInterfaceDevices.logitechAttack3D(1);
        ContinuousRange throttle = joystick.getThrottle();
        ContinuousRange sensitivity = throttle.map(t -> (t + 1.0) / 2.0);
        driveSpeed = joystick.getPitch().scale(sensitivity::read); // scaled
        turnSpeed = joystick.getRoll().scale(sensitivity::read).invert(); // scaled and inverted

        // Capture the state change of the trigger ...
        Strongback.switchReactor().
                  .onTriggered(joystick.getTrigger(),
                               ()->Strongback.eventRecorder().record("Trigger",true))
                  .onUnTriggered(joystick.getTrigger(),
                                 ()->Strongback.eventRecorder().record("Trigger",false));

        // Get the RoboRIO's accelerometer ...
        ThreeAxisAccelerometer accel = Hardware.Accelerometers.builtIn();
        Accelerometer xAccel = accel.getXDirection();
        Accelerometer yAccel = accel.getYDirection();
        Accelerometer zAccel = accel.getZDirection();

        VoltageSensor battery = Hardware.powerPanel().getVoltageSensor();
        CurrentSensor current = Hardware.powerPanel().getCurrentSensor();

        Strongback.dataRecorder()
                  .register("Battery Volts",1000, battery)
                  .register("Current load", 1000, current)
                  .register("Left Motors",  left)
                  .register("Right Motors", right)
                  .register("Throttle",     1000, throttle::read)
                  .register("Drive Speed",  1000, driveSpeed::read)
                  .register("Turn Speed",   1000, turnSpeed::read)
                  .register("X-Accel",      1000, xAccel::getAcceleration)
                  .register("Y-Accel",      1000, yAccel::getAcceleration)
                  .register("Z-Accel",      ()->zAccel.getAcceleration()*1000));
        ...
    }
    ...
}

Of course, in the function we supplied to the SwitchReactor we could do more than just record the trigger state change. For example, we might also want start a command.

Recording commands

By default Strongback automatically records the state changes of each Command as it transitions through its lifecycle. This can be helpful to understand and explain changes in continuous data or other events. For example, if you have a command that runs the compressor, post-processing a log that includes commands can help explain an increase in the current load and decrease in the battery voltage.

Turning off recording

The data and event recorders do consume resources, so if you decide to not use this feature be sure to configure Strongback to disable the recorders. Again, this is done in the robotInit() method before you call Strongback.initialize():

public class SimpleTankDriveRobot extends IterativeRobot {
    ....
    @Override
    public void robotInit() {
        // Set up Strongback using its configurator. This is entirely optional, but we won't
        // record data, events, or commands, so we'll turn them off. All other defaults are fine.
        Strongback.configure()
                  .recordNoEvents()
                  .recordNoCommands()
                  .recordNoData()
                  .initialize();
        ...
    }
    ...
}

Decoding binary log files

As mentioned above, the Strongback can record various channels of data while your robot runs. The resulting data is captured in a binary file on the robot, so to do anything with this you first need to download the file from the robot and then decode it. The Strongback CLI’s decode command will convert the binary file into a comma separated values (or CSV) file that you can import into Google Sheets, Excel, Tableau, or many other programs. To decode a binary file, run:

$ strongback decode <input> <output>

where <input> is the name and path of the binary file you want to convert, and <output> is the name and path for the CSV output file.

For example, imagine that you’ve set up your robot to use Strongback’s data recorder to capture in a file named robot.dat data for two channels named Foo and Bar. The raw binary file will conceptually look something like the following (new lines and brackets added for clarity and are not part of the file format):

[l o g]
[3]
[4] [2] [2]
[4][T i m e] [3][F o o] [3][B a r]
[00 00 00 00] [00 52] [00 37]
[00 00 00 0A] [04 D5] [23 AF]
[00 00 00 14] [3F 00] [12 34]
[FF FF FF FF]

If this is in a file named robot.dat downloaded from the robot onto your computer, then the following Strongback CLI command will convert this binary file to a CSV file:

$ strongback decode robot.dat robot.csv

The CSV file will look like:

Time, Foo, Bar
0, 82, 55,
10, 1237, 9135,
20, 16128, 4660

The first row contains the comma-separated names of the channels, followed by a newline character. Each of the subsequent line lists the integer value of the data encoded to the precision specified in your robot program. The Time channel is always first and the values are in milliseconds.

Strongback CLI’s decode command has a few other options, which you can see by running:

$ strongback help decode

results matching ""

    No results matching ""