Back to Journal
2026-07-17
Research Entry

Architecture: HAL Boundary, Sim Time, and 6 ADRs

ROS 2ArchitectureSim-to-RealHALADRMuJoCo

Part 2 — Architecture: HAL Boundary, Sim Time, and 6 ADRs

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 exec — setup.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