Georg commited on
Commit
0551bb3
·
1 Parent(s): ec12dc0

Enhance homing functionality and client management in mujoco_server.py

Browse files

- Updated the homing state management to include timeout checks, preventing drift when home messages cease.
- Improved the handling of identified and unidentified clients in the `broadcast_state()` function, ensuring accurate client status reporting.
- Introduced continuous command sending in the NovaJogger class for smoother joint movements during jogging.
- Enhanced the UI to display both identified and unidentified clients, improving user feedback on connection status.
- Added comprehensive tests for homing behavior, including drift prevention and overshoot detection, ensuring robust performance in various scenarios.
- Updated README.md to reflect changes in homing behavior and client management.

README.md CHANGED
@@ -847,9 +847,7 @@ env = UR5Env(
847
  )
848
  ```
849
 
850
- **Note**: When state streaming is enabled, the simulation becomes a read-only digital twin that displays the real robot's movements. Local target controls and IK computations are ignored since the robot is controlled by external hardware/software.
851
-
852
- **Sending Actions to Robot**: To send motion actions to the real robot through Nova API, use Nova's motion execution endpoints directly (not currently implemented in the web UI). The simulation is designed to visualize robot state, not to control it.
853
 
854
  ### Environment Variables Reference
855
 
@@ -876,7 +874,7 @@ When `use_state_stream` is enabled:
876
  4. The simulation updates its joint positions to match the real robot
877
  5. MuJoCo forward kinematics computes the end-effector pose
878
 
879
- **Note:** When state streaming is active, the simulation becomes a digital twin of the real robot.
880
 
881
  #### Inverse Kinematics
882
  When `use_ik` is enabled:
 
847
  )
848
  ```
849
 
850
+ **Note**: When state streaming is enabled, the simulation becomes a digital twin that mirrors the real robot's state. The robot can still be controlled via Nova API jogging commands (when `use_jogging` is enabled). Local IK target updates are ignored in favor of the real robot's state, but the robot can be moved through Nova API commands.
 
 
851
 
852
  ### Environment Variables Reference
853
 
 
874
  4. The simulation updates its joint positions to match the real robot
875
  5. MuJoCo forward kinematics computes the end-effector pose
876
 
877
+ **Note:** When state streaming is active, the simulation becomes a digital twin that mirrors the real robot's state. The robot can still be moved via Nova API commands (e.g., jogging).
878
 
879
  #### Inverse Kinematics
880
  When `use_ik` is enabled:
frontend/index.html CHANGED
@@ -765,9 +765,6 @@
765
  style="font-size: 0.85em; color: var(--wb-success); display: flex; align-items: center; gap: 6px;">
766
  Nova Connected
767
  </strong>
768
- <div style="font-size: 0.75em; color: rgba(255, 255, 255, 0.85);">
769
- <span id="nova_mode_text">Digital Twin Mode</span> - Read Only
770
- </div>
771
  </div>
772
  </div>
773
  </div>
@@ -1190,19 +1187,30 @@
1190
  const NOVA_JOINT_VELOCITY = 0.5;
1191
  let novaVelocitiesConfigured = false;
1192
 
1193
- function updateConnectedClients(clients) {
1194
  if (!clientsStatusCard || !clientsStatusText || !clientsList) {
1195
  return;
1196
  }
1197
 
1198
  // Update text and list
1199
- if (!clients || clients.length === 0) {
 
1200
  clientsStatusText.innerText = "No clients connected";
1201
  clientsList.innerHTML = "";
1202
  clientsList.style.display = "none";
1203
  } else {
1204
- clientsStatusText.innerText = `${clients.length} client${clients.length > 1 ? 's' : ''} connected`;
1205
- clientsList.innerHTML = clients.map(id => `<li>${id}</li>`).join('');
 
 
 
 
 
 
 
 
 
 
1206
  clientsList.style.display = "block";
1207
  }
1208
  }
@@ -1429,6 +1437,12 @@
1429
  metadataCache = await resp.json();
1430
  const selection = populateRobotOptions(metadataCache);
1431
  if (selection) {
 
 
 
 
 
 
1432
  updateRobotUI(selection.robot, selection.scene);
1433
  refreshOverlayTiles();
1434
  }
@@ -1471,6 +1485,12 @@
1471
  loadEnvData();
1472
 
1473
  ws.onopen = () => {
 
 
 
 
 
 
1474
  markConnectedState();
1475
  refreshVideoStreams();
1476
  refreshOverlayTiles();
@@ -1540,8 +1560,10 @@
1540
  }
1541
 
1542
  // Handle both new and legacy client status formats
1543
- if (data.connected_clients && Array.isArray(data.connected_clients)) {
1544
- updateConnectedClients(data.connected_clients);
 
 
1545
  } else if (typeof data.trainer_connected === 'boolean') {
1546
  // Legacy format
1547
  updateTrainerStatus(data.trainer_connected);
@@ -1659,6 +1681,9 @@
1659
  const badgeTitle = document.getElementById('nova_badge_title');
1660
  const modeText = document.getElementById('nova_mode_text');
1661
 
 
 
 
1662
  // Only update toggle state if available or enabled changed
1663
  if (prevNovaState.available !== novaApi.available || prevNovaState.enabled !== novaApi.enabled) {
1664
  setNovaToggleState(novaApi.available, novaApi.enabled);
@@ -1695,12 +1720,14 @@
1695
 
1696
  // Update badge visibility and classes
1697
  if (badge) {
1698
- if (prevNovaState.available !== novaApi.available) {
 
1699
  badge.style.display = novaApi.available ? 'block' : 'none';
1700
  }
1701
- if (prevNovaState.enabled !== novaApi.enabled) {
1702
- badge.classList.toggle('connected', novaApi.enabled);
1703
- badge.classList.toggle('disconnected', !novaApi.enabled);
 
1704
  }
1705
  }
1706
 
@@ -1710,13 +1737,13 @@
1710
  stateController.innerText = novaApi.state_streaming ? 'Nova API' : 'Internal';
1711
  stateController.style.color = connectedColor;
1712
  }
1713
- if (ikController && (prevNovaState.ik !== novaApi.ik)) {
1714
  ikController.innerText = novaApi.ik ? 'Nova API' : 'Internal';
1715
- ikController.style.color = novaApi.ik ? 'var(--wb-success)' : 'var(--wb-highlight)';
1716
  }
1717
 
1718
  // Update badge title
1719
- if (badgeTitle && (prevNovaState.enabled !== novaApi.enabled || prevNovaState.connected !== novaApi.connected)) {
1720
  if (novaApi.enabled) {
1721
  badgeTitle.innerText = novaApi.connected ? '🌐 Nova Connected' : '🌐 Nova Powering Up';
1722
  } else {
@@ -1727,16 +1754,22 @@
1727
  // Update mode text
1728
  if (modeText) {
1729
  let newModeText = '';
1730
- if (novaApi.connected) {
1731
- if (novaApi.state_streaming && novaApi.ik) {
 
 
 
 
1732
  newModeText = 'Hybrid Mode';
1733
- } else if (novaApi.state_streaming) {
1734
  newModeText = 'Digital Twin Mode';
1735
- } else if (novaApi.ik) {
1736
- newModeText = 'Nova IK Mode';
 
 
 
 
1737
  }
1738
- } else if (novaApi.enabled) {
1739
- newModeText = 'Awaiting connection';
1740
  } else {
1741
  newModeText = 'Internal control';
1742
  }
@@ -1836,9 +1869,9 @@
1836
  }
1837
  } else if (msg.type === 'connected_clients') {
1838
  const payload = msg.data || {};
1839
- if (payload.clients && Array.isArray(payload.clients)) {
1840
- updateConnectedClients(payload.clients);
1841
- }
1842
  } else if (msg.type === 'trainer_status') {
1843
  // Legacy support
1844
  const payload = msg.data || {};
@@ -1953,6 +1986,11 @@
1953
  if (sceneLabel) {
1954
  sceneLabel.innerText = humanizeScene(scene);
1955
  }
 
 
 
 
 
1956
  // Toggle controls based on robot type
1957
  if (robot === 'ur5' || robot === 'ur5_t_push') {
1958
  locomotionControls.classList.add('hidden');
 
765
  style="font-size: 0.85em; color: var(--wb-success); display: flex; align-items: center; gap: 6px;">
766
  Nova Connected
767
  </strong>
 
 
 
768
  </div>
769
  </div>
770
  </div>
 
1187
  const NOVA_JOINT_VELOCITY = 0.5;
1188
  let novaVelocitiesConfigured = false;
1189
 
1190
+ function updateConnectedClients(clients, unidentifiedClients = []) {
1191
  if (!clientsStatusCard || !clientsStatusText || !clientsList) {
1192
  return;
1193
  }
1194
 
1195
  // Update text and list
1196
+ const totalClients = (clients || []).length + (unidentifiedClients || []).length;
1197
+ if (totalClients === 0) {
1198
  clientsStatusText.innerText = "No clients connected";
1199
  clientsList.innerHTML = "";
1200
  clientsList.style.display = "none";
1201
  } else {
1202
+ clientsStatusText.innerText = `${totalClients} client${totalClients > 1 ? 's' : ''} connected`;
1203
+
1204
+ // Build list with identified clients first, then unidentified
1205
+ let html = '';
1206
+ if (clients && clients.length > 0) {
1207
+ html += clients.map(id => `<li><strong>${id}</strong></li>`).join('');
1208
+ }
1209
+ if (unidentifiedClients && unidentifiedClients.length > 0) {
1210
+ html += unidentifiedClients.map(id => `<li style="color: #888; font-style: italic;">${id} (not identified)</li>`).join('');
1211
+ }
1212
+
1213
+ clientsList.innerHTML = html;
1214
  clientsList.style.display = "block";
1215
  }
1216
  }
 
1437
  metadataCache = await resp.json();
1438
  const selection = populateRobotOptions(metadataCache);
1439
  if (selection) {
1440
+ // Set pending selection so the backend switches to the default robot when WebSocket opens
1441
+ pendingRobotSelection = {
1442
+ value: buildSelectionValue(selection.robot, selection.scene),
1443
+ robot: selection.robot,
1444
+ scene: selection.scene
1445
+ };
1446
  updateRobotUI(selection.robot, selection.scene);
1447
  refreshOverlayTiles();
1448
  }
 
1485
  loadEnvData();
1486
 
1487
  ws.onopen = () => {
1488
+ // Send client identification immediately upon connection
1489
+ send('client_identity', {
1490
+ client_id: 'ui',
1491
+ name: 'UI Client'
1492
+ });
1493
+
1494
  markConnectedState();
1495
  refreshVideoStreams();
1496
  refreshOverlayTiles();
 
1560
  }
1561
 
1562
  // Handle both new and legacy client status formats
1563
+ if (data.connected_clients !== undefined) {
1564
+ const clients = data.connected_clients || [];
1565
+ const unidentifiedClients = data.unidentified_clients || [];
1566
+ updateConnectedClients(clients, unidentifiedClients);
1567
  } else if (typeof data.trainer_connected === 'boolean') {
1568
  // Legacy format
1569
  updateTrainerStatus(data.trainer_connected);
 
1681
  const badgeTitle = document.getElementById('nova_badge_title');
1682
  const modeText = document.getElementById('nova_mode_text');
1683
 
1684
+ // Check if this is the first update (all prev values are null)
1685
+ const isFirstUpdate = prevNovaState.available === null && prevNovaState.enabled === null;
1686
+
1687
  // Only update toggle state if available or enabled changed
1688
  if (prevNovaState.available !== novaApi.available || prevNovaState.enabled !== novaApi.enabled) {
1689
  setNovaToggleState(novaApi.available, novaApi.enabled);
 
1720
 
1721
  // Update badge visibility and classes
1722
  if (badge) {
1723
+ // Always update on first load, or when values change
1724
+ if (isFirstUpdate || prevNovaState.available !== novaApi.available) {
1725
  badge.style.display = novaApi.available ? 'block' : 'none';
1726
  }
1727
+ if (isFirstUpdate || prevNovaState.enabled !== novaApi.enabled) {
1728
+ const isEnabled = Boolean(novaApi.enabled);
1729
+ badge.classList.toggle('connected', isEnabled);
1730
+ badge.classList.toggle('disconnected', !isEnabled);
1731
  }
1732
  }
1733
 
 
1737
  stateController.innerText = novaApi.state_streaming ? 'Nova API' : 'Internal';
1738
  stateController.style.color = connectedColor;
1739
  }
1740
+ if (ikController && (prevNovaState.ik !== novaApi.ik || prevNovaState.connected !== novaApi.connected)) {
1741
  ikController.innerText = novaApi.ik ? 'Nova API' : 'Internal';
1742
+ ikController.style.color = novaApi.ik && novaApi.connected ? 'var(--wb-success)' : 'var(--wb-highlight)';
1743
  }
1744
 
1745
  // Update badge title
1746
+ if (badgeTitle && (isFirstUpdate || prevNovaState.enabled !== novaApi.enabled || prevNovaState.connected !== novaApi.connected)) {
1747
  if (novaApi.enabled) {
1748
  badgeTitle.innerText = novaApi.connected ? '🌐 Nova Connected' : '🌐 Nova Powering Up';
1749
  } else {
 
1754
  // Update mode text
1755
  if (modeText) {
1756
  let newModeText = '';
1757
+ if (novaApi.enabled) {
1758
+ // Check what Nova features are active
1759
+ const hasStateStream = novaApi.state_streaming && novaApi.connected;
1760
+ const hasIK = novaApi.ik;
1761
+
1762
+ if (hasStateStream && hasIK) {
1763
  newModeText = 'Hybrid Mode';
1764
+ } else if (hasStateStream) {
1765
  newModeText = 'Digital Twin Mode';
1766
+ } else if (hasIK) {
1767
+ newModeText = novaApi.connected ? 'Nova IK Mode' : 'Nova IK Mode (connecting...)';
1768
+ } else if (novaApi.connected) {
1769
+ newModeText = 'Nova Connected (idle)';
1770
+ } else {
1771
+ newModeText = 'Awaiting connection';
1772
  }
 
 
1773
  } else {
1774
  newModeText = 'Internal control';
1775
  }
 
1869
  }
1870
  } else if (msg.type === 'connected_clients') {
1871
  const payload = msg.data || {};
1872
+ const clients = payload.clients || [];
1873
+ const unidentifiedClients = payload.unidentified_clients || [];
1874
+ updateConnectedClients(clients, unidentifiedClients);
1875
  } else if (msg.type === 'trainer_status') {
1876
  // Legacy support
1877
  const payload = msg.data || {};
 
1986
  if (sceneLabel) {
1987
  sceneLabel.innerText = humanizeScene(scene);
1988
  }
1989
+ // Sync robot selector with active robot
1990
+ const expectedValue = buildSelectionValue(robot, scene);
1991
+ if (robotSelect.value !== expectedValue) {
1992
+ robotSelect.value = expectedValue;
1993
+ }
1994
  // Toggle controls based on robot type
1995
  if (robot === 'ur5' || robot === 'ur5_t_push') {
1996
  locomotionControls.classList.add('hidden');
mujoco_server.py CHANGED
@@ -127,12 +127,13 @@ homing_state = {
127
  "active": False,
128
  "current_joint": None,
129
  "joint_errors": [],
130
- "current_velocity": 0.2,
131
  "previous_error": None,
132
  "previous_abs_error": None,
133
  "stuck_count": 0,
134
  "is_jogging": False,
135
  "current_direction": None,
 
136
  }
137
  homing_lock = threading.Lock()
138
 
@@ -622,8 +623,19 @@ def broadcast_state():
622
  if hasattr(env, "get_task_reward"):
623
  reward_value = env.get_task_reward()
624
 
625
- with external_ws_clients_lock:
626
- connected_clients = [info.get("identity", "client") for ws, info in list(client_metadata.items()) if ws in external_ws_clients]
 
 
 
 
 
 
 
 
 
 
 
627
 
628
  # Get scene objects
629
  scene_objects = _get_scene_objects()
@@ -645,11 +657,17 @@ def broadcast_state():
645
  nova_ik = getattr(env, '_use_nova_ik', False)
646
  # Check if actually connected (WebSocket active and receiving data)
647
  nova_connected = False
648
- if nova_client is not None and hasattr(nova_client, 'is_state_stream_connected'):
649
- nova_connected = nova_client.is_state_stream_connected()
650
- elif nova_client is not None and not nova_state_streaming:
651
- # If not using state streaming, consider connected if client exists
652
- nova_connected = True
 
 
 
 
 
 
653
 
654
  nova_available = False
655
  nova_enabled_pref = False
@@ -687,7 +705,8 @@ def broadcast_state():
687
  'available': nova_available,
688
  'enabled': nova_enabled_pref
689
  },
690
- 'connected_clients': connected_clients
 
691
  }
692
  })
693
  else:
@@ -708,7 +727,8 @@ def broadcast_state():
708
  'reward': reward_value,
709
  'teleop_action': teleop_snapshot,
710
  'scene_objects': scene_objects,
711
- 'connected_clients': connected_clients
 
712
  }
713
  })
714
 
@@ -732,7 +752,7 @@ def _handle_external_client_message(ws, data):
732
  identity = payload.get("client_id") or payload.get("trainer_id") or payload.get("name") or "client"
733
  with external_ws_clients_lock:
734
  external_ws_clients.add(ws)
735
- _register_external_client(ws)
736
  _set_client_identity(ws, identity)
737
  broadcast_state()
738
  broadcast_connected_clients_status()
@@ -746,9 +766,21 @@ def _handle_external_client_message(ws, data):
746
  def _build_connected_clients_payload():
747
  """Build a summary payload describing connected external clients."""
748
  with client_metadata_lock:
749
- identities = [info.get("identity", "client") for ws, info in client_metadata.items() if ws in external_ws_clients and info.get("identity")]
 
 
 
 
 
 
 
 
 
 
 
750
  payload = {
751
- "clients": identities,
 
752
  }
753
  return payload
754
 
@@ -767,6 +799,9 @@ def _set_client_identity(ws, identity: str):
767
  with client_metadata_lock:
768
  info = client_metadata.get(ws)
769
  if not info:
 
 
 
770
  return
771
  info["identity"] = identity
772
  info["last_seen"] = time.time()
@@ -879,6 +914,19 @@ def simulation_loop():
879
  if env is None:
880
  return
881
 
 
 
 
 
 
 
 
 
 
 
 
 
 
882
  sim_dt = env.model.opt.timestep
883
 
884
  # Run physics steps
@@ -1122,9 +1170,12 @@ def handle_ws_message(ws, data):
1122
  _schedule_robot_switch(robot, scene)
1123
 
1124
  elif msg_type == 'home':
1125
- # State-based homing: processes one step each time a 'home' message is received
1126
  # UI sends home messages continuously while button is pressed
 
1127
  with mujoco_lock, homing_lock:
 
 
1128
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1129
  home_pose = getattr(env, "home_pose", None)
1130
  get_joints = getattr(env, "get_joint_positions", None)
@@ -1140,121 +1191,87 @@ def handle_ws_message(ws, data):
1140
  current_positions = get_joints()
1141
  tolerance = 0.01 # rad (~0.57 degrees)
1142
 
1143
- # Initialize homing state if not active
1144
- if not homing_state["active"]:
1145
- # Compute differences for all joints
1146
- joint_errors = []
1147
- for joint_idx in range(min(len(target_positions), len(current_positions))):
1148
- current = current_positions[joint_idx]
1149
- target = target_positions[joint_idx]
1150
- error = target - current
1151
- # Wrap angle to [-pi, pi]
1152
- error = (error + np.pi) % (2 * np.pi) - np.pi
1153
- abs_error = abs(error)
1154
-
1155
- if abs_error > tolerance:
1156
- joint_errors.append({
1157
- 'index': joint_idx,
1158
- 'error': error,
1159
- 'abs_error': abs_error
1160
- })
1161
-
1162
- if joint_errors:
1163
- # Sort by absolute error (largest first)
1164
- joint_errors.sort(key=lambda x: x['abs_error'], reverse=True)
1165
- homing_state["active"] = True
1166
- homing_state["joint_errors"] = joint_errors
1167
- homing_state["current_joint"] = joint_errors[0]['index']
1168
- homing_state["current_velocity"] = 0.15
1169
- homing_state["previous_error"] = None
1170
- homing_state["previous_abs_error"] = None
1171
- homing_state["stuck_count"] = 0
1172
- homing_state["is_jogging"] = False
1173
- homing_state["current_direction"] = None
1174
- else:
1175
- return
1176
-
1177
- # Process current joint
1178
- joint_idx = homing_state["current_joint"]
1179
- current_pos = current_positions[joint_idx]
1180
- target_pos = target_positions[joint_idx]
1181
-
1182
- # Compute shortest angular distance
1183
- error = target_pos - current_pos
1184
- error = (error + np.pi) % (2 * np.pi) - np.pi
1185
- abs_error = abs(error)
1186
-
1187
- # Check if current joint reached target
1188
- if abs_error <= tolerance:
1189
- if homing_state["is_jogging"]:
1190
  stop_jog()
1191
  homing_state["is_jogging"] = False
1192
-
1193
- # Move to next joint
1194
- homing_state["joint_errors"].pop(0)
1195
- if homing_state["joint_errors"]:
1196
- homing_state["current_joint"] = homing_state["joint_errors"][0]['index']
1197
- homing_state["current_velocity"] = 0.15
1198
- homing_state["previous_error"] = None
1199
- homing_state["previous_abs_error"] = None
1200
- homing_state["stuck_count"] = 0
1201
- homing_state["is_jogging"] = False
1202
- homing_state["current_direction"] = None
1203
- else:
1204
- # All joints homed
1205
  homing_state["active"] = False
 
 
1206
  return
1207
 
1208
- # Check if stuck
1209
- if homing_state["previous_abs_error"] is not None:
1210
- if abs(abs_error - homing_state["previous_abs_error"]) < 0.0001:
1211
- homing_state["stuck_count"] += 1
1212
- if homing_state["stuck_count"] > 20: # Stuck for ~2 seconds
1213
- if abs_error <= tolerance * 3:
1214
- if homing_state["is_jogging"]:
1215
- stop_jog()
1216
- homing_state["is_jogging"] = False
1217
- # Move to next joint
1218
- homing_state["joint_errors"].pop(0)
1219
- if homing_state["joint_errors"]:
1220
- homing_state["current_joint"] = homing_state["joint_errors"][0]['index']
1221
- homing_state["current_velocity"] = 0.15
1222
- homing_state["previous_error"] = None
1223
- homing_state["previous_abs_error"] = None
1224
- homing_state["stuck_count"] = 0
1225
- homing_state["is_jogging"] = False
1226
- homing_state["current_direction"] = None
1227
- else:
1228
- homing_state["active"] = False
1229
- return
1230
- else:
1231
- homing_state["stuck_count"] = 0
1232
-
1233
- # Check for overshoot
1234
- if homing_state["previous_error"] is not None:
1235
- prev_err = homing_state["previous_error"]
1236
- if (prev_err > 0 and error < 0) or (prev_err < 0 and error > 0):
1237
- homing_state["current_velocity"] = homing_state["current_velocity"] / 2.0
1238
- homing_state["current_velocity"] = max(0.005, homing_state["current_velocity"])
1239
- if homing_state["is_jogging"]:
1240
- stop_jog()
1241
- homing_state["is_jogging"] = False
1242
-
1243
- # Calculate velocity and direction
1244
  direction = '+' if error > 0 else '-'
1245
- desired_velocity = min(homing_state["current_velocity"], max(0.005, abs_error * 1.0))
1246
 
1247
- # Start/restart jogging if needed
1248
- if homing_state["current_direction"] != direction or not homing_state["is_jogging"]:
1249
- if homing_state["is_jogging"]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1250
  stop_jog()
1251
 
1252
- start_jog('joint', joint=joint_idx + 1, direction=direction, velocity=desired_velocity)
1253
  homing_state["is_jogging"] = True
1254
- homing_state["current_direction"] = direction
1255
-
1256
- homing_state["previous_error"] = error
1257
- homing_state["previous_abs_error"] = abs_error
1258
 
1259
  elif env is not None:
1260
  # Instant homing for non-UR5 robots
@@ -1500,12 +1517,19 @@ def _create_env(robot, scene):
1500
  @sock.route(f'{API_PREFIX}/ws')
1501
  def websocket_handler(ws):
1502
  """Handle WebSocket connections."""
1503
- print('WebSocket client connected')
 
 
 
1504
 
1505
- # Register client
1506
  with ws_clients_lock:
1507
  ws_clients.add(ws)
1508
 
 
 
 
 
1509
  try:
1510
  while True:
1511
  message = ws.receive()
@@ -1513,6 +1537,10 @@ def websocket_handler(ws):
1513
  break
1514
  try:
1515
  data = json.loads(message)
 
 
 
 
1516
  handle_ws_message(ws, data)
1517
  except json.JSONDecodeError:
1518
  print(f"Invalid JSON received: {message}")
@@ -1527,10 +1555,10 @@ def websocket_handler(ws):
1527
  with external_ws_clients_lock:
1528
  was_external = ws in external_ws_clients
1529
  external_ws_clients.discard(ws)
1530
- if was_external:
1531
- _unregister_external_client(ws)
1532
- broadcast_state()
1533
- broadcast_connected_clients_status()
1534
  print('WebSocket client disconnected')
1535
 
1536
 
 
127
  "active": False,
128
  "current_joint": None,
129
  "joint_errors": [],
130
+ "current_velocity": 0.5, # Max velocity for homing
131
  "previous_error": None,
132
  "previous_abs_error": None,
133
  "stuck_count": 0,
134
  "is_jogging": False,
135
  "current_direction": None,
136
+ "saved_velocity": None, # Store velocity to restore after homing
137
  }
138
  homing_lock = threading.Lock()
139
 
 
623
  if hasattr(env, "get_task_reward"):
624
  reward_value = env.get_task_reward()
625
 
626
+ # Build lists of identified and unidentified clients
627
+ with client_metadata_lock:
628
+ identified_clients = []
629
+ unidentified_clients = []
630
+ for ws, info in list(client_metadata.items()):
631
+ identity = info.get("identity")
632
+ if identity:
633
+ # Client has identified themselves
634
+ if ws in external_ws_clients:
635
+ identified_clients.append(identity)
636
+ else:
637
+ # Client connected but hasn't sent identity yet
638
+ unidentified_clients.append(f"unidentified-{id(ws) % 10000}")
639
 
640
  # Get scene objects
641
  scene_objects = _get_scene_objects()
 
657
  nova_ik = getattr(env, '_use_nova_ik', False)
658
  # Check if actually connected (WebSocket active and receiving data)
659
  nova_connected = False
660
+ if nova_client is not None:
661
+ if hasattr(nova_client, 'is_state_stream_connected'):
662
+ # Check actual stream connection status
663
+ nova_connected = nova_client.is_state_stream_connected()
664
+ elif hasattr(nova_client, 'is_connected'):
665
+ # Fallback: check general connection status
666
+ nova_connected = nova_client.is_connected()
667
+ else:
668
+ # If client exists but we can't verify connection, assume connected
669
+ # This handles the IK-only case where state streaming isn't used
670
+ nova_connected = True
671
 
672
  nova_available = False
673
  nova_enabled_pref = False
 
705
  'available': nova_available,
706
  'enabled': nova_enabled_pref
707
  },
708
+ 'connected_clients': identified_clients,
709
+ 'unidentified_clients': unidentified_clients
710
  }
711
  })
712
  else:
 
727
  'reward': reward_value,
728
  'teleop_action': teleop_snapshot,
729
  'scene_objects': scene_objects,
730
+ 'connected_clients': identified_clients,
731
+ 'unidentified_clients': unidentified_clients
732
  }
733
  })
734
 
 
752
  identity = payload.get("client_id") or payload.get("trainer_id") or payload.get("name") or "client"
753
  with external_ws_clients_lock:
754
  external_ws_clients.add(ws)
755
+ # Don't call _register_external_client again - client is already registered on connection
756
  _set_client_identity(ws, identity)
757
  broadcast_state()
758
  broadcast_connected_clients_status()
 
766
  def _build_connected_clients_payload():
767
  """Build a summary payload describing connected external clients."""
768
  with client_metadata_lock:
769
+ identified = []
770
+ unidentified = []
771
+ for ws, info in client_metadata.items():
772
+ identity = info.get("identity")
773
+ if identity:
774
+ # Client has identified themselves
775
+ if ws in external_ws_clients:
776
+ identified.append(identity)
777
+ else:
778
+ # Client connected but hasn't sent identity yet
779
+ unidentified.append(f"unidentified-{id(ws) % 10000}")
780
+
781
  payload = {
782
+ "clients": identified,
783
+ "unidentified_clients": unidentified,
784
  }
785
  return payload
786
 
 
799
  with client_metadata_lock:
800
  info = client_metadata.get(ws)
801
  if not info:
802
+ # Client wasn't registered on connection (shouldn't happen with new flow)
803
+ # Register them now for backward compatibility
804
+ client_metadata[ws] = {"identity": identity, "connected_at": time.time(), "last_seen": time.time()}
805
  return
806
  info["identity"] = identity
807
  info["last_seen"] = time.time()
 
914
  if env is None:
915
  return
916
 
917
+ # Check for homing timeout (prevent drift when home messages stop)
918
+ with homing_lock:
919
+ if homing_state.get("is_jogging", False):
920
+ last_home_time = homing_state.get("last_home_time", 0)
921
+ timeout = 0.5 # 500ms timeout - if no home message for this long, stop
922
+ if time.time() - last_home_time > timeout:
923
+ stop_jog = getattr(env, "stop_jog", None)
924
+ if callable(stop_jog):
925
+ stop_jog()
926
+ homing_state["is_jogging"] = False
927
+ homing_state["active"] = False
928
+ print("[Homing] Stopped jogging due to timeout (no home messages)")
929
+
930
  sim_dt = env.model.opt.timestep
931
 
932
  # Run physics steps
 
1170
  _schedule_robot_switch(robot, scene)
1171
 
1172
  elif msg_type == 'home':
1173
+ # Simplified homing with overshoot detection and drift prevention
1174
  # UI sends home messages continuously while button is pressed
1175
+ # When messages stop, jogging should stop to prevent drift
1176
  with mujoco_lock, homing_lock:
1177
+ # Update last home message timestamp
1178
+ homing_state["last_home_time"] = time.time()
1179
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1180
  home_pose = getattr(env, "home_pose", None)
1181
  get_joints = getattr(env, "get_joint_positions", None)
 
1191
  current_positions = get_joints()
1192
  tolerance = 0.01 # rad (~0.57 degrees)
1193
 
1194
+ # Find the joint with the largest error
1195
+ max_error = 0
1196
+ max_error_joint = None
1197
+
1198
+ for joint_idx in range(min(len(target_positions), len(current_positions))):
1199
+ current = current_positions[joint_idx]
1200
+ target = target_positions[joint_idx]
1201
+ # Compute shortest angular distance
1202
+ error = target - current
1203
+ error = (error + np.pi) % (2 * np.pi) - np.pi
1204
+ abs_error = abs(error)
1205
+
1206
+ if abs_error > max_error and abs_error > tolerance:
1207
+ max_error = abs_error
1208
+ max_error_joint = (joint_idx, error)
1209
+
1210
+ # If no joint needs homing, we're done
1211
+ if max_error_joint is None:
1212
+ if homing_state.get("is_jogging", False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1213
  stop_jog()
1214
  homing_state["is_jogging"] = False
 
 
 
 
 
 
 
 
 
 
 
 
 
1215
  homing_state["active"] = False
1216
+ homing_state.pop("joint_velocities", None)
1217
+ homing_state.pop("previous_errors", None)
1218
  return
1219
 
1220
+ # Jog the joint with the largest error
1221
+ joint_idx, error = max_error_joint
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1222
  direction = '+' if error > 0 else '-'
 
1223
 
1224
+ # Initialize velocity tracking per joint if needed
1225
+ if "joint_velocities" not in homing_state:
1226
+ homing_state["joint_velocities"] = {i: 0.5 for i in range(6)}
1227
+ if "previous_errors" not in homing_state:
1228
+ homing_state["previous_errors"] = {}
1229
+
1230
+ # Check for overshoot (direction changed)
1231
+ prev_error = homing_state["previous_errors"].get(joint_idx)
1232
+ if prev_error is not None:
1233
+ # If error sign changed, we overshot - reduce velocity aggressively
1234
+ if (prev_error > 0 and error < 0) or (prev_error < 0 and error > 0):
1235
+ homing_state["joint_velocities"][joint_idx] *= 0.3 # More aggressive reduction
1236
+ # Minimum velocity to keep moving
1237
+ homing_state["joint_velocities"][joint_idx] = max(0.05, homing_state["joint_velocities"][joint_idx])
1238
+
1239
+ # Store current error for next iteration
1240
+ homing_state["previous_errors"][joint_idx] = error
1241
+
1242
+ # Calculate velocity based on distance
1243
+ # When far from target: use stored velocity (default 0.5 rad/s)
1244
+ # When close to target: scale velocity proportionally to distance
1245
+ abs_error = abs(error)
1246
+ base_velocity = homing_state["joint_velocities"][joint_idx]
1247
+
1248
+ # Define distance threshold for proportional control (0.15 rad ≈ 8.6°)
1249
+ proximity_threshold = 0.15
1250
+ min_velocity = 0.1 # Minimum velocity to ensure convergence
1251
+
1252
+ if abs_error < proximity_threshold:
1253
+ # Proportional control: velocity scales linearly with distance
1254
+ # Velocity range: min_velocity to base_velocity
1255
+ # Formula: v = min_vel + (base_vel - min_vel) * (error / threshold)
1256
+ velocity_range = base_velocity - min_velocity
1257
+ velocity = min_velocity + velocity_range * (abs_error / proximity_threshold)
1258
+ else:
1259
+ # Far from target: use base velocity
1260
+ velocity = base_velocity
1261
+
1262
+ # Only call start_jog if we're not already jogging this joint in this direction
1263
+ current_jog = homing_state.get("current_jog")
1264
+ new_jog = (joint_idx, direction, velocity)
1265
+
1266
+ # Restart jog if joint, direction, or velocity changed
1267
+ if current_jog != new_jog:
1268
+ if homing_state.get("is_jogging", False):
1269
  stop_jog()
1270
 
1271
+ start_jog('joint', joint=joint_idx + 1, direction=direction, velocity=velocity)
1272
  homing_state["is_jogging"] = True
1273
+ homing_state["current_jog"] = new_jog
1274
+ homing_state["active"] = True
 
 
1275
 
1276
  elif env is not None:
1277
  # Instant homing for non-UR5 robots
 
1517
  @sock.route(f'{API_PREFIX}/ws')
1518
  def websocket_handler(ws):
1519
  """Handle WebSocket connections."""
1520
+ with open('/tmp/ws_debug.log', 'a') as f:
1521
+ f.write('WebSocket client connected\n')
1522
+ f.flush()
1523
+ print('WebSocket client connected', flush=True)
1524
 
1525
+ # Register client (track all clients, even before they identify)
1526
  with ws_clients_lock:
1527
  ws_clients.add(ws)
1528
 
1529
+ # Register in metadata as unidentified
1530
+ _register_external_client(ws)
1531
+ broadcast_connected_clients_status()
1532
+
1533
  try:
1534
  while True:
1535
  message = ws.receive()
 
1537
  break
1538
  try:
1539
  data = json.loads(message)
1540
+ with open('/tmp/ws_debug.log', 'a') as f:
1541
+ f.write(f"Received message type: {data.get('type', 'unknown')}\n")
1542
+ f.flush()
1543
+ print(f"[WS] Received message type: {data.get('type', 'unknown')}")
1544
  handle_ws_message(ws, data)
1545
  except json.JSONDecodeError:
1546
  print(f"Invalid JSON received: {message}")
 
1555
  with external_ws_clients_lock:
1556
  was_external = ws in external_ws_clients
1557
  external_ws_clients.discard(ws)
1558
+ # Always unregister from metadata (all clients are now tracked, not just external ones)
1559
+ _unregister_external_client(ws)
1560
+ broadcast_state()
1561
+ broadcast_connected_clients_status()
1562
  print('WebSocket client disconnected')
1563
 
1564
 
robots/ur5/model/scene.xml CHANGED
@@ -345,7 +345,7 @@
345
  </actuator>
346
 
347
  <keyframe>
348
- <!-- Official MuJoCo Menagerie UR5e home pose with gripper open (0=open) -->
349
  <key name="home" qpos="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0 0 0 0 0 0 0 0 0.5 0.2 0.45 1 0 0 0"
350
  ctrl="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0"/>
351
  </keyframe>
 
345
  </actuator>
346
 
347
  <keyframe>
348
+ <!-- UR5e home pose updated to stable settled position with gripper open (0=open) -->
349
  <key name="home" qpos="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0 0 0 0 0 0 0 0 0.5 0.2 0.45 1 0 0 0"
350
  ctrl="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0"/>
351
  </keyframe>
robots/ur5/model/scene_with_gripper.xml CHANGED
@@ -185,7 +185,7 @@
185
  </actuator>
186
 
187
  <keyframe>
188
- <!-- Official MuJoCo Menagerie UR5e home pose with gripper open (0=open) -->
189
  <key name="home" qpos="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0 0 0 0 0 0 0 0 0.5 0.2 0.45 1 0 0 0"
190
  ctrl="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0"/>
191
  </keyframe>
 
185
  </actuator>
186
 
187
  <keyframe>
188
+ <!-- UR5e home pose updated to stable settled position with gripper open (0=open) -->
189
  <key name="home" qpos="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0 0 0 0 0 0 0 0 0.5 0.2 0.45 1 0 0 0"
190
  ctrl="-3.22 -1.14 1.93 -2.36 -1.57 -1.72 0"/>
191
  </keyframe>
robots/ur5/nova_jogger.py CHANGED
@@ -66,6 +66,41 @@ class NovaJogger:
66
  self._receive_thread = None
67
  self._stop_thread = False
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def _receive_loop(self):
70
  """Background thread to receive WebSocket messages."""
71
  while not self._stop_thread and self._jogger_ws is not None:
@@ -138,6 +173,12 @@ class NovaJogger:
138
  self._receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
139
  self._receive_thread.start()
140
 
 
 
 
 
 
 
141
  self._current_mode = mode
142
  print(f"[Nova Jogger] Connected to {mode} endpoint")
143
  return True
@@ -169,13 +210,15 @@ class NovaJogger:
169
  else:
170
  joint_velocities[joint] = -velocity_rads_per_sec
171
 
172
- # Send velocity command (matching nova-js format)
173
  command = {
174
  "motion_group": self.motion_group_id,
175
  "joint_velocities": joint_velocities
176
  }
177
- self._jogger_ws.send(json.dumps(command))
178
- print(f"[Nova Jogger] Joint {joint} jogging at {joint_velocities[joint]:.2f} rad/s")
 
 
179
  return True
180
 
181
  except Exception as e:
@@ -198,7 +241,7 @@ class NovaJogger:
198
  else:
199
  position_direction[axis] = -1
200
 
201
- # Send velocity command (matching nova-js format)
202
  command = {
203
  "motion_group": self.motion_group_id,
204
  "position_direction": position_direction,
@@ -208,8 +251,10 @@ class NovaJogger:
208
  "tcp": tcp_id,
209
  "coordinate_system": coord_system_id
210
  }
211
- self._jogger_ws.send(json.dumps(command))
212
- print(f"[Nova Jogger] Cartesian {axis.upper()} translation at {velocity_mm_per_sec} mm/s (dir: {position_direction[axis]})")
 
 
213
  return True
214
 
215
  except Exception as e:
@@ -232,7 +277,7 @@ class NovaJogger:
232
  else:
233
  rotation_direction[axis] = -1
234
 
235
- # Send velocity command (matching nova-js format)
236
  command = {
237
  "motion_group": self.motion_group_id,
238
  "position_direction": {"x": 0, "y": 0, "z": 0},
@@ -242,8 +287,10 @@ class NovaJogger:
242
  "tcp": tcp_id,
243
  "coordinate_system": coord_system_id
244
  }
245
- self._jogger_ws.send(json.dumps(command))
246
- print(f"[Nova Jogger] Cartesian {axis.upper()} rotation at {velocity_rads_per_sec} rad/s (dir: {rotation_direction[axis]})")
 
 
247
  return True
248
 
249
  except Exception as e:
@@ -251,33 +298,40 @@ class NovaJogger:
251
  return False
252
 
253
  def stop(self) -> bool:
254
- """Stop any active jogging movement by sending zero velocities."""
255
- if not self._connected or self._jogger_ws is None:
256
  return False
257
 
258
  try:
259
- if self._current_mode == "joint":
260
- # Send zero velocities for all joints
261
- command = {
262
- "motion_group": self.motion_group_id,
263
- "joint_velocities": [0.0] * 6
264
- }
265
- elif self._current_mode == "cartesian":
266
- # Send zero velocities for cartesian
267
- command = {
268
- "motion_group": self.motion_group_id,
269
- "position_direction": {"x": 0, "y": 0, "z": 0},
270
- "rotation_direction": {"x": 0, "y": 0, "z": 0},
271
- "position_velocity": 0,
272
- "rotation_velocity": 0,
273
- "tcp": getattr(self, '_tcp_id', 'Flange'),
274
- "coordinate_system": getattr(self, '_coord_system_id', 'world')
275
- }
276
- else:
277
- return False
278
-
279
- self._jogger_ws.send(json.dumps(command))
280
- print("[Nova Jogger] Stopped (zero velocities sent)")
 
 
 
 
 
 
 
281
  return True
282
 
283
  except Exception as e:
@@ -286,7 +340,13 @@ class NovaJogger:
286
 
287
  def disconnect(self):
288
  """Disconnect from the jogger."""
289
- # Stop the receive thread first
 
 
 
 
 
 
290
  self._stop_thread = True
291
  if self._receive_thread is not None:
292
  self._receive_thread.join(timeout=2.0)
 
66
  self._receive_thread = None
67
  self._stop_thread = False
68
 
69
+ # Continuous command sending
70
+ self._send_thread = None
71
+ self._stop_send_thread = False
72
+ self._current_command: Optional[Dict[str, Any]] = None
73
+ self._command_rate = 0.1 # Send command every 100ms
74
+
75
+ def _send_loop(self):
76
+ """Background thread to send velocity commands continuously."""
77
+ import sys
78
+ print(f"[Nova Jogger] Send loop started", flush=True)
79
+ sys.stdout.flush()
80
+ send_count = 0
81
+ while not self._stop_send_thread:
82
+ try:
83
+ with self._lock:
84
+ if self._current_command and self._jogger_ws:
85
+ self._jogger_ws.send(json.dumps(self._current_command))
86
+ send_count += 1
87
+ if send_count == 1: # Log first send
88
+ print(f"[Nova Jogger] First command sent", flush=True)
89
+ sys.stdout.flush()
90
+ if send_count % 10 == 0: # Log every 10 sends (every second)
91
+ print(f"[Nova Jogger] Sent {send_count} commands (continuous mode active)", flush=True)
92
+ sys.stdout.flush()
93
+ time.sleep(self._command_rate)
94
+ except Exception as e:
95
+ if not self._stop_send_thread:
96
+ print(f"[Nova Jogger] Send error: {e}", flush=True)
97
+ sys.stdout.flush()
98
+ import traceback
99
+ traceback.print_exc()
100
+ break
101
+ print(f"[Nova Jogger] Send loop exited (sent {send_count} total commands)", flush=True)
102
+ sys.stdout.flush()
103
+
104
  def _receive_loop(self):
105
  """Background thread to receive WebSocket messages."""
106
  while not self._stop_thread and self._jogger_ws is not None:
 
173
  self._receive_thread = threading.Thread(target=self._receive_loop, daemon=True)
174
  self._receive_thread.start()
175
 
176
+ # Start send loop for continuous commands
177
+ self._stop_send_thread = False
178
+ self._send_thread = threading.Thread(target=self._send_loop, daemon=True)
179
+ self._send_thread.start()
180
+ print(f"[Nova Jogger] Send thread started (continuous command mode)")
181
+
182
  self._current_mode = mode
183
  print(f"[Nova Jogger] Connected to {mode} endpoint")
184
  return True
 
210
  else:
211
  joint_velocities[joint] = -velocity_rads_per_sec
212
 
213
+ # Store command for continuous sending
214
  command = {
215
  "motion_group": self.motion_group_id,
216
  "joint_velocities": joint_velocities
217
  }
218
+ with self._lock:
219
+ self._current_command = command
220
+
221
+ print(f"[Nova Jogger] Joint {joint} jogging at {joint_velocities[joint]:.2f} rad/s (continuous)")
222
  return True
223
 
224
  except Exception as e:
 
241
  else:
242
  position_direction[axis] = -1
243
 
244
+ # Store command for continuous sending
245
  command = {
246
  "motion_group": self.motion_group_id,
247
  "position_direction": position_direction,
 
251
  "tcp": tcp_id,
252
  "coordinate_system": coord_system_id
253
  }
254
+ with self._lock:
255
+ self._current_command = command
256
+
257
+ print(f"[Nova Jogger] Cartesian {axis.upper()} translation at {velocity_mm_per_sec} mm/s (dir: {position_direction[axis]}, continuous)")
258
  return True
259
 
260
  except Exception as e:
 
277
  else:
278
  rotation_direction[axis] = -1
279
 
280
+ # Store command for continuous sending
281
  command = {
282
  "motion_group": self.motion_group_id,
283
  "position_direction": {"x": 0, "y": 0, "z": 0},
 
287
  "tcp": tcp_id,
288
  "coordinate_system": coord_system_id
289
  }
290
+ with self._lock:
291
+ self._current_command = command
292
+
293
+ print(f"[Nova Jogger] Cartesian {axis.upper()} rotation at {velocity_rads_per_sec} rad/s (dir: {rotation_direction[axis]}, continuous)")
294
  return True
295
 
296
  except Exception as e:
 
298
  return False
299
 
300
  def stop(self) -> bool:
301
+ """Stop any active jogging movement by clearing the command."""
302
+ if not self._connected:
303
  return False
304
 
305
  try:
306
+ # Clear the current command so the send loop stops sending
307
+ with self._lock:
308
+ self._current_command = None
309
+
310
+ # Send one final zero-velocity command
311
+ if self._jogger_ws:
312
+ if self._current_mode == "joint":
313
+ # Send zero velocities for all joints
314
+ command = {
315
+ "motion_group": self.motion_group_id,
316
+ "joint_velocities": [0.0] * 6
317
+ }
318
+ elif self._current_mode == "cartesian":
319
+ # Send zero velocities for cartesian
320
+ command = {
321
+ "motion_group": self.motion_group_id,
322
+ "position_direction": {"x": 0, "y": 0, "z": 0},
323
+ "rotation_direction": {"x": 0, "y": 0, "z": 0},
324
+ "position_velocity": 0,
325
+ "rotation_velocity": 0,
326
+ "tcp": getattr(self, '_tcp_id', 'Flange'),
327
+ "coordinate_system": getattr(self, '_coord_system_id', 'world')
328
+ }
329
+ else:
330
+ return False
331
+
332
+ self._jogger_ws.send(json.dumps(command))
333
+
334
+ print("[Nova Jogger] Stopped (cleared command)")
335
  return True
336
 
337
  except Exception as e:
 
340
 
341
  def disconnect(self):
342
  """Disconnect from the jogger."""
343
+ # Stop the send thread first
344
+ self._stop_send_thread = True
345
+ if self._send_thread is not None:
346
+ self._send_thread.join(timeout=2.0)
347
+ self._send_thread = None
348
+
349
+ # Stop the receive thread
350
  self._stop_thread = True
351
  if self._receive_thread is not None:
352
  self._receive_thread.join(timeout=2.0)
robots/ur5/ur5_env.py CHANGED
@@ -171,11 +171,15 @@ class UR5Env(gym.Env):
171
  self._nova_api_config = dict(nova_api_config) if nova_api_config else None
172
  self._target_pos = None
173
  self._target_euler = None
174
- self._nova_user_enabled = False
 
 
175
  if self._nova_api_config:
176
  enabled = self.set_nova_enabled(True)
177
  if not enabled:
178
  print("[Nova] WARNING: Nova API configuration was provided but failed to initialize.")
 
 
179
 
180
  # IK controller for end-effector control
181
  self.controller = IKController(
@@ -190,6 +194,9 @@ class UR5Env(gym.Env):
190
  self.home_pose = loaded_pose if loaded_pose is not None else self.DEFAULT_HOME_POSE.copy()
191
  self.data.qpos[:6] = self.home_pose.copy()
192
  self.data.ctrl[:6] = self.home_pose.copy()
 
 
 
193
  mujoco.mj_forward(self.model, self.data)
194
 
195
  # Target position/orientation from FK of home pose
@@ -210,7 +217,8 @@ class UR5Env(gym.Env):
210
  # Control mode: 'ik' (end-effector target) or 'joint' (direct joint positions)
211
  self._control_mode = 'ik'
212
  # Direct joint targets (used when control_mode is 'joint')
213
- self._joint_targets = self._compute_joint_targets()
 
214
 
215
  # Configure Nova API if requested (must be after Nova attributes are initialized)
216
  if nova_api_config:
@@ -272,8 +280,8 @@ class UR5Env(gym.Env):
272
  print("[Nova] Starting state stream...")
273
  self._nova_client.start_state_stream()
274
  print("[Nova] State stream started successfully")
275
- print("[Nova] WARNING: State streaming enabled - simulation is now a digital twin.")
276
- print("[Nova] Robot state comes from Nova API, local targets/IK are ignored.")
277
 
278
  if self._use_nova_jogging:
279
  if nova_jogger is None:
@@ -518,14 +526,33 @@ class UR5Env(gym.Env):
518
  True if command sent successfully
519
  """
520
  # Try Nova jogging first if available
 
 
 
 
 
 
521
  if self._use_nova_jogging and self._nova_jogger is not None:
522
  try:
523
  if jog_type == 'joint':
524
- return self._nova_jogger.start_joint_jog(
525
- joint=max(0, min(5, kwargs.get('joint', 1) - 1)),
526
- direction=kwargs.get('direction', '+'),
527
- velocity_rads_per_sec=kwargs.get('velocity', 0.5)
 
 
 
 
 
 
 
 
528
  )
 
 
 
 
 
529
  elif jog_type == 'cartesian_translation':
530
  return self._nova_jogger.start_cartesian_translation(
531
  axis=kwargs.get('axis', 'x'),
@@ -750,6 +777,10 @@ class UR5Env(gym.Env):
750
  elif self.t_target_body_id != -1:
751
  self._reset_freejoint("t_object_joint", [0.45, 0.2, 0.43], [1, 0, 0, 0])
752
 
 
 
 
 
753
  # Compute forward kinematics to get EE pose from home joints
754
  mujoco.mj_forward(self.model, self.data)
755
 
@@ -826,8 +857,9 @@ class UR5Env(gym.Env):
826
  def step_with_controller(self, dt: float = 0.002):
827
  """Step using controller (IK or direct joint control)."""
828
  if self._use_nova_state_stream:
829
- # In digital twin mode, sync robot pose from Nova but still run physics
830
  # This allows simulated objects (like T-shape) to interact with the real robot
 
831
  self._sync_nova_state()
832
 
833
  # Set robot joint positions from Nova (override qpos)
 
171
  self._nova_api_config = dict(nova_api_config) if nova_api_config else None
172
  self._target_pos = None
173
  self._target_euler = None
174
+ # Initialize Nova enabled state based on config presence to avoid race condition
175
+ # This will be set to the actual success/failure status after configuration attempt
176
+ self._nova_user_enabled = bool(self._nova_api_config)
177
  if self._nova_api_config:
178
  enabled = self.set_nova_enabled(True)
179
  if not enabled:
180
  print("[Nova] WARNING: Nova API configuration was provided but failed to initialize.")
181
+ # Update to reflect actual failure
182
+ self._nova_user_enabled = False
183
 
184
  # IK controller for end-effector control
185
  self.controller = IKController(
 
194
  self.home_pose = loaded_pose if loaded_pose is not None else self.DEFAULT_HOME_POSE.copy()
195
  self.data.qpos[:6] = self.home_pose.copy()
196
  self.data.ctrl[:6] = self.home_pose.copy()
197
+ # Run physics for several steps to let the controllers stabilize at home position
198
+ for _ in range(100):
199
+ mujoco.mj_step(self.model, self.data)
200
  mujoco.mj_forward(self.model, self.data)
201
 
202
  # Target position/orientation from FK of home pose
 
217
  # Control mode: 'ik' (end-effector target) or 'joint' (direct joint positions)
218
  self._control_mode = 'ik'
219
  # Direct joint targets (used when control_mode is 'joint')
220
+ # Initialize to home pose instead of computing from IK to ensure robot starts at home
221
+ self._joint_targets = self.home_pose.copy()
222
 
223
  # Configure Nova API if requested (must be after Nova attributes are initialized)
224
  if nova_api_config:
 
280
  print("[Nova] Starting state stream...")
281
  self._nova_client.start_state_stream()
282
  print("[Nova] State stream started successfully")
283
+ print("[Nova] Digital twin mode: Simulation mirrors real robot state from Nova API.")
284
+ print("[Nova] Robot can still be moved via jogging commands sent to Nova API.")
285
 
286
  if self._use_nova_jogging:
287
  if nova_jogger is None:
 
526
  True if command sent successfully
527
  """
528
  # Try Nova jogging first if available
529
+ print(f"[Jog] start_jog called: type={jog_type}, kwargs={kwargs}")
530
+ print(f"[Jog] Checking Nova jogging: _use_nova_jogging={self._use_nova_jogging}, _nova_jogger={self._nova_jogger is not None}")
531
+ with open('/tmp/ws_debug.log', 'a') as f:
532
+ f.write(f"[Jog] start_jog called: type={jog_type}, kwargs={kwargs}\n")
533
+ f.write(f"[Jog] _use_nova_jogging={self._use_nova_jogging}, _nova_jogger={self._nova_jogger is not None}\n")
534
+ f.flush()
535
  if self._use_nova_jogging and self._nova_jogger is not None:
536
  try:
537
  if jog_type == 'joint':
538
+ joint_num = kwargs.get('joint', 1)
539
+ joint_idx = max(0, min(5, joint_num - 1))
540
+ direction = kwargs.get('direction', '+')
541
+ velocity = kwargs.get('velocity', 0.5)
542
+ print(f"[Jog] Calling Nova jogger: joint={joint_idx} ({joint_num}), dir={direction}, vel={velocity:.3f}")
543
+ with open('/tmp/ws_debug.log', 'a') as f:
544
+ f.write(f"[Jog] Calling Nova jogger: joint={joint_idx} ({joint_num}), dir={direction}, vel={velocity:.3f}\n")
545
+ f.flush()
546
+ result = self._nova_jogger.start_joint_jog(
547
+ joint=joint_idx,
548
+ direction=direction,
549
+ velocity_rads_per_sec=velocity
550
  )
551
+ print(f"[Jog] Nova jogger result: {result}")
552
+ with open('/tmp/ws_debug.log', 'a') as f:
553
+ f.write(f"[Jog] Nova jogger result: {result}\n")
554
+ f.flush()
555
+ return result
556
  elif jog_type == 'cartesian_translation':
557
  return self._nova_jogger.start_cartesian_translation(
558
  axis=kwargs.get('axis', 'x'),
 
777
  elif self.t_target_body_id != -1:
778
  self._reset_freejoint("t_object_joint", [0.45, 0.2, 0.43], [1, 0, 0, 0])
779
 
780
+ # Run physics for several steps to let the robot stabilize at home position
781
+ for _ in range(100):
782
+ mujoco.mj_step(self.model, self.data)
783
+
784
  # Compute forward kinematics to get EE pose from home joints
785
  mujoco.mj_forward(self.model, self.data)
786
 
 
857
  def step_with_controller(self, dt: float = 0.002):
858
  """Step using controller (IK or direct joint control)."""
859
  if self._use_nova_state_stream:
860
+ # In digital twin mode: sync robot pose from Nova API and run physics
861
  # This allows simulated objects (like T-shape) to interact with the real robot
862
+ # Note: Robot can still be moved via Nova API jogging commands
863
  self._sync_nova_state()
864
 
865
  # Set robot joint positions from Nova (override qpos)
tests/test_client.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight WebSocket test client for nova-sim tests (no external dependencies)."""
2
+
3
+ import json
4
+ import time
5
+ import requests
6
+ from websockets.sync.client import connect
7
+
8
+
9
+ class NovaSimTestClient:
10
+ """Simple WebSocket client for testing nova-sim."""
11
+
12
+ def __init__(self, base_url: str = "http://localhost:3004/nova-sim/api/v1"):
13
+ self.base_url = base_url
14
+ self.ws = None
15
+ self.latest_state = {}
16
+
17
+ def connect(self):
18
+ """Connect to nova-sim via WebSocket."""
19
+ # Build WebSocket URL
20
+ ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws"
21
+
22
+ # Connect WebSocket
23
+ self.ws = connect(ws_url, timeout=10, ping_interval=None)
24
+
25
+ # Send client identity
26
+ self.ws.send(json.dumps({
27
+ "type": "client_identity",
28
+ "data": {"client_id": "test_client"}
29
+ }))
30
+
31
+ # Wait for first state
32
+ time.sleep(0.5)
33
+ self._receive_state()
34
+
35
+ def close(self):
36
+ """Close WebSocket connection."""
37
+ if self.ws:
38
+ self.ws.close()
39
+ self.ws = None
40
+
41
+ def _receive_state(self):
42
+ """Receive and parse state message."""
43
+ try:
44
+ msg = self.ws.recv(timeout=0.1)
45
+ if msg:
46
+ data = json.loads(msg)
47
+ if data.get("type") == "state":
48
+ self.latest_state = data.get("data", {})
49
+ except TimeoutError:
50
+ pass
51
+
52
+ def send_message(self, msg: dict):
53
+ """Send a message and update latest state."""
54
+ self.ws.send(json.dumps(msg))
55
+ self._receive_state()
56
+
57
+ def send_home(self):
58
+ """Send a home message."""
59
+ self.send_message({"type": "home"})
60
+
61
+ def get_joint_positions(self):
62
+ """Get current joint positions from latest state."""
63
+ obs = self.latest_state.get("observation", {})
64
+ return obs.get("joint_positions", [0, 0, 0, 0, 0, 0])
65
+
66
+ def get_scene_objects(self):
67
+ """Get scene objects from latest state."""
68
+ return self.latest_state.get("scene_objects", [])
tests/test_homing.py ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Comprehensive homing test for nova-sim."""
2
+
3
+ import time
4
+ import math
5
+ import sys
6
+ import os
7
+ import json
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+
11
+ from test_client import NovaSimTestClient
12
+
13
+
14
+ def test_homing():
15
+ """Test complete homing workflow: verify robot moves to configured home pose."""
16
+ print("\n" + "=" * 70)
17
+ print("Comprehensive Homing Test")
18
+ print("=" * 70)
19
+ print("\nThis test verifies:")
20
+ print(" 1. Robot position can be read from state stream")
21
+ print(" 2. Sending 'home' messages triggers homing behavior")
22
+ print(" 3. Robot moves toward configured home_pose")
23
+ print(" 4. Robot reaches home within acceptable tolerance")
24
+
25
+ # Expected home pose (from scene XML)
26
+ expected_home = [-3.22, -1.14, 1.93, -2.36, -1.57, -1.72]
27
+
28
+ # Create test client
29
+ print(f"\n[1] Connecting to Nova-Sim...")
30
+ client = NovaSimTestClient("http://localhost:3004/nova-sim/api/v1")
31
+
32
+ try:
33
+ client.connect()
34
+ print("✓ Connected to Nova-Sim")
35
+
36
+ # Get initial position
37
+ time.sleep(0.5)
38
+ initial_joints = client.get_joint_positions()
39
+
40
+ print(f"\n[2] Initial joint positions:")
41
+ print(f" [{', '.join(f'{j:7.4f}' for j in initial_joints)}]")
42
+
43
+ # Calculate initial error from home
44
+ initial_errors = [abs(actual - target) for actual, target in zip(initial_joints, expected_home)]
45
+ initial_max_error = max(initial_errors)
46
+ print(f" Max error from home: {initial_max_error:.4f} rad ({math.degrees(initial_max_error):.2f}°)")
47
+
48
+ # Check if already at home
49
+ if initial_max_error < 0.1:
50
+ print(f" ℹ Robot already near home position")
51
+ print(f" Note: In digital twin mode, robot position comes from real hardware")
52
+
53
+ # Start homing by sending home messages continuously
54
+ print(f"\n[3] Starting homing (sending 'home' messages)...")
55
+ print(" Sending home messages for 10 seconds...")
56
+
57
+ home_start_time = time.time()
58
+ message_count = 0
59
+ joints_before = initial_joints.copy()
60
+
61
+ # Send home messages for 10 seconds
62
+ while time.time() - home_start_time < 10.0:
63
+ client.send_home()
64
+ message_count += 1
65
+ time.sleep(0.1)
66
+
67
+ # Check progress every second
68
+ if message_count % 10 == 0:
69
+ current_joints = client.get_joint_positions()
70
+ current_errors = [abs(actual - target) for actual, target in zip(current_joints, expected_home)]
71
+ current_max_error = max(current_errors)
72
+ elapsed = time.time() - home_start_time
73
+ print(f" t={elapsed:.1f}s: max_error={math.degrees(current_max_error):.2f}° ({message_count} messages)")
74
+
75
+ # Check if reached home
76
+ if current_max_error < 0.05:
77
+ print(f"\n✓ Homing completed in {elapsed:.2f}s!")
78
+ break
79
+
80
+ homing_time = time.time() - home_start_time
81
+
82
+ # Get final position
83
+ time.sleep(0.5)
84
+ final_joints = client.get_joint_positions()
85
+
86
+ print(f"\n[4] Final joint positions after {homing_time:.1f}s:")
87
+ print(f" [{', '.join(f'{j:7.4f}' for j in final_joints)}]")
88
+
89
+ # Calculate movement
90
+ total_movement = sum(abs(after - before) for before, after in zip(joints_before, final_joints))
91
+ print(f" Total joint movement: {total_movement:.3f} rad")
92
+
93
+ # Verify final position
94
+ print(f"\n[5] Verifying final position against configured home...")
95
+ print(f" Expected home: [{', '.join(f'{j:7.4f}' for j in expected_home)}]")
96
+
97
+ joint_errors = [abs(actual - target) for actual, target in zip(final_joints, expected_home)]
98
+
99
+ print(f"\n Joint errors:")
100
+ tolerance = 0.1 # 0.1 rad = 5.7 degrees
101
+ for i, (error, actual, target) in enumerate(zip(joint_errors, final_joints, expected_home), 1):
102
+ status = "✓" if error < tolerance else "⚠"
103
+ print(f" {status} Joint {i}: error={error:.4f} rad ({math.degrees(error):.2f}°), "
104
+ f"actual={actual:.4f}, target={target:.4f}")
105
+
106
+ max_error = max(joint_errors)
107
+ avg_error = sum(joint_errors) / len(joint_errors)
108
+ print(f"\n Max error: {max_error:.4f} rad ({math.degrees(max_error):.2f}°)")
109
+ print(f" Average error: {avg_error:.4f} rad ({math.degrees(avg_error):.2f}°)")
110
+
111
+ # Check scene objects
112
+ scene_objects = client.get_scene_objects()
113
+ if scene_objects:
114
+ print(f"\n[6] Scene objects:")
115
+ for obj in scene_objects:
116
+ pos = obj["position"]
117
+ print(f" - {obj['name']}: pos=({pos['x']:.3f}, {pos['y']:.3f}, {pos['z']:.3f})")
118
+
119
+ # Assessment
120
+ print("\n" + "=" * 70)
121
+ success = False
122
+ if max_error < 0.01:
123
+ print("✓ EXCELLENT - Robot at home within 0.01 rad (0.57°)")
124
+ success = True
125
+ elif max_error < 0.05:
126
+ print("✓ GOOD - Robot at home within 0.05 rad (2.9°)")
127
+ success = True
128
+ elif max_error < 0.1:
129
+ print("⚠ ACCEPTABLE - Robot within 0.1 rad (5.7°) of home")
130
+ success = True
131
+ else:
132
+ print(f"✗ FAILED - Robot not at home (max error: {math.degrees(max_error):.1f}°)")
133
+ print(f"\nNote: In digital twin mode with Nova API connected:")
134
+ print(f" - Robot position mirrors real hardware")
135
+ print(f" - Homing requires working Nova API jogging integration")
136
+ print(f" - Check that Nova Jogger is properly configured and connected")
137
+
138
+ if total_movement < 0.01:
139
+ print(f"\n⚠ WARNING: Robot did not move during homing (movement: {total_movement:.3f} rad)")
140
+ print(f" - This may indicate jogging commands are not reaching the robot")
141
+ print(f" - Check server logs for jogging errors")
142
+
143
+ print("=" * 70)
144
+
145
+ # Only assert success on movement if robot wasn't already at home
146
+ if initial_max_error > 0.1:
147
+ assert success, f"Robot did not reach home position (max error: {math.degrees(max_error):.1f}°)"
148
+ if total_movement < 0.01:
149
+ print(f"\n⚠ Robot didn't move but test passed (was it already at home?)")
150
+ else:
151
+ print(f"\n✓ Test completed (robot was already near home)")
152
+
153
+ finally:
154
+ # Cleanup
155
+ try:
156
+ client.close()
157
+ print("\n✓ Disconnected from Nova-Sim")
158
+ except Exception as e:
159
+ print(f"\n⚠ Error during disconnect: {e}")
160
+
161
+
162
+ def test_drift_prevention():
163
+ """Test that robot stops moving when home messages cease."""
164
+ print("\n" + "=" * 70)
165
+ print("Drift Prevention Test")
166
+ print("=" * 70)
167
+ print("\nThis test verifies that the robot stops moving when home")
168
+ print("messages stop, preventing drift.\n")
169
+
170
+ client = NovaSimTestClient("http://localhost:3004/nova-sim/api/v1")
171
+
172
+ try:
173
+ client.connect()
174
+ print("✓ Connected to Nova-Sim")
175
+
176
+ # Get initial position
177
+ time.sleep(0.5)
178
+ initial_joints = client.get_joint_positions()
179
+ print(f"\n[1] Initial joint positions:")
180
+ print(f" [{', '.join(f'{j:7.4f}' for j in initial_joints)}]")
181
+
182
+ # Send home messages for 2 seconds (not enough to fully home)
183
+ print(f"\n[2] Sending home messages for 2 seconds...")
184
+ duration = 2.0
185
+ rate_hz = 10
186
+ num_messages = int(duration * rate_hz)
187
+
188
+ for i in range(num_messages):
189
+ client.send_home()
190
+ time.sleep(1.0 / rate_hz)
191
+
192
+ # Get position right after stopping
193
+ time.sleep(0.1)
194
+ position_after_homing = client.get_joint_positions()
195
+ print(f"\n[3] Position immediately after stopping home messages:")
196
+ print(f" [{', '.join(f'{j:7.4f}' for j in position_after_homing)}]")
197
+
198
+ # Wait 2 seconds and check if robot drifted
199
+ print(f"\n[4] Waiting 2 seconds to check for drift...")
200
+ time.sleep(2.0)
201
+
202
+ final_position = client.get_joint_positions()
203
+ print(f"\n[5] Position 2 seconds after stopping:")
204
+ print(f" [{', '.join(f'{j:7.4f}' for j in final_position)}]")
205
+
206
+ # Calculate drift
207
+ max_drift = 0
208
+ drift_joint = None
209
+ for i, (before, after) in enumerate(zip(position_after_homing, final_position)):
210
+ drift = abs(after - before)
211
+ if drift > max_drift:
212
+ max_drift = drift
213
+ drift_joint = i
214
+
215
+ print(f"\n[6] Results:")
216
+ print(f" Max drift: {max_drift:.4f} rad ({math.degrees(max_drift):.2f}°)")
217
+ if drift_joint is not None:
218
+ print(f" Joint with max drift: Joint {drift_joint + 1}")
219
+
220
+ # A small amount of drift is acceptable due to gravity/dynamics
221
+ # But large drift indicates jogging didn't stop
222
+ drift_threshold = 0.005 # 0.005 rad ≈ 0.29°
223
+
224
+ print("\n" + "=" * 70)
225
+ if max_drift < drift_threshold:
226
+ print(f"✓ NO DRIFT - Robot stopped properly (drift < {drift_threshold:.4f} rad)")
227
+ success = True
228
+ else:
229
+ print(f"✗ DRIFT DETECTED - Robot kept moving after home messages stopped!")
230
+ print(f" This indicates the jogging command wasn't stopped.")
231
+ print(f" Check the timeout mechanism in the simulation loop.")
232
+ success = False
233
+ print("=" * 70)
234
+
235
+ client.close()
236
+ print("\n✓ Disconnected")
237
+
238
+ assert success, f"Drift detected: {max_drift:.4f} rad exceeds threshold {drift_threshold:.4f} rad"
239
+
240
+ except Exception as e:
241
+ print(f"\n✗ Test error: {e}")
242
+ import traceback
243
+ traceback.print_exc()
244
+ try:
245
+ client.close()
246
+ except:
247
+ pass
248
+ raise
249
+
250
+
251
+ def test_overshoot_detection():
252
+ """Test that homing detects and corrects overshoots by reducing velocity."""
253
+ print("\n" + "=" * 70)
254
+ print("Overshoot Detection Test")
255
+ print("=" * 70)
256
+ print("\nThis test verifies that homing detects overshoots and reduces velocity.")
257
+ print("It will move joint 1 away from home, then home it and monitor for overshoots.\n")
258
+
259
+ client = NovaSimTestClient("http://localhost:3004/nova-sim/api/v1")
260
+
261
+ try:
262
+ client.connect()
263
+ print("✓ Connected to Nova-Sim")
264
+
265
+ # Get home position
266
+ time.sleep(0.5)
267
+ home_joints = [-3.2200, -1.1400, 1.9300, -2.3600, -1.5700, -1.7200]
268
+ print(f"\n[1] Home position:")
269
+ print(f" [{', '.join(f'{j:7.4f}' for j in home_joints)}]")
270
+
271
+ # Move joint 1 far from home
272
+ print(f"\n[2] Moving joint 1 to +1.0 rad (far from home of -3.22 rad)...")
273
+
274
+ # Send action messages to move joint 1 positive
275
+ duration = 2.0
276
+ rate_hz = 10
277
+ num_messages = int(duration * rate_hz)
278
+
279
+ for i in range(num_messages):
280
+ client.ws.send(json.dumps({
281
+ 'type': 'action',
282
+ 'data': {'j1': 1.0} # Move joint 1 in positive direction
283
+ }))
284
+ time.sleep(1.0 / rate_hz)
285
+
286
+ # Stop moving
287
+ client.ws.send(json.dumps({'type': 'action', 'data': {}}))
288
+ time.sleep(0.5)
289
+
290
+ # Get position after movement
291
+ moved_joints = client.get_joint_positions()
292
+ print(f"\n[3] Position after moving joint 1:")
293
+ print(f" [{', '.join(f'{j:7.4f}' for j in moved_joints)}]")
294
+ print(f" Joint 1 error from home: {abs(moved_joints[0] - home_joints[0]):.4f} rad")
295
+
296
+ # Now home and monitor for overshoots
297
+ print(f"\n[4] Starting homing (monitoring for overshoots)...")
298
+ print(f" Sending home messages for 15 seconds...")
299
+
300
+ duration = 15.0
301
+ rate_hz = 10
302
+ num_messages = int(duration * rate_hz)
303
+
304
+ prev_j1_error = None
305
+ overshoot_count = 0
306
+
307
+ for i in range(num_messages):
308
+ # Send home message
309
+ client.send_home()
310
+ time.sleep(1.0 / rate_hz)
311
+
312
+ # Check position every second
313
+ if (i + 1) % 10 == 0:
314
+ current_joints = client.get_joint_positions()
315
+ j1_error = current_joints[0] - home_joints[0]
316
+
317
+ # Check for overshoot (sign change)
318
+ if prev_j1_error is not None:
319
+ if (prev_j1_error > 0 and j1_error < 0) or (prev_j1_error < 0 and j1_error > 0):
320
+ overshoot_count += 1
321
+ print(f" t={(i+1)/10:.1f}s: ⚠️ OVERSHOOT #{overshoot_count} detected!")
322
+ print(f" prev_error={prev_j1_error:+.4f} -> current_error={j1_error:+.4f}")
323
+
324
+ # Check if close to home
325
+ if abs(j1_error) < 0.01:
326
+ print(f" t={(i+1)/10:.1f}s: ✓ Reached home (error={abs(j1_error):.4f} rad)")
327
+ break
328
+ else:
329
+ print(f" t={(i+1)/10:.1f}s: j1_error={j1_error:+.4f} rad ({i+1} messages)")
330
+
331
+ prev_j1_error = j1_error
332
+
333
+ # Get final position
334
+ time.sleep(0.5)
335
+ final_joints = client.get_joint_positions()
336
+
337
+ print(f"\n[5] Final position:")
338
+ print(f" [{', '.join(f'{j:7.4f}' for j in final_joints)}]")
339
+
340
+ # Check all joints
341
+ print(f"\n[6] Final errors from home:")
342
+ max_error = 0
343
+ for i, (actual, target) in enumerate(zip(final_joints, home_joints)):
344
+ error = abs(actual - target)
345
+ max_error = max(max_error, error)
346
+ status = "✓" if error < 0.01 else "✗"
347
+ print(f" {status} Joint {i+1}: error={error:.4f} rad ({math.degrees(error):.2f}°)")
348
+
349
+ print(f"\n[7] Results:")
350
+ print(f" Total overshoots detected: {overshoot_count}")
351
+ print(f" Max final error: {max_error:.4f} rad ({math.degrees(max_error):.2f}°)")
352
+
353
+ print("\n" + "=" * 70)
354
+ if max_error < 0.05:
355
+ print(f"✓ Homing succeeded (max error < 0.05 rad)")
356
+ success = True
357
+ else:
358
+ print(f"⚠ Homing incomplete (max error = {max_error:.4f} rad)")
359
+ success = False
360
+
361
+ if overshoot_count > 0:
362
+ print(f"ℹ Overshoot detection working ({overshoot_count} overshoots detected and corrected)")
363
+ else:
364
+ print(f"✓ No overshoots needed (smooth convergence)")
365
+ print("=" * 70)
366
+
367
+ client.close()
368
+ print("\n✓ Disconnected")
369
+
370
+ assert success, f"Homing failed with max error {max_error:.4f} rad"
371
+
372
+ except Exception as e:
373
+ print(f"\n✗ Test error: {e}")
374
+ import traceback
375
+ traceback.print_exc()
376
+ try:
377
+ client.close()
378
+ except:
379
+ pass
380
+ raise
381
+
382
+
383
+ if __name__ == "__main__":
384
+ print("\n" + "=" * 70)
385
+ print("Nova-Sim Homing Test")
386
+ print("=" * 70)
387
+ print("\nThis test requires Nova-Sim to be running.")
388
+ print("Start it with:")
389
+ print(" cd /Users/georgpuschel/repos/robot-ml/nova-sim")
390
+ print(" source .venv/bin/activate")
391
+ print(" python mujoco_server.py --robot ur5_t_push")
392
+
393
+ if sys.stdin.isatty():
394
+ print("\nPress Enter when Nova-Sim is ready...")
395
+ input()
396
+ else:
397
+ print("\nRunning in non-interactive mode, proceeding in 2 seconds...\n")
398
+ time.sleep(2)
399
+
400
+ # Check if specific test requested
401
+ test_name = sys.argv[1] if len(sys.argv) > 1 else "all"
402
+
403
+ all_passed = True
404
+
405
+ try:
406
+ if test_name in ("all", "basic"):
407
+ print("\n" + "="*70)
408
+ print("Running: Basic Homing Test")
409
+ print("="*70)
410
+ try:
411
+ test_homing()
412
+ print("\n✓ Basic Homing Test PASSED")
413
+ except Exception as e:
414
+ print(f"\n✗ Basic Homing Test FAILED: {e}")
415
+ all_passed = False
416
+
417
+ if test_name in ("all", "drift"):
418
+ print("\n" + "="*70)
419
+ print("Running: Drift Prevention Test")
420
+ print("="*70)
421
+ try:
422
+ test_drift_prevention()
423
+ print("\n✓ Drift Prevention Test PASSED")
424
+ except Exception as e:
425
+ print(f"\n✗ Drift Prevention Test FAILED: {e}")
426
+ all_passed = False
427
+
428
+ if test_name in ("all", "overshoot"):
429
+ print("\n" + "="*70)
430
+ print("Running: Overshoot Detection Test")
431
+ print("="*70)
432
+ try:
433
+ test_overshoot_detection()
434
+ print("\n✓ Overshoot Detection Test PASSED")
435
+ except Exception as e:
436
+ print(f"\n✗ Overshoot Detection Test FAILED: {e}")
437
+ all_passed = False
438
+
439
+ print("\n" + "="*70)
440
+ if all_passed:
441
+ print("✓ ALL TESTS COMPLETED SUCCESSFULLY")
442
+ print("="*70)
443
+ print("\nCheck /tmp/nova_sim_server.log for detailed server logs")
444
+ sys.exit(0)
445
+ else:
446
+ print("✗ SOME TESTS FAILED")
447
+ print("="*70)
448
+ print("\nCheck /tmp/nova_sim_server.log for detailed server logs")
449
+ sys.exit(1)
450
+
451
+ except KeyboardInterrupt:
452
+ print("\n\n✗ Tests interrupted by user")
453
+ sys.exit(1)
tests/test_jogging.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test jogging to compare UI jogging vs homing jogging."""
2
+
3
+ import time
4
+ import json
5
+ import sys
6
+ import os
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
+
10
+ from test_client import NovaSimTestClient
11
+
12
+
13
+ def test_ui_jogging():
14
+ """Test UI-style jogging (sending continuous action messages)."""
15
+ print("\n" + "=" * 70)
16
+ print("UI-Style Jogging Test")
17
+ print("=" * 70)
18
+ print("\nThis test simulates how the UI sends jogging commands.")
19
+ print("It sends continuous 'action' messages with joint velocities.\n")
20
+
21
+ client = NovaSimTestClient("http://localhost:3004/nova-sim/api/v1")
22
+
23
+ try:
24
+ client.connect()
25
+ print("✓ Connected to Nova-Sim")
26
+
27
+ # Get initial position
28
+ time.sleep(0.5)
29
+ initial_joints = client.get_joint_positions()
30
+ print(f"\n[1] Initial joint positions:")
31
+ print(f" [{', '.join(f'{j:7.4f}' for j in initial_joints)}]")
32
+
33
+ # Send continuous action messages for joint 6, like the UI does
34
+ print(f"\n[2] Sending continuous 'action' messages (UI style)...")
35
+ print(f" Jogging joint 6 in negative direction for 3 seconds")
36
+
37
+ duration = 3.0
38
+ rate_hz = 10 # UI typically sends at ~10 Hz
39
+ num_messages = int(duration * rate_hz)
40
+
41
+ for i in range(num_messages):
42
+ # Send action message with j6 velocity (like UI does)
43
+ client.ws.send(json.dumps({
44
+ 'type': 'action',
45
+ 'data': {
46
+ 'j6': -0.5 # Negative velocity for joint 6
47
+ }
48
+ }))
49
+ time.sleep(1.0 / rate_hz)
50
+ if (i + 1) % 10 == 0:
51
+ print(f" Sent {i + 1}/{num_messages} messages")
52
+
53
+ # Stop jogging
54
+ client.ws.send(json.dumps({
55
+ 'type': 'action',
56
+ 'data': {
57
+
58
+ }
59
+ }))
60
+
61
+ time.sleep(0.5)
62
+
63
+ # Get final position
64
+ final_joints = client.get_joint_positions()
65
+ print(f"\n[3] Final joint positions:")
66
+ print(f" [{', '.join(f'{j:7.4f}' for j in final_joints)}]")
67
+
68
+ # Calculate movement
69
+ movement = [abs(final - initial) for final, initial in zip(final_joints, initial_joints)]
70
+ total_movement = sum(movement)
71
+
72
+ print(f"\n[4] Results:")
73
+ print(f" Total movement: {total_movement:.4f} rad")
74
+ print(f" Joint 6 movement: {movement[5]:.4f} rad")
75
+ print(f" Joint 6 change: {initial_joints[5]:.4f} -> {final_joints[5]:.4f}")
76
+
77
+ if movement[5] > 0.1:
78
+ print(f"\n✓ UI jogging works - joint 6 moved {movement[5]:.4f} rad")
79
+ else:
80
+ print(f"\n✗ UI jogging failed - joint 6 barely moved")
81
+
82
+ client.disconnect()
83
+ print("✓ Disconnected")
84
+
85
+ except Exception as e:
86
+ print(f"\n✗ Test error: {e}")
87
+ import traceback
88
+ traceback.print_exc()
89
+ try:
90
+ client.disconnect()
91
+ except:
92
+ pass
93
+
94
+ if __name__ == "__main__":
95
+ print("\n" + "=" * 70)
96
+ print("JOGGING COMPARISON TEST")
97
+ print("=" * 70)
98
+ print("\nThis test compares UI jogging vs homing jogging.")
99
+ print("Both should trigger continuous jogging, but we'll see if they behave differently.\n")
100
+
101
+ input("Press Enter to start UI jogging test (or Ctrl+C to exit)...")
102
+ test_ui_jogging()
103
+
104
+ time.sleep(2)
105
+
106
+ input("\nPress Enter to start homing jogging test (or Ctrl+C to exit)...")
107
+ test_homing_jogging()
108
+
109
+ print("\n" + "=" * 70)
110
+ print("Check /tmp/nova_sim_server.log for detailed logs")
111
+ print("Compare the 'Nova Jogger' logs from both tests")
112
+ print("=" * 70)