Skip to content

feat(go2): RPP Trajectory controller + decoupled pub/sub benchmark & scoring#2605

Open
mustafab0 wants to merge 1 commit into
mainfrom
mustafa/go2-controller-benchmark
Open

feat(go2): RPP Trajectory controller + decoupled pub/sub benchmark & scoring#2605
mustafab0 wants to merge 1 commit into
mainfrom
mustafa/go2-controller-benchmark

Conversation

@mustafab0

Copy link
Copy Markdown
Contributor

A clean Go2 regulated-pure-pursuit controller and a decoupled benchmark off main.

The controller and benchmark are separate Modules that talk only over LCM.

The controller owns its calibration (loads the tuning artifact itself); the benchmark and scorer know nothing about it. Scoring is a separate offline step.

Interface:

Topic Type Flow
/path nav_msgs/Path benchmark → controller
/speed std_msgs/Float32 benchmark → controller
/go2/odom geometry_msgs/PoseStamped controller → benchmark
/cmd_vel geometry_msgs/Twist controller → benchmark
/benchmark/gate std_msgs/Int8 operator (teleop) → benchmark

Needs a 5x5 m open space

Run the benchmark (one terminal):

dimos run unitree-go2-rpp-benchmark

Focus the pygame window: WASD to position the robot, ENTER to start each run (K = skip, Backspace = quit). Completion is detected from odom automatically.

Score the recorded runs (offline):

python -m dimos.utils.benchmarking.score data/benchmark/go2

Swap in a different controller: make a Module that subscribes to /path + /speed and publishes /go2/odom + /cmd_vel, then compose it with Benchmarker in a blueprint in place of unitree_go2_rpp_controller. Drive a controller standalone (paths from any source) with:

dimos run unitree-go2-rpp-controller

@mustafab0 mustafab0 changed the title feat(go2): RPP controller + decoupled pub/sub benchmark & scoring feat(go2): RPP Trajecotry controller + decoupled pub/sub benchmark & scoring Jun 26, 2026
@mustafab0 mustafab0 changed the title feat(go2): RPP Trajecotry controller + decoupled pub/sub benchmark & scoring feat(go2): RPP Trajectory controller + decoupled pub/sub benchmark & scoring Jun 26, 2026
@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
1997 2 1995 159
View the top 2 failed test(s) by shortest run time
dimos.codebase_checks.test_no_sections::test_no_section_markers
Stack Traces | 0.517s run time
def test_no_section_markers():
        """
        Fail if any file contains section-style comment markers.
    
        If a file is too complicated to be understood without sections, then the
        sections should be files. We don't need "subfiles".
        """
        violations = find_section_markers()
        if violations:
            report_lines = [
                f"Found {len(violations)} section marker(s). "
                "If a file is too complicated to be understood without sections, "
                'then the sections should be files. We don\'t need "subfiles".',
                "",
            ]
            for path, lineno, text in violations:
                report_lines.append(f"  {path}:{lineno}: {text.strip()}")
>           raise AssertionError("\n".join(report_lines))
E           AssertionError: Found 52 section marker(s). If a file is too complicated to be understood without sections, then the sections should be files. We don't need "subfiles".
E           
E             .../control/tasks/velocity_profiler.py:79: # ------------------------------------------------------------------
E             .../control/tasks/velocity_profiler.py:81: # ------------------------------------------------------------------
E             .../tasks/path_follower_task/test_path_follower_task.py:63: # --- adaptive lookahead ----------------------------------------------------
E             .../tasks/path_follower_task/test_path_follower_task.py:103: # --- yaw-rate clamp --------------------------------------------------------
E             .../tasks/path_follower_task/test_path_follower_task.py:137: # --- forward-only contract -------------------------------------------------
E             .../tasks/path_follower_task/test_path_follower_task.py:161: # --- configure() signature -------------------------------------------------
E             .../tasks/path_follower_task/path_follower_task.py:97: # --- Regulated-pure-pursuit adaptive lookahead (RPP's own knob) ---
E             .../tasks/path_follower_task/path_follower_task.py:195: # ------------------------------------------------------------------
E             .../tasks/path_follower_task/path_follower_task.py:197: # ------------------------------------------------------------------
E             .../tasks/path_follower_task/path_follower_task.py:316: # ------------------------------------------------------------------
E             .../tasks/path_follower_task/path_follower_task.py:318: # ------------------------------------------------------------------
E             .../tasks/path_follower_task/path_follower_task.py:404: # ------------------------------------------------------------------
E             .../tasks/path_follower_task/path_follower_task.py:406: # ------------------------------------------------------------------
E             .../tasks/rpp_path_follower_task/test_rpp_path_follower_task.py:231: # --- tangent-heading synthesis (for position-only planners e.g. MLS) -----------
E             .../utils/benchmarking/benchmark.py:124: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/benchmark.py:126: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/benchmark.py:195: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/benchmark.py:197: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/benchmark.py:294: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/benchmark.py:296: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/benchmark.py:350: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/benchmark.py:352: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/paths.py:78: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/paths.py:80: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/paths.py:190: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/paths.py:192: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/paths.py:344: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/paths.py:346: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/plant.py:213: # --- Vendored fitted FOPDT plant for the Go2 base ------------------------
E             .../utils/benchmarking/plant.py:241: # --- Vendored fitted FOPDT plant for the FlowBase ------------------------
E             .../utils/benchmarking/plant.py:273: # --- Per-robot profile (single source of truth for robot specifics) -----
E             .../utils/benchmarking/score.py:130: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/score.py:132: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:60: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:62: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:112: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:114: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:137: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:139: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:194: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/test_benchmark.py:196: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/tuning.py:60: # --- DERIVE tunable constants (documented; single source of truth) -------
E             .../utils/benchmarking/tuning.py:93: # --- Artifact schema -----------------------------------------------------
E             .../utils/benchmarking/tuning.py:208: # --- methodology v2: floor/ceiling envelope + per-amplitude tables -------
E             .../utils/benchmarking/tuning.py:290: # --- serialization ---
E             .../utils/benchmarking/tuning.py:374: # --- helpers -------------------------------------------------------------
E             .../utils/benchmarking/tuning.py:539: # --- DERIVE: pure model -> config ---------------------------------------
E             .../utils/benchmarking/tuning.py:728: # --- tolerance -> max-safe-speed inversion (pure) ------------------------
E             .../utils/benchmarking/scoring.py:68: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/scoring.py:70: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/scoring.py:115: # ---------------------------------------------------------------------------
E             .../utils/benchmarking/scoring.py:117: # ---------------------------------------------------------------------------

lineno     = 117
path       = '.../utils/benchmarking/scoring.py'
report_lines = ['Found 52 section marker(s). If a file is too complicated to be understood without sections, then the sections should...sk/test_path_follower_task.py:103: # --- yaw-rate clamp --------------------------------------------------------', ...]
text       = '# ---------------------------------------------------------------------------'
violations = [('.../control/tasks/velocity_profiler.py', 79, '    # -------------------------------------------------------------...est_path_follower_task.py', 161, '# --- configure() signature -------------------------------------------------'), ...]

dimos/codebase_checks/test_no_sections.py:145: AssertionError
dimos.codebase_checks.test_no_all::test_no_all
Stack Traces | 4.59s run time
def test_no_all():
        """Fail if any file defines `__all__`."""
        dimos_dir = DIMOS_PROJECT_ROOT / "dimos"
        hits = find_all_definitions()
        if hits:
            listing = "\n".join(f"  - {p.relative_to(dimos_dir)}:{lineno}" for p, lineno in hits)
>           raise AssertionError(
                f"Found __all__ definition(s) in dimos/:\n{listing}\n\n"
                "__all__ is not allowed. We don't use `from x import *`, so __all__ "
                "lists serve no purpose and are tedious to maintain. Remove them. For "
                "an import that exists purely to be re-exported, use `# noqa: F401`."
            )
E           AssertionError: Found __all__ definition(s) in dimos/:
E             - control/tasks/feedforward_gain_compensator.py:93
E             - .../tasks/path_follower_task/path_follower_task.py:611
E             - .../tasks/rpp_path_follower_task/rpp_path_follower_task.py:241
E             - control/tasks/velocity_profiler.py:158
E             - control/tasks/velocity_tracking_pid.py:167
E             - .../blueprints/basic/unitree_go2_rpp_benchmark.py:79
E             - .../blueprints/basic/unitree_go2_rpp_controller.py:147
E             - utils/benchmarking/gate.py:25
E             - utils/benchmarking/paths.py:469
E             - utils/benchmarking/plant.py:490
E             - utils/benchmarking/tuning.py:772
E             - utils/benchmarking/velocity_profile.py:135
E           
E           __all__ is not allowed. We don't use `from x import *`, so __all__ lists serve no purpose and are tedious to maintain. Remove them. For an import that exists purely to be re-exported, use `# noqa: F401`.

dimos_dir  = PosixPath('.../dimos/dimos/dimos')
hits       = [(PosixPath('.../dimos/dimos/dimos/control/tasks/feedforward_gain_compensator.py'), 93), (PosixPath('/ho...xPath('.../dimos/dimos/dimos/.../blueprints/basic/unitree_go2_rpp_benchmark.py'), 79), ...]
listing    = '  - control/tasks/feedforward_gain_compensator.py:93\n  - .../tasks/path_follower_task/path_follower_task.py:611\... utils/benchmarking/plant.py:490\n  - utils/benchmarking/tuning.py:772\n  - utils/benchmarking/velocity_profile.py:135'

dimos/codebase_checks/test_no_all.py:51: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a regulated-pure-pursuit (RPP) path-follower task and a decoupled benchmark/scoring pipeline for the Unitree Go2. The controller self-calibrates from a vendored tuning artifact on first use, and the benchmark talks to it purely over LCM (/path, /speed, /go2/odom, /cmd_vel, /benchmark/gate) with no import-level coupling.

  • RPPPathFollowerTask extends PathFollowerTask with lazy artifact loading (feedforward gain, curvature speed cap, yaw-rate clamp from the FOPDT characterization artifact) and a tangent-heading synthesizer for planners that stamp identity orientation.
  • Benchmarker runs a fixed path battery, anchors each path to the robot's current odom, records ticks, detects odom-based completion, and writes per-run JSON; scoring is a fully separate offline step (score.py) that reads recordings and emits CTE metrics + tolerance-inversion tables.
  • ControlCoordinator gains path: In[Path] and speed: In[Float32] ports with corresponding _on_path / _on_speed dispatch to any task implementing set_path / set_speed.

Confidence Score: 4/5

Safe to merge; all findings are quality-oriented and none affect core runtime correctness for the intended use case.

The architecture is clean: the benchmarker and controller are genuinely decoupled over LCM, artifact loading is lazy and well-guarded, and the scoring pipeline correctly separates acquisition from analysis. The assert in the forward_only hot path disappears with -O but the invariant it guards (vy == 0 from pure pursuit) holds structurally. The yaw-spread check in _with_tangent_headings uses max−min instead of a wraparound-safe accumulation, which could suppress tangent-heading synthesis for paths that straddle the ±π boundary, though no current benchmark path exercises this. The remaining findings are naming conventions and a minor double-call.

path_follower_task.py (assert in control loop), rpp_path_follower_task.py (yaw spread check), tuning.py (FopdtChannelParamsLike naming + re_derive_config export).

Important Files Changed

Filename Overview
dimos/control/tasks/path_follower_task/path_follower_task.py Core path-follower task, extended with adaptive lookahead, yaw-rate clamp, and set_path/set_speed coordinator hooks. Uses assert in production hot path for forward_only invariant.
dimos/control/tasks/rpp_path_follower_task/rpp_path_follower_task.py RPP subclass that self-calibrates from a tuning artifact on first start_path; yaw-spread degenerate check in _with_tangent_headings uses max-min which doesn't handle ±π wraparound.
dimos/utils/benchmarking/benchmark.py Benchmarker module: path battery runner, OdomRecorder, CompletionMonitor. Clean decoupling via LCM. path_set() called twice in _run (log + iteration) is a minor redundancy.
dimos/utils/benchmarking/score.py Offline scorer: loads run recordings, computes CTE/heading metrics, inverts tolerance→max-speed, emits JSON + matplotlib plots. Clean separation from acquisition.
dimos/utils/benchmarking/tuning.py Tuning artifact schema and DERIVE step. re_derive_config and FopdtChannelParamsLike (PascalCase function) are not exported in all; minor naming inconsistency.
dimos/control/coordinator.py Adds path/speed In ports with _on_path/_on_speed dispatch to tasks implementing set_path/set_speed; subscriptions gated on task capability checks; unsubscription handled in stop().
dimos/utils/benchmarking/scoring.py Pure geometric scorer computing CTE (nearest-segment), heading error, smoothness integral. No coupling to specific controller or transport.
dimos/robot/unitree/go2/blueprints/basic/unitree_go2_rpp_controller.py Blueprint wiring GO2Connection + ControlCoordinator (vel_go2 + rpp_follower tasks) + KeyboardTeleop. Transport mappings look correct.
dimos/robot/unitree/go2/blueprints/basic/unitree_go2_rpp_benchmark.py Composes controller blueprint with Benchmarker; correctly remaps odom topic to /go2/odom for the benchmarker.

Reviews (1): Last reviewed commit: "feat(go2): RPP controller + decoupled pu..." | Re-trigger Greptile

Comment on lines +301 to +303
if self._config.forward_only:
assert abs(vy) < 1e-6, f"PathFollowerTask forward_only: vy={vy} must be 0"
vx = max(0.0, vx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Using assert inside the control-loop compute() method is fragile: Python's -O (optimize) flag strips all assert statements at runtime, so this check silently disappears in an optimized deployment. If vy were ever non-zero (e.g., due to a future PController change), the robot would receive an unexpected lateral velocity without any error. An explicit guard with an early return is safer.

Suggested change
if self._config.forward_only:
assert abs(vy) < 1e-6, f"PathFollowerTask forward_only: vy={vy} must be 0"
vx = max(0.0, vx)
if self._config.forward_only:
if abs(vy) > 1e-6:
logger.warning(
f"PathFollowerTask '{self._name}' forward_only: unexpected vy={vy:.6f}; clamping to 0"
)
vy = 0.0
vx = max(0.0, vx)

Comment on lines +90 to +92
yaws = [p.orientation.euler[2] for p in poses]
if max(yaws) - min(yaws) > _HEADING_DEGENERATE_EPS:
return path # planner provided real per-pose headings; leave them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The heading-degenerate check uses max(yaws) - min(yaws), which fails when yaws straddle the ±π boundary. For example, a path with poses at yaw ≈ π and a few at yaw ≈ −π (same physical direction) would show a spread of ~2π and be incorrectly classified as having valid per-pose headings, suppressing tangent-heading synthesis. Using pairwise angle differences is more robust.

Suggested change
yaws = [p.orientation.euler[2] for p in poses]
if max(yaws) - min(yaws) > _HEADING_DEGENERATE_EPS:
return path # planner provided real per-pose headings; leave them.
yaws = [p.orientation.euler[2] for p in poses]
# Use pairwise angle differences to handle ±π wraparound correctly.
total_spread = sum(
abs(((b - a + math.pi) % (2 * math.pi)) - math.pi) for a, b in zip(yaws, yaws[1:])
)
if total_spread > _HEADING_DEGENERATE_EPS:
return path # planner provided real per-pose headings; leave them.

Comment on lines +424 to +430
logger.info(
f"Benchmarker: {cfg.robot} speeds={speeds} over {len(path_set())} paths "
f"(gate_source={cfg.gate_source}, out={out_root})"
)

idx = 0
for path_name, path in path_set().items():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 path_set() is called twice — once for the log message and once to drive the loop. Capturing it once avoids double-constructing all paths.

Suggested change
logger.info(
f"Benchmarker: {cfg.robot} speeds={speeds} over {len(path_set())} paths "
f"(gate_source={cfg.gate_source}, out={out_root})"
)
idx = 0
for path_name, path in path_set().items():
battery = path_set()
logger.info(
f"Benchmarker: {cfg.robot} speeds={speeds} over {len(battery)} paths "
f"(gate_source={cfg.gate_source}, out={out_root})"
)
idx = 0
for path_name, path in battery.items():

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +719 to +725
def FopdtChannelParamsLike(dc: FopdtChannelDC):
"""Lightweight adapter: derive_config wants a TwistBasePlantParams
(made of FopdtChannelParams), but the artifact stores them as
FopdtChannelDC. Return a duck-typed object with .K, .tau, .L."""
from dimos.utils.benchmarking.plant import FopdtChannelParams

return FopdtChannelParams(K=dc.K, tau=dc.tau, L=dc.L)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 FopdtChannelParamsLike is a plain function but named with PascalCase (reserved for classes in PEP 8), making every call site look like a constructor. Consider renaming to _fopdt_channel_as_params.

Suggested change
def FopdtChannelParamsLike(dc: FopdtChannelDC):
"""Lightweight adapter: derive_config wants a TwistBasePlantParams
(made of FopdtChannelParams), but the artifact stores them as
FopdtChannelDC. Return a duck-typed object with .K, .tau, .L."""
from dimos.utils.benchmarking.plant import FopdtChannelParams
return FopdtChannelParams(K=dc.K, tau=dc.tau, L=dc.L)
def _fopdt_channel_as_params(dc: FopdtChannelDC):
"""Lightweight adapter: derive_config wants a TwistBasePlantParams
(made of FopdtChannelParams), but the artifact stores them as
FopdtChannelDC. Return a duck-typed object with .K, .tau, .L."""
from dimos.utils.benchmarking.plant import FopdtChannelParams
return FopdtChannelParams(K=dc.K, tau=dc.tau, L=dc.L)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +570 to +583
def _on_path(self, msg: Path) -> None:
"""Forward a planned path + a fresh odom snapshot to any task exposing
``set_path(path, odom)``. Single-arg ``set_path(path)`` callers still
work (TypeError fallback)."""
odom = self._read_base_odom()
with self._task_lock:
for task in self._tasks.values():
set_path = getattr(task, "set_path", None)
if set_path is None:
continue
try:
set_path(msg, odom)
except TypeError:
set_path(msg) # backwards compat with single-arg set_path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 set_path called under _task_lock — blocks tick loop during path setup

_on_path holds _task_lock for the entire duration of the set_path call chain, which resolves to start_pathPathDistancer.__init__ (numpy cumulative-distance array) + VelocityProfiler.precompute_profile (forward/backward accel passes). For the benchmark's 100–200 waypoint paths this is sub-millisecond and harmless, but it becomes more visible with longer nav-stack paths. Consider copying task references before releasing the lock and doing the setup work outside it.

Comment on lines +667 to +716
def re_derive_config(
artifact: TuningConfig,
*,
vx_max: float = GO2_VX_MAX,
wz_max: float = GO2_WZ_MAX,
floor_probe_amplitudes: dict[str, list[float]] | None = None,
min_speed_floor: float = 0.0,
sag_threshold: float = 0.15,
) -> TuningConfig:
"""Post-hoc apply the current envelope + DERIVE logic to an existing
artifact. Uses the stored ``dynamics_by_amplitude`` +
``floor_probe_results`` — no re-run on hardware needed.

Useful after a methodology bugfix (the K-sag ceiling was too
conservative on noisy fits; switched to ``max(K·amp)`` for
operational use): pass the artifact through here and you get a
corrected JSON without re-collecting data.

Plant, FF (the canonical FOPDT) and provenance are passed through
unchanged — this only recomputes envelope + velocity_profile +
caveats."""
K_linear = {
"vx": artifact.plant.vx.K,
"vy": artifact.plant.vy.K,
"wz": artifact.plant.wz.K,
}
env = compute_envelope(
artifact.floor_probe_results,
artifact.dynamics_by_amplitude,
vx_cap=vx_max,
wz_cap=wz_max,
floor_probe_amplitudes=floor_probe_amplitudes,
K_linear=K_linear,
sag_threshold=sag_threshold,
)
plant = TwistBasePlantParams(
vx=FopdtChannelParamsLike(artifact.plant.vx),
vy=FopdtChannelParamsLike(artifact.plant.vy),
wz=FopdtChannelParamsLike(artifact.plant.wz),
)
return derive_config(
plant,
artifact.provenance,
vx_max=vx_max,
wz_max=wz_max,
velocity_envelope=env,
dynamics_by_amplitude=artifact.dynamics_by_amplitude,
floor_probe_results=artifact.floor_probe_results,
min_speed_floor=min_speed_floor,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 re_derive_config not exported in __all__

re_derive_config is the primary entry point for the "post-hoc re-derive from stored data" workflow documented in its docstring, but it is absent from __all__. Adding it (and compute_envelope) would align with the other public helpers already listed there.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant