Skip to content

[Bug Report] compute_first_contact / compute_first_air miss most transitions: default abs_tol is far below the float32 timer error #7283

Description

@bbjyzzwwy

Describe the bug

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_contact and (cat > 0.0)
if is_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

if t > 0.0 and t < 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.

"""Minimal reproducer: compute_first_contact() misses most touchdowns as the sim clock grows."""

import numpy as np
import warp as wp

from isaaclab.sensors.kernels import update_outdated_envs_kernel, update_timestamp_kernel
from isaaclab_newton.sensors.contact_sensor.contact_sensor_kernels import (
    compute_first_transition_kernel,
    update_contact_sensor_kernel,
)

DEVICE = "cpu"
PHYSICS_DT = 0.005
DECIMATION = 4
STEP_DT = PHYSICS_DT * DECIMATION
DURATION_S = 20.0
ABS_TOL = 1.0e-8  # the default of compute_first_contact() / compute_first_air()


def main() -> None:
    wp.init()
    n_steps = int(DURATION_S / STEP_DT)

    timestamp = wp.zeros(1, dtype=wp.float32, device=DEVICE)
    timestamp_last = wp.zeros(1, dtype=wp.float32, device=DEVICE)
    is_outdated = wp.ones(1, dtype=wp.bool, device=DEVICE)
    env_mask = wp.ones(1, dtype=wp.bool, device=DEVICE)
    forces = wp.zeros((1, 1), dtype=wp.vec3f, device=DEVICE)
    current_air = wp.zeros((1, 1), dtype=wp.float32, device=DEVICE)
    current_contact = wp.zeros((1, 1), dtype=wp.float32, device=DEVICE)
    last_air = wp.zeros((1, 1), dtype=wp.float32, device=DEVICE)
    last_contact = wp.zeros((1, 1), dtype=wp.float32, device=DEVICE)
    transition = wp.zeros((1, 1), dtype=wp.float32, device=DEVICE)
    history = wp.zeros((1, 1, 1), dtype=wp.vec3f, device=DEVICE)

    missed_td, missed_lo, seen = [], [], []
    for step in range(n_steps):
        in_contact = step % 2 == 0  # touchdown on even steps, lift-off on odd steps
        for _ in range(DECIMATION):
            forces.assign(np.array([[[0.0, 0.0, 100.0 if in_contact else 0.0]]], dtype=np.float32))
            wp.launch(
                update_timestamp_kernel,
                dim=1,
                inputs=[is_outdated, timestamp, timestamp_last, PHYSICS_DT, PHYSICS_DT],
                device=DEVICE,
            )
            wp.launch(
                update_contact_sensor_kernel,
                dim=(1, 1),
                inputs=[
                    1, 0, 1.0, env_mask, forces, None, timestamp, timestamp_last,
                    history, None, current_air, current_contact, last_air, last_contact,
                ],
                device=DEVICE,
            )
            wp.launch(
                update_outdated_envs_kernel,
                dim=1,
                inputs=[is_outdated, timestamp, timestamp_last],
                device=DEVICE,
            )

        t_now = (step + 1) * STEP_DT
        timer = current_contact if in_contact else current_air
        wp.launch(
            compute_first_transition_kernel,
            dim=(1, 1),
            inputs=[float(STEP_DT + ABS_TOL), timer],
            outputs=[transition],
            device=DEVICE,
        )
        detected = transition.numpy()[0, 0] > 0.5
        seen.append((t_now, float(timer.numpy()[0, 0])))
        if not detected:
            (missed_td if in_contact else missed_lo).append(t_now)

    n_td, n_lo = (n_steps + 1) // 2, n_steps // 2
    print(f"physics_dt={PHYSICS_DT}  decimation={DECIMATION}  step_dt={STEP_DT}  abs_tol={ABS_TOL:g}")
    print(f"touchdowns: {len(missed_td)}/{n_td} MISSED   lift-offs: {len(missed_lo)}/{n_lo} MISSED\n")
    print("current_*_time read at the transition step (exact value is step_dt = 0.02):")
    for t, v in seen[::50]:
        print(f"{t:8.2f} s  {v:.9f}  {'ok' if v < STEP_DT + ABS_TOL else 'MISSED'}")


if __name__ == "__main__":
    main()

Output:

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.

System Info

  • Commit: 155f31a
  • 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:

detector touchdowns missed lift-offs missed
compute_first_contact/air(step_dt) (default abs_tol) 556 / 761 (73%) 560 / 765 (73%)
same call with abs_tol = 0.5 * physics_dt 0 / 761 0 / 765

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:

  1. 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.
  2. Accumulate the exact dt passed into update() instead of differencing a growing clock, so
    the 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.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions