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 +2 -4
- frontend/index.html +64 -26
- mujoco_server.py +153 -125
- robots/ur5/model/scene.xml +1 -1
- robots/ur5/model/scene_with_gripper.xml +1 -1
- robots/ur5/nova_jogger.py +94 -34
- robots/ur5/ur5_env.py +41 -9
- tests/test_client.py +68 -0
- tests/test_homing.py +453 -0
- tests/test_jogging.py +112 -0
README.md
CHANGED
|
@@ -847,9 +847,7 @@ env = UR5Env(
|
|
| 847 |
)
|
| 848 |
```
|
| 849 |
|
| 850 |
-
**Note**: When state streaming is enabled, the simulation becomes a
|
| 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
|
| 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 |
-
|
|
|
|
| 1200 |
clientsStatusText.innerText = "No clients connected";
|
| 1201 |
clientsList.innerHTML = "";
|
| 1202 |
clientsList.style.display = "none";
|
| 1203 |
} else {
|
| 1204 |
-
clientsStatusText.innerText = `${
|
| 1205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1544 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 1699 |
badge.style.display = novaApi.available ? 'block' : 'none';
|
| 1700 |
}
|
| 1701 |
-
if (prevNovaState.enabled !== novaApi.enabled) {
|
| 1702 |
-
|
| 1703 |
-
badge.classList.toggle('
|
|
|
|
| 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.
|
| 1731 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1732 |
newModeText = 'Hybrid Mode';
|
| 1733 |
-
} else if (
|
| 1734 |
newModeText = 'Digital Twin Mode';
|
| 1735 |
-
} else if (
|
| 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 |
-
|
| 1840 |
-
|
| 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.
|
| 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 |
-
|
| 626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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':
|
|
|
|
| 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':
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
payload = {
|
| 751 |
-
"clients":
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 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 |
-
#
|
| 1209 |
-
|
| 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 |
-
#
|
| 1248 |
-
if
|
| 1249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1250 |
stop_jog()
|
| 1251 |
|
| 1252 |
-
start_jog('joint', joint=joint_idx + 1, direction=direction, velocity=
|
| 1253 |
homing_state["is_jogging"] = True
|
| 1254 |
-
homing_state["
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 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 |
-
<!--
|
| 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 |
-
<!--
|
| 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 |
-
#
|
| 173 |
command = {
|
| 174 |
"motion_group": self.motion_group_id,
|
| 175 |
"joint_velocities": joint_velocities
|
| 176 |
}
|
| 177 |
-
self.
|
| 178 |
-
|
|
|
|
|
|
|
| 179 |
return True
|
| 180 |
|
| 181 |
except Exception as e:
|
|
@@ -198,7 +241,7 @@ class NovaJogger:
|
|
| 198 |
else:
|
| 199 |
position_direction[axis] = -1
|
| 200 |
|
| 201 |
-
#
|
| 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.
|
| 212 |
-
|
|
|
|
|
|
|
| 213 |
return True
|
| 214 |
|
| 215 |
except Exception as e:
|
|
@@ -232,7 +277,7 @@ class NovaJogger:
|
|
| 232 |
else:
|
| 233 |
rotation_direction[axis] = -1
|
| 234 |
|
| 235 |
-
#
|
| 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.
|
| 246 |
-
|
|
|
|
|
|
|
| 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
|
| 255 |
-
if not self._connected
|
| 256 |
return False
|
| 257 |
|
| 258 |
try:
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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]
|
| 276 |
-
print("[Nova]
|
| 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 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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)
|