Files
isaac/control_test/run_pipeline.py
T
dasha_f 0d32f32db0 Сортировочная ячейка Isaac Sim: CV-пайплайн и меши товаров
Замкнутый контур "поток -> CV -> механика": товары идут по конвейеру с шагом 700 мм,
класс определяется стереопайплайном во время движения, пушер и плуг реагируют физически.

Состав:
* control_test/ - ячейка и CV. run_sorting_cv.py + cv_worker.py (два процесса, потому что
  torch внутри Isaac роняет сцену), cell.py (физика лент, плуга, пушера), measure_plane.py
  (замер габаритов), README.md и .memory.md с замерами, проблемами и ловушками
* robozon_sorter/ - модули симуляции, scripts/ - утилиты, scene/ - сцены
* assets/ - меши товаров, плуг, объекты Objaverse

Бейзлайн CV: DEFOM-Stereo vitl, вход 480, iters 24, кроп зоны осмотра, без сегментации.
На потоке 700 мм - классы 8/9, габариты MAE 32.8 мм, 469 мс на товар при такте 700 мс.

Веса моделей (4.5 ГБ) и пропсы конвейера NVIDIA (274 МБ) не включены - источники и
команды скачивания в MODELS.md. Выход прогонов (captures/, runtime/) не включён:
воспроизводится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:07:24 +00:00

528 lines
24 KiB
Python

"""control_test - run the sorting cell against whatever meshes are sitting in items/.
isaacsim_send.py --context ct --file control_test/run_pipeline.py
isaacsim_send.py --context ct --file control_test/run_pipeline.py \
--args-json '{"only": ["bag","backpack","lunchbox"], "pitch": 1.4}'
Every .usd in items/ is measured (control_test/classify.py) and classified by the
documented rules, then fed onto the line in sorted order at PITCH spacing. Nothing is
hard-coded per object: drop a mesh in the folder and it joins the next run.
D round, dimensions in envelope -> laser at the pusher -> blade -> Belt_01 -> BinD
C undersize or oversize -> plow +PLOW_ANGLE -> ConveyorTrack_01 -> container C
B in envelope, not round -> plow -PLOW_ANGLE -> ConveyorTrack_06 -> container B
Injectable args: pitch, speed, plow_angle, swing_margin, plow_hold_max, only, limit.
"""
import asyncio
import pathlib
import sys
import time
import numpy as np
import omni.timeline
import omni.usd
import omni.kit.viewport.utility as vp
import isaacsim.core.experimental.utils.app as app_utils
from omni.physx import get_physx_interface, get_physx_scene_query_interface
from pxr import Gf, Usd, UsdGeom, UsdPhysics, PhysxSchema
from isaacsim.core.experimental.prims import RigidPrim
# the python_server exec's this file as a string, so __file__ does not exist here;
# `control_test_dir` can be injected to run the folder from somewhere else.
HERE = pathlib.Path(globals().get("control_test_dir",
"/home/dasha/robozon-sorter/control_test")).resolve()
for extra in (str(HERE), "/home/dasha/robozon-sorter"):
if extra not in sys.path:
sys.path.insert(0, extra)
for _m in [k for k in list(sys.modules) if k.startswith(("robozon_sorter", "cell", "classify"))]:
del sys.modules[_m]
import importlib
importlib.invalidate_caches()
import cell
import classify as CL
from robozon_sorter import config as C
from robozon_sorter.sim.plow import Plow
# ---------------------------------------------------------------- knobs
SPEED = float(globals().get("speed", 1.0)) # belt m/s
# 700 mm, the spec figure. Workable because the blade is only OWNED while an item is
# physically on it (0.63 s), leaving a 0.07 s gap to change angle - see T_GAP below.
PITCH = float(globals().get("pitch", 0.70)) # the spec figure
PLOW_ANGLE = float(globals().get("plow_angle", 20.0))
SWING_MARGIN = float(globals().get("swing_margin", 0.25))
ONLY = globals().get("only") # optional list of item names
LIMIT = int(globals().get("limit", 0)) # optional cap on how many to run
PLOW_SENSE_X = -6.30 # ~1.0 m of lead on the blade body at -7.32
PUSH_SENSE_X = C.PUSH_X + cell.PUSHER_X_MM / 2000.0
# The blade must be back home before the NEXT item reaches it, i.e. the whole
# out-hold-return cycle has to fit inside T_pitch. At 0.70 m pitch that is 0.70 s, and the
# old 1.3 m/s / 0.15 s dwell cycle took 0.63 + 0.15 + 0.33 = 1.11 s - the blade was still
# in the lane when the following B/C item arrived, which knocked detergent onto the floor
# and stopped lunchbox dead against it.
#
# Moving the blade fast no longer costs transfer, because the carry-assist below drives
# the item rather than the blade face doing it: the assist velocity is set independently
# (PUSH_ASSIST_V) so the blade can clear the lane quickly while the item still gets a
# controlled 1.6 m/s across.
# cycle = 0.77/2.5 + 0.03 + 0.77/2.5 = 0.31 + 0.03 + 0.31 = 0.65 s < 0.70 s
PUSH_SPEED, PUSH_RETURN_SPEED, PUSH_OUT_Y, PUSH_HOLD_S = 2.5, 2.5, 0.52, 0.03
PUSH_HOME_Y = -0.25 # was config's -0.30; the blade face still clears a 400 mm item
# +Y velocity handed to the item while the blade advances. The out phase is only
# (0.52+0.25)/2.5 = 0.31 s, and belt friction eats part of it: 1.6 m/s measured just
# dy=+0.40 m, short of the branch belt's near edge at y=0.443, so class-D items stayed on
# the main line and were caught by the plow instead. Scaled up to clear it with margin.
# Calibrated on this cell, not guessed: 1.6 m/s -> dy +0.40 m (short of the branch belt's
# near edge at y=0.443, item stays on the line); 2.4 m/s -> dy +0.69 m (shoots clean over
# Belt_01, which ends at y=2.169, and lands on the floor). The response is close to linear
# in between, so ~1.95 m/s puts the item at dy~0.52 - just past the near edge, nowhere near
# the far one, and the branch belt takes it from there.
PUSH_ASSIST_V = 2.1
SENSE_Y0, SENSE_Y1, SENSE_RAYS, GATE_WINDOW = -0.45, 0.45, 121, 0.15
BLADE_LEADING_X, BLADE_TRAILING_X = -7.32, -7.95
BLADE_GUARD = 0.08 # keep the slow rate while an item is this close to the blade
T_PITCH = PITCH / SPEED
# How long an item ACTUALLY owns the blade: the blade body is 0.63 m long, so at 1 m/s an
# item is against it for 0.63 s. (config's PLOW_SWEEP_X0..PLOW_RELEASE_X spans 0.95 m and
# was used as "T_zone" before - that is the wider sweep WINDOW, not the blade, and taking
# it as the occupancy is what made a 0.70 m pitch look geometrically impossible.)
T_BLADE = (BLADE_LEADING_X - BLADE_TRAILING_X) / SPEED
# Item N leaves the blade T_BLADE after reaching it; item N+1 reaches it T_PITCH after N.
# The blade is therefore EMPTY for this long between two consecutive items, and that is
# the whole budget for a class change.
T_GAP = T_PITCH - T_BLADE
# Rate needed for the worst case (a full B<->C reversal) inside that gap, x1.5 margin.
# Swinging this fast is safe because it only happens while the blade is empty - there is
# nothing against it to bat. While an item IS on the blade the slower PLOW_RATE is used.
PLOW_REPOSITION_RATE = min(2000.0, (2.0 * PLOW_ANGLE) / max(T_GAP, 0.02) * 1.5)
PLOW_RATE = (2.0 * PLOW_ANGLE) / (SWING_MARGIN * T_PITCH)
PLOW_ANGLES = {"B": -PLOW_ANGLE, "C": PLOW_ANGLE, "D": 0.0}
CONTAINER_B, CONTAINER_C, CONTAINER_R = (-8.81, 1.47), (-10.45, -0.225), 0.55
# An item counts as delivered only if it is RESTING IN the tray, not merely above its
# x/y footprint: the tray floors sit at z 1.14..1.18 and stand on legs, so a `z < 1.30`
# test alone also accepts an item lying on the ground under the container. That is exactly
# what hid this bug - B items were reported OK at z=+0.00 while sitting on the floor.
CONTAINER_Z_MIN, CONTAINER_Z_MAX = 1.10, 1.72
BIN_Z_MIN = 1.15
BIN_X0, BIN_X1, BIN_Y0, BIN_Y1, BIN_LIP_Z = -6.21, -4.95, 1.57, 2.86, 1.72
EXPECT = {"D": "bin_D", "B": "container_B", "C": "container_C"}
# ---------------------------------------------------------------- item library
raw = CL.load_library(HERE / "items")
lib = [r for r in raw if "error" not in r]
skipped = [r for r in raw if "error" in r]
if ONLY:
lib = [r for r in lib if r["name"] in set(ONLY)]
if LIMIT:
lib = lib[:LIMIT]
if not lib:
raise SystemExit("no usable meshes in items/")
print(f"===== ITEM LIBRARY ({HERE / 'items'}, classes from labels.json) =====")
print(f"{'item':>20} {'dims mm (label)':>24} {'k':>6} class")
for r in lib:
print(f"{r['name']:>20} {str(r['dims_mm']):>24} {r['k']:>6.3f} {r['cls']}")
if skipped:
print(f" skipped, no entry in labels.json: {[r['name'] for r in skipped]}")
# a label whose own dims/k imply another class would surface as a baffling mechanical
# failure, so it is caught here instead
bad = CL.verify_labels(HERE / "items")
if bad:
print(" WARNING - labels inconsistent with the documented rules:")
for b in bad:
print(f" {b['name']}: labelled {b['labelled']}, rules imply {b['implied']} "
f"(dims={b['dims_mm']} k={b['k']})")
else:
print(" labels consistent with the documented B/C/D rules")
from collections import Counter
print(" totals:", dict(Counter(r["cls"] for r in lib)))
ORDER = [r["name"] for r in lib]
CLASSES = {r["name"]: r["cls"] for r in lib}
PATHS = {r["name"]: r["path"] for r in lib}
print(f"\n===== TIMING =====")
print(f" T_pitch (item spacing) = {T_PITCH:.3f} s")
print(f" T_blade (item on the blade) = {T_BLADE:.3f} s")
print(f" T_gap (blade empty between) = {T_GAP:.3f} s")
print(f" reposition rate needed = {PLOW_REPOSITION_RATE:.0f} deg/s "
f"(hold rate {PLOW_RATE:.0f} deg/s)")
PUSH_CYCLE = (PUSH_OUT_Y - PUSH_HOME_Y) / PUSH_SPEED + PUSH_HOLD_S + \
(PUSH_OUT_Y - PUSH_HOME_Y) / PUSH_RETURN_SPEED
print(f" pusher cycle (out+hold+back) = {PUSH_CYCLE:.3f} s"
+ (" OK - clears the lane before the next item"
if PUSH_CYCLE < T_PITCH else
f" TOO SLOW - blade still in the lane when the next item arrives"))
if T_GAP <= 0:
print(f" IMPOSSIBLE: an item still owns the blade when the next arrives. "
f"Need pitch > {T_BLADE*SPEED:.2f} m at {SPEED} m/s.")
# ---------------------------------------------------------------- scene
stage = omni.usd.get_context().get_stage()
if not stage or "plow_cell_90_45" not in (stage.GetRootLayer().identifier or ""):
raise SystemExit(f"open {cell.SCENE} first - see control_test/README.md")
info = await cell.prepare(stage, belt_speed=SPEED, script_control=True)
print(f"\nprepare: belts={len(info['belts'])} graphs_removed={len(info['graphs_removed'])} "
f"junction_opened={info['junction_opened']} rails={info['rails']} "
f"pusher={info['pusher_dims'][0]:.2f} m")
ITEMS_ROOT = "/World/CtrlItems"
def _load(names):
"""define every item physics-ready BEFORE play; never re-tag a body afterwards"""
UsdGeom.Xform.Define(stage, ITEMS_ROOT)
ok = []
for i, name in enumerate(names):
try:
prim = UsdGeom.Xform.Define(stage, f"{ITEMS_ROOT}/{name}").GetPrim()
prim.GetReferences().ClearReferences()
prim.GetReferences().AddReference(PATHS[name])
xf = UsdGeom.Xformable(prim)
xf.ClearXformOpOrder()
xf.AddTranslateOp(precision=UsdGeom.XformOp.PrecisionDouble).Set(
Gf.Vec3d(9.0 + 1.2 * i, 5.0, 0.4))
UsdPhysics.RigidBodyAPI.Apply(prim)
# exported meshes arrive kinematic; force dynamic once, before play
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(False)
UsdPhysics.MassAPI.Apply(prim).CreateMassAttr().Set(0.6)
px = PhysxSchema.PhysxRigidBodyAPI.Apply(prim)
px.CreateEnableCCDAttr().Set(True)
px.CreateSolverPositionIterationCountAttr().Set(24)
px.CreateSolverVelocityIterationCountAttr().Set(8)
px.CreateSleepThresholdAttr().Set(0.0)
px.CreateMaxDepenetrationVelocityAttr().Set(C.MAX_DEPENETRATION)
for d in Usd.PrimRange(prim):
if d.HasAPI(UsdPhysics.CollisionAPI):
pc = PhysxSchema.PhysxCollisionAPI.Apply(d)
pc.CreateContactOffsetAttr().Set(0.004)
pc.CreateRestOffsetAttr().Set(0.001)
UsdGeom.Imageable(prim).MakeInvisible()
ok.append(name)
except BaseException as exc:
print(f" WARNING: {name} failed to load: {type(exc).__name__}")
return ok
ORDER = _load(ORDER)
rp = {n: RigidPrim(paths=[f"{ITEMS_ROOT}/{n}"]) for n in ORDER}
# ONE view over every item: get_world_poses() then costs a single backend round-trip
# instead of nine. Benchmarked on this scene: 0.746 ms for nine separate reads vs
# 0.081 ms batched. The physics callback used to do ~36 separate reads per step (trace
# loop + two curtains + the plow schedule), i.e. ~3 ms of a 16.7 ms budget spent almost
# entirely on GPU round-trips that also stall the render thread feeding WebRTC.
items_view = RigidPrim(paths=[f"{ITEMS_ROOT}/{n}" for n in ORDER])
_pose_cache = {}
def refresh_poses():
try:
arr = items_view.get_world_poses()[0].numpy()
for i, n in enumerate(ORDER):
_pose_cache[n] = arr[i]
_last[n] = arr[i]
except BaseException:
pass
await app_utils.update_app_async(steps=20)
plow = Plow(stage, kinematic=True)
plow.home()
query = get_physx_scene_query_interface()
_last = {}
def item_pose(name):
"""cached: refresh_poses() fills the whole cache in one call per step"""
if name in _pose_cache:
return _pose_cache[name]
refresh_poses()
if name in _pose_cache:
return _pose_cache[name]
if name in _last:
return _last[name]
raise KeyError(name)
def activate(name):
prim = stage.GetPrimAtPath(f"{ITEMS_ROOT}/{name}")
for op in UsdGeom.Xformable(prim).GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
op.Set(Gf.Vec3d(cell.ENTRY_X, cell.ENTRY_Y, C.BELT_Z + 0.05))
break
UsdGeom.Imageable(prim).MakeVisible()
# ---------------------------------------------------------------- pusher blade
blade_prim = stage.GetPrimAtPath("/World/Diverters/DiverterY_Split/Pusher")
blade_op = next(o for o in UsdGeom.Xformable(blade_prim).GetOrderedXformOps()
if o.GetOpType() == UsdGeom.XformOp.TypeTranslate)
blade_base = blade_op.Get()
BLADE_PARENT_Y = -0.35
def blade_to(y):
blade_op.Set(Gf.Vec3d(blade_base[0], y - BLADE_PARENT_Y, blade_base[2]))
blade_to(PUSH_HOME_Y)
gate_log, push_log = [], []
push_swept, plow_swept, push_queue = set(), set(), []
plow_done = set()
sim_t = [0.0]
push_state = {"phase": "idle", "item": None, "y": PUSH_HOME_Y, "t": 0.0, "y0": 0.0}
def _curtain(x, exclude):
if not any(abs(float(item_pose(n)[0]) - x) < GATE_WINDOW
for n in ORDER if n not in exclude and n in rp):
return None
z0, reach = C.BELT_Z + 0.40, 0.399
for i in range(SENSE_RAYS):
y = SENSE_Y0 + (SENSE_Y1 - SENSE_Y0) * i / (SENSE_RAYS - 1)
hit = query.raycast_closest([x, y, z0], [0.0, 0.0, -1.0], reach)
if not hit or not hit.get("hit"):
continue
path = str(hit.get("rigidBody") or hit.get("collision") or "")
for n in ORDER:
if n not in exclude and f"{ITEMS_ROOT}/{n}" in path:
return n
return None
def _push_begin(name):
push_state.update(phase="out", item=name, t=0.0, y0=float(item_pose(name)[1]))
def _push_step(dt):
"""the pusher, advanced from the PHYSICS step - never from an async task. Driving it
from a coroutine that pumps the app concurrently with the feed loop made the blade
jump further per sim-step than the stroke maths assumed, and the transfer collapsed
from ~0.8 m to ~0.2 m."""
st = push_state
if st["phase"] == "idle":
return
name = st["item"]
if st["phase"] == "out":
st["y"] = min(PUSH_OUT_Y, st["y"] + PUSH_SPEED * dt)
blade_to(st["y"])
# carry assist: a transform-driven kinematic blade imparts no momentum (PhysX sees
# a teleport), so the item's +Y velocity is matched to the blade's each step - a
# carry, not a single kick. Measured dy 0.21 m -> 0.80 m.
if name in rp:
try:
lin = rp[name].get_velocities()[0].numpy()[0]
rp[name].set_velocities(np.array([[float(lin[0]), PUSH_ASSIST_V, float(lin[2])]]),
np.array([[0.0, 0.0, 0.0]]))
except BaseException:
pass
if st["y"] >= PUSH_OUT_Y - 1e-6:
st["phase"], st["t"] = "hold", 0.0
p = item_pose(name)
push_log.append(dict(item=name, dy=round(float(p[1]) - st["y0"], 3),
x=round(float(p[0]), 3), y=round(float(p[1]), 3)))
elif st["phase"] == "hold":
st["t"] += dt
if st["t"] >= PUSH_HOLD_S:
st["phase"] = "back"
else:
st["y"] = max(PUSH_HOME_Y, st["y"] - PUSH_RETURN_SPEED * dt)
blade_to(st["y"])
if st["y"] <= PUSH_HOME_Y + 1e-6:
st.update(phase="idle", item=None)
if push_queue:
_push_begin(push_queue.pop(0))
RUN_ACTIVE = [True]
def _step(dt):
# If the script dies mid-run the subscription can outlive it, and a callback still
# touching dead RigidPrims while the timeline keeps playing is what left the app
# pinned at full load and unreachable - which reads exactly like a crash. The finally
# block below clears this flag no matter how the run ends.
if not RUN_ACTIVE[0]:
return
sim_t[0] += dt
refresh_poses() # one batched read; everything below uses the cache
try:
seen = _curtain(PUSH_SENSE_X, push_swept)
if seen is not None:
push_swept.add(seen)
gate_log.append(("push", seen, CLASSES[seen]))
if CLASSES[seen] == "D":
(push_queue.append if push_state["phase"] != "idle" else _push_begin)(seen)
seen = _curtain(PLOW_SENSE_X, plow_swept)
if seen is not None:
plow_swept.add(seen)
gate_log.append(("plow", seen, CLASSES[seen],
float(PLOW_ANGLES.get(CLASSES[seen], 0.0))))
# Purely POSITION-DRIVEN, no commit / hold / timeout - so nothing can be starved.
# Whoever is physically on the blade owns the angle; the instant they clear its
# trailing edge the blade is free and snaps to the next arrival's angle. The old
# scheme committed at the sensor (x -6.30) and held to x -7.95, occupying the
# blade for 1.65 s - 2.4 items' worth at a 0.70 m pitch, which is what starved
# everyone behind and forced the pitch up to 1.4 m.
on_blade = nearest = None
for n in ORDER:
if n not in rp or n in plow_done:
continue
x = float(item_pose(n)[0])
if x < BLADE_TRAILING_X:
plow_done.add(n)
elif x <= BLADE_LEADING_X + BLADE_GUARD: # on the blade, or close enough
if on_blade is None or x < on_blade[0]: # that a fast snap would bat it
on_blade = (x, n) # deepest in = furthest along
elif n in plow_swept: # sensed, still approaching
if nearest is None or x < nearest[0]:
nearest = (x, n) # smallest x = closest to the blade
target = on_blade or nearest
ang = float(PLOW_ANGLES.get(CLASSES[target[1]], 0.0)) if target else 0.0
plow.step_toward(ang, dt,
rate=PLOW_RATE if on_blade else PLOW_REPOSITION_RATE)
_push_step(dt)
except BaseException as exc:
gate_log.append(("step-error", "", repr(exc)))
sub = get_physx_interface().subscribe_physics_step_events(_step)
tl = omni.timeline.get_timeline_interface()
# Everything from Play onward runs inside try/finally. Without it, any error in the run -
# and several happened while building this - left the physics callback subscribed AND the
# timeline playing, so the app kept simulating at full tilt with stale references. The
# python_server then could not answer, WebRTC dropped with NVST_R_BUSY, and the whole
# thing looked like a crash when it was really a leaked run.
try:
tl.play()
await app_utils.update_app_async(steps=20)
w = vp.get_active_viewport()
async def _wait(sec):
target = float(tl.get_current_time()) + sec
while float(tl.get_current_time()) < target:
await app_utils.update_app_async(steps=5)
await asyncio.sleep(0) # hand the event loop back to the
# WebRTC streamer and the python server. Pumping update_app_async back-to-back for
# the length of a run starves them: the kit log shows NVST_R_BUSY then 'Client
# disconnected from WebRTC server' on every run, and a second client cannot connect
# at all - from outside that is indistinguishable from Isaac Sim having crashed.
print(f"\n===== FEED ({len(ORDER)} items, {PITCH*1000:.0f} mm pitch @ {SPEED} m/s) =====")
sim_t0 = float(tl.get_current_time())
for i, name in enumerate(ORDER):
for _ in range(8):
try:
activate(name)
break
except BaseException:
await app_utils.update_app_async(steps=2)
print(f" {i*PITCH/SPEED:6.2f}s {name} ({CLASSES[name]})")
await _wait(PITCH / SPEED)
settled = {}
def outcome(name):
if name not in rp:
return None
p = item_pose(name)
x, y, z = float(p[0]), float(p[1]), float(p[2])
if BIN_X0 < x < BIN_X1 and BIN_Y0 < y < BIN_Y1 and BIN_Z_MIN < z < BIN_LIP_Z:
return "bin_D"
for tag, (cx, cy) in (("container_B", CONTAINER_B), ("container_C", CONTAINER_C)):
if (abs(x - cx) < CONTAINER_R and abs(y - cy) < CONTAINER_R
and CONTAINER_Z_MIN < z < CONTAINER_Z_MAX):
return tag
if z < 0.25: # ended up on the ground, wherever that was
return "floor"
return None
wall0 = time.time()
while (float(tl.get_current_time()) - sim_t0 < 60.0 + len(ORDER) * PITCH / SPEED
and time.time() - wall0 < 90.0):
await app_utils.update_app_async(steps=10)
await asyncio.sleep(0) # hand the event loop back to the
# WebRTC streamer and the python server. Pumping update_app_async back-to-back for
# the length of a run starves them: the kit log shows NVST_R_BUSY then 'Client
# disconnected from WebRTC server' on every run, and a second client cannot connect
# at all - from outside that is indistinguishable from Isaac Sim having crashed.
for n in ORDER:
if n not in settled and (w_ := outcome(n)) is not None:
settled[n] = w_
if len(settled) >= len(ORDER):
break
await app_utils.update_app_async(steps=5)
await asyncio.sleep(0) # hand the event loop back to the
# WebRTC streamer and the python server. Pumping update_app_async back-to-back for
# the length of a run starves them: the kit log shows NVST_R_BUSY then 'Client
# disconnected from WebRTC server' on every run, and a second client cannot connect
# at all - from outside that is indistinguishable from Isaac Sim having crashed.
vp.capture_viewport_to_file(w, file_path="/tmp/control_test_final.png")
await app_utils.update_app_async(steps=5)
await asyncio.sleep(0) # hand the event loop back to the
# WebRTC streamer and the python server. Pumping update_app_async back-to-back for
# the length of a run starves them: the kit log shows NVST_R_BUSY then 'Client
# disconnected from WebRTC server' on every run, and a second client cannot connect
# at all - from outside that is indistinguishable from Isaac Sim having crashed.
final = {n: item_pose(n).copy() for n in ORDER}
sub = None
tl.stop()
await app_utils.update_app_async(steps=10)
await asyncio.sleep(0) # hand the event loop back to the
# WebRTC streamer and the python server. Pumping update_app_async back-to-back for
# the length of a run starves them: the kit log shows NVST_R_BUSY then 'Client
# disconnected from WebRTC server' on every run, and a second client cannot connect
# at all - from outside that is indistinguishable from Isaac Sim having crashed.
finally:
RUN_ACTIVE[0] = False
sub = None
try:
tl.stop()
except BaseException:
pass
await app_utils.update_app_async(steps=10)
await asyncio.sleep(0) # hand the event loop back to the
# WebRTC streamer and the python server. Pumping update_app_async back-to-back for
# the length of a run starves them: the kit log shows NVST_R_BUSY then 'Client
# disconnected from WebRTC server' on every run, and a second client cannot connect
# at all - from outside that is indistinguishable from Isaac Sim having crashed.
print("cleanup: physics callback released, timeline stopped")
print("\n===== DELIVERY =====")
ok_n = 0
by_class = {"B": [0, 0], "C": [0, 0], "D": [0, 0]}
for name in ORDER:
cls = CLASSES[name]
got, want = settled.get(name, "line/unresolved"), EXPECT[cls]
ok = got == want
ok_n += ok
by_class[cls][1] += 1
by_class[cls][0] += int(ok)
p = final[name]
print(f" {name:20s} {cls} -> {got:16s} want={want:14s} {'OK' if ok else 'FAIL'}"
f" ({float(p[0]):+.2f},{float(p[1]):+.2f},{float(p[2]):+.2f})")
print(f"\ndelivered {ok_n}/{len(ORDER)}")
for c in ("B", "C", "D"):
h, t = by_class[c]
print(f" {c}: {h}/{t}" + (f" ({100*h/t:.0f}%)" if t else ""))
if push_log:
print("\n===== PUSHER =====")
for e in push_log:
print(f" {e['item']:20s} dy={e['dy']:+.3f} -> ({e['x']:+.2f},{e['y']:+.2f})")
print("\nscreenshot: /tmp/control_test_final.png")