rational-linkages 2.0.0__cp312-cp312-macosx_12_0_arm64.whl → 2.2.3__cp312-cp312-macosx_12_0_arm64.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.
Files changed (31) hide show
  1. rational_linkages/CollisionAnalyser.py +323 -21
  2. rational_linkages/CollisionFreeOptimization.py +8 -4
  3. rational_linkages/DualQuaternion.py +5 -2
  4. rational_linkages/ExudynAnalysis.py +2 -1
  5. rational_linkages/FactorizationProvider.py +6 -5
  6. rational_linkages/MiniBall.py +9 -2
  7. rational_linkages/MotionApproximation.py +7 -3
  8. rational_linkages/MotionDesigner.py +553 -540
  9. rational_linkages/MotionFactorization.py +6 -5
  10. rational_linkages/MotionInterpolation.py +7 -7
  11. rational_linkages/NormalizedLine.py +1 -1
  12. rational_linkages/NormalizedPlane.py +1 -1
  13. rational_linkages/Plotter.py +1 -1
  14. rational_linkages/PlotterMatplotlib.py +27 -13
  15. rational_linkages/PlotterPyqtgraph.py +596 -534
  16. rational_linkages/PointHomogeneous.py +6 -3
  17. rational_linkages/RationalBezier.py +64 -4
  18. rational_linkages/RationalCurve.py +13 -5
  19. rational_linkages/RationalDualQuaternion.py +5 -4
  20. rational_linkages/RationalMechanism.py +48 -33
  21. rational_linkages/SingularityAnalysis.py +4 -5
  22. rational_linkages/StaticMechanism.py +4 -5
  23. rational_linkages/__init__.py +3 -2
  24. rational_linkages/utils.py +60 -3
  25. rational_linkages/utils_rust.cpython-312-darwin.so +0 -0
  26. {rational_linkages-2.0.0.dist-info → rational_linkages-2.2.3.dist-info}/METADATA +32 -18
  27. rational_linkages-2.2.3.dist-info/RECORD +40 -0
  28. rational_linkages-2.0.0.dist-info/RECORD +0 -40
  29. {rational_linkages-2.0.0.dist-info → rational_linkages-2.2.3.dist-info}/WHEEL +0 -0
  30. {rational_linkages-2.0.0.dist-info → rational_linkages-2.2.3.dist-info}/licenses/LICENSE +0 -0
  31. {rational_linkages-2.0.0.dist-info → rational_linkages-2.2.3.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,14 @@
1
- from .RationalMechanism import RationalMechanism
2
- from .RationalCurve import RationalCurve
3
- from .MiniBall import MiniBall
4
- from .DualQuaternion import DualQuaternion
5
- from .PointHomogeneous import PointOrbit
6
-
7
1
  import numpy
8
- import sympy
2
+
3
+ from sympy import symbols, Poly
4
+
5
+ from .Linkage import LineSegment
6
+ from .DualQuaternion import DualQuaternion
7
+ from .NormalizedLine import NormalizedLine
8
+ from .PointHomogeneous import PointHomogeneous, PointOrbit
9
+ from .RationalBezier import RationalSoo
10
+ from .RationalCurve import RationalCurve
11
+ from .RationalMechanism import RationalMechanism
9
12
 
10
13
 
11
14
  class CollisionAnalyser:
@@ -22,7 +25,7 @@ class CollisionAnalyser:
22
25
  self.segments[segment.id] = segment
23
26
 
24
27
  self.motions = self.get_motions()
25
- self.bezier_splits = self.get_bezier_splits(50)
28
+ self.bezier_splits = self.get_bezier_splits(20)
26
29
 
27
30
  def get_bezier_splits(self, min_splits: int = 0) -> list:
28
31
  """
@@ -44,11 +47,12 @@ class CollisionAnalyser:
44
47
 
45
48
  relative_motions = branch0 + branch1[::-1]
46
49
 
47
- t = sympy.symbols('t')
50
+ t = symbols('t')
48
51
 
49
52
  motions = []
50
53
  for motion in relative_motions:
51
- motions.append(RationalCurve([sympy.Poly(c, t) for c in motion],
54
+ motions.append(RationalCurve([Poly(c, t, greedy=False)
55
+ for c in motion],
52
56
  metric=self.metric))
53
57
  return motions
54
58
 
@@ -63,11 +67,9 @@ class CollisionAnalyser:
63
67
  """
64
68
  Get the orbit of a segment.
65
69
  """
66
- import time
67
-
68
70
  segment = self.segments[segment_id]
69
71
 
70
- if segment.type == 'l' or segment.type == 't' or segment.type == 'b':
72
+ if segment.type == 'l':
71
73
  if segment.factorization_idx == 0:
72
74
  split_idx = segment.idx - 1
73
75
  p0_idx = 2 * segment.idx - 1
@@ -196,14 +198,6 @@ class CollisionAnalyser:
196
198
 
197
199
  return it_collides
198
200
 
199
- @staticmethod
200
- def get_object_type(obj):
201
- """
202
- Get the type of an object.
203
- """
204
- if isinstance(obj, MiniBall):
205
- return 'is_miniball'
206
-
207
201
  @staticmethod
208
202
  def check_two_miniballs(ball0, ball1):
209
203
  """
@@ -212,3 +206,311 @@ class CollisionAnalyser:
212
206
  diff = ball0.center.coordinates - ball1.center.coordinates
213
207
  center_dist_squared = numpy.dot(diff, diff)
214
208
  return center_dist_squared < ball0.radius_squared + ball1.radius_squared
209
+
210
+ def get_split_and_point_indices(self, segment):
211
+ """
212
+ Compute split index and point indices for a segment.
213
+ """
214
+ if segment.type == 'l':
215
+ if segment.factorization_idx == 0:
216
+ split_idx = segment.idx - 1
217
+ p0_idx = 2 * segment.idx - 1
218
+ p1_idx = 2 * segment.idx
219
+ else:
220
+ split_idx = -1 * segment.idx
221
+ p0_idx = -2 * segment.idx - 1
222
+ p1_idx = -2 * segment.idx
223
+ else: # type == 'j'
224
+ if segment.factorization_idx == 0:
225
+ split_idx = segment.idx - 1
226
+ p0_idx = 2 * segment.idx
227
+ p1_idx = 2 * segment.idx + 1
228
+ else:
229
+ split_idx = -1 * segment.idx
230
+ p0_idx = -2 * segment.idx - 1
231
+ p1_idx = -2 * segment.idx - 2
232
+ return split_idx, p0_idx, p1_idx
233
+
234
+ def optimize_curved_link(self,
235
+ segment_id: str,
236
+ min_splits: int = 20,
237
+ curve_degree: int = 3):
238
+ """
239
+ Optimize the curved link to avoid collisions.
240
+ """
241
+ if segment_id.startswith('j'):
242
+ raise ValueError('Joints cannot be optimized as curved lines, only links.')
243
+
244
+ # get segment creation index
245
+ segment_id_num = None
246
+ for s_id, segment in enumerate(self.segments.values()):
247
+ if segment.id == segment_id:
248
+ segment_id_num = s_id
249
+ break
250
+
251
+ indices = list(range(len(self.mechanism.segments)))
252
+
253
+ # remove index of segment to optimize and the two neighboring segments
254
+ indices.remove(segment_id_num)
255
+ if segment_id_num != 0:
256
+ indices.remove(segment_id_num - 1)
257
+ else:
258
+ indices.remove(indices[-1]) # remove last if first segment is optimized
259
+ if segment_id_num != len(self.mechanism.segments) - 1:
260
+ indices.remove(segment_id_num + 1)
261
+ else:
262
+ indices.remove(indices[0]) # remove first if last segment is optimized
263
+
264
+ # remove odd indices which correspond to joints; keep also zero
265
+ indices_reduced = [idx for i, idx in enumerate(indices)
266
+ if idx % 2 == 0 or idx == 0]
267
+
268
+ bounding_balls = self.obtain_global_bounding_balls(segment_id_num,
269
+ indices_reduced,
270
+ min_splits)
271
+
272
+ dh, design_params, design_points = self.mechanism.get_design(
273
+ return_point_homogeneous=True,
274
+ update_design=True,
275
+ pretty_print=False)
276
+
277
+ joint_id = segment_id_num // 2
278
+ pt0 = design_points[joint_id - 1][1]
279
+ pt1 = design_points[joint_id][0]
280
+
281
+ link_cps = RationalSoo.control_points_between_two_points(pt0,
282
+ pt1,
283
+ degree=curve_degree)
284
+ init_control_points = link_cps[1:-1] # remove the first and last control points
285
+
286
+ new_cps = self.optimize_control_points(init_control_points,
287
+ bounding_balls)
288
+ new_cps.insert(0, pt0)
289
+ new_cps.append(pt1)
290
+
291
+ return RationalSoo(new_cps)
292
+
293
+ @staticmethod
294
+ def optimize_control_points(init_points: list[PointHomogeneous],
295
+ bounding_orbits: list[list[PointOrbit]]):
296
+ """
297
+ Optimize the link control points to avoid collisions with the bounding orbits.
298
+ """
299
+ try:
300
+ from scipy.optimize import minimize # lazy import
301
+ except ImportError:
302
+ raise RuntimeError("Scipy import failed. Check its installation.")
303
+
304
+ def flatten_cps(cps):
305
+ return numpy.array([cp.normalized_in_3d() for cp in cps]).flatten()
306
+
307
+ def unflatten_cps(cps_flat):
308
+ return [PointHomogeneous([1, cps_flat[i], cps_flat[i + 1], cps_flat[i + 2]])
309
+ for i in range(0, len(cps_flat), 3)]
310
+
311
+ flattened_orbits = []
312
+ for i in range(len(bounding_orbits)):
313
+ for j in range(len(bounding_orbits[i])):
314
+ flattened_orbits.extend(bounding_orbits[i][j][1:])
315
+
316
+ orbit_centers = [orbit.center.normalized_in_3d() for orbit in flattened_orbits]
317
+ orbit_radii = [orbit.radius for orbit in flattened_orbits]
318
+
319
+ init_cps = flatten_cps(init_points)
320
+ lambda_reg = 0.1
321
+
322
+ def loss(params):
323
+ cps = unflatten_cps(params)
324
+ margin = 0.01
325
+ penalty = 0.0
326
+ for cp in cps:
327
+ for i, orbit in enumerate(flattened_orbits):
328
+ dist = numpy.linalg.norm(cp.normalized_in_3d() - orbit_centers[i])
329
+ if dist < orbit_radii[i] + margin:
330
+ penalty += (orbit_radii[i] + margin - dist) ** 2
331
+ # Regularization: keep cps close to initial guess
332
+ penalty += lambda_reg * numpy.sum((params - init_cps) ** 2)
333
+ return penalty
334
+
335
+ res = minimize(loss, init_cps)
336
+
337
+ if not res.success:
338
+ raise RuntimeError(f'Optimization failed: {res.message}')
339
+ else:
340
+ new_control_points = unflatten_cps(res.x)
341
+
342
+ return new_control_points
343
+
344
+ def obtain_global_bounding_balls(self,
345
+ segment_id_number: int,
346
+ reduced_indices: list[int],
347
+ min_splits: int = 20):
348
+ """
349
+ Obtain global covering balls for a segment to optimize it as a curved link.
350
+
351
+ :param segment_id_number: The index of the segment to optimize.
352
+ :param reduced_indices: Indices of segments to consider for bounding balls.
353
+ :param min_splits: Minimum number of splits for the bezier curves.
354
+ """
355
+
356
+ t = symbols('t')
357
+ motions = []
358
+ for i, idx in enumerate(reduced_indices):
359
+ rel_motion = self.mechanism.relative_motion(segment_id_number, idx)
360
+ motions.append(RationalCurve([Poly(c, t, greedy=False)
361
+ for c in rel_motion],
362
+ metric=self.metric))
363
+
364
+ bezier_splits = [motion.split_in_beziers(min_splits) for motion in motions]
365
+
366
+ all_orbits = []
367
+ for i, segment_idx in enumerate(reduced_indices):
368
+
369
+ split_idx = i
370
+ p0_idx = segment_idx - 1
371
+ p1_idx = segment_idx
372
+
373
+ rel_bezier_splits = bezier_splits[split_idx]
374
+
375
+ p0 = self.mechanism_points[p0_idx]
376
+ p1 = self.mechanism_points[p1_idx]
377
+
378
+ orbits0 = [PointOrbit(*p0.get_point_orbit(acting_center=split.ball.center,
379
+ acting_radius=split.ball.radius_squared,
380
+ metric=self.metric),
381
+ t_interval=split.t_param_of_motion_curve)
382
+ for split in rel_bezier_splits]
383
+ orbits1 = [PointOrbit(*p1.get_point_orbit(acting_center=split.ball.center,
384
+ acting_radius=split.ball.radius_squared,
385
+ metric=self.metric),
386
+ t_interval=split.t_param_of_motion_curve)
387
+ for split in rel_bezier_splits]
388
+
389
+ all_orbits_of_a_link = []
390
+ for i in range(len(orbits0)):
391
+ orbits_for_t = [orbits0[i].t_interval, orbits0[i]]
392
+ dist = numpy.linalg.norm(orbits0[i].center.normalized_in_3d() - orbits1[
393
+ i].center.normalized_in_3d())
394
+ radius_sum = orbits0[i].radius + orbits1[i].radius
395
+ if dist > radius_sum:
396
+ add_balls = dist / radius_sum
397
+ num_steps = int(add_balls) * 2 + 1
398
+
399
+ # linear interpolation from smaller ball to bigger ball
400
+ radii = 0
401
+ radius_diff = orbits1[i].radius - orbits0[i].radius
402
+ center_diff = orbits1[i].center - orbits0[i].center
403
+ for j in range(1, num_steps):
404
+ new_radius = orbits0[i].radius + j * radius_diff / num_steps
405
+ radii += new_radius
406
+ new_center = orbits0[i].center + 2 * radii * center_diff / (
407
+ dist * 2)
408
+ orbits_for_t.append(PointOrbit(new_center, new_radius ** 2,
409
+ orbits0[i].t_interval))
410
+ orbits_for_t.append(orbits1[i])
411
+ all_orbits_of_a_link.append(orbits_for_t)
412
+ all_orbits.append(all_orbits_of_a_link)
413
+
414
+ return all_orbits
415
+
416
+ @staticmethod
417
+ def quantify_collision(segment0: LineSegment,
418
+ segment1: LineSegment,
419
+ t_val):
420
+ """
421
+ Quantify the collision between two line segments.
422
+ """
423
+ p00 = segment0.point0.evaluate(t_val)
424
+ p01 = segment0.point1.evaluate(t_val)
425
+
426
+ p10 = segment1.point0.evaluate(t_val)
427
+ p11 = segment1.point1.evaluate(t_val)
428
+
429
+ l0 = NormalizedLine.from_two_points(p00, p01)
430
+ l1 = NormalizedLine.from_two_points(p10, p11)
431
+
432
+ pts, dist, cos_alpha = l0.common_perpendicular_to_other_line(l1)
433
+
434
+ if numpy.isclose(dist, 0.0): # lines are intersecting
435
+ quantif = CollisionAnalyser.quatif_intersection_location(
436
+ PointHomogeneous.from_3d_point(pts[0]),
437
+ p00,
438
+ p01)
439
+ else: # lines are not intersecting, there is a distance
440
+ quantif_l0 = CollisionAnalyser.quatif_intersection_location(
441
+ PointHomogeneous.from_3d_point(pts[0]),
442
+ p00,
443
+ p01)
444
+ quantif_l1 = CollisionAnalyser.quatif_intersection_location(
445
+ PointHomogeneous.from_3d_point(pts[1]),
446
+ p10,
447
+ p11)
448
+ quantif_dist = CollisionAnalyser.map_to_exponential_decay(dist, k=10.0)
449
+
450
+ quantif = quantif_dist * (quantif_l0 + quantif_l1)
451
+
452
+ return quantif
453
+
454
+ @staticmethod
455
+ def quatif_intersection_location(interection_pt, segment_pt0, segment_pt1):
456
+ a = numpy.linalg.norm(
457
+ segment_pt0.normalized_in_3d() - interection_pt.normalized_in_3d())
458
+ b = numpy.linalg.norm(
459
+ segment_pt1.normalized_in_3d() - interection_pt.normalized_in_3d())
460
+
461
+ segment_lenght = numpy.linalg.norm(
462
+ segment_pt0.normalized_in_3d() - segment_pt1.normalized_in_3d())
463
+ if a + b > segment_lenght:
464
+ val = a + b - segment_lenght
465
+ quantif_val = CollisionAnalyser.map_to_exponential_decay(val, k=2.0)
466
+ else:
467
+ val = a if a < b else b
468
+ quantif_val = CollisionAnalyser.map_to_range_inside(val, segment_lenght, 2)
469
+
470
+ return quantif_val
471
+
472
+ @staticmethod
473
+ def map_to_exponential_decay(x, k=1.0):
474
+ """
475
+ Maps a value x using an exponential decay function to the range [0, 1].
476
+
477
+ Maps a value x from the range [0, inf) to the interval [0, 1] using an
478
+ exponential decay function.
479
+
480
+ :param float x: The input value in the range [0, inf).
481
+ :param float k: The decay rate (must be positive). Default is 1.0.
482
+
483
+ :return: The mapped value in the range [0, 1].
484
+ :rtype: float
485
+ """
486
+ if x < 0:
487
+ raise ValueError("x must be non-negative")
488
+ if k <= 0:
489
+ raise ValueError("k must be positive")
490
+
491
+ # Exponential decay formula
492
+ y = numpy.exp(-k * x)
493
+ return y
494
+
495
+ @staticmethod
496
+ def map_to_range_inside(x, x_max, weight=1.0):
497
+ """
498
+ Maps a value x from the range [0, x_max] to a value y in the range [1, 2].
499
+
500
+ Function preserves the ratio of the input value relative to the maximum value.
501
+
502
+ :param float x: The input value in the range [0, x_max].
503
+ :param float x_max: The maximum value of the input range.
504
+ :param float weight: The weight to scale the output value. Default is 1.0.
505
+
506
+ :return: The mapped value in the range [1, 2].
507
+ :rtype: float
508
+ """
509
+ if x < 0 or x > x_max:
510
+ raise ValueError("x must be in the range [0, x_max]")
511
+ if x_max <= 0:
512
+ raise ValueError("x_max must be greater than 0")
513
+
514
+ # linear mapping formula
515
+ y = 1 + (x / x_max) * weight
516
+ return y
@@ -1,8 +1,9 @@
1
- from .RationalMechanism import RationalMechanism
2
- from .NormalizedLine import NormalizedLine
1
+ from itertools import product
3
2
 
4
3
  import numpy as np
5
- from itertools import product
4
+
5
+ from .NormalizedLine import NormalizedLine
6
+ from .RationalMechanism import RationalMechanism
6
7
 
7
8
 
8
9
  class CollisionFreeOptimization:
@@ -29,7 +30,10 @@ class CollisionFreeOptimization:
29
30
  parameters of the points, result of the optimization
30
31
  :rtype: tuple
31
32
  """
32
- from scipy.optimize import minimize
33
+ try:
34
+ from scipy.optimize import minimize # lazy import
35
+ except ImportError:
36
+ raise RuntimeError("Scipy import failed. Check its installation.")
33
37
 
34
38
  # get the axes represented as normalized lines
35
39
  if len(self.mechanism.factorizations) == 1:
@@ -226,10 +226,13 @@ class DualQuaternion:
226
226
  :return: DualQuaternion with rational elements
227
227
  :rtype: DualQuaternion
228
228
  """
229
- from sympy import Rational, nsimplify
229
+ from sympy import Rational
230
230
 
231
231
  if study_parameters is not None:
232
- rational_numbers = [nsimplify(x, tolerance=1*(-10)) for x in study_parameters]
232
+ rational_numbers = [x if isinstance(x, Expr)
233
+ else Rational(*x) if isinstance(x, tuple)
234
+ else Rational(x)
235
+ for x in study_parameters]
233
236
  else:
234
237
  rational_numbers = [Rational(1), Rational(0), Rational(0), Rational(0),
235
238
  Rational(0), Rational(0), Rational(0), Rational(0)]
@@ -1,6 +1,7 @@
1
- import numpy as np
2
1
  from typing import Union
3
2
 
3
+ import numpy as np
4
+
4
5
  from .RationalMechanism import RationalMechanism
5
6
 
6
7
 
@@ -3,7 +3,8 @@ from warnings import warn
3
3
 
4
4
  import biquaternion_py
5
5
  import numpy as np
6
- import sympy as sp
6
+
7
+ from sympy import Symbol, Rational
7
8
 
8
9
  from .DualQuaternion import DualQuaternion
9
10
  from .MotionFactorization import MotionFactorization
@@ -45,7 +46,7 @@ class FactorizationProvider:
45
46
 
46
47
  :warning: If the given curve has not only rational numbers as input.
47
48
  """
48
- t = sp.Symbol("t")
49
+ t = Symbol("t")
49
50
 
50
51
  if isinstance(curve, RationalCurve):
51
52
  bi_quat = biquaternion_py.BiQuaternion(curve.extract_expressions())
@@ -58,7 +59,7 @@ class FactorizationProvider:
58
59
  poly_coeffs = bi_poly.all_coeffs()
59
60
  for i in range(len(poly_coeffs)):
60
61
  for j in range(len(poly_coeffs[i].args)):
61
- if not isinstance(poly_coeffs[i].args[j], sp.Rational):
62
+ if not isinstance(poly_coeffs[i].args[j], Rational):
62
63
  warn('The given curve has not only rational numbers as input. The factorization will be performed with floating point numbers, but may be instable.')
63
64
  break
64
65
 
@@ -92,7 +93,7 @@ class FactorizationProvider:
92
93
  'as input. The factorization will be performed with floating '
93
94
  'point numbers, but may be instable.')
94
95
 
95
- t = sp.Symbol("t")
96
+ t = Symbol("t")
96
97
 
97
98
  bi_poly = t - biquaternion_py.BiQuaternion(factorization.dq_axes[0].array())
98
99
  for i in range(1, factorization.number_of_factors):
@@ -147,7 +148,7 @@ class FactorizationProvider:
147
148
  :return: The rotation axis of the factor.
148
149
  :rtype: DualQuaternion
149
150
  """
150
- t = sp.Symbol("t")
151
+ t = Symbol("t")
151
152
  t_dq = DualQuaternion([t, 0, 0, 0, 0, 0, 0, 0])
152
153
 
153
154
  factor_dq = DualQuaternion(factor.poly.coeffs)
@@ -1,8 +1,7 @@
1
1
  import numpy as np
2
- from scipy.optimize import minimize
3
2
 
4
- from .PointHomogeneous import PointHomogeneous
5
3
  from .AffineMetric import AffineMetric
4
+ from .PointHomogeneous import PointHomogeneous
6
5
 
7
6
 
8
7
  class MiniBall:
@@ -55,6 +54,14 @@ class MiniBall:
55
54
  return PointHomogeneous(center), radius_squared
56
55
 
57
56
  def get_ball_minimize(self):
57
+ """
58
+ Find the smallest ball containing all given points using optimization
59
+ """
60
+ try:
61
+ from scipy.optimize import minimize # lazy import
62
+ except ImportError:
63
+ raise RuntimeError("Scipy import failed. Check the package installation.")
64
+
58
65
  def objective_function(x):
59
66
  """
60
67
  Objective function to minimize the squared radius r^2 of the ball
@@ -1,12 +1,16 @@
1
- import numpy as np
2
- from scipy.optimize import minimize
3
1
  from typing import Union
4
2
 
5
- from .DualQuaternion import DualQuaternion
3
+ import numpy as np
4
+
6
5
  from .AffineMetric import AffineMetric
6
+ from .DualQuaternion import DualQuaternion
7
7
  from .PointHomogeneous import PointHomogeneous
8
8
  from .RationalCurve import RationalCurve
9
9
 
10
+ try:
11
+ from scipy.optimize import minimize # lazy import, optional dependency
12
+ except ImportError:
13
+ minimize = None
10
14
 
11
15
  ### NOT YET in the documentation ### TODO: add to docs
12
16