MuJoCo rangefinder feedback: from sensor data to control

A changing MuJoCo rangefinder value changes motion only when control code reads the correct slice of mjData.sensordata, handles the value correctly and writes a later control input. Debug that chain in order: address and dimension, valid hit or no-hit, actual consumer, then sensor and callback timing. Declaring the sensor creates an observation. It does not create a feedback law.

Read the sensor slice before questioning the measurement

MuJoCo stores sensor outputs in one concatenated sensordata array. A sensor ID is therefore not automatically its data offset. The compiled model supplies sensor_adr[i] and sensor_dim[i]; code should read sensordata[sensor_adr[i]:sensor_adr[i] + sensor_dim[i]]. This matters even for a familiar sensor name because requested output fields can change the number of scalars.

For this case, the MuJoCo 3.15.0 rangefinder reference says a site rangefinder casts along the site’s positive Z axis and reports the nearest eligible geom surface. Its default output is distance. A miss is represented by -1, which should remain distinct from zero clearance. Geoms on the sensor site’s own body and fully transparent geoms are excluded, while hiding a geom group in the visualizer does not remove it from the range calculation.

Check What to record What a mismatch can mean
Address and width sensor_adr, sensor_dim and the exact slice read The loop is reading another sensor or only part of this one
Site ray World pose and positive-Z direction The ray is aimed away from the expected surface
Raw value Distance or the exact -1 miss value A miss was mistaken for contact, zero clearance or stale data
Consumer Code location and later input it writes The value is logged but never used to change control
Stage Entry point and sensor type The loop expects data before that sensor stage has run

The field layout is explicit in the pinned 3.15.0 mjModel sensor arrays and the mjData.sensordata declaration. Logging those fields with the raw slice is more useful than printing a bare sensor index and one unexplained number.

The control consumer is a separate part of the loop

The 3.15.0 forward-dynamics sequence separates sensor computation from control. In the normal mj_forwardSkip path, position-dependent sensors are computed first, followed by velocity-dependent sensors. The pinned engine_forward.c path then invokes mjcb_control only when the callback is defined and actuation is enabled. Actuation and acceleration follow, and force- or acceleration-dependent sensor work comes later.

That order is useful when a controller consumes a position-dependent site rangefinder: the current range can be available before that callback. It does not mean every sensor is current at every callback. Force or acceleration sensing belongs to a later stage, delayed and interval sensors add timing, sensor computation can be skipped, and mj_step1 has a different callback condition. Plugins and an external application loop can also introduce other control paths.

Find the code that reads the intended slice, then find the exact later field or function it changes. If the trace ends at a logger, dashboard or observation buffer, there is no feedback edge yet. The broader see-think-act loop explains that system pattern; the MuJoCo-specific task is to locate it in data and execution order.

The measured range was identical until one branch consumed it

An actual Blender 5.2.2 geometry experiment used a finite carriage, a finite wall and a site whose positive Z axis pointed toward the wall. The native BVH was built from the evaluated wall mesh in each isolated snapshot. These duplicated snapshots are measurement cases, not a continuously simulated world, and Blender physics was not run.

The two branches shared the same measured ranges from k0 through k9. At k8, the native range was 0.5999999642 m, so both declared a 0.15 m next translation. That fixed increment carried the next saved pose past the 0.5 m threshold. At k9, both measured 0.4499999285 m; the observe-only branch still declared 0.15 m, while the explicit-feedback branch declared 0.0 m. Their first pose difference therefore appeared at k10, one sample after the first command difference.

Saved sample Observe-only native range Feedback native range Observe next dx Feedback next dx
k8 0.5999999642 m 0.5999999642 m 0.15 m 0.15 m
k9 0.4499999285 m 0.4499999285 m 0.15 m 0.0 m
k10 0.2999999523 m 0.4499999285 m 0.15 m 0.0 m
k11 0.1499999464 m 0.4499999285 m 0.15 m prospective 0.0 m prospective

The independent audit recovered clearance from the evaluated carriage and wall vertices. Across the actual hit rows, the largest absolute difference between native range and recovered mesh clearance was about 1.44e-7 m. This checks the finite ray geometry; it does not validate MuJoCo rangefinder runtime behavior.

Actual finite carriage and wall geometry with native range traces for observe-only and explicit-feedback branches.
Both branches share the same native range through k9. Only the explicit branch consumes it to choose the next transform. Static Blender geometry and prescribed transforms; no MuJoCo dynamics or safety-stop test.

The run dispatched 22 successor commands: 11 in each branch. Both k11 commands were recorded as prospective and no k12 pose was created. A separate off-face control preserved Blender’s raw four-None BVH miss; its declared 0.0 m fallback was also prospective and was not dispatched. That native Blender representation remains separate from MuJoCo’s source-defined rangefinder miss value of -1.

The threshold rule is a binary switch, not minimum-clearance enforcement. Because it acts only after a sampled measurement, one full prescribed increment moved the range from roughly 0.60 m to 0.45 m before the branch changed its next command.

Trace the failure from observation to generalized force

When a fresh range appears to have no effect, use a short trace rather than changing gains first.

  1. Resolve the sensor ID, sensor_adr and sensor_dim, then log the exact slice.
  2. Preserve the raw -1 miss state and confirm the site’s positive-Z ray reaches an eligible geom.
  3. Identify the consumer that reads the range and the later control value it writes.
  4. Record the simulation entry point and verify that this sensor stage precedes that consumer.
  5. Log the next control input beside the observation so a missing edge is visible.
  6. If control changes but generalized force still looks wrong, continue downstream with the MuJoCo actuator-force and joint-force mapping.

This sequence separates three failures that otherwise look alike: reading the wrong data, receiving a valid miss, and never routing a valid observation into control.

Reproduce the geometry and branch rule

The reader below reproduces the frozen finite-wall geometry and binary branch rule with Python’s standard library. It is independent arithmetic, not MuJoCo or Blender telemetry.

"""Standalone finite-ray geometry and a declared recurrence, not engine telemetry.

Sample labels are prescribed indices, not elapsed time. The threshold switch
does not enforce a minimum clearance and is not a validated safety controller.
"""
import math

def rotation(x,y,z):
    x,y,z=map(math.radians,(x,y,z));cx,sx=math.cos(x),math.sin(x);cy,sy=math.cos(y),math.sin(y);cz,sz=math.cos(z),math.sin(z)
    return ((cz*cy,cz*sy*sx-sz*cx,cz*sy*cx+sz*sx),(sz*cy,sz*sy*sx+cz*cx,sz*sy*cx-cz*sx),(-sy,cy*sx,cy*cx))

def mv(m,v):return tuple(sum(m[i][j]*v[j] for j in range(3)) for i in range(3))
def add(a,b):return tuple(a[i]+b[i] for i in range(3))
def sub(a,b):return tuple(a[i]-b[i] for i in range(3))

def finite_box_ray(origin,direction,low,high,limit):
    values=(*origin,*direction,*low,*high,limit)
    if not all(math.isfinite(v) for v in values) or limit<0 or any(low[i]>=high[i] for i in range(3)):
        raise ValueError('finite nondegenerate box and distance required')
    length=math.sqrt(sum(v*v for v in direction))
    if length==0:raise ValueError('zero direction')
    direction=tuple(v/length for v in direction);enter=-math.inf;leave=math.inf
    for p,v,a,b in zip(origin,direction,low,high):
        if abs(v)<1e-14:
            if p<a or p>b:return None
        else:
            s,t=sorted(((a-p)/v,(b-p)/v));enter=max(enter,s);leave=min(leave,t)
            if enter>leave:return None
    distance=enter if enter>=0 else leave
    return distance if 0<=distance<=limit else None

R=rotation(11,-19,33);RT=tuple(zip(*R));translation=(.12,-.08,.06)
site_rotation=rotation(0,90,0)
direction_world=mv(R,mv(site_rotation,(0,0,1)))

def range_model(x,y=0):
    world_origin=add(translation,mv(R,(x+.2,y,0)))
    local_origin=mv(RT,sub(world_origin,translation));local_direction=mv(RT,direction_world)
    return finite_box_ray(local_origin,local_direction,(2.0,-.4,-.4),(2.1,.4,.4),5.0)

def command(branch,distance):
    if branch=='observe_only':return .15
    return 0.0 if distance is None or distance<=.5 else .15

for branch in ('observe_only','explicit_feedback'):
    x=0.0
    print(branch,'k x_m model_range_m prospective_dx_m dispatched')
    for k in range(12):
        distance=range_model(x);dx=command(branch,distance);dispatched=k<11
        print(k,f'{x:.3f}',f'{distance:.3f}' if distance is not None else None,f'{dx:.3f}',dispatched)
        if dispatched:x+=dx
missing=range_model(0,.5)
assert missing is None
print('off-face control:',missing,'fallback_dx_m:',command('explicit_feedback',missing),'dispatched:',False)

The experiment measured finite Blender geometry and executed prescribed transform logic. It did not run MuJoCo, dynamics, force feedback, controller stability, latency, hardware or a certified safety stop. Delays, sensor history, skipped stages and custom plugin behavior remain outside this example.