Skip to content

Commit 74e760b

Browse files
authored
Merge pull request #57 from KraHsu/fix/command-gate-and-metrics-parity
Align command gate + 5 gait metrics + dormant parity fixes
2 parents 481a274 + 44ad13a commit 74e760b

8 files changed

Lines changed: 632 additions & 98 deletions

File tree

‎examples/unitree/src/genelab_unitree/g1/env_cfg.py‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,10 @@ def unitree_g1_velocity_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg:
405405
"self_collisions": RewardTermCfg(
406406
func=mdp.self_collision_cost,
407407
weight=-1.0,
408-
params={"sensor_name": "self_collision", "force_threshold": 10.0},
408+
# Force threshold lives on ``SelfContactSensorCfg.force_threshold`` (10.0
409+
# above); the reward used to accept a redundant param of the same name,
410+
# which is dropped here so the cfg has one source of truth.
411+
params={"sensor_name": "self_collision"},
409412
),
410413
},
411414
terminations_cfg={
@@ -446,9 +449,39 @@ def unitree_g1_velocity_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg:
446449
),
447450
},
448451
metrics_cfg={
449-
# Logged to ``Episode_Metrics/mean_action_acc``; smoother is better.
450-
# Name matches mjlab's metrics dict so tensorboard tags line up.
452+
# Logged to ``Episode_Metrics/<name>``; values mirror mjlab's
453+
# ``Metrics/<name>_mean`` cross-(env, foot) writes from inside the
454+
# reward bodies (see ``mjlab/.../velocity/mdp/rewards.py:205, 229,
455+
# 317, 352, 373``). GeneLab's MetricsManager averages over the
456+
# episode and reports at reset; mjlab averages over the rollout via
457+
# rsl-rl. Per-step scalars match; the time horizons differ.
451458
"mean_action_acc": MetricsTermCfg(func=mdp.mean_action_acc),
459+
"angular_momentum_mean": MetricsTermCfg(
460+
func=mdp.angular_momentum_mean,
461+
params={"sensor_name": "root_angmom"},
462+
),
463+
"air_time_mean": MetricsTermCfg(
464+
func=mdp.air_time_mean,
465+
params={"sensor_name": "feet_ground_contact"},
466+
),
467+
"slip_velocity_mean": MetricsTermCfg(
468+
func=mdp.slip_velocity_mean,
469+
params={
470+
"sensor_name": "feet_ground_contact",
471+
"asset_cfg": _feet_cfg(),
472+
},
473+
),
474+
"landing_force_mean": MetricsTermCfg(
475+
func=mdp.landing_force_mean,
476+
params={"sensor_name": "feet_ground_contact"},
477+
),
478+
"peak_height_mean": MetricsTermCfg(
479+
func=mdp.peak_height_mean,
480+
params={
481+
"sensor_name": "feet_ground_contact",
482+
"asset_cfg": _feet_cfg(),
483+
},
484+
),
452485
},
453486
)
454487

‎src/genelab/mdp/__init__.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@
1616
reset_joints_to_default,
1717
reset_root_state_uniform,
1818
)
19-
from genelab.mdp.metrics import mean_action_acc
19+
from genelab.mdp.metrics import (
20+
air_time_mean,
21+
angular_momentum_mean,
22+
landing_force_mean,
23+
mean_action_acc,
24+
peak_height_mean,
25+
slip_velocity_mean,
26+
)
2027
from genelab.mdp.noise import Gnoise, NoiseCfg, Unoise
2128
from genelab.mdp.observations import (
2229
base_ang_vel,
@@ -81,6 +88,8 @@
8188
"UniformVelocityCommandCfg",
8289
"Unoise",
8390
"action_rate_l2",
91+
"air_time_mean",
92+
"angular_momentum_mean",
8493
"angular_momentum_penalty",
8594
"bad_anchor_ori",
8695
"bad_anchor_pos_z_only",
@@ -105,6 +114,7 @@
105114
"joint_pos_limits",
106115
"joint_pos_rel",
107116
"joint_vel_rel",
117+
"landing_force_mean",
108118
"last_action",
109119
"mean_action_acc",
110120
"motion_anchor_ori_b",
@@ -115,6 +125,7 @@
115125
"motion_global_body_linear_velocity_error_exp",
116126
"motion_relative_body_orientation_error_exp",
117127
"motion_relative_body_position_error_exp",
128+
"peak_height_mean",
118129
"projected_gravity",
119130
"push_by_setting_velocity",
120131
"reset_joints_by_offset",
@@ -125,6 +136,7 @@
125136
"root_height_below",
126137
"self_collision_cost",
127138
"sensor_data",
139+
"slip_velocity_mean",
128140
"soft_landing",
129141
"terrain_levels_vel",
130142
"time_out",

‎src/genelab/mdp/_helpers.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Shared helpers used across :mod:`genelab.mdp` modules.
2+
3+
These were previously defined in :mod:`genelab.mdp.rewards` with a leading
4+
underscore (module-private). They're now used by :mod:`genelab.mdp.metrics`
5+
too — moving them here gives both modules a public-within-package import path
6+
and lets pyright stop warning about cross-module private access. They are not
7+
re-exported from :mod:`genelab.mdp` (no entry in ``mdp/__init__.py:__all__``);
8+
external code should keep importing the rewards / metrics functions, not these.
9+
"""
10+
11+
from typing import TYPE_CHECKING
12+
13+
import torch
14+
15+
from genelab.managers.scene_entity_cfg import SceneEntityCfg
16+
from genelab.sensor.contact import ContactSensor
17+
from genelab.utils.math import quat_apply
18+
19+
if TYPE_CHECKING:
20+
from genelab.envs.manager_based_rl_env import ManagerBasedRlEnv
21+
22+
23+
def link_ids(asset_cfg: SceneEntityCfg) -> tuple[int, ...]:
24+
"""Pull resolved link ids off an ``asset_cfg`` or fail loudly if they're missing.
25+
26+
``asset_cfg`` is normally resolved by ``managers._base.instantiate_class_term``
27+
at manager construction; this helper double-checks so a misconfigured term
28+
raises with a useful message instead of silently passing ``None`` to a
29+
tensor indexer.
30+
"""
31+
if asset_cfg.link_ids is None:
32+
raise ValueError(
33+
f"SceneEntityCfg(name={asset_cfg.name!r}) has no link_ids — set link_names "
34+
f"and let the manager resolve it, or set link_ids explicitly"
35+
)
36+
return asset_cfg.link_ids
37+
38+
39+
def contact_sensor(env: "ManagerBasedRlEnv", sensor_name: str) -> ContactSensor:
40+
"""Look up a :class:`ContactSensor` by name; raise ``TypeError`` if the wrong type."""
41+
sensor = env.sensors[sensor_name]
42+
if not isinstance(sensor, ContactSensor):
43+
raise TypeError(
44+
f"sensor {sensor_name!r} is not a ContactSensor (got {type(sensor).__name__})"
45+
)
46+
return sensor
47+
48+
49+
def command_active(env: "ManagerBasedRlEnv", command_name: str, threshold: float) -> torch.Tensor:
50+
"""Returns ``(B,)`` float mask: 1 where ``||cmd_xy|| + |cmd_z| > threshold``, else 0.
51+
52+
mjlab parity: each gait-shaping reward (``feet_clearance`` / ``feet_slip`` /
53+
``soft_landing`` / ``feet_swing_height``) computes the gate this way — sum of
54+
the linear-xy norm and the absolute yaw rate — rather than an L2 norm of the
55+
full 3-vector. The two formulas diverge near the threshold: for cmd
56+
``(0.03, 0, 0.03)`` and threshold 0.05, mjlab's gate fires (0.06 > 0.05)
57+
while the L2-of-3 version stays silent (≈0.042). Matters whenever the policy
58+
is asked to do gentle motion that doesn't break the L2 ball but does exceed
59+
the L1-y-axes envelope.
60+
"""
61+
cmd = env.command_manager.get_command(command_name)
62+
total = torch.norm(cmd[:, :2], dim=-1) + torch.abs(cmd[:, 2])
63+
return (total > threshold).float()
64+
65+
66+
def site_pos_w(
67+
env: "ManagerBasedRlEnv",
68+
indices: list[int],
69+
offsets: torch.Tensor | None,
70+
) -> torch.Tensor:
71+
"""World-frame site position for each selected link.
72+
73+
``site_pos_w = link_pos_w + R_link · offset_local``. With ``offsets=None``
74+
this reduces to ``link_pos_w`` — matches the pre-parity behaviour.
75+
76+
Returns ``(B, F, 3)`` aligned with ``indices``.
77+
"""
78+
rs = env.robot_state
79+
link_pos = rs.link_pos[:, indices] # (B, F, 3)
80+
if offsets is None:
81+
return link_pos
82+
link_quat = rs.link_quat_w[:, indices] # (B, F, 4)
83+
offsets_b = offsets.unsqueeze(0).expand_as(link_pos).contiguous()
84+
return link_pos + quat_apply(link_quat, offsets_b)
85+
86+
87+
def site_lin_vel_w(
88+
env: "ManagerBasedRlEnv",
89+
indices: list[int],
90+
offsets: torch.Tensor | None,
91+
) -> torch.Tensor:
92+
"""World-frame linear velocity at each selected link's site.
93+
94+
``v_site = v_link + ω × (R_link · offset_local)``. With ``offsets=None``
95+
this reduces to ``link_lin_vel_w`` — matches the pre-parity behaviour.
96+
97+
Returns ``(B, F, 3)`` aligned with ``indices``.
98+
"""
99+
rs = env.robot_state
100+
link_lin_vel = rs.link_lin_vel_w[:, indices] # (B, F, 3)
101+
if offsets is None:
102+
return link_lin_vel
103+
link_quat = rs.link_quat_w[:, indices] # (B, F, 4)
104+
link_ang_vel = rs.link_ang_vel_w[:, indices] # (B, F, 3)
105+
offsets_b = offsets.unsqueeze(0).expand_as(link_lin_vel).contiguous()
106+
r_world = quat_apply(link_quat, offsets_b)
107+
return link_lin_vel + torch.cross(link_ang_vel, r_world, dim=-1)

‎src/genelab/mdp/commands/velocity_command.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,12 @@ def _resample_command(self, env_ids: torch.Tensor) -> None:
153153

154154
# Standing envs override everything; assign last so an env that's both standing
155155
# and forward ends up at zero (matches mjlab precedence — standing wins).
156+
# Clear both ``_command`` and ``_command_w`` so the cached world-frame command
157+
# also reads zero for standing envs (mjlab parity — ``velocity_command.py:137-138``).
156158
standing_ids = env_ids[self._is_standing[env_ids]]
157159
if standing_ids.numel() > 0:
158160
self._command[standing_ids] = 0.0
161+
self._command_w[standing_ids] = 0.0
159162

160163
def _update_command(self) -> None:
161164
# 1. Heading PD overrides ωz for heading envs only.
@@ -184,4 +187,8 @@ def _update_command(self) -> None:
184187
self._command[w_ids, 1] = -sin_h * vx_w + cos_h * vy_w
185188

186189
# 3. Standing envs zero out — final assignment so it overrides heading/world.
190+
# mjlab parity (velocity_command.py:137-138): clear both buffers, not just the
191+
# body-frame one, so a standing env in world mode doesn't read a stale
192+
# ``_command_w`` value at the next ``_update_command`` step before resample.
187193
self._command[self._is_standing] = 0.0
194+
self._command_w[self._is_standing] = 0.0

‎src/genelab/mdp/metrics.py‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,28 @@
44
tensorboard via ``env.extras["log"]["Episode_Metrics/<name>"]``. Useful for surface
55
indicators (action smoothness, sensor health, etc.) where a weight-zero reward
66
term is the wrong tool because it bakes the metric into the reward bookkeeping.
7+
8+
mjlab logs the corresponding diagnostics as ``Metrics/<name>_mean`` directly from
9+
inside the reward functions, then rsl-rl averages them across a rollout. GeneLab's
10+
:class:`~genelab.managers.metrics_manager.MetricsManager` instead averages over
11+
an episode and reports at env reset under ``Episode_Metrics/<name>``. The
12+
per-step scalar is the same; the time horizon (rollout vs episode) differs by
13+
a constant factor for diagnostic comparison.
714
"""
815

916
from typing import TYPE_CHECKING
1017

1118
import torch
1219

20+
from genelab.managers.reward_manager import RewardTermCfg
21+
from genelab.managers.scene_entity_cfg import SceneEntityCfg
22+
from genelab.mdp._helpers import (
23+
contact_sensor as _contact_sensor,
24+
link_ids as _link_ids,
25+
site_lin_vel_w as _site_lin_vel_w,
26+
site_pos_w as _site_pos_w,
27+
)
28+
1329
if TYPE_CHECKING:
1430
from genelab.envs.manager_based_rl_env import ManagerBasedRlEnv
1531

@@ -29,3 +45,127 @@ def mean_action_acc(env: "ManagerBasedRlEnv") -> torch.Tensor:
2945
a_prev_prev = env.action_manager.prev_prev_action
3046
accel = a - 2.0 * a_prev + a_prev_prev
3147
return torch.mean(torch.abs(accel), dim=-1)
48+
49+
50+
# --------------------------------------------------------------------- gait diagnostics
51+
#
52+
# The five metrics below mirror mjlab's ``Metrics/{air_time,peak_height,
53+
# slip_velocity,landing_force,angular_momentum}_mean`` writes (see
54+
# ``mjlab/tasks/velocity/mdp/rewards.py:205, 229, 317, 352, 373``). Each
55+
# computes mjlab's exact cross-(env, foot) scalar each step, then broadcasts
56+
# to ``(B,)`` so :class:`MetricsManager` can accumulate it uniformly across
57+
# envs — the broadcast collapses cleanly at reset because every env sees the
58+
# same value.
59+
60+
61+
def _broadcast_scalar(value: torch.Tensor, env: "ManagerBasedRlEnv") -> torch.Tensor:
62+
"""Expand a 0-dim tensor to ``(B,)`` so MetricsManager can accumulate per-env."""
63+
return value.expand(env.num_envs)
64+
65+
66+
def angular_momentum_mean(env: "ManagerBasedRlEnv", sensor_name: str) -> torch.Tensor:
67+
"""``||L||`` of the root-frame angular momentum.
68+
69+
mjlab logs ``torch.mean(angmom_magnitude)`` (a cross-env scalar) inside the
70+
``angular_momentum_penalty`` reward; per-env ``||L||`` averaged over the
71+
episode gives the same per-batch value when MetricsManager reduces across
72+
envs at reset.
73+
"""
74+
angmom = env.sensors[sensor_name].data
75+
return torch.norm(angmom, dim=-1)
76+
77+
78+
def air_time_mean(env: "ManagerBasedRlEnv", sensor_name: str) -> torch.Tensor:
79+
"""Mean ``current_air_time`` across (env, foot) pairs that are in the air.
80+
81+
Replicates mjlab's ``Metrics/air_time_mean`` in ``feet_air_time``: sum of
82+
air times divided by number of mid-air feet (clamped to 1). Broadcasted to
83+
``(B,)`` for MetricsManager.
84+
"""
85+
data = _contact_sensor(env, sensor_name).data
86+
current_air_time = data.current_air_time
87+
in_air = current_air_time > 0
88+
in_air_f = in_air.float()
89+
num_in_air = in_air_f.sum().clamp(min=1.0)
90+
mean = (current_air_time * in_air_f).sum() / num_in_air
91+
return _broadcast_scalar(mean, env)
92+
93+
94+
def slip_velocity_mean(
95+
env: "ManagerBasedRlEnv", sensor_name: str, asset_cfg: SceneEntityCfg
96+
) -> torch.Tensor:
97+
"""Mean ``||v_xy||`` across grounded (env, foot) pairs.
98+
99+
Replicates mjlab's ``Metrics/slip_velocity_mean`` in ``feet_slip``. Reads
100+
site-frame foot velocity (``link_offsets`` honoured) and weights by
101+
in-contact bool. Broadcasted to ``(B,)``.
102+
"""
103+
indices = list(_link_ids(asset_cfg))
104+
data = _contact_sensor(env, sensor_name).data
105+
in_contact = data.found.float() # (B, F)
106+
foot_vel_xy = _site_lin_vel_w(env, indices, asset_cfg.link_offsets_tensor)[..., :2]
107+
vel_norm = torch.norm(foot_vel_xy, dim=-1) # (B, F)
108+
num_in_contact = in_contact.sum().clamp(min=1.0)
109+
mean = (vel_norm * in_contact).sum() / num_in_contact
110+
return _broadcast_scalar(mean, env)
111+
112+
113+
def landing_force_mean(env: "ManagerBasedRlEnv", sensor_name: str) -> torch.Tensor:
114+
"""Mean ``|F|`` across (env, foot) pairs that just landed.
115+
116+
Replicates mjlab's ``Metrics/landing_force_mean`` in ``soft_landing``:
117+
contact-force magnitude on the substep ``first_contact`` fires, averaged
118+
over the landings (clamped to 1). Broadcasted to ``(B,)``.
119+
"""
120+
data = _contact_sensor(env, sensor_name).data
121+
first_contact = data.first_contact.float()
122+
landing_impact = data.force_norm * first_contact
123+
num_landings = first_contact.sum().clamp(min=1.0)
124+
mean = landing_impact.sum() / num_landings
125+
return _broadcast_scalar(mean, env)
126+
127+
128+
class peak_height_mean:
129+
"""Mean swing-apex height across (env, foot) pairs at landing.
130+
131+
Stateful: tracks per-foot peak height during the air phase, samples the
132+
peak on the substep ``first_contact`` fires, then resets the slot for the
133+
next swing. Mirrors :class:`~genelab.mdp.rewards.feet_swing_height`'s
134+
bookkeeping (separate buffer so the metric works whether the reward is
135+
weight=0 or active — currently weight=−0.25 in G1).
136+
137+
Replicates mjlab's ``Metrics/peak_height_mean`` in ``feet_swing_height``
138+
(``mjlab/.../rewards.py:317``). Broadcasted to ``(B,)`` for MetricsManager.
139+
"""
140+
141+
def __init__(self, cfg: RewardTermCfg, env: "ManagerBasedRlEnv") -> None:
142+
asset_cfg: SceneEntityCfg = cfg.params["asset_cfg"]
143+
indices = list(_link_ids(asset_cfg))
144+
self._foot_indices: list[int] = indices
145+
self._offsets_tensor: torch.Tensor | None = asset_cfg.link_offsets_tensor
146+
self._peak_heights = torch.zeros(env.num_envs, len(indices), device=env.device)
147+
148+
def __call__(
149+
self,
150+
env: "ManagerBasedRlEnv",
151+
sensor_name: str,
152+
asset_cfg: SceneEntityCfg,
153+
) -> torch.Tensor:
154+
del asset_cfg # consumed at __init__
155+
data = _contact_sensor(env, sensor_name).data
156+
foot_z = _site_pos_w(env, self._foot_indices, self._offsets_tensor)[..., 2]
157+
# Accumulate the peak while airborne.
158+
in_air = ~data.found
159+
self._peak_heights = torch.where(
160+
in_air, torch.maximum(self._peak_heights, foot_z), self._peak_heights
161+
)
162+
first_contact = data.first_contact.float()
163+
peak_at_landing = self._peak_heights * first_contact
164+
num_landings = first_contact.sum().clamp(min=1.0)
165+
mean = peak_at_landing.sum() / num_landings
166+
# Zero the peak buffer for feet that just landed so the next swing
167+
# starts fresh.
168+
self._peak_heights = torch.where(
169+
data.first_contact, torch.zeros_like(self._peak_heights), self._peak_heights
170+
)
171+
return _broadcast_scalar(mean, env)

0 commit comments

Comments
 (0)