TurtleBotJEPA-PPO / lds02.py
penji2803's picture
TurtleBotJEPA-PPO: ready-to-run JEPA + PPO policy for a real TurtleBot3 (LDS-02), guide incl. mapping and map goals
4421dc3 verified
Raw History Blame Contribute Delete
3.92 kB
"""LDS-02 lidar model and the scan-cleaning step shared by the simulator, the dataset pipeline and the real robot.
ROBOTIS LDS-02 (docs.robotis.com, more_info/lds_02): range 0.16 - 8 m, accuracy +-10 mm below 0.3 m and +-3 % from
0.3 to 6 m, ~1 deg resolution, 5 Hz or above. Invalid readings (no return, or closer than 0.16 m) arrive as 0 / inf.
params = sample_params(rng) # once per episode
raw = lds02_corrupt(true360, rng, params) # simulator only: true ranges -> what an LDS-02 would report
cleaner = ScanCleaner(); clean = cleaner(raw, t) # sim AND robot: invalid -> near-field hold or 3.5, clip
Near-field hold: an invalid ray keeps the smallest valid reading among rays i-2..i+2 in the current scan and the scans
of the last 0.45 s, if that reading is below HOLD_BELOW (an obstacle that came too close to be seen); otherwise it
becomes 3.5 ("no return"), which is how the simulator encoded missing returns during training.
"""
import numpy as np
N_RAYS = 360
MIN_VALID = 0.16 # LDS-02 minimum range
R_MIN, R_MAX = 0.12, 3.5 # training range (JEPA preprocessing and the simulator)
HOLD_BELOW = 0.35
HOLD_WINDOW_S = 0.45
HOLD_NEIGHBOURS = 2
def sample_params(rng):
"""Per-episode sensor parameters (a range of plausible LDS-02 units / surfaces)."""
return dict(sigma_rel=float(rng.uniform(0.010, 0.020)), sigma_min=0.005, p_drop=float(rng.uniform(0.02, 0.06)),
p_arc=float(rng.uniform(0.1, 0.4)), offset=int(rng.integers(-2, 3)))
def lds02_corrupt(true_r, rng, p):
"""(360,) true ranges in metres (inf / >= 3.5 = nothing within 3.5 m) -> LDS-02-like readings (0 = invalid)."""
r = np.asarray(true_r, np.float64).copy()
far = ~np.isfinite(r) | (r >= R_MAX)
r = np.where(far, R_MAX, r)
if p['offset']:
r, far = np.roll(r, p['offset']), np.roll(far, p['offset']) # mounting offset, whole degrees
sigma = np.maximum(p['sigma_min'], p['sigma_rel'] * r)
r = r + rng.normal(0.0, 1.0, r.shape) * sigma
r = np.where(far, R_MAX, r) # far stays "far" (clipped at 3.5)
bad = rng.random(r.shape) < p['p_drop'] # isolated dropouts
if rng.random() < p['p_arc']: # 1-3 contiguous dropout arcs (dark / shiny)
for _ in range(int(rng.integers(1, 4))):
c, w = int(rng.integers(0, N_RAYS)), int(rng.integers(3, 16))
bad[(c + np.arange(w)) % N_RAYS] = True
bad |= r < MIN_VALID # too close to measure
return np.where(bad, 0.0, np.minimum(r, R_MAX)).astype(np.float32)
def _neighbour_min(v, k=HOLD_NEIGHBOURS):
out = v.copy()
for s in range(1, k + 1):
out = np.minimum(out, np.minimum(np.roll(v, s, -1), np.roll(v, -s, -1)))
return out
class ScanCleaner:
"""Stateful per-episode cleaner: call reset() at episode start, then clean = cleaner(raw360, t_seconds).
hold_window: seconds of memory for the near-field hold (stretch it when the robot is slowed down)."""
def __init__(self, hold_window=HOLD_WINDOW_S):
self.hold_window = hold_window
self.reset()
def reset(self):
self.recent = [] # [(t, valid ranges with inf for invalid)]
def __call__(self, raw, t):
r = np.asarray(raw, np.float64)
invalid = ~np.isfinite(r) | (r < R_MIN) | (r <= 0)
v = np.where(invalid, np.inf, r)
self.recent = [(tt, vv) for tt, vv in self.recent if t - tt <= self.hold_window] + [(t, v)]
near = _neighbour_min(np.min(np.stack([vv for _, vv in self.recent]), 0))
held = np.where(near < HOLD_BELOW, near, R_MAX)
out = np.where(invalid, held, r)
return np.clip(out, R_MIN, R_MAX).astype(np.float32), int((invalid & (near < HOLD_BELOW)).sum())