fix(simulator): record contact forces per physics substep, uniformly - #214
Draft
tkevinbest wants to merge 1 commit into
Draft
tkevinbest wants to merge 1 commit into
tkevinbest wants to merge 1 commit into
Conversation
`contact_forces_history` was a configurable-length rolling window that meant three different things across the backends, two of them broken: - IsaacSim copied only the newest `min(control_decimation, contact_sensor_history_length)` frames of IsaacLab's ring buffer into a buffer allocated at the full length. Raising the knob past the decimation left a tail that was never written -- permanently zero and indistinguishable from real zero-force samples, so a consumer differentiating the history saw a spurious rising edge once per control step (#212). - MuJoCo rotated inside `refresh_sim_tensors`, making a frame one *control* step. The reset path calls that method a second time within the same control step, duplicating the newest frame for every env whenever any env reset. - IsaacGym rotated per physics step but never called `refresh_net_contact_force_tensor`, which backs `contact_forces` -- so every substep recorded the same stale frame. Replace it with `contact_forces_substep`, following the substep-recording paradigm already used by `torques_substep`/`dof_pos_substep` in joint_control: exactly `control_decimation_steps` frames, oldest at index 0, one written per physics step from each backend's `simulate_at_each_physics_step`, slot index re-anchored by a FRAME_BEGIN hook. The buffer is fully rewritten every control step, so `clear_contact_forces_history` and `contact_sensor_history_length` are both retired -- the dead tail, the reset staleness and the cross-backend divergence go away by construction rather than by three patches. Also drive the IsaacSim contact sensor's `update_period` from the physics step rather than a hardcoded 5 ms, which only matched the sim dt at the shipped `fps=200`. Add `contact_substep_assert.py`, a cross-backend harness asserting eight properties of the buffer (shape, every slot written, per-substep ordering, not-all-one-frame, agreement with `contact_forces`, full rewrite each control step, FRAME_BEGIN re-anchoring, and that `refresh_sim_tensors` never touches it), plus per-backend runners and pure unit tests for the recorder. "Written" is checked by NaN-poisoning rather than looking for non-zero force, since a slot is legitimately zero when the body is airborne. `UndesiredContacts` now means "peak contact during this control step" on every backend. A 120-iteration WBT A/B at a fixed seed puts the term within 1% of before (mean raw 1.339 -> 1.325), and 100-iteration locomotion learning curves are bitwise identical on IsaacSim.
tkevinbest
force-pushed
the
dev/tkbest/contact-substep-buffer
branch
from
September 22, 2026 21:26
e80aea1 to
7a8c7ba
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #212 — and two sibling bugs the investigation turned up.
The problem
contact_forces_historywas a configurable-length rolling window that meant three different things on the three backends, two of them outright broken. All three are reproduced live onmainbelow.mainmain(history_length=6, decimation 4)min(decimation, history_length)slots were ever written; the buffer was allocated at the full configured length.[716.15, 253.59, 263.82, 305.59, 0.0, 0.0]— slots 4–5 permanently zerorefresh_net_contact_force_tensor, which backscontact_forces.[365.39, 365.39, 365.39, 365.39, 343.71, 343.71]— one frame, duplicatedrefresh_sim_tensors, so a frame was one control step — and the reset path calls that method a second time within the same control step, duplicating a frame for every env whenever any env reset.Permanent zeros are indistinguishable from real zero-force samples, so a consumer that differentiates the history sees a large spurious rising edge once per policy step.
contact_sensor_history_lengthwas documented as "frames of contact data retained" with no hint that values above the decimation did nothing.The fix
Replace it with
contact_forces_substep, conforming to the substep-recording paradigm the repo already uses fortorques_substep/dof_pos_substep(managers/action/terms/joint_control.py): exactlycontrol_decimation_stepsframes, oldest at index 0, one written per physics step from each backend'ssimulate_at_each_physics_step, with the slot index re-anchored by aFRAME_BEGINhook.Because the buffer is fully rewritten every control step,
clear_contact_forces_historyand thecontact_sensor_history_lengthknob are both retired — the dead tail, the reset staleness and the cross-backend divergence go away by construction rather than by three separate patches.Recording deliberately does not live in
refresh_sim_tensors: that method runs a variable number of times per control step (the reset path calls it again;run_simand the test harnesses call it per physics step), so nothing time-varying can live there. That invariant is now pinned by a test.Also drives the IsaacSim contact sensor's
update_periodfrom the physics step instead of a hardcoded 5 ms, which only equaled the sim dt at the shippedfps=200.Reviewing this
Three files carry the design; the rest follows mechanically.
simulator/shared/contact_substep.py— 29 lines, the whole mechanism.simulator/base_simulator/base_simulator.py— theFRAME_BEGINhook and the two forwarders.tests/simulators/contact_substep_assert.py— what "correct" means, stated as eight named properties.The three backend diffs are each a handful of lines: delete the old rotation, call
record_contact_substepafter the physics step.Verification
A new cross-backend harness asserts eight properties: SHAPE, FILLED (every slot written), ORDERED (slot i written at substep i, not before, not again), DISTINCT, AGREES (with
contact_forces), REWRITTEN (each control step), RESTARTS (FRAME_BEGINre-anchors slot 0), STABLE (refresh_sim_tensorsnever touches the buffer). "Written" is checked by NaN-poisoning rather than looking for non-zero force, since a slot is legitimately zero when the body is airborne.Green on all four backends, 7 live cases: IsaacSim (1, 4 envs) · IsaacGym (1, 4 envs) · MuJoCo classic (CPU) · mjwarp (1, 4 envs). Plus 11 pure unit tests, 212
no_simtests, and 82 MuJoCo sim tests.I ran four deliberate mutations to confirm the harness bites — each fails exactly the check that names it:
begin_framemade a no-oprefresh_sim_tensors(the old MuJoCo placement)RESTARTS and STABLE exist because the first version of the harness passed two of those mutations.
Behavior and cost
UndesiredContactsnow means "peak contact during this control step" on every backend. A 120-iteration WBT A/B at a fixed seed puts it within 1% of before (mean raw1.339→1.325).-0.868 → 2.854, episode length12.727 → 56.780on both); IsaacGym converges the same (3.081vs3.000), with the small divergence expected from the added per-substep refresh that loco terminations read.refresh_net_contact_force_tensor). MuJoCo ClassicBackend physics-step rate 7006 → 4881 steps/s (+62 µs/step atncon=6), sincecompute_contact_forcesnow runs per substep — still 2.4× realtime at 200 Hz. That loop is per-contact, so the cost scales withncon; vectorizingClassicBackend.compute_contact_forceswould remove the concern if it ever matters.Also verified against the pinned IsaacLab v2.3.0 source:
net_forces_wis written unconditionally (only the extra history buffers are gated onhistory_length, default 0),update_period=0.0marks the sensor outdated every call, andtrack_air_timeuses elapsed timestamps rather thanupdate_period, so nothing rescales.Breaking change for downstream configs
contact_sensor_history_lengthis gone fromSimulatorInitConfig, so a downstream registry entry that still sets it willTypeErrorat import. Saved checkpoint/wandb configs are unaffected. The attribute rename is deliberate too: a consumer reading the old name gets anAttributeErrorrather than a silently reversed index order feeding a finite difference.