wandelbots-nova 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nova/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from nova.core.nova import Nova, Cell
2
+ from nova.core.motion_group import MotionGroup
3
+ from nova.core.controller import Controller
4
+ from nova.types.pose import Pose
5
+ from nova.types.action import Action, lin, ptp, jnt, cir
6
+
7
+ __all__ = [
8
+ "Nova",
9
+ "Cell",
10
+ "MotionGroup",
11
+ "Controller",
12
+ "lin",
13
+ "ptp",
14
+ "jnt",
15
+ "cir",
16
+ "Action",
17
+ "Pose",
18
+ ]
nova/core/__init__.py ADDED
File without changes
@@ -0,0 +1,60 @@
1
+ from typing import final
2
+
3
+ import wandelbots_api_client as wb
4
+ from nova.core.motion_group import MotionGroup
5
+ from loguru import logger
6
+
7
+ from nova.gateway import ApiGateway
8
+
9
+
10
+ class Controller:
11
+ def __init__(self, *, api_gateway: ApiGateway, cell: str, controller_host: str):
12
+ self._api_gateway = api_gateway
13
+ self._controller_api = api_gateway.controller_api
14
+ self._motion_group_api = api_gateway.motion_group_api
15
+ self._cell = cell
16
+ self._controller_host = controller_host
17
+ self._motion_groups: dict[str, MotionGroup] = {}
18
+
19
+ async def _get_controller(self, host: str) -> wb.models.ControllerInstance | None:
20
+ controller_list_response = await self._controller_api.list_controllers(cell=self._cell)
21
+ controller_list = list(controller_list_response.instances)
22
+ return next((c for c in controller_list if c.host == host), None)
23
+
24
+ @final
25
+ async def __aenter__(self):
26
+ activate_all_motion_groups_response = (
27
+ await self._motion_group_api.activate_all_motion_groups(
28
+ cell=self._cell, controller=self._controller_host
29
+ )
30
+ )
31
+ # TODO: should we store these states? I dont like storing state, then you have to manage them
32
+ # stateless looks simpler
33
+ # TODO: should we deactivate these motions groups? what does wandelscript does?
34
+ motion_groups = activate_all_motion_groups_response.instances
35
+ for mg in motion_groups:
36
+ logger.info(f"Found motion group {mg.motion_group}")
37
+ motion_group = MotionGroup(
38
+ api_gateway=self._api_gateway, cell=self._cell, motion_group_id=mg.motion_group
39
+ )
40
+ self._motion_groups[motion_group.motion_group_id] = motion_group
41
+ return self
42
+
43
+ @final
44
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
45
+ # TODO: should we deactivate these motions groups? what does wandelscript does?
46
+ pass
47
+
48
+ def get_motion_groups(self) -> dict[str, MotionGroup]:
49
+ return self._motion_groups
50
+
51
+ def get_motion_group(self, motion_group_id: str = "0") -> MotionGroup:
52
+ # TODO: I know this doesnt looks good :)
53
+ # here are some considerations for a better implementation:
54
+ # If possible I would prefer stateless approach,
55
+ # so we dont return it from the internal state, but we fetch it from the API with the id
56
+ # in that case having str id is more fleixble than having an int id
57
+ return self._motion_groups[f"{motion_group_id}@{self._controller_host}"]
58
+
59
+ def __getitem__(self, item):
60
+ return self._motion_groups[f"{item}@{self._controller_host}"]
@@ -0,0 +1,13 @@
1
+ import json
2
+
3
+ import wandelbots_api_client as wb
4
+
5
+
6
+ class ControllerNotFoundException(Exception):
7
+ def __init__(self, controller: str = None):
8
+ super().__init__(f"Controller {controller} not found.")
9
+
10
+
11
+ class PlanTrajectoryFailed(Exception):
12
+ def __init__(self, error: wb.models.PlanTrajectoryFailedResponse):
13
+ super().__init__(f"Plan trajectory failed: {json.dumps(error.to_dict(), indent=2)}")
@@ -0,0 +1,287 @@
1
+ from collections.abc import AsyncGenerator
2
+
3
+ from nova.core.exceptions import PlanTrajectoryFailed
4
+ from nova.gateway import ApiGateway
5
+ from nova.types.state import MotionState
6
+ from nova.types.action import Action, CombinedActions
7
+ from nova.types.pose import Pose
8
+ from nova.types.collision_scene import CollisionScene
9
+ from loguru import logger
10
+ import wandelbots_api_client as wb
11
+
12
+ MAX_JOINT_VELOCITY_PREPARE_MOVE = 0.2
13
+ START_LOCATION_OF_MOTION = 0.0
14
+
15
+
16
+ class MotionGroup:
17
+ def __init__(self, api_gateway: ApiGateway, cell: str, motion_group_id: str):
18
+ self._api_gateway = api_gateway
19
+ self._motion_api_client = api_gateway.motion_api
20
+ self._cell = cell
21
+ self._motion_group_id = motion_group_id
22
+ self._current_motion: str | None = None
23
+
24
+ @property
25
+ def motion_group_id(self) -> str:
26
+ return self._motion_group_id
27
+
28
+ @property
29
+ def current_motion(self) -> str:
30
+ # if not self._current_motion:
31
+ # raise ValueError("No MotionId attached. There is no planned motion available.")
32
+ return self._current_motion
33
+
34
+ async def stream_run(
35
+ self,
36
+ actions: list[Action] | Action,
37
+ tcp: str,
38
+ # collision_scene: dts.CollisionScene | None,
39
+ response_rate_in_ms: int = 200,
40
+ ) -> AsyncGenerator[MotionState, None]:
41
+ """An asynchronous generator that tracks and yields motion states along a given path.
42
+
43
+ This method iterates over the motion states of a given path while handling motion events
44
+ associated with specific path parameters. It tracks the motion states in the `_motion_recording`
45
+ attribute and yields each state as it progresses through the path. Additionally, if there are any
46
+ motion events defined in the path tags, they will be executed when the corresponding path parameter
47
+ is reached.
48
+
49
+ Args:
50
+ actions: the motion defining the path
51
+ tcp (Optional[str]): The tool center point (TCP) to be used for the motion.
52
+ collision_scene (Optional[CollisionScene]): The collision scene to be used for collision detection.
53
+ response_rate_in_ms (int): The sample time in milliseconds to be used for the motion.
54
+
55
+ Returns:
56
+ Context manager returns an iterator
57
+ Yields:
58
+ MotionState: The current motion state along the path.
59
+ Raises:
60
+ RobotMotionError: if the robot motion stop without reaching the target and stops motion then on exit
61
+ StopAsyncIteration: If the motion path iteration is completed.
62
+
63
+ """
64
+ if not isinstance(actions, list):
65
+ actions = [actions]
66
+
67
+ # TODO get default tcp if tcp is not set
68
+ motion_iter = self._planned_motion_iter(
69
+ actions=actions, tcp=tcp, collision_scene=None, response_rate_in_ms=response_rate_in_ms
70
+ )
71
+ async for motion_state in motion_iter:
72
+ yield motion_state
73
+
74
+ async def run(self, actions: list[Action] | Action, tcp: str):
75
+ async for _ in self.stream_run(actions=actions, tcp=tcp):
76
+ pass
77
+
78
+ async def get_state(self, tcp: str) -> wb.models.MotionGroupStateResponse:
79
+ response = await self._api_gateway.motion_group_infos_api.get_current_motion_group_state(
80
+ cell=self._cell, motion_group=self.motion_group_id, tcp=tcp
81
+ )
82
+ return response
83
+
84
+ async def joints(self, tcp: str) -> wb.models.Joints:
85
+ state = await self.get_state(tcp=tcp)
86
+ return state.state.joint_position
87
+
88
+ async def tcp_pose(self, tcp: str) -> Pose:
89
+ state = await self.get_state(tcp=tcp)
90
+ return Pose(state.state.tcp_pose)
91
+
92
+ async def _get_number_of_joints(self) -> int:
93
+ spec = await self._api_gateway.motion_group_infos_api.get_motion_group_specification(
94
+ cell=self._cell, motion_group=self.motion_group_id
95
+ )
96
+ return len(spec.mechanical_joint_limits)
97
+
98
+ async def _get_optimizer_setup(self, tcp: str) -> wb.models.OptimizerSetup:
99
+ return await self._api_gateway.motion_group_infos_api.get_optimizer_configuration(
100
+ cell=self._cell, motion_group=self._motion_group_id, tcp=tcp
101
+ )
102
+
103
+ async def plan(
104
+ self, actions: list[Action] | Action, tcp: str
105
+ ) -> wb.models.PlanTrajectoryResponse:
106
+ if not isinstance(actions, list):
107
+ actions = [actions]
108
+
109
+ if len(actions) == 0:
110
+ raise ValueError("Actions are empty")
111
+
112
+ current_joints = await self.joints(tcp=tcp)
113
+ robot_setup = await self._get_optimizer_setup(tcp=tcp)
114
+
115
+ # TODO: paths = [wb.models.MotionCommandPath(**path.model_dump()) for path in path.motions]
116
+ combined_actions = CombinedActions(items=actions)
117
+ motions = [
118
+ wb.models.MotionCommandPath.from_dict(motion.model_dump())
119
+ for motion in combined_actions.motions
120
+ ]
121
+ print(motions)
122
+ motion_commands = [wb.models.MotionCommand(path=motion) for motion in motions]
123
+
124
+ request = wb.models.PlanTrajectoryRequest(
125
+ robot_setup=robot_setup,
126
+ motion_group=self.motion_group_id,
127
+ start_joint_position=current_joints.joints,
128
+ motion_commands=motion_commands,
129
+ tcp=tcp,
130
+ )
131
+
132
+ motion_api_client = self._api_gateway.motion_api
133
+ plan_response = await motion_api_client.plan_trajectory(
134
+ cell=self._cell, plan_trajectory_request=request
135
+ )
136
+
137
+ if isinstance(
138
+ plan_response.response.actual_instance, wb.models.PlanTrajectoryFailedResponse
139
+ ):
140
+ failed_response = plan_response.response.actual_instance
141
+ raise PlanTrajectoryFailed(failed_response)
142
+
143
+ return plan_response
144
+
145
+ async def _get_trajectory_sample(
146
+ self, location: float
147
+ ) -> wb.models.GetTrajectorySampleResponse:
148
+ """Call the RAE to get single sample of trajectory from a previously planned path
149
+
150
+ Args:
151
+ location: The path parameter along the trajectory to sample
152
+
153
+ Returns:
154
+ The trajectory sample at the specified location
155
+ """
156
+ if location < 0:
157
+ raise ValueError("location cannot be negative")
158
+
159
+ return await self._motion_api_client.get_motion_trajectory_sample(
160
+ cell=self._cell, motion=self.current_motion, location_on_trajectory=location
161
+ )
162
+
163
+ async def _planned_motion_iter(
164
+ self,
165
+ actions: list[Action],
166
+ tcp: str,
167
+ collision_scene: CollisionScene | None,
168
+ response_rate_in_ms: int,
169
+ ) -> AsyncGenerator[MotionState]:
170
+ number_of_joints = await self._get_number_of_joints()
171
+
172
+ async def move_along_path(
173
+ motion_id: str,
174
+ joint_velocities: list[float] | None = None,
175
+ joint_trajectory: wb.models.JointTrajectory | None = None,
176
+ ) -> AsyncGenerator[MotionState]:
177
+ motion_api = self._api_gateway.motion_api
178
+ load_plan_response = await motion_api.load_planned_motion(
179
+ cell=self._cell,
180
+ planned_motion=wb.models.PlannedMotion(
181
+ motion_group=self.motion_group_id,
182
+ times=joint_trajectory.times,
183
+ joint_positions=joint_trajectory.joint_positions,
184
+ locations=joint_trajectory.locations,
185
+ tcp="Flange",
186
+ ),
187
+ )
188
+
189
+ load_plan_response = load_plan_response.plan_successful_response
190
+
191
+ limit_override = wb.models.LimitsOverride()
192
+ if joint_velocities is not None:
193
+ limit_override.joint_velocity_limits = wb.models.Joints(joints=joint_velocities)
194
+
195
+ # Iterator that moves the robot to start of motion
196
+ move_to_trajectory_stream = motion_api.stream_move_to_trajectory_via_joint_ptp(
197
+ cell=self._cell, motion=load_plan_response.motion, location_on_trajectory=0
198
+ )
199
+ async for motion_state_move_to_trajectory in move_to_trajectory_stream:
200
+ yield motion_state_move_to_trajectory
201
+
202
+ responses = []
203
+
204
+ async def movement_controller(
205
+ response_stream: AsyncGenerator,
206
+ ) -> (AsyncGenerator)[
207
+ wb.models.ExecuteTrajectoryRequest, wb.models.ExecuteTrajectoryResponse
208
+ ]:
209
+ yield wb.models.InitializeMovementRequest(
210
+ trajectory=load_plan_response.motion, initial_location=0
211
+ )
212
+
213
+ combined_actions = CombinedActions(items=actions)
214
+ initialize_movement_response = await anext(response_stream)
215
+ print(f"initial move response {initialize_movement_response}")
216
+ set_io_list = [
217
+ wb.models.SetIO(io=action.model_dump(), location=action.path_parameter)
218
+ for action in combined_actions.actions
219
+ ]
220
+
221
+ yield wb.models.StartMovementRequest(
222
+ set_ios=set_io_list, start_on_io=None, pause_on_io=None
223
+ )
224
+
225
+ async for execute_trajectory_response in response_stream:
226
+ response = execute_trajectory_response.actual_instance
227
+ print(f"execute_trajectory_response {response}")
228
+ responses.append(response)
229
+
230
+ # Terminate the generator
231
+ if isinstance(response, wb.models.Standstill):
232
+ if (
233
+ response.standstill.reason
234
+ == wb.models.StandstillReason.REASON_MOTION_ENDED
235
+ ):
236
+ return
237
+
238
+ await motion_api.execute_trajectory(self._cell, movement_controller)
239
+
240
+ """
241
+ if any(isinstance(motion, dts.UnresolvedMotion) for motion in path.motions) or collision_scene is not None:
242
+ current_joints = await self._get_current_joints()
243
+ optimizer_setup = await self.get_optimizer_setup(tcp)
244
+ path = await resolve_motions_and_check_collisions(
245
+ initial_joints=current_joints,
246
+ motion_trajectory=path,
247
+ collision_scene=collision_scene,
248
+ optimizer_setup=optimizer_setup,
249
+ moving_robot_identifier=self.identifier,
250
+ )
251
+ """
252
+
253
+ plan_response = await self.plan(actions, tcp)
254
+
255
+ # if not rae_pb_parser.plan_response.is_executable(plan_response):
256
+ # raise MotionException(plan_response, [], [])
257
+
258
+ # await self._get_trajectory_sample(response_rate_in_ms)
259
+ # TODO: take velocity override into account.
260
+ # if len(trajectory.sample) > 0 and not math.isnan(trajectory[-1].time):
261
+ # self._execution_duration += trajectory[-1].time
262
+
263
+ # self._current_motion = plan_response.response.actual_instance
264
+ logger.debug(f"Planned move session: {self.current_motion}")
265
+
266
+ # TODO refactor RAE commands vs. multiple motion chains
267
+ joints_velocities = [MAX_JOINT_VELOCITY_PREPARE_MOVE] * number_of_joints
268
+ move_iter = move_along_path(
269
+ self.current_motion,
270
+ joint_velocities=joints_velocities,
271
+ joint_trajectory=plan_response.response.actual_instance,
272
+ )
273
+ async for motion_state in move_iter:
274
+ yield motion_state
275
+
276
+ logger.debug(f"Move session '{self.current_motion}' was successfully executed.")
277
+ self._current_motion = None
278
+
279
+ async def stop(self):
280
+ logger.debug(f"Stopping motion of {self}...")
281
+ try:
282
+ await self._motion_api_client.stop_execution(
283
+ cell=self._cell, motion=self.current_motion
284
+ )
285
+ logger.debug(f"Motion {self.current_motion} stopped.")
286
+ except ValueError as e:
287
+ logger.debug(f"No motion to stop for {self}: {e}")
nova/core/nova.py ADDED
@@ -0,0 +1,46 @@
1
+ from nova.core.controller import Controller
2
+ from nova.core.exceptions import ControllerNotFoundException
3
+ from nova.gateway import ApiGateway
4
+
5
+
6
+ class Nova:
7
+ def __init__(
8
+ self,
9
+ *,
10
+ host: str | None = None,
11
+ username: str | None = None,
12
+ password: str | None = None,
13
+ access_token: str | None = None,
14
+ version: str = "v1",
15
+ ):
16
+ self._api_client = ApiGateway(
17
+ host=host,
18
+ username=username,
19
+ password=password,
20
+ access_token=access_token,
21
+ version=version,
22
+ )
23
+
24
+ def cell(self, cell_id: str = "cell") -> "Cell":
25
+ # TODO check if the cell exists
26
+ return Cell(self._api_client, cell_id)
27
+
28
+
29
+ class Cell:
30
+ def __init__(self, api_gateway: ApiGateway, cell_id: str):
31
+ self._api_gateway = api_gateway
32
+ self._cell_id = cell_id
33
+
34
+ async def controller(self, controller_host: str = None) -> "Controller":
35
+ controller_api = self._api_gateway.controller_api
36
+ controller_list = await controller_api.list_controllers(cell=self._cell_id)
37
+ found_controller = next(
38
+ (c for c in controller_list.instances if c.host == controller_host), None
39
+ )
40
+
41
+ if found_controller is None:
42
+ raise ControllerNotFoundException(controller=controller_host)
43
+
44
+ return Controller(
45
+ api_gateway=self._api_gateway, cell=self._cell_id, controller_host=found_controller.host
46
+ )
nova/gateway.py ADDED
@@ -0,0 +1,93 @@
1
+ import asyncio
2
+ import functools
3
+ import time
4
+ from typing import TypeVar
5
+
6
+ from loguru import logger
7
+ import wandelbots_api_client as wb
8
+ from decouple import config
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ def intercept(api_instance: T) -> T:
14
+ class Interceptor:
15
+ def __init__(self, instance: T):
16
+ self._instance = instance
17
+
18
+ def __getattr__(self, name):
19
+ # Retrieve the original attribute
20
+ original_attr = getattr(self._instance, name)
21
+
22
+ # If it's not callable, return it as is
23
+ if not callable(original_attr):
24
+ return original_attr
25
+
26
+ # Wrap async callables
27
+ if asyncio.iscoroutinefunction(original_attr):
28
+
29
+ @functools.wraps(original_attr)
30
+ async def async_wrapper(*args, **kwargs):
31
+ logger.info(f"Calling {name} with args={args}, kwargs={kwargs}")
32
+ start = time.time()
33
+ try:
34
+ return await original_attr(*args, **kwargs)
35
+ finally:
36
+ duration = time.time() - start
37
+ logger.info(f"{name} took {duration:.2f} seconds")
38
+
39
+ return async_wrapper
40
+
41
+ # Wrap sync callables
42
+ @functools.wraps(original_attr)
43
+ def sync_wrapper(*args, **kwargs):
44
+ logger.debug(f"Calling {name} with args={args}, kwargs={kwargs}")
45
+ start = time.time()
46
+ try:
47
+ return original_attr(*args, **kwargs)
48
+ finally:
49
+ duration = time.time() - start
50
+ logger.debug(f"{name} took {duration:.2f} seconds")
51
+
52
+ return sync_wrapper
53
+
54
+ return Interceptor(api_instance)
55
+
56
+
57
+ class ApiGateway:
58
+ def __init__(
59
+ self,
60
+ *,
61
+ host: str | None = None,
62
+ username: str | None = None,
63
+ password: str | None = None,
64
+ access_token: str | None = None,
65
+ version: str = "v1",
66
+ ):
67
+ if host is None:
68
+ host = config("NOVA_HOST")
69
+
70
+ if username is None:
71
+ username = config("NOVA_USERNAME", default=None)
72
+
73
+ if password is None:
74
+ password = config("NOVA_PASSWORD", default=None)
75
+
76
+ if access_token is None:
77
+ access_token = config("NOVA_ACCESS", default=None)
78
+
79
+ api_client_config = wb.Configuration(
80
+ host=f"http://{host}/api/{version}",
81
+ username=username,
82
+ password=password,
83
+ access_token=access_token,
84
+ ssl_ca_cert=False,
85
+ )
86
+
87
+ self._api_client = wb.ApiClient(api_client_config)
88
+
89
+ # Use the intercept function to wrap each API client
90
+ self.controller_api = intercept(wb.ControllerApi(api_client=self._api_client))
91
+ self.motion_group_api = intercept(wb.MotionGroupApi(api_client=self._api_client))
92
+ self.motion_api = intercept(wb.MotionApi(api_client=self._api_client))
93
+ self.motion_group_infos_api = intercept(wb.MotionGroupInfosApi(api_client=self._api_client))
nova/types/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from wandelbots_api_client.models import * # noqa: F401, F403
2
+ from nova.types.pose import Pose
3
+ from nova.types.vector3d import Vector3d
4
+ from nova.types.action import Action, Motion, MotionSettings, lin, spl, ptp, cir, jnt
5
+
6
+ __all__ = [
7
+ "Vector3d",
8
+ "Pose",
9
+ "Motion",
10
+ "MotionSettings",
11
+ "lin",
12
+ "spl",
13
+ "ptp",
14
+ "cir",
15
+ "jnt",
16
+ "Action",
17
+ ]