Hoot Replay

Note

Information on logging to hoot files can be found in Signal Logging.

Hoot Replay allows users to playback hoot logs in their robot program. This allows for testing changes to robot code in simulation using measurements recorded from the real robot.

Important

Hoot Replay requires the hoot log to have a Pro-licensed device. Currently, only one hoot log may be replayed at a time.

Compared to other replay frameworks, Hoot Replay offers automatic status signal playback without any code changes. However, replaying custom logged signals still requires modifications to the robot’s subsystems.

Hoot Replay is controlled using the HootReplay class (Java, C++, Python) and supports playing back device status signals and custom user signals. Configs and control requests are ignored during replay.

Hoot Replay uses a different vendordep (see Installing Phoenix 6) that replaces Hardware-Attached Simulation with Hoot Replay. Note that only one Phoenix 6 vendordep may be used in the vendordeps folder at a time.

Starting Hoot Replay

At the start of the robot program, the desired hoot log can be loaded using HootReplay.loadFile(fileName) (Java, C++, Python). Hoot Replay will start automatically after the log has been loaded.

HootReplay.loadFile("./logs/example.hoot");
HootReplay::LoadFile{"./logs/example.hoot"};
HootReplay.load_file("./logs/example.hoot")

It is important that the hoot log is loaded before any devices are constructed or configured. To make this process easier, the CANBus(canbus, fileName) constructor (Java, C++, Python) can instead be used to load the hoot log.

// construct the CANBus with the hoot log
final CANBus canbus = new CANBus("canivore", "./logs/example.hoot");
// now the log is guaranteed to be loaded before devices are constructed
final TalonFX talonFX = new TalonFX(0, canbus);
// construct the CANBus with the hoot log
CANBus canbus{"canivore", "./logs/example.hoot"};
// now the log is guaranteed to be loaded before devices are constructed
hardware::TalonFX talonFX{0, canbus};
# construct the CANBus with the hoot log
self._canbus = CANBus("canivore", "./logs/example.hoot")
# now the log is guaranteed to be loaded before devices are constructed
self._talon_fx = hardware.TalonFX(0, canbus)

Controlling Replay

During Hoot Replay, the simulated robot will automatically enable and run through all the maneuvers recorded in the hoot log. There are also several APIs that can be used to manage playback.

  • HootReplay.pause() temporarily pauses playback

  • HootReplay.play() resumes playback from the current point in the file

  • HootReplay.stop() pauses playback and returns to the start of the file

  • HootReplay.restart() restarts playback from the start of the file

  • HootReplay.setSpeed(speed) changes the speed of playback by a scalar

The status of Hoot Replay can be checked using HootReplay.isFileLoaded(), HootReplay.isPlaying(), and HootReplay.isFinished(). Threads can also wait for Hoot Replay to start using HootReplay.waitForPlaying(timeout).

Tip

HootReplay.isPlaying() and HootReplay.waitForPlaying(timeout) immediately return true when running on the robot or in regular simulation.

Additionally, after pausing or stopping Hoot Replay, playback can advance by a fixed timestep using HootReplay.stepTiming(timeDelta), updating all signals accordingly.

// stop and return to start of log
HootReplay.stop();
var startPos = talonFX.getPosition().getValue();
// advance by 1 second and compare positions
HootReplay.stepTiming(1.0);
var endPos = talonFX.getPosition().getValue();
// stop and return to start of log
HootReplay::Stop();
auto const startPos = talonFX.GetPosition().GetValue();
// advance by 1 second and compare positions
HootReplay::StepTiming(1_s);
auto const endPos = talonFX.GetPosition().GetValue();
# stop and return to start of log
HootReplay.stop()
start_pos = self._talon_fx.get_position().value
# advance by 1 second and compare positions
HootReplay.step_timing(1.0)
end_pos = self._talon_fx.get_position().value

Replaying Custom Signals

Users can also fetch custom signals written to the loaded hoot log by utilizing the get*() functions. An example application of this is replaying vision data to test changes in the drivetrain pose estimator.

All custom signal getters return a StatusSignal.SignalMeasurement (Java, C++, Python) object containing information about the signal, including its timestamp and any logged units. The success of fetching the custom signal can be validated by checking the status field.

// Fetch the logged raw vision measurements
var visionData = HootReplay.getStruct("camera pose", Pose2d.struct);
if (visionData.status.isOK() && visionData.value.isPresent()) {
   // now run regular vision processing on the vision data
   var cameraPose = visionData.value.get();
   if (isCameraPoseValid(cameraPose)) {
      drivetrain.addVisionMeasurement(cameraPose, visionData.timestamp);
   }
}
// Fetch the logged raw vision measurements
auto const visionData = HootReplay::GetStruct<frc::Pose2d>("camera pose");
if (visionData.status.IsOK() && visionData.value.has_value()) {
   // now run regular vision processing on the vision data
   auto const cameraPose = visionData.value.value();
   if (IsCameraPoseValid(cameraPose)) {
      drivetrain.AddVisionMeasurement(cameraPose, visionData.timestamp);
   }
}
# Fetch the logged raw vision measurements
vision_data = HootReplay.get_struct("camera pose", Pose2d)
if visionData.status.is_ok() and vision_data.value is not None:
   # now run regular vision processing on the vision data
   camera_pose = vision_data.value
   if is_camera_pose_valid(camera_pose):
      drivetrain.add_vision_measurement(camera_pose, vision_data.timestamp)

Additionally, the Signal Logger is supported in Hoot Replay with the following behavior:

  • Signal Logger is always enabled during replay. As a result, SignalLogger::Start() and SignalLogger::Stop() are ignored.

  • The replayed log will always be written to a replay_<date>_<time>/ subfolder next to the original log.

  • All custom signals written during replay will be automatically placed under hoot_replay/ in the log.

  • The replayed log also contains all status signals and custom signals from the original log, excluding those starting with hoot_replay/.

This can be useful to log the new outputs after making changes to program logic.

Adjusting Robot Code Architecture

Replaying custom signals typically requires changes to the architecture of the robot’s subsystems or other hardware classes. As an example, consider changes to a Vision class.

When working with vision, a robot program will typically fetch raw/unfiltered camera/pose data (the “inputs”) from the vision library (such as a list of targets). Then, it will perform some additional processing and filtering on that data to get the final vision pose estimate (the “output”).

The goal is to replay the “inputs” so the replayed program can test changes to the subsequent data processing and filtering. This means the robot program should only replay data that is unaffected by code logic, letting the program determine all the new outputs.

The simplest way to make the code easily replayed is to consolidate all input fetches into a single fetchInputs() function and save the results to class member variables. From there, a single periodic function would be responsible for calling that function when Utils.isReplay() (Java, C++, Python) returns false, as well as performing all data processing to get the outputs.

For example, if the only data pulled from the vision library is the raw vision pose estimate and its timestamp:

public class Vision {
   private final Camera camera = new Camera(...);
   /* vision inputs */
   private PoseEstimate cameraPoseEst = new PoseEstimate();

   // ...

   private void fetchInputs() {
      cameraPoseEst = camera.getPoseEstimate();
   }

   public void periodic() {
      if (!Utils.isReplay()) {
         fetchInputs();
      }

      // process inputs here...
   }
}
class Vision {
private:
   Camera camera{...};
   /* vision inputs */
   PoseEstimate cameraPoseEst{};

   // ...

   void FetchInputs()
   {
      cameraPoseEst = camera.GetPoseEstimate();
   }

public:
   void Periodic()
   {
      if (!utils::IsReplay()) {
         FetchInputs();
      }

      // process inputs here...
   }
};
class Vision:
   def __init__(self):
      self._camera = Camera(...)
      # vision inputs
      self._camera_pose_est = PoseEstimate()

      # ...

   def _fetch_inputs(self):
      self._camera_pose_est = camera.getPoseEstimate()

   def periodic(self):
      if not utils.is_replay():
         self._fetch_inputs()

      # process inputs here...

At this point, there are two methods to set up Hoot Replay of the inputs.

The inputs can be registered to a HootAutoReplay (Java, C++, Python) instance using getter and setter lambdas. The periodic function then calls update() on the HootAutoReplay instance to write the inputs to the hoot log on the real robot (or simulation) and fetch the inputs from the log during Hoot Replay.

Tip

In a WPILib robot program, HootAutoReplay also supports replaying Timer.getTimestamp() and joystick inputs. These can be registered using withTimestampReplay() and withJoystickReplay(), respectively. See the API documentation for more information.

For example, the previous Vision example would now have:

public class Vision {
   private final Camera camera = new Camera(...);
   /* vision inputs */
   private PoseEstimate cameraPoseEst = new PoseEstimate();

   /* NEW: register inputs for replay */
   private final HootAutoReplay autoReplay = new HootAutoReplay()
      .withStruct(
         "CameraPoseEst/pose", Pose2d.struct,
         /* getter lambda returns the value to log */
         () -> cameraPoseEst.pose,
         /* setter lambda applies the value from the log */
         val -> cameraPoseEst.pose = val.value
      )
      .withDouble(
         "CameraPoseEst/timestamp",
         () -> cameraPoseEst.timestamp,
         val -> cameraPoseEst.timestamp = val.value
      );

   // ...

   private void fetchInputs() {
      cameraPoseEst = camera.getPoseEstimate();
   }

   public void periodic() {
      if (!Utils.isReplay()) {
         fetchInputs();
      }
      /* NEW: log the inputs on hardware/sim, and playback in replay */
      autoReplay.update();

      // process inputs here...
   }
}
class Vision {
private:
   Camera camera{...};
   /* vision inputs */
   PoseEstimate cameraPoseEst{};

   /* NEW: register inputs for replay */
   HootAutoReplay autoReplay = HootAutoReplay{}
      .WithStruct<frc::Pose2d>(
         "CameraPoseEst/pose",
         /* getter lambda returns the value to log */
         [this] -> frc::Pose2d const& { return cameraPoseEst.pose; },
         /* setter lambda applies the value from the log */
         [this](HootReplay::SignalData<frc::Pose2d> val) {
            cameraPoseEst.pose = val.value;
         }
      )
      .WithValue<units::second_t>(
         "CameraPoseEst/timestamp",
         [this] -> units::second_t const& { return cameraPoseEst.timestamp; },
         [this](HootReplay::SignalData<units::second_t> val) {
            cameraPoseEst.timestamp = val.value;
         }
      );

   // ...

   void FetchInputs()
   {
      cameraPoseEst = camera.GetPoseEstimate();
   }

public:
   void Periodic()
   {
      if (!utils::IsReplay()) {
         FetchInputs();
      }
      /* NEW: log the inputs on hardware/sim, and playback in replay */
      autoReplay.Update();

      // process inputs here...
   }
};
class Vision:
   def __init__(self):
      self._camera = Camera(...)
      # vision inputs
      self._camera_pose_est = PoseEstimate()

      # NEW: register inputs for replay
      def _set_camera_pose(val: HootReplay.SignalData[Pose2d]):
         self._camera_pose_est.pose = val.value
      def _set_camera_timestamp(val: HootReplay.SignalData[float]):
         self._camera_pose_est.timestamp = val.value
      self._auto_replay = (
         HootAutoReplay()
         .with_struct(
            "CameraPoseEst/pose",
            # getter lambda returns the value to log
            lambda: self._camera_pose_est.pose,
            # setter lambda applies the value from the log
            _set_camera_pose,
         )
         .with_double(
            "CameraPoseEst/timestamp",
            lambda: self._camera_pose_est.timestamp,
            _set_camera_timestamp
         )
      )

      # ...

   def _fetch_inputs(self):
      self._camera_pose_est = camera.getPoseEstimate()

   def periodic(self):
      if not utils.is_replay():
         self._fetch_inputs()
      # NEW: log the inputs on hardware/sim, and playback in replay
      self._auto_replay.update()

      # process inputs here...

The fetchInputs() function should also log the inputs using SignalLogger. Then, it is possible to implement a fetchInputsReplay() function, which should be called when Utils.isReplay() returns true. This function would do the reverse of the regular fetchInputs(), pulling out the data from HootReplay and saving it to the class member variables.

For example, the previous Vision example would now have:

public class Vision {
   private final Camera camera = new Camera(...);
   /* vision inputs */
   private PoseEstimate cameraPoseEst = new PoseEstimate();

   // ...

   private void fetchInputs() {
      cameraPoseEst = camera.getPoseEstimate();

      /* NEW: manually log the inputs */
      SignalLogger.writeStruct("CameraPoseEst/pose", Pose2d.struct, cameraPoseEst.pose);
      SignalLogger.writeDouble("CameraPoseEst/timestamp", cameraPoseEst.timestamp);
   }

   private void fetchInputsReplay() {
      /* NEW: pull out inputs from the log */
      final var cameraPoseEst_pose = HootReplay.getStruct("CameraPoseEst/pose", Pose2d.struct);
      final var cameraPoseEst_timestamp = HootReplay.getDouble("CameraPoseEst/timestamp");

      if (
         cameraPoseEst_pose.status.isOK() &&
         cameraPoseEst_timestamp.status.isOK()
      ) {
         cameraPoseEst = new PoseEstimate();
         cameraPoseEst.pose = cameraPoseEst_pose.value;
         cameraPoseEst.timestamp = cameraPoseEst_timestamp.value;
      }
   }

   public void periodic() {
      /* NEW: fetch the correct inputs based on if we're in replay */
      if (Utils.isReplay()) {
         fetchInputsReplay();
      } else {
         fetchInputs();
      }

      // process inputs here...
   }
}
class Vision {
private:
   Camera camera{...};
   /* vision inputs */
   PoseEstimate cameraPoseEst{};

   // ...

   void FetchInputs()
   {
      cameraPoseEst = camera.GetPoseEstimate();

      /* NEW: manually log the inputs */
      SignalLogger::WriteStruct<frc::Pose2d>("CameraPoseEst/pose", cameraPoseEst.pose);
      SignalLogger::WriteValue("CameraPoseEst/timestamp", cameraPoseEst.timestamp);
   }

   void FetchInputsReplay()
   {
      /* NEW: pull out inputs from the log */
      auto const cameraPoseEst_pose =
         HootReplay::GetStruct<frc::Pose2d>("CameraPoseEst/pose");
      auto const cameraPoseEst_timestamp =
         HootReplay::GetValue<units::second_t>("CameraPoseEst/timestamp");

      if (
         cameraPoseEst_pose.status.IsOK() &&
         cameraPoseEst_timestamp.status.IsOK()
      ) {
         cameraPoseEst = PoseEstimate{};
         cameraPoseEst.pose = cameraPoseEst_pose.value.value();
         cameraPoseEst.timestamp = cameraPoseEst_timestamp.value;
      }
   }

public:
   void Periodic()
   {
      /* NEW: fetch the correct inputs based on if we're in replay */
      if (utils::IsReplay()) {
         FetchInputsReplay();
      } else {
         FetchInputs();
      }

      // process inputs here...
   }
};
class Vision:
   def __init__(self):
      self._camera = Camera(...)
      # vision inputs
      self._camera_pose_est = PoseEstimate()

      # ...

   def _fetch_inputs(self):
      self._camera_pose_est = camera.getPoseEstimate()

      # NEW: manually log the inputs
      SignalLogger.write_struct("CameraPoseEst/pose", Pose2d, self._camera_pose_est.pose)
      SignalLogger.write_double("CameraPoseEst/timestamp", self._camera_pose_est.timestamp)

   def _fetch_inputs_replay(self):
      # NEW: pull out inputs from the log
      camera_pose_est_pose = HootReplay.get_struct("CameraPoseEst/pose")
      camera_pose_est_timestamp = HootReplay.get_double("CameraPoseEst/timestamp")

      if (
         camera_pose_est_pose.status.is_ok()
         and camera_pose_est_timestamp.status.is_ok()
      ):
         self._camera_pose_est = PoseEstimate()
         self._camera_pose_est.pose = camera_pose_est_pose.value
         self._camera_pose_est.timestamp = camera_pose_est_timestamp.value

   def periodic(self):
      # NEW: fetch the correct inputs based on if we're in replay
      if utils.is_replay():
         self._fetch_inputs_replay()
      else:
         self._fetch_inputs()

      # process inputs here...