Track-frame localization

Racing controllers and reward shapers want positions in the TRACK’s frame, not the world’s: how far along the lap (s), how far off the racing line (n), and how sharp the road ahead is. track_gen.localize answers all three against the same batched Track buffers as the sibling utilities.

The (s, n) frame

TrackLocalizer projects one point per env onto the centerline and returns a TrackFrame — arc length s (same units as the track, consistent with Track.arclen/Track.length), signed lateral offset n, and the nearest centerline segment index:

from track_gen.localize import TrackLocalizer

loc = TrackLocalizer(track, position=robot_pos)   # latch onto sim buffer
for _ in range(steps):
    sim.step()                    # writes robot_pos in place
    frame = loc.query()           # no args: bound mode
    # frame.s [E] float32, frame.n [E] float32, frame.segment [E] int32

n is positive to the RIGHT of the centerline’s direction of travel (the direction of increasing s), negative to the left. Whether the right side is the outer or the inner boundary depends on the loop’s winding, which is generator-dependent (e.g. polar loops wind CCW — outer on the right; bezier loops wind CW — outer on the left); either way |n| <= half_width means the point is on the road. NaN positions yield NaN frames (segment == -1) — the same pause semantics as the sibling utilities.

Warm starts

Consumers query every sim step with small motions, so scanning the whole centerline each time is wasted work. TrackLocalizer(track, warm_window=W) scans only the 2*W + 1 segments centered on each env’s previous result — exact (identical to the full scan) whenever the nearest segment stays inside the window, the usual case when per-step motion is small against W * spacing. Small motion alone does not guarantee it: where the loop pinches close to itself, the nearest segment can jump half a lap discontinuously, and the warm result then stays on the traveled branch (often preferable for racing consumers — s stays continuous — but not the cold answer). Both modes are fixed-bound kernels: no host sync, CUDA-graph capturable via track_gen.set_capturing.

The warm memory refers to segment INDICES, so it goes stale when the geometry changes: call reset(mask) for all envs after regenerating the bound track, and for any env that teleports beyond the window. Cold-scan localizers (warm_window=None) need neither.

Curvature & speed profiles

Two per-generation helpers share the module (they ALLOCATE their results — call them after generate(), outside capture regions, unlike the allocation-free query()):

from track_gen.localize import curvature, speed_profile

kappa = curvature(track)                          # [E*N_max] signed, 1/units
v_ref = speed_profile(track, a_lat_max=6.0,       # [E*N_max] target speeds
                      a_accel=3.0, a_brake=5.0,
                      v_cap=4.0, kappa=kappa)     # kappa= optional reuse

curvature is the discrete turn angle over the mean incident edge length with a wrap-aware moving average (window=2 by default); positive where the loop turns counter-clockwise — a CCW circle of radius R gives +1/R. speed_profile is the classic three-stage racing profile: steady-state min(sqrt(a_lat_max / |kappa|), v_cap), a forward pass capping acceleration out of corners, and a backward pass capping braking into them, each run twice around the closed loop so the wrap converges. Pair frame.segment with these arrays to read the local target speed or curvature at the vehicle every step.