Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Fixed
^^^^^

* Fixed :meth:`~isaaclab.sensors.contact_sensor.BaseContactSensor.compute_first_contact` and
:meth:`~isaaclab.sensors.contact_sensor.BaseContactSensor.compute_first_air` silently missing
touchdowns and lift-offs once the simulation had run for a few seconds (issue #7283). Their
``abs_tol`` argument now defaults to ``None``, which resolves to half the sensor update interval
instead of a fixed ``1e-8``. The old value was around 100x smaller than the float32 rounding
error of the sensor clock, so most transitions were dropped. Callers that relied on the previous
behavior can pass ``abs_tol=1e-8`` explicitly.
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,28 @@ def find_sensors(self, name_keys: str | Sequence[str], preserve_order: bool = Fa
"""
return string_utils.resolve_matching_names(name_keys, self.body_names, preserve_order)

def _resolve_first_transition_tolerance(self, abs_tol: float | None) -> float:
"""Resolves the tolerance used to detect a first contact or first air transition.

Valid air and contact timers are integer multiples of the sensor update interval, so half an
interval is the midpoint between "one update ago" and "two updates ago". Using it as the
tolerance keeps the comparison robust to the float32 rounding error of the sensor clock,
which grows with simulated time and quickly exceeds any fixed tolerance.

Args:
abs_tol: The caller-provided tolerance [s]. If None, half the sensor update interval
is used.

Returns:
The absolute tolerance to add to the queried time period [s].
"""
if abs_tol is not None:
return abs_tol
# An update period of 0.0 means the sensor is updated on every physics step.
return 0.5 * max(self.cfg.update_period, self._sim_physics_dt)

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.

P1 Long-running transitions fail again

When a simulation with a 0.005-second physics step reaches 65536 seconds, the next float32 timestamp increment is 0.0078125 seconds while the new default threshold is only 0.0075 seconds. The strict timer comparison therefore silently drops touchdowns and lift-offs again across all three backends.

Knowledge Base Used: Simulation, rendering, and sensors


@abstractmethod
def compute_first_contact(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray:
def compute_first_contact(self, dt: float, abs_tol: float | None = None) -> ProxyArray:
"""Checks if bodies that have established contact within the last :attr:`dt` seconds.

This function checks if the bodies have established contact within the last :attr:`dt` seconds
Expand All @@ -164,7 +184,8 @@ def compute_first_contact(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArra

Args:
dt: The time period since the contact was established.
abs_tol: The absolute tolerance for the comparison.
abs_tol: The absolute tolerance for the comparison [s]. Defaults to None, in which case
half the sensor update interval is used.

Returns:
A boolean tensor indicating the bodies that have established contact within the last
Expand All @@ -177,7 +198,7 @@ def compute_first_contact(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArra
raise NotImplementedError(f"Compute first contact is not implemented for {self.__class__.__name__}.")

@abstractmethod
def compute_first_air(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray:
def compute_first_air(self, dt: float, abs_tol: float | None = None) -> ProxyArray:
"""Checks if bodies that have broken contact within the last :attr:`dt` seconds.

This function checks if the bodies have broken contact within the last :attr:`dt` seconds
Expand All @@ -192,7 +213,8 @@ def compute_first_air(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray:

Args:
dt: The time period since the contract is broken.
abs_tol: The absolute tolerance for the comparison.
abs_tol: The absolute tolerance for the comparison [s]. Defaults to None, in which case
half the sensor update interval is used.

Returns:
A boolean tensor indicating the bodies that have broken contact within the last :attr:`dt` seconds.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Fixed
^^^^^

* Fixed :meth:`compute_first_contact` and :meth:`compute_first_air` on the contact sensor silently
missing touchdowns and lift-offs once the simulation had run for a few seconds (issue #7283).
Their ``abs_tol`` argument now defaults to ``None``, which resolves to half the sensor update
interval instead of a fixed ``1e-8``. The old value was around 100x smaller than the float32
rounding error of the sensor clock, so most transitions were dropped. Callers that relied on the
previous behavior can pass ``abs_tol=1e-8`` explicitly.
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def find_sensors(self, name_keys: str | Sequence[str], preserve_order: bool = Fa
)
return string_utils.resolve_matching_names(name_keys, sensor_names, preserve_order)

def compute_first_contact(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray:
def compute_first_contact(self, dt: float, abs_tol: float | None = None) -> ProxyArray:
"""Checks if sensors that have established contact within the last :attr:`dt` seconds.

This function checks if the sensors have established contact within the last :attr:`dt` seconds
Expand All @@ -215,7 +215,8 @@ def compute_first_contact(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArra

Args:
dt: The time period since the contact was established.
abs_tol: The absolute tolerance for the comparison.
abs_tol: The absolute tolerance for the comparison [s]. Defaults to None, in which case
half the sensor update interval is used.

Returns:
A float array (1.0/0.0) indicating the sensors that have established contact within the
Expand All @@ -232,16 +233,17 @@ def compute_first_contact(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArra
"The contact sensor is not configured to track contact time."
"Please enable the 'track_air_time' in the sensor configuration."
)
tol = self._resolve_first_transition_tolerance(abs_tol)
wp.launch(
compute_first_transition_kernel,
dim=(self._num_envs, self._num_sensors),
inputs=[float(dt + abs_tol), self._data._current_contact_time],
inputs=[float(dt + tol), self._data._current_contact_time],
outputs=[self._data._first_transition],
device=self._device,
)
return self._data._first_transition_ta

def compute_first_air(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray:
def compute_first_air(self, dt: float, abs_tol: float | None = None) -> ProxyArray:
"""Checks if sensors that have broken contact within the last :attr:`dt` seconds.

This function checks if the sensors have broken contact within the last :attr:`dt` seconds
Expand All @@ -256,7 +258,8 @@ def compute_first_air(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray:

Args:
dt: The time period since the contract is broken.
abs_tol: The absolute tolerance for the comparison.
abs_tol: The absolute tolerance for the comparison [s]. Defaults to None, in which case
half the sensor update interval is used.

Returns:
A float array (1.0/0.0) indicating the sensors that have broken contact within the last
Expand All @@ -274,10 +277,11 @@ def compute_first_air(self, dt: float, abs_tol: float = 1.0e-8) -> ProxyArray:
"Please enable the 'track_air_time' in the sensor configuration."
)

tol = self._resolve_first_transition_tolerance(abs_tol)
wp.launch(
compute_first_transition_kernel,
dim=(self._num_envs, self._num_sensors),
inputs=[float(dt + abs_tol), self._data._current_air_time],
inputs=[float(dt + tol), self._data._current_air_time],
outputs=[self._data._first_transition],
device=self._device,
)
Expand Down
98 changes: 98 additions & 0 deletions source/isaaclab_newton/test/sensors/test_contact_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1194,3 +1194,101 @@ def test_invalid_expression_raises_regex_error():
"""Reject malformed selector expressions at contact sensor construction."""
with pytest.raises(re.error):
_compile_label_pattern("foo(")


@pytest.mark.parametrize("device", test_devices())
@pytest.mark.parametrize("clock_age", [2.5, 10.0, 30.0])
@pytest.mark.parametrize("history_length", [1, 0], ids=["substep_refresh", "lazy_refresh"])
def test_first_transition_with_aged_clock(device: str, clock_age: float, history_length: int):
"""Regression for #7283: transitions must still be reported once the sensor clock has aged.

The sensor clock is a float32 accumulator whose rounding error grows with simulated time. On a
transition step the contact (resp. air) timer is exactly one polling period, so the default
tolerance of :meth:`compute_first_contact` has to absorb that error. A fixed 1e-8 tolerance is
~100x too small after a few seconds and silently drops touchdowns and lift-offs.
"""
# With history, the sensor refreshes every physics step; without it, only when data is read.
decimation = 1 if history_length > 0 else 4
poll_dt = decimation * SIM_DT
settle_steps = 40
poll_steps = 120 // decimation

sim_cfg = make_sim_cfg(use_mujoco_contacts=False, device=device, gravity=(0.0, 0.0, -9.81))
with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim:
sim._app_control_on_stop_handle = None

scene_cfg = ContactSensorTestSceneCfg(num_envs=1, env_spacing=5.0)
scene_cfg.object_a = create_shape_cfg(
ShapeType.BOX,
"{ENV_REGEX_NS}/Object",
pos=(0.0, 0.0, get_shape_height(ShapeType.BOX) / 2),
disable_gravity=False,
activate_contact_sensors=True,
)
scene_cfg.contact_sensor_a = ContactSensorCfg(
prim_path="{ENV_REGEX_NS}/Object",
update_period=0.0,
history_length=history_length,
track_air_time=True,
)

scene = InteractiveScene(scene_cfg)
sim.reset()
scene.reset()

sensor: ContactSensor = scene["contact_sensor_a"]
obj: RigidObject = scene["object_a"]

def _in_contact() -> bool:
"""Ground truth for the contact state, read through the public data accessor."""
return torch.norm(sensor.data.net_normal_forces_w.torch, dim=-1).max().item() > 0.1

# Let the box come to rest on the ground so the sensor starts in contact.
for _ in range(settle_steps):
perform_sim_step(sim, scene, SIM_DT)
assert _in_contact(), "Box should be resting on the ground before the clock is aged."

# Age the sensor clock without stepping physics: the resting contact state is unchanged, so
# this isolates the float32 clock drift from any change in the contact forces.
for tick in range(int(round(clock_age / SIM_DT))):
sensor.update(SIM_DT)
if history_length == 0 and (tick + 1) % decimation == 0:
_in_contact() # lazy refresh, mirroring a policy-rate reader
aged_clock = wp.to_torch(sensor._timestamp).max().item()
assert aged_clock == pytest.approx(clock_age + settle_steps * SIM_DT, abs=0.05)

# Launch the box so that it leaves the ground and lands again within the polling window.
velocity = torch.zeros(1, 6, device=obj.device)
velocity[:, 2] = 3.0
obj.write_root_velocity_to_sim_index(root_velocity=velocity)

reported_air: list[int] = []
reported_contact: list[int] = []
expected_air: list[int] = []
expected_contact: list[int] = []
was_in_contact = True
for step in range(poll_steps):
for _ in range(decimation):
perform_sim_step(sim, scene, SIM_DT)
# Read the data first, exactly as a policy-rate consumer would, then poll transitions.
in_contact = _in_contact()
if in_contact and not was_in_contact:
expected_contact.append(step)
if not in_contact and was_in_contact:
expected_air.append(step)
was_in_contact = in_contact
if sensor.compute_first_contact(poll_dt).torch.any().item():
reported_contact.append(step)
if sensor.compute_first_air(poll_dt).torch.any().item():
reported_air.append(step)

assert len(expected_air) == 1, f"Expected exactly one lift-off in the window; got {expected_air}."
assert len(expected_contact) == 1, f"Expected exactly one touchdown in the window; got {expected_contact}."
assert reported_air == expected_air, (
f"compute_first_air missed or mis-reported the lift-off at clock {aged_clock:.3f}s: "
f"reported {reported_air}, expected {expected_air}."
)
assert reported_contact == expected_contact, (
f"compute_first_contact missed or mis-reported the touchdown at clock {aged_clock:.3f}s: "
f"reported {reported_contact}, expected {expected_contact}."
)
Loading
Loading