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