pib-sdk 0.5__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.
pib_sdk/__init__.py ADDED
File without changes
pib_sdk/control.py ADDED
@@ -0,0 +1,573 @@
1
+ from __future__ import annotations
2
+ from typing import Optional, Any, Dict, Callable, Iterable, Tuple, List
3
+ import threading
4
+ import time
5
+ import json
6
+ import logging
7
+ import argparse
8
+ import roslibpy
9
+
10
+ # ----------------------------- Logging setup -----------------------------
11
+
12
+ log = logging.getLogger("pib.control")
13
+ handler = logging.StreamHandler()
14
+ formatter = logging.Formatter("[%(levelname)s] %(asctime)s %(name)s: %(message)s")
15
+ handler.setFormatter(formatter)
16
+ if not log.handlers:
17
+ log.addHandler(handler)
18
+ log.setLevel(logging.INFO)
19
+
20
+
21
+ # ----------------------------- Conversions -------------------------------
22
+
23
+ def _as_joint_trajectory_dict(motor_name: str, position_int: int) -> Dict[str, Any]:
24
+ """
25
+ trajectory_msgs/JointTrajectory represented as a rosbridge JSON dict.
26
+ """
27
+ return {
28
+ "joint_names": [motor_name],
29
+ "points": [{"positions": [int(position_int)]}],
30
+ }
31
+
32
+
33
+ def _deg_to_internal(position_deg: float) -> int:
34
+ """
35
+ Map degrees (−90..90) to internal units ×100 (−9000..9000).
36
+ """
37
+ if not -90.0 <= float(position_deg) <= 90.0:
38
+ raise ValueError(f"position_deg must be between -90 and 90 (got {position_deg})")
39
+ return int(round(float(position_deg) * 100))
40
+
41
+
42
+ def _internal_to_deg(position: Optional[float]) -> Optional[float]:
43
+ if position is None:
44
+ return None
45
+ try:
46
+ return float(position) / 100.0
47
+ except Exception:
48
+ return None
49
+
50
+
51
+ # ----------------------------- Writers -----------------------------------
52
+
53
+ class Write:
54
+ """
55
+ Publish motor updates through ROSBridge:
56
+ - Positions -> /joint_trajectory (trajectory_msgs/JointTrajectory)
57
+ - Other settings -> /motor_settings (datatypes/MotorSettings)
58
+
59
+ Diagnostics:
60
+ - debug logging
61
+ - send_with_ack() waits until your message is observed on the bus
62
+ - health_check() verifies rosbridge and required topics/types
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ host: str = "localhost",
68
+ port: int = 9090,
69
+ jt_topic_name: str = "/joint_trajectory",
70
+ jt_message_type: str = "trajectory_msgs/JointTrajectory",
71
+ ms_topic_name: str = "/motor_settings",
72
+ ms_message_type: str = "datatypes/MotorSettings",
73
+ debug: bool = False,
74
+ ):
75
+ if debug:
76
+ log.setLevel(logging.DEBUG)
77
+
78
+ self.host = host
79
+ self.port = port
80
+ self.jt_topic_name = jt_topic_name
81
+ self.jt_message_type = jt_message_type
82
+ self.ms_topic_name = ms_topic_name
83
+ self.ms_message_type = ms_message_type
84
+
85
+ # Connect to ROSBridge (websocket)
86
+ self.ros = roslibpy.Ros(host=host, port=port)
87
+
88
+ # Register event hooks for visibility
89
+ try:
90
+ self.ros.on_ready(lambda: log.info("ROSBridge connection established"))
91
+ self.ros.on_close(lambda: log.warning("ROSBridge connection closed"))
92
+ self.ros.on_error(lambda err: log.error(f"ROSBridge error: {err}"))
93
+ except Exception:
94
+ # Older roslibpy might not support the on_* helpers
95
+ pass
96
+
97
+ log.debug("Connecting to ROSBridge...")
98
+ self.ros.run()
99
+ log.debug("Connected: %s", self.ros.is_connected)
100
+
101
+ # Publishers
102
+ self._jt_topic = roslibpy.Topic(self.ros, jt_topic_name, jt_message_type)
103
+ self._ms_topic = roslibpy.Topic(self.ros, ms_topic_name, ms_message_type)
104
+
105
+ log.debug("Advertised publishers: %s, %s", jt_topic_name, ms_topic_name)
106
+
107
+ # ------------------ Diagnostics / health ------------------
108
+
109
+ def health_check(self, timeout: float = 3.0) -> Dict[str, Any]:
110
+ """
111
+ Confirms connectivity and that required topics exist with the expected types,
112
+ using rosapi services (requires rosbridge_server + rosapi).
113
+ """
114
+ if not self.ros.is_connected:
115
+ raise ConnectionError("Not connected to ROSBridge")
116
+
117
+ try:
118
+ svc = roslibpy.Service(self.ros, "/rosapi/topics", "rosapi/Topics")
119
+ req = roslibpy.ServiceRequest()
120
+ done = threading.Event()
121
+ out: Dict[str, Any] = {}
122
+
123
+ def _cb(resp: Dict[str, Any]):
124
+ out.update(resp or {})
125
+ done.set()
126
+
127
+ svc.call(req, callback=_cb, errback=lambda e: done.set())
128
+ ok = done.wait(timeout)
129
+ if not ok:
130
+ raise TimeoutError("Timed out calling /rosapi/topics")
131
+
132
+ topics: List[str] = out.get("topics", [])
133
+ types: List[str] = out.get("types", [])
134
+ tm = dict(zip(topics, types))
135
+
136
+ result = {
137
+ "connected": self.ros.is_connected,
138
+ "topics_present": {
139
+ self.jt_topic_name: tm.get(self.jt_topic_name),
140
+ self.ms_topic_name: tm.get(self.ms_topic_name),
141
+ },
142
+ "types_ok": (
143
+ tm.get(self.jt_topic_name) == self.jt_message_type
144
+ and tm.get(self.ms_topic_name) == self.ms_message_type
145
+ ),
146
+ }
147
+
148
+ log.info("Health: %s", json.dumps(result, indent=2))
149
+ return result
150
+ except Exception as e:
151
+ log.error("Health check failed: %s", e)
152
+ raise
153
+
154
+ # ------------------ Publish APIs ------------------
155
+
156
+ def send(
157
+ self,
158
+ motor_name: str,
159
+ *,
160
+ position_deg: Optional[float] = None,
161
+ velocity: Optional[int] = None,
162
+ acceleration: Optional[int] = None,
163
+ deceleration: Optional[int] = None,
164
+ turned_on: Optional[bool] = None,
165
+ pulse_width_min: Optional[int] = None,
166
+ pulse_width_max: Optional[int] = None,
167
+ rotation_range_min: Optional[int] = None,
168
+ rotation_range_max: Optional[int] = None,
169
+ period: Optional[int] = None,
170
+ visible: Optional[bool] = None,
171
+ invert: Optional[bool] = None,
172
+ extra_ms_fields: Optional[Dict[str, Any]] = None,
173
+ **kwargs: Any,
174
+ ) -> None:
175
+ """
176
+ - If position is provided, publish it via /joint_trajectory (only).
177
+ - All other non-None settings are sent via /motor_settings.
178
+ """
179
+ # 1) Position via JointTrajectory
180
+ if position_deg is not None:
181
+ pos_int = _deg_to_internal(position_deg)
182
+ jt_msg = _as_joint_trajectory_dict(motor_name, pos_int)
183
+ log.debug("Publishing JT: %s", jt_msg)
184
+ self._jt_topic.publish(roslibpy.Message(jt_msg))
185
+ log.info("JT published -> %s: position=%s (int=%s)", motor_name, position_deg, pos_int)
186
+
187
+ # 2) Remaining settings via MotorSettings (exclude position entirely)
188
+ ms_payload: Dict[str, Any] = {"motor_name": motor_name}
189
+ maybe = dict(
190
+ velocity=velocity,
191
+ acceleration=acceleration,
192
+ deceleration=deceleration,
193
+ turned_on=turned_on,
194
+ pulse_width_min=pulse_width_min,
195
+ pulse_width_max=pulse_width_max,
196
+ rotation_range_min=rotation_range_min,
197
+ rotation_range_max=rotation_range_max,
198
+ period=period,
199
+ visible=visible,
200
+ invert=invert,
201
+ )
202
+ for k, v in maybe.items():
203
+ if v is not None:
204
+ ms_payload[k] = v
205
+
206
+ if extra_ms_fields:
207
+ ms_payload.update({k: v for k, v in extra_ms_fields.items() if k != "position"})
208
+ if kwargs:
209
+ for k, v in kwargs.items():
210
+ if k != "position" and v is not None:
211
+ ms_payload[k] = v
212
+
213
+ if any(k for k in ms_payload.keys() if k != "motor_name"):
214
+ log.debug("Publishing MS: %s", ms_payload)
215
+ self._ms_topic.publish(roslibpy.Message(ms_payload))
216
+ log.info("MS published -> %s: fields=%s", motor_name, [k for k in ms_payload if k != "motor_name"])
217
+
218
+ def send_with_ack(
219
+ self,
220
+ motor_name: str,
221
+ *,
222
+ position_deg: Optional[float] = None,
223
+ ack_timeout: float = 2.5,
224
+ observe_ms: bool = False,
225
+ **settings: Any,
226
+ ) -> bool:
227
+ """
228
+ Publish (like send) then wait to *observe* the matching message on the bus.
229
+ Returns True if observed within timeout, else False.
230
+ - For position: listens on /joint_trajectory for the motor name + position.
231
+ - If observe_ms=True: also waits for a /motor_settings update for that motor.
232
+ """
233
+ evt_jt = threading.Event()
234
+ evt_ms = threading.Event() if observe_ms else None
235
+
236
+ jt_expected_int = None
237
+ if position_deg is not None:
238
+ jt_expected_int = _deg_to_internal(position_deg)
239
+
240
+ # Subscribe BEFORE publish to avoid missing fast round-trips
241
+ def _on_jt(msg: Dict[str, Any]):
242
+ try:
243
+ names = msg.get("joint_names") or []
244
+ pts = msg.get("points") or []
245
+ if not names or not pts:
246
+ return
247
+ name = names[0]
248
+ positions = pts[0].get("positions") or []
249
+ if not positions:
250
+ return
251
+ pos = int(positions[0])
252
+ if name == motor_name and (jt_expected_int is None or pos == jt_expected_int):
253
+ log.debug("Observed JT echo for %s: %s", motor_name, msg)
254
+ evt_jt.set()
255
+ except Exception:
256
+ pass
257
+
258
+ self._jt_topic.subscribe(_on_jt)
259
+
260
+ def _on_ms(msg: Dict[str, Any]):
261
+ try:
262
+ if msg.get("motor_name") == motor_name:
263
+ log.debug("Observed MS echo for %s: %s", motor_name, msg)
264
+ if evt_ms:
265
+ evt_ms.set()
266
+ except Exception:
267
+ pass
268
+
269
+ if evt_ms is not None:
270
+ self._ms_topic.subscribe(_on_ms)
271
+
272
+ # Publish
273
+ try:
274
+ self.send(motor_name, position_deg=position_deg, **settings)
275
+ except Exception as e:
276
+ log.error("Publish failed: %s", e)
277
+ # Unsubscribe before raising
278
+ try:
279
+ self._jt_topic.unsubscribe(_on_jt)
280
+ except Exception:
281
+ pass
282
+ if evt_ms is not None:
283
+ try:
284
+ self._ms_topic.unsubscribe(_on_ms)
285
+ except Exception:
286
+ pass
287
+ raise
288
+
289
+ # Wait
290
+ ok_jt = evt_jt.wait(ack_timeout) if jt_expected_int is not None else True
291
+ ok_ms = evt_ms.wait(ack_timeout) if evt_ms is not None else True
292
+
293
+ # Cleanup subscriptions
294
+ try:
295
+ self._jt_topic.unsubscribe(_on_jt)
296
+ except Exception:
297
+ pass
298
+ if evt_ms is not None:
299
+ try:
300
+ self._ms_topic.unsubscribe(_on_ms)
301
+ except Exception:
302
+ pass
303
+
304
+ if ok_jt and ok_ms:
305
+ log.info("ACK OK for %s (JT%s%s)", motor_name, "" if jt_expected_int is None else f"={jt_expected_int}",
306
+ " + MS" if observe_ms else "")
307
+ return True
308
+ else:
309
+ if not ok_jt:
310
+ log.warning("ACK TIMEOUT: Did not observe JointTrajectory for %s within %.2fs", motor_name, ack_timeout)
311
+ if evt_ms is not None and not ok_ms:
312
+ log.warning("ACK TIMEOUT: Did not observe MotorSettings for %s within %.2fs", motor_name, ack_timeout)
313
+ return False
314
+
315
+ def close(self) -> None:
316
+ # Cleanly close
317
+ try:
318
+ self._jt_topic.unadvertise()
319
+ except Exception:
320
+ pass
321
+ try:
322
+ self._ms_topic.unadvertise()
323
+ except Exception:
324
+ pass
325
+ try:
326
+ self.ros.terminate()
327
+ except Exception:
328
+ pass
329
+
330
+
331
+ def write(
332
+ motor_name: str,
333
+ *,
334
+ position_deg: Optional[float] = None,
335
+ host: str = "localhost",
336
+ port: int = 9090,
337
+ **settings: Any,
338
+ ) -> None:
339
+ """
340
+ One-shot helper: sends position (if provided) to /joint_trajectory,
341
+ and other settings to /motor_settings.
342
+ """
343
+ w = Write(host=host, port=port)
344
+ try:
345
+ w.send(motor_name=motor_name, position_deg=position_deg, **settings)
346
+ finally:
347
+ w.close()
348
+
349
+
350
+ # ----------------------------- Readers -----------------------------------
351
+
352
+ class Read:
353
+ """
354
+ Subscribe to both /joint_trajectory and /motor_settings.
355
+ Your callback receives a merged dict per motor update:
356
+
357
+ {
358
+ 'motor_name': 'joint1',
359
+ 'position': 1234, # from JointTrajectory (if available)
360
+ 'position_deg': 12.34, # convenience
361
+ 'velocity': 100, ... # from MotorSettings (if provided)
362
+ }
363
+
364
+ Diagnostics: set debug=True to get verbose logs as messages arrive.
365
+ """
366
+
367
+ def __init__(
368
+ self,
369
+ callback: Callable[[Dict[str, Any]], None],
370
+ host: str = "localhost",
371
+ port: int = 9090,
372
+ jt_topic_name: str = "/joint_trajectory",
373
+ jt_message_type: str = "trajectory_msgs/JointTrajectory",
374
+ ms_topic_name: str = "/motor_settings",
375
+ ms_message_type: str = "datatypes/MotorSettings",
376
+ debug: bool = False,
377
+ ):
378
+ if debug:
379
+ log.setLevel(logging.DEBUG)
380
+
381
+ self._cb = callback
382
+ self._cache: Dict[str, Dict[str, Any]] = {}
383
+
384
+ # Connect
385
+ self.ros = roslibpy.Ros(host=host, port=port)
386
+ self.ros.run()
387
+
388
+ # Topics
389
+ self._jt = roslibpy.Topic(self.ros, jt_topic_name, jt_message_type)
390
+ self._ms = roslibpy.Topic(self.ros, ms_topic_name, ms_message_type)
391
+
392
+ # Subscriptions
393
+ self._jt.subscribe(self._on_jt)
394
+ self._ms.subscribe(self._on_ms)
395
+
396
+ log.debug("Read subscribed to %s and %s", jt_topic_name, ms_topic_name)
397
+
398
+ # ---- internal handlers ----
399
+
400
+ def _emit(self, motor_name: str) -> None:
401
+ data = dict(self._cache.get(motor_name, {}))
402
+ if "position" in data:
403
+ data["position_deg"] = _internal_to_deg(data.get("position"))
404
+ log.debug("Emit merged update: %s", data)
405
+ self._cb(data)
406
+
407
+ def _on_jt(self, msg: Dict[str, Any]) -> None:
408
+ names = msg.get("joint_names") or []
409
+ points = msg.get("points") or []
410
+ if not names or not points:
411
+ return
412
+ motor_name = names[0]
413
+ positions = points[0].get("positions") or []
414
+ if not positions:
415
+ return
416
+ pos = int(positions[0])
417
+
418
+ entry = self._cache.setdefault(motor_name, {"motor_name": motor_name})
419
+ entry["position"] = pos
420
+ self._emit(motor_name)
421
+
422
+ def _on_ms(self, msg: Dict[str, Any]) -> None:
423
+ motor_name = msg.get("motor_name")
424
+ if not motor_name:
425
+ return
426
+ entry = self._cache.setdefault(motor_name, {"motor_name": motor_name})
427
+ for k, v in msg.items():
428
+ if k in ("motor_name", "position"):
429
+ continue # ignore 'position' here; position comes from JointTrajectory
430
+ entry[k] = v
431
+ self._emit(motor_name)
432
+
433
+ def close(self) -> None:
434
+ try:
435
+ self._jt.unsubscribe(self._on_jt)
436
+ except Exception:
437
+ pass
438
+ try:
439
+ self._ms.unsubscribe(self._on_ms)
440
+ except Exception:
441
+ pass
442
+ try:
443
+ self.ros.terminate()
444
+ except Exception:
445
+ pass
446
+
447
+
448
+ def read_stream(
449
+ callback: Callable[[Dict[str, Any]], None],
450
+ *,
451
+ host: str = "localhost",
452
+ port: int = 9090,
453
+ debug: bool = False,
454
+ ) -> Read:
455
+ """
456
+ Continuous merged stream from /joint_trajectory + /motor_settings.
457
+ """
458
+ return Read(callback, host=host, port=port, debug=debug)
459
+
460
+
461
+ def read(
462
+ motor_name: str,
463
+ *,
464
+ host: str = "localhost",
465
+ port: int = 9090,
466
+ timeout: float = 3.0,
467
+ debug: bool = False,
468
+ ) -> Dict[str, Any]:
469
+ """
470
+ One-shot blocking read: waits for either a JointTrajectory or MotorSettings
471
+ update for the given motor and returns the latest merged view (within timeout).
472
+ """
473
+ if debug:
474
+ log.setLevel(logging.DEBUG)
475
+
476
+ result: Dict[str, Any] = {}
477
+ evt = threading.Event()
478
+
479
+ def _cb(data: Dict[str, Any]) -> None:
480
+ if data.get("motor_name") == motor_name:
481
+ result.clear()
482
+ result.update(data)
483
+ evt.set()
484
+
485
+ r = Read(_cb, host=host, port=port, debug=debug)
486
+ ok = evt.wait(timeout)
487
+ r.close()
488
+
489
+ if not ok:
490
+ raise TimeoutError(f"No update for '{motor_name}' within {timeout} seconds")
491
+ return result
492
+
493
+
494
+ # ----------------------------- CLI / Demos --------------------------------
495
+
496
+ def _cli_check(args: argparse.Namespace) -> int:
497
+ w = Write(host=args.host, port=args.port, debug=args.debug)
498
+ try:
499
+ w.health_check(timeout=args.timeout)
500
+ return 0
501
+ finally:
502
+ w.close()
503
+
504
+
505
+ def _cli_send(args: argparse.Namespace) -> int:
506
+ w = Write(host=args.host, port=args.port, debug=args.debug)
507
+ try:
508
+ if args.ack:
509
+ ok = w.send_with_ack(
510
+ args.motor,
511
+ position_deg=args.position_deg,
512
+ ack_timeout=args.timeout,
513
+ observe_ms=args.observe_ms,
514
+ )
515
+ print("ACK:", "OK" if ok else "TIMEOUT")
516
+ return 0 if ok else 2
517
+ else:
518
+ w.send(args.motor, position_deg=args.position_deg)
519
+ return 0
520
+ finally:
521
+ w.close()
522
+
523
+
524
+ def _cli_echo(args: argparse.Namespace) -> int:
525
+ def _cb(d: Dict[str, Any]):
526
+ if (args.motor is None) or (d.get("motor_name") == args.motor):
527
+ print(json.dumps(d, ensure_ascii=False))
528
+ r = Read(_cb, host=args.host, port=args.port, debug=args.debug)
529
+ try:
530
+ while True:
531
+ time.sleep(0.2)
532
+ except KeyboardInterrupt:
533
+ pass
534
+ finally:
535
+ r.close()
536
+ return 0
537
+
538
+
539
+ def _build_parser() -> argparse.ArgumentParser:
540
+ p = argparse.ArgumentParser(description="pib control SDK over rosbridge")
541
+ p.add_argument("--host", default="localhost")
542
+ p.add_argument("--port", type=int, default=9090)
543
+ p.add_argument("--debug", action="store_true", help="enable verbose logging")
544
+
545
+ sub = p.add_subparsers(dest="cmd", required=True)
546
+
547
+ p_check = sub.add_parser("check", help="verify connection & topics via rosapi")
548
+ p_check.add_argument("--timeout", type=float, default=3.0)
549
+ p_check.set_defaults(func=_cli_check)
550
+
551
+ p_send = sub.add_parser("send", help="send a single position (deg)")
552
+ p_send.add_argument("--motor", required=True, help="motor/joint name")
553
+ p_send.add_argument("--position-deg", type=float, required=False, default=None)
554
+ p_send.add_argument("--ack", action="store_true", help="wait to observe echo on the bus")
555
+ p_send.add_argument("--observe-ms", action="store_true", help="also wait for /motor_settings echo")
556
+ p_send.add_argument("--timeout", type=float, default=2.5)
557
+ p_send.set_defaults(func=_cli_send)
558
+
559
+ p_echo = sub.add_parser("echo", help="print merged messages")
560
+ p_echo.add_argument("--motor", required=False, help="filter by motor name")
561
+ p_echo.set_defaults(func=_cli_echo)
562
+
563
+ return p
564
+
565
+
566
+ def main():
567
+ parser = _build_parser()
568
+ args = parser.parse_args()
569
+ return args.func(args)
570
+
571
+
572
+ if __name__ == "__main__":
573
+ raise SystemExit(main())
pib_sdk/kinematics.py ADDED
@@ -0,0 +1,123 @@
1
+ from importlib import import_module
2
+ from typing import Iterable, Literal, Sequence, Optional
3
+ import numpy as np
4
+ from spatialmath import SE3
5
+ from roboticstoolbox import DHRobot
6
+
7
+
8
+ def _to_rad(seq_deg: Sequence[float]) -> np.ndarray:
9
+ #Degrees → radians (returns NumPy array)
10
+ return np.deg2rad(np.asarray(seq_deg, dtype=float))
11
+
12
+
13
+ def _to_deg(seq_rad: Sequence[float]) -> np.ndarray:
14
+ #Radians → degrees (returns NumPy array)
15
+ return np.rad2deg(np.asarray(seq_rad, dtype=float))
16
+
17
+
18
+ def _get_robot(side: Literal["right", "left"]) -> DHRobot:
19
+ #Instantiate either pib_right() or pib_left() from DH_model.pib_DH.
20
+ mod = import_module("pib_sdk.pib_DH")
21
+ cls_name = {"right": "pib_right", "left": "pib_left"}[side.lower()]
22
+ return getattr(mod, cls_name)()
23
+
24
+
25
+ # Forward kinematics
26
+
27
+ class FK:
28
+ def __init__(self, side: Literal["right", "left"] = "right"):
29
+ self.robot: DHRobot = _get_robot(side)
30
+ self.joint_names = [f"theta{i+1}" for i in range(self.robot.n)]
31
+
32
+ def pose(self, q_deg: Iterable[float]) -> SE3:
33
+ #Compute end-effector SE3 pose for a joint vector (degrees) raises ValueError if the length of `q_deg` is not equal to DOF.
34
+ q_deg = list(q_deg)
35
+ if len(q_deg) != self.robot.n:
36
+ raise ValueError(f"Expected {self.robot.n} joint values, got {len(q_deg)}")
37
+ return self.robot.fkine(_to_rad(q_deg))
38
+
39
+
40
+ # Inverse kinematics
41
+
42
+ class IK:
43
+ def __init__(self, side: Literal["right", "left"] = "right"):
44
+ self.robot: DHRobot = _get_robot(side)
45
+
46
+ def solve(
47
+ self,
48
+ xyz: Sequence[float],
49
+ rpy_deg: Optional[Sequence[float]] = None,
50
+ q0_deg: Optional[Iterable[float]] = None,
51
+ tol: float = 1e-4,
52
+ max_steps: int = 100,
53
+ custom_mask: Optional[Sequence[float]] = None,
54
+ ) -> np.ndarray:
55
+ """
56
+ Return joint angles (degrees) for the requested pose.
57
+
58
+ Parameters
59
+ ----------
60
+ xyz : (3,) sequence
61
+ Target position in millimetres.
62
+ rpy_deg : (3,) sequence, optional
63
+ Target orientation (roll, pitch, yaw in degrees). If ``None``,
64
+ orientation is ignored (position-only IK).
65
+ q0_deg : initial guess in degrees (defaults to robot.qz).
66
+ tol, max_steps : convergence settings passed to ikine_LM().
67
+ custom_mask : optional 6-element mask overriding the automatic one raises ValueError if the solver fails to converge.
68
+ """
69
+ # Build target SE3
70
+ T = SE3(*xyz) if rpy_deg is None else SE3(*xyz) * SE3.RPY(*rpy_deg, unit="deg")
71
+
72
+ # Default mask
73
+ mask = [1, 1, 1, 0, 0, 0] if rpy_deg is None else [1, 1, 1, 1, 1, 1]
74
+ if custom_mask is not None:
75
+ if len(custom_mask) != 6:
76
+ raise ValueError("custom_mask must have 6 elements")
77
+ mask = list(custom_mask)
78
+
79
+ # Initial guess
80
+ q0 = self.robot.qz if q0_deg is None else _to_rad(q0_deg)
81
+
82
+ # Solve
83
+ sol = self.robot.ikine_LM(
84
+ T,
85
+ q0=q0,
86
+ tol=tol,
87
+ ilimit=max_steps,
88
+ mask=mask,
89
+ )
90
+ if not sol.success:
91
+ raise ValueError(f"IK failed: {sol.reason}")
92
+ return _to_deg(sol.q)
93
+
94
+
95
+
96
+ # One-liner convenience functions
97
+
98
+ def fk(side: Literal["right", "left"], q_deg: Iterable[float]) -> SE3:
99
+ """
100
+ One-call forward kinematics.
101
+
102
+ Example
103
+ -------
104
+ >>> pose = fk("right", [0, 45, 0, 0, 90, 0])
105
+ """
106
+ return FK(side).pose(q_deg)
107
+
108
+
109
+ def ik(
110
+ side: Literal["right", "left"],
111
+ *,
112
+ xyz: Sequence[float],
113
+ rpy_deg: Optional[Sequence[float]] = None,
114
+ **kw,
115
+ ) -> np.ndarray:
116
+ """
117
+ One-call inverse kinematics.
118
+
119
+ Example
120
+ -------
121
+ >>> q = ik("left", xyz=[150, 0, 350])
122
+ """
123
+ return IK(side).solve(xyz=xyz, rpy_deg=rpy_deg, **kw)