Back to Journal
2026-07-17
Research Entry

Architecture: HAL Boundary, Sim Time, and 6 ADRs

ROS 2ArchitectureSim-to-RealHALADRMuJoCo

The entire project is one control loop. nav publishes a Twist, kinematics converts v,ω → ωL,ωR, the HAL writes it into MuJoCo, mj_step advances the world, sensors flow back, and nav recomputes. Everything else in this post — the HAL boundary, sim time, the six ADRs — exists to keep that loop honest across sim and real, across a developer laptop and a CI runner.

One Data Loop

flowchart LR
  nav["otonav_nav<br/>P-controller + lidar stop"]
  cmd["/cmd_vel<br/>(geometry/Twist)"]
  kin["otonav_control<br/>diff-drive kinematics"]
  hal["RobotHardwareInterface<br/>(HAL boundary)"]
  mj["MuJoCo<br/>mj_step ×N"]
  sens["sensordata<br/>qpos/qvel/rangefinder"]
  odom["/odom /scan /imu"]
  clock["/clock<br/>(sim time)"]

  nav -->|"/cmd_vel"| cmd
  cmd --> kin
  kin -->|WheelCommand| hal
  hal -->|"ctrl[]"| mj
  mj --> sens
  sens --> hal
  hal --> odom
  odom -->|use_sim_time:=true| nav
  clock --> nav
  clock --> hal

The bridge owns /clock and is the sim-time authority. Every other node runs with use_sim_time:=true and reads the clock from /clock. There is no wall-clock in this loop in CI — only sim time, which advances exactly as fast as mj_step is called. On a fast runner that's 100 Hz of sim time per 100 Hz of wall; on a loaded runner it's still 100 Hz of sim time, just delivered slower in wall time. The tests don't care which.

The HAL Boundary — Sim-to-Real in One Box

This is the heart of the project, and it's a single abstract class. RobotHardwareInterface defines the contract between the ROS world above and the physics world below:

classDiagram
  class RobotHardwareInterface {
    <<abstract>>
    +write(WheelCommand)
    +step()
    +sim_time() double
    +read_wheels() WheelState
    +read_imu() ImuSample
    +read_scan() RangeScan
  }
  class MujocoInterface {
    -mjModel* m
    -mjData* d
    +write(WheelCommand)
    +step()
    +sim_time() double
    +read_wheels() WheelState
    +read_imu() ImuSample
    +read_scan() RangeScan
  }
  class CanBusInterface {
    <<stub>>
    +write(WheelCommand)
    +step()
    +read_wheels() WheelState
    +read_imu() ImuSample
    +read_scan() RangeScan
  }
  RobotHardwareInterface <|-- MujocoInterface
  RobotHardwareInterface <|-- CanBusInterface

The interface lives in otonav_mujoco_bridge/include/otonav_mujoco_bridge/robot_hardware_interface.hpp. MujocoInterface is the only real implementation; CanBusInterface is a stub waiting for the day we bolt on real motors.

Hard Rule #4: MuJoCo types are confined to a single .cpp file. The header mujoco_interface.hpp forward-declares mjModel and mjData as opaque pointers. Nothing above the HAL ever #includes mujoco.h. The control code, the nav code, the test code — none of them know MuJoCo exists. They know WheelCommand, WheelState, ImuSample, RangeScan, and that's it.

Sim-to-real is then literally a swap of one box in the diagram. Replace MujocoInterface with CanBusInterface and the entire stack above — kinematics, nav, bringup, all 45 tests — stays byte-identical. That's the thesis: the abstraction isn't cosmetic, it's the load-bearing wall. If a real-hardware change ever forces a change in otonav_control or otonav_nav, the HAL failed and we rethink it.

Why not ros2_control? It's the standard ROS 2 answer, and we deliberately did not take it. ADR-4 documents this as a time-boxed choice: building a thin HAL ourselves cost a weekend and gave us a contract tuned to our robot. Migrating to ros2_control later is a documented stretch path — the interface philosophy is identical, the adapter is mechanical. We chose speed-to-CI over ecosystem fit, and we wrote down the trade.

Sim Time (ADR-2)

Wall-clock timeouts are flaky tests waiting to happen. A GitHub Actions runner has unpredictable CPU: sometimes it's snappy, sometimes it's a throttled vCPU shared with a noisy neighbor. A test that asserts "the robot reaches the goal in 5 seconds" against wall time passes on your laptop and fails intermittently in CI for reasons that have nothing to do with your code.

flowchart LR
  subgraph Bridge["Bridge node (use_sim_time:=false)"]
    wall["wall_timer @ 100Hz"]
    step["mj_step ×N"]
    pubclk["publish /clock"]
  end
  subgraph Nodes["All other nodes (use_sim_time:=true)"]
    sub["subscribe /clock"]
    logic["test logic<br/>asserts on sim time"]
  end
  wall --> step --> pubclk --> sub --> logic

The bridge publishes /clock; every node consumes it with use_sim_time:=true. Tests assert on sim time, which advances deterministically with mj_step regardless of how fast the host CPU is. A test that says "reach goal by t=5.0s" means 5.0 seconds of simulation, not 5.0 seconds of wall.

The subtlety that bit us: the bridge that produces /clock cannot consume it. If the bridge ran use_sim_time:=true, it would wait for a clock message that it itself has to publish — a deadlock. So the bridge runs use_sim_time:=false and drives the loop with a wall_timer at 100 Hz, calling mj_step N times per cycle and publishing /clock afterward. It's the one node that lives in wall time so everything else can live in sim time.

No sleep() in tests. This is a hard rule in the test suite. Conditional waits only — spin_until_future_complete on a future, or a predicate loop that checks "has the goal been reached?" against sim time. A rclcpp::sleep_for(2s) would couple the test to wall-clock speed and reintroduce the exact flakiness sim time was meant to kill.

ADR-1: Headless via Runtime Flag, Not #ifdef

mj_step has zero OpenGL dependency. The physics step doesn't render; it integrates the equations of motion. The viewer is a separate, optional concern.

One binary serves both the developer's screen and the CI runner. Launch with gui:=true and you get the viewer window; launch with gui:=false in CI and the viewer is simply never instantiated. Same binary, same code path, same physics.

#ifdef HEADLESS would create two divergent binaries — one you develop with, one CI tests. That directly violates "test what you ship." If offscreen rendering is ever needed (e.g., for a vision-based test), MUJOCO_GL=egl flips the GL backend at runtime without a recompile. The decision is encoded in the environment, not the compiler.

ADR-3: DDS in CI — Simplest Thing First

GitHub-hosted runners block multicast but loopback works fine. ROS 2's default DDS profile assumes multicast discovery, which a shared CI runner won't allow. The fix is layered:

Layer Mechanism When it kicks in
1 (default) ROS_LOCALHOST_ONLY=1 + rmw_fastrtps_cpp Almost always sufficient in CI
2 (fallback) config/fastdds_ci_profile.xml — UDPv4 unicast, initialPeers=127.0.0.1 When Layer 1 still can't discover
3 (manual) Explicit peer list in profile For self-hosted runners with odd networking

We don't ship Layer 2 as the default because Layer 1 works for ~95% of runs and adding a custom profile is one more thing to maintain. But the fallback is written, committed, and documented — when a runner image flips its network policy, we flip one env var, not the architecture.

ADR-5: Multi-Stage Docker with a Correct Entrypoint

Two stages, one image family. The builder carries the full toolchain — ROS 2 base, MuJoCo SDK, colcon, every build dep — and runs colcon build. The runner stage starts from ros-core, installs only runtime libraries, and copies the install/ tree from the builder. The final image is small and has nothing a compiler needs.

The critical line is in entrypoint.sh: source /opt/ros/humble/setup.bash && source /install/setup.bash && exec "$@". Note source, not execsetup.bash exports environment variables into the current shell. If you exec it, the variables vanish with the child process. Getting this wrong produces the classic "command not found: ros2" inside the container, and it's a 30-minute waste of a CI cycle.

ccache is volume-mounted and reused across runs in CI. The builder stage hits the cache on anything short of a clean rebuild, taking cold-build times from minutes to seconds on incremental changes.

ADR-6: Merge Queue as Integration Gate

The textbook integration-engineer problem: two PRs pass CI in isolation, both land on main, main is red. Classical SE calls this the integration problem and solves it with a dedicated integration branch and a person. We solve it with platform tooling.

GitHub's native merge queue re-tests each PR against the latest main before merging. When you add a PR to the queue, GitHub updates it with the tip of main, reruns the required checks, and only merges if they pass. "Passed on my branch, broke on main" becomes a category that structurally cannot happen — the queue is the integration engineer, and it doesn't sleep.

Package Layout

Package Role
otonav_description MJCF robot model + scene (sensor sites, floor, walls)
otonav_mujoco_bridge C++ MuJoCo bridge — the HAL boundary lives here
otonav_control Diff-drive kinematics, header-only, no ROS deps (unit-tested in isolation)
otonav_nav Go-to-goal P-controller + lidar stop-on-obstacle
otonav_bringup Launch files + SIL test scenarios

otonav_control having zero ROS dependencies is deliberate. It's pure math: Twist → wheel velocities → odometry. That purity is what lets us unit-test it with a plain gtest binary, no rclcpp spin, no nodes, no DDS — the fastest and most reliable tier in the test pyramid.

ADR Summary

ADR Decision Key trade
1 Headless via runtime gui:=false, not #ifdef One binary, not two
2 Single sim clock; bridge produces, nodes consume No wall-clock flakiness in CI
3 DDS in CI: ROS_LOCALHOST_ONLY=1 first, profile fallback Simplicity over generality
4 Custom HAL instead of ros2_control (time-boxed) Speed-to-CI now; migration path documented
5 Multi-stage Docker, source not exec in entrypoint Small image, correct env
6 Merge queue as integration gate Platform tooling replaces process discipline

Six decisions, all reversible, all written down. The point of an ADR isn't the decision — it's the why. When someone (including future me) asks "why didn't we just use ros2_control?", the answer is one grep away.

What's Next

The architecture is settled, the contracts are drawn, and the ADRs are filed. Part 3 takes this skeleton and wraps it in the CI/CD pipeline that actually enforces it — the GitHub Actions workflow, the Docker build, the merge queue configuration, and the exact sequence of jobs that turns a git push into a green check or a red X. That's where the abstract promises of this post meet the concrete YAML that keeps them.

End of Protocol — part-2-architecture-hal-sim-time-adrs.md