You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ContactSensor.compute_first_contact(dt) / compute_first_air(dt) silently miss most of the
transitions they are supposed to report, because their default abs_tol=1e-8 is two to three
orders of magnitude smaller than the float32 rounding error of the timer they compare against.
Whether an event is reported depends on the magnitude of the simulation clock, so detection
quality changes over the course of an episode. Nothing raises, nothing warns — the events just
disappear, and rewards built on them (including isaaclab_tasks' own feet_air_time) silently
lose a large fraction of their signal.
Why the comparison is exact by construction. In update_contact_sensor_kernel, the interval
that ends at a transition sample is added to both the phase that just ended and the phase that
just started:
is_first_contact=in_contactand (cat>0.0)
ifis_first_contact:
last_air_time[env, sensor] =cat+elapsed_time# interval counted as air
...
current_contact_time[env, sensor] =wp.where(in_contact, cct+elapsed_time, 0.0) # ...and as contact
So on the first in-contact sample current_contact_time equals exactly one sensor update
interval. When the caller passes dt = env.step_dt and the sensor is updated at that same rate, compute_first_transition_kernel's test
ift>0.0andt<threshold: # threshold = dt + abs_tol
degenerates into dt < dt + abs_tol — it holds only by the tolerance margin.
Why the margin is not enough.elapsed_time is computed as timestamp[env] - timestamp_last_update[env] with float32 timestamps that grow with simulation time
(sensor_base.py: self._timestamp = wp.zeros(self._num_envs, dtype=wp.float32, ...)). The
float32 ULP at t ≈ 16 s is ≈ 1e-6, so accumulating four substeps carries an error of ≈ 4e-6 —
roughly 400× the default abs_tol=1e-8. The sign of that error flips with the clock magnitude,
which is what makes the failure look intermittent:
sim clock
current_contact_time read at the touchdown sample
< 0.02 + 1e-8 ?
0.02 s
0.020000000
detected
0.10 – 1.02 s
0.020000041
missed
2.02 – 15.98 s
0.020000458
missed
16.02 – 20.00 s
0.019996643
detected
SensorBase.reset() zeroes the timestamp per environment, so the clock is bounded by the episode
length — but a typical 20 s locomotion episode spends most of its time inside a bad band.
This is the same class of defect as mujocolab/mjlab#1101 (fixed upstream there in #1103 by accumulating the exact dt handed to update() instead of differencing a growing clock).
Steps to reproduce
The reproducer drives Isaac Lab's own kernels with a synthetic contact signal — no physics, no
Isaac Sim, no GPU (runs on CPU in ~3 s). The foot is in contact for one policy step, then in the
air for one policy step, with every transition landing exactly on a policy-step boundary, so every touchdown and lift-off must be reported.
physics_dt=0.005 decimation=4 step_dt=0.02 abs_tol=1e-08
touchdowns: 352/500 MISSED lift-offs: 352/500 MISSED
current_*_time read at the transition step (exact value is step_dt = 0.02):
0.02 s 0.020000000 ok
1.02 s 0.020000041 MISSED
2.02 s 0.020000458 MISSED
3.02 s 0.020000458 MISSED
...
15.02 s 0.020000458 MISSED
16.02 s 0.019996643 ok
17.02 s 0.019996643 ok
18.02 s 0.019996643 ok
19.02 s 0.019996643 ok
Passing abs_tol >= 1e-6 makes the same run report 0 missed events.
Isaac Sim Version: not installed — this is the Newton backend (isaaclab_newton), with
newton 1.5.0, mujoco_warp 3.11.0, warp 1.16.0, torch 2.11.0+cu128, Python 3.12.13
OS: Ubuntu 22.04.5 LTS
GPU: RTX 5090 D (the reproducer above runs on CPU)
CUDA: 12.9
GPU Driver: 580.142
Additional context
Effect in a real rollout. Rolling out a trained biped policy on flat ground (8 envs, 60 s) and
comparing both detectors against a substep-level ground truth taken from the Newton contact view:
Exposure inside this repo.isaaclab_tasks/core/velocity/mdp/rewards.py:41
(feet_air_time), isaaclab_tasks_experimental/core/velocity/mdp/rewards.py:63 and isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py:143 all call compute_first_contact(env.step_dt) with the default tolerance. How badly a given task is hit
depends on ContactSensorCfg.history_length, because it decides the sensor's update cadence:
history_length > 0 (e.g. velocity_env_cfg.py:113 uses 3) — BaseContactSensor.update()
refreshes the buffers every physics substep, so only transitions that land on a policy-step
boundary are knife-edge (≈ 25% of events for decimation=4). Measured on a rough-terrain task
with the same setting, on one rollout scored against the same ground truth: 19.9% of touchdowns
missed with the default tolerance versus 2.0% with abs_tol = 0.5 * physics_dt.
history_length == 0 (the config default) — buffers are refreshed lazily once per policy step,
so current_contact_time at a touchdown is always exactly step_dt and every event is
knife-edge. This is the 73% case measured above.
Possible fixes, roughly in increasing order of invasiveness:
The information the API is trying to recover already exists and is thrown away: update_contact_sensor_kernel computes is_first_contact / is_first_detached exactly, as a
boolean state comparison, uses them to write last_air_time / last_contact_time, and then
discards them. Persisting those flags (latched over the policy step when the sensor updates
faster than the caller polls) would make compute_first_contact exact and cost nothing.
Failing both, at least raise the default abs_tol (1e-6 is enough for realistic episode
lengths, but it is still a fixed tolerance against a scale-dependent error, so it only moves
the failure further out).
Related observation (not the main report). Because the transition interval is added to both
the ending and the starting phase, last_air_time / last_contact_time overestimate every phase
by exactly one sensor update interval: an n-sample phase is reported as (n + 1) * dt. Over a
gait cycle that is a systematic +2 dt bias on the duty factor.
Scope. All numbers above were measured on the Newton backend. The PhysX backend's kernel
(isaaclab_physx/.../contact_sensor/kernels.py:271) uses the same timestamp - timestamp_last_update float32 differencing and the same default abs_tol, so it
looks structurally identical, but I have no PhysX installation here and have not verified it.
Checklist
I have checked that there is no similar issue in the repo (required)
I have checked that the issue is not in running Isaac Sim itself and is related to the repo
Acceptance Criteria
compute_first_contact / compute_first_air report every transition regardless of the
simulation clock magnitude, for both history_length == 0 and history_length > 0.
The reproducer above prints 0 MISSED for both polarities with default arguments.
A regression test covers an aged simulation clock (e.g. a rollout starting several tens of
seconds in).
Describe the bug
ContactSensor.compute_first_contact(dt)/compute_first_air(dt)silently miss most of thetransitions they are supposed to report, because their default
abs_tol=1e-8is two to threeorders of magnitude smaller than the float32 rounding error of the timer they compare against.
Whether an event is reported depends on the magnitude of the simulation clock, so detection
quality changes over the course of an episode. Nothing raises, nothing warns — the events just
disappear, and rewards built on them (including
isaaclab_tasks' ownfeet_air_time) silentlylose a large fraction of their signal.
Why the comparison is exact by construction. In
update_contact_sensor_kernel, the intervalthat ends at a transition sample is added to both the phase that just ended and the phase that
just started:
So on the first in-contact sample
current_contact_timeequals exactly one sensor updateinterval. When the caller passes
dt = env.step_dtand the sensor is updated at that same rate,compute_first_transition_kernel's testdegenerates into
dt < dt + abs_tol— it holds only by the tolerance margin.Why the margin is not enough.
elapsed_timeis computed astimestamp[env] - timestamp_last_update[env]with float32 timestamps that grow with simulation time(
sensor_base.py:self._timestamp = wp.zeros(self._num_envs, dtype=wp.float32, ...)). Thefloat32 ULP at t ≈ 16 s is ≈ 1e-6, so accumulating four substeps carries an error of ≈ 4e-6 —
roughly 400× the default
abs_tol=1e-8. The sign of that error flips with the clock magnitude,which is what makes the failure look intermittent:
current_contact_timeread at the touchdown sample< 0.02 + 1e-8?SensorBase.reset()zeroes the timestamp per environment, so the clock is bounded by the episodelength — but a typical 20 s locomotion episode spends most of its time inside a bad band.
This is the same class of defect as
mujocolab/mjlab#1101 (fixed upstream there in
#1103 by accumulating the exact
dthanded toupdate()instead of differencing a growing clock).Steps to reproduce
The reproducer drives Isaac Lab's own kernels with a synthetic contact signal — no physics, no
Isaac Sim, no GPU (runs on CPU in ~3 s). The foot is in contact for one policy step, then in the
air for one policy step, with every transition landing exactly on a policy-step boundary, so
every touchdown and lift-off must be reported.
Output:
Passing
abs_tol >= 1e-6makes the same run report 0 missed events.System Info
isaaclab_newton), withnewton 1.5.0, mujoco_warp 3.11.0, warp 1.16.0, torch 2.11.0+cu128, Python 3.12.13
Additional context
Effect in a real rollout. Rolling out a trained biped policy on flat ground (8 envs, 60 s) and
comparing both detectors against a substep-level ground truth taken from the Newton contact view:
compute_first_contact/air(step_dt)(defaultabs_tol)abs_tol = 0.5 * physics_dtExposure inside this repo.
isaaclab_tasks/core/velocity/mdp/rewards.py:41(
feet_air_time),isaaclab_tasks_experimental/core/velocity/mdp/rewards.py:63andisaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py:143all callcompute_first_contact(env.step_dt)with the default tolerance. How badly a given task is hitdepends on
ContactSensorCfg.history_length, because it decides the sensor's update cadence:history_length > 0(e.g.velocity_env_cfg.py:113uses 3) —BaseContactSensor.update()refreshes the buffers every physics substep, so only transitions that land on a policy-step
boundary are knife-edge (≈ 25% of events for
decimation=4). Measured on a rough-terrain taskwith the same setting, on one rollout scored against the same ground truth: 19.9% of touchdowns
missed with the default tolerance versus 2.0% with
abs_tol = 0.5 * physics_dt.history_length == 0(the config default) — buffers are refreshed lazily once per policy step,so
current_contact_timeat a touchdown is always exactlystep_dtand every event isknife-edge. This is the 73% case measured above.
Possible fixes, roughly in increasing order of invasiveness:
update_contact_sensor_kernelcomputesis_first_contact/is_first_detachedexactly, as aboolean state comparison, uses them to write
last_air_time/last_contact_time, and thendiscards them. Persisting those flags (latched over the policy step when the sensor updates
faster than the caller polls) would make
compute_first_contactexact and cost nothing.dtpassed intoupdate()instead of differencing a growing clock, sothe timer's error scales with the phase duration rather than with the simulation time. This is
what mjlab did in Accumulate exact substep dt for contact air-time mujocolab/mjlab#1103.
abs_tol(1e-6is enough for realistic episodelengths, but it is still a fixed tolerance against a scale-dependent error, so it only moves
the failure further out).
Related observation (not the main report). Because the transition interval is added to both
the ending and the starting phase,
last_air_time/last_contact_timeoverestimate every phaseby exactly one sensor update interval: an n-sample phase is reported as
(n + 1) * dt. Over agait cycle that is a systematic +2
dtbias on the duty factor.Scope. All numbers above were measured on the Newton backend. The PhysX backend's kernel
(
isaaclab_physx/.../contact_sensor/kernels.py:271) uses the sametimestamp - timestamp_last_updatefloat32 differencing and the same defaultabs_tol, so itlooks structurally identical, but I have no PhysX installation here and have not verified it.
Checklist
Acceptance Criteria
compute_first_contact/compute_first_airreport every transition regardless of thesimulation clock magnitude, for both
history_length == 0andhistory_length > 0.0 MISSEDfor both polarities with default arguments.seconds in).