diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b308db..e76a6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2-alpha] - 2021-07-07 +### Changed + - The ``step`` function execution speed has been increased by 25% when ``return_res`` is ``True``! Small performance improvement when ``return_res`` is ``False``. + - The ``size`` argument of ``step`` function is now known as ``witdh``. + - We now require pylinkage>=0.4.0. + +### Fixed + - Files in ``leggedsnake/examples/`` were not included in the PyPi package. + - The example was incompatible with [pylinkage](https://pypi.org/project/pylinkage/) 0.4.0. + - Test suite was unusable by tox. + - Tests fixed. + +### Security + - Tests with `tox.ini`` now include Python 3.9 and Flake 8. + ## [0.1.1-alpha] - 2021-06-26 ### Added - The example file ``examples/strider.py`` is now shipped with the Python package. diff --git a/LICENSE b/LICENSE index f99e716..162e278 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ MIT License +=========== Copyright (c) 2021 Hugo FARAJALLAH diff --git a/README.md b/README.md index 631e417..40e7860 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ +[![PyPI version fury.io](https://badge.fury.io/py/leggedsnake.svg)](https://pypi.python.org/pypi/leggedsnake/) # leggedsnake -This package aims to provide reliable computation techniques in Python to build, simulate and optimize planar [leg mechanisms](https://en.wikipedia.org/wiki/Leg_mechanism). It is divided in three main parts: +LeggedSnake is a Python library providing reliable computationnal techniques to build, simulate and optimize planar [leg mechanisms](https://en.wikipedia.org/wiki/Leg_mechanism). It is divided in three main parts: * Linkage conception in simple Python and kinematic optimization relying on [pylinkage](https://github.com/HugoFara/pylinkage). * Leg mechanism definition, with ``Walker`` heriting from the ``Linkage`` class. * Dynamic simulation and its optimization thanks to genetic algorithms. @@ -13,6 +14,8 @@ The package is hosted on PyPi as [leggedsnake](https://pypi.org/project/leggedsn ### Setting up Virtual Environment We provide an [environment.yml](https://github.com/HugoFara/leggedsnake/blob/master/environment.yml) file for conda. Use ``conda env update --file environment.yml --name leggedsnake-env`` to install the requirements in a separate environment. +If you are looking for a development version, check the GitHub repo under [HugoFara/leggedsnake](https://github.com/HugoFara/leggedsnake). + ## Requirements Python 3, numpy for calculation, matplotlib for drawing, and standard libraries. @@ -47,10 +50,10 @@ my_walker = Walker( ### Kinematic optimization using Particle Swarm Optimization (PSO) No change compared to a classic linkage optimization. You should use the ``step`` and ``stride`` method from the [utility module](https://github.com/HugoFara/leggedsnake/blob/master/leggedsnake/utility.py) as fitness functions. This set of rules should work well for a stride **maximisation** problem: -#. Rebuild the Walker with the provided set of dimensions, and do a complete turn. -#. If the Walker raise an UnbuildableError, its score is 0 (or ``- float('inf')`` if you use other evaluation functions. -#. Verify if it can pass a certain obstacke using ``step`` function. If not, its score is 0. -#. Eventually mesure the length of its stide with the ``stride`` function. Return this length as its score. +1. Rebuild the Walker with the provided set of dimensions, and do a complete turn. +1. If the Walker raise an UnbuildableError, its score is 0 (or ``- float('inf')`` if you use other evaluation functions. +1. Verify if it can pass a certain obstacke using ``step`` function. If not, its score is 0. +1. Eventually mesure the length of its stide with the ``stride`` function. Return this length as its score. ### Dynamic Optimization using Genetic Algorithm (GA) Kinematic optimization is fast, however it can return weird results, and it has no sense of gravity while walking heavily relies on gravity. This is why you may need to use dynamic optimization thanks to [Pymunk](http://www.pymunk.org/en/latest/index.html). However the calculation is much more slower, and you can no longer tests millions of linkages as in PSO (or you will need time). This is why we use [genetic algorithm](https://en.wikipedia.org/wiki/Genetic_algorithm), because it can provide good results with less parents. diff --git a/environment.yml b/environment.yml index 14812c6..eadff63 100644 --- a/environment.yml +++ b/environment.yml @@ -3,13 +3,13 @@ channels: - conda-forge - defaults dependencies: - - matplotlib>=3.3.4=py38h06a4308_0 - - matplotlib-base>=3.3.4=py38h62a2d02_0 - - numpy>=1.20.2=py38h2d18471_0 - - numpy-base>=1.20.2=py38hfae3a4d_0 - - numpydoc>=1.1.0=pyhd3eb1b0_1 + - matplotlib>=3.3.4 + - matplotlib-base>=3.3.4 + - numpy>=1.20.2 + - numpy-base>=1.20.2 + - numpydoc>=1.1.0 - pymunk>=6.0.0 - pip - pip: - pygad>=2.10.0 - - pylinkage + - pylinkage>=0.4.0 diff --git a/leggedsnake/dynamiclinkage.py b/leggedsnake/dynamiclinkage.py index 75dc322..c3b8634 100644 --- a/leggedsnake/dynamiclinkage.py +++ b/leggedsnake/dynamiclinkage.py @@ -86,7 +86,7 @@ def __generate_body__(self, index=0): if ( hasattr(self, 'joint' + sindex) and isinstance(getattr(self, 'joint' + sindex), linkage.Joint) - ): + ): body = pm.Body() body.mass = 1 parent_pos = pm.Vec2d(*getattr(self, 'joint' + sindex).coord()) @@ -205,7 +205,7 @@ def __init__(self, x=0, y=0, joint0=None, space=None, if ( isinstance(self.joint0, linkage.Joint) and isinstance(self.joint1, linkage.Joint) - ): + ): linkage.Fixed.reload(self) if self.joint0 is not None: self.set_anchor_a(self.joint0, self.r, self.angle) @@ -284,11 +284,11 @@ def __init__(self, x=0, y=0, joint0=None, space=None, joint0=joint0, joint1=joint1, name=name, distance0=distance0, distance1=distance1 - ) + ) DynamicJoint.__init__( self, space=space, radius=radius, density=density, - shape_filter=shape_filter) - + shape_filter=shape_filter + ) if self.joint0 is not None: self.set_anchor_a(self.joint0, self.r0) if self.joint1 is not None: @@ -426,7 +426,7 @@ def __generate_body__(self): """Generate the crank body only.""" if hasattr(self, 'joint0') and isinstance(self.joint0, linkage.Joint): body = pm.Body() - body.position = (pm.Vec2d(*self.coord()) + self.joint0.coord())/2 + body.position = (pm.Vec2d(*self.coord()) + self.joint0.coord()) / 2 seg = self.__generate_link__(body, self.joint0.coord()) self._a = self._b = body self._anchor_b = body.world_to_local(self.coord()) @@ -549,32 +549,52 @@ def convert_to_dynamic_joints(self, joints): raise Exception('Linkage {} Space not defined yet!'.format(self)) dynajoints = [] conversion_dict = {} - common = {'space': self.space, 'radius': self._thickness, - 'density': self.density, 'shape_filter': self.filter} + common = { + 'space': self.space, + 'radius': self._thickness, + 'density': self.density, + 'shape_filter': self.filter + } for joint in joints: common.update({'x': joint.x, 'y': joint.y, 'name': joint.name}) if isinstance(joint, DynamicJoint): djoint = joint elif isinstance(joint, linkage.Static): djoint = Nail(body=self.body, **common) - elif isinstance(joint, linkage.Fixed): - djoint = PinUp(distance=joint.r, angle=joint.angle, - joint0=conversion_dict[joint.joint0], - joint1=conversion_dict[joint.joint1], - **common) - elif isinstance(joint, linkage.Crank): - djoint = Motor( - joint0=conversion_dict[joint.joint0], - distance=joint.r, angle=joint.angle, - **common - ) - elif isinstance(joint, linkage.Pivot): - djoint = DynamicPivot( - joint0=conversion_dict[joint.joint0], - joint1=conversion_dict[joint.joint1], - distance0=joint.r0, distance1=joint.r1, - **common - ) + # Joints with at least one reference + else: + """ + Useless while qe don't support quick joint definition + if ( + isinstance(joint.joint0, linkage.Static) + and joint.joint0 not in conversion_dict + ): + conversion_dict[joint.joint0] = joint.joint0 + if ( + hasattr(joint, "joint1") + and isinstance(joint.joint1, linkage.Static) + and joint.joint1 not in conversion_dict + ): + conversion_dict[joint.joint1] = joint.joint1 + """ + if isinstance(joint, linkage.Fixed): + djoint = PinUp(distance=joint.r, angle=joint.angle, + joint0=conversion_dict[joint.joint0], + joint1=conversion_dict[joint.joint1], + **common) + elif isinstance(joint, linkage.Crank): + djoint = Motor( + joint0=conversion_dict[joint.joint0], + distance=joint.r, angle=joint.angle, + **common + ) + elif isinstance(joint, linkage.Pivot): + djoint = DynamicPivot( + joint0=conversion_dict[joint.joint0], + joint1=conversion_dict[joint.joint1], + distance0=joint.r0, distance1=joint.r1, + **common + ) dynajoints.append(djoint) conversion_dict[joint] = djoint self.joints = tuple(dynajoints) @@ -587,7 +607,7 @@ def build_load(self, position, load_mass): segs = [] for i, vertex in enumerate(vertices): segs.append(pm.Segment(load, vertex, - vertices[(i+1) % len(vertices)], + vertices[(i + 1) % len(vertices)], self._thickness)) segs[-1].density = self.density # Rigodbodies in this group won't collide diff --git a/leggedsnake/examples/strider.py b/leggedsnake/examples/strider.py index f31b4a3..09fbcf6 100644 --- a/leggedsnake/examples/strider.py +++ b/leggedsnake/examples/strider.py @@ -143,7 +143,7 @@ def complete_strider(constraints, prev): name="Strider" ) strider.set_coords(prev) - strider.set_num_constraints(constraints) + strider.set_num_constraints(constraints, flat=False) return strider @@ -207,7 +207,7 @@ def strider_builder(constraints, prev, n_leg_pairs=1, minimal=False): prev.pop(-1) constraints.pop(-1) strider.set_coords(prev) - strider.set_num_constraints(constraints) + strider.set_num_constraints(constraints, flat=False) if n_leg_pairs > 1: strider.add_legs(n_leg_pairs - 1) return strider @@ -234,7 +234,7 @@ def show_physics(linkage, prev=None, debug=False, duration=40, save=False): def sym_stride_evaluator(linkage, dims, pos): """Give score to each dimension set for symmetric strider.""" - linkage.set_num_constraints(param2dimensions(dims)) + linkage.set_num_constraints(param2dimensions(dims), flat=False) linkage.set_coords(pos) try: points = 12 @@ -437,7 +437,7 @@ def fitness(dna, linkage_hollow): List of two elements: score (a float), and initial positions. Score is -float('inf') when mechanism building is impossible. """ - linkage_hollow.set_num_constraints(param2dimensions(dna[0])) + linkage_hollow.set_num_constraints(param2dimensions(dna[0]), flat=False) linkage_hollow.rebuild(dna[2]) # Check if mecanism is buildable try: @@ -523,19 +523,19 @@ def show_optimized(linkage, data, n_show=10, duration=5, symmetric=True): if datum[0] == 0: continue if symmetric: - linkage.set_num_constraints(param2dimensions(datum[1])) + linkage.set_num_constraints(param2dimensions(datum[1]), flat=False) else: - linkage.set_num_constraints(datum[1]) + linkage.set_num_constraints(datum[1], flat=False) visu.show_linkage(linkage, prev=begin, title=str(datum[0]), duration=10) - -#from cProfile import run -strider = complete_strider(param2dimensions(param), begin) +#wu.step([(0, 0), (-1, 0), (-1, 1), (0, 1), (0, .5)], 0, .5) +from cProfile import run +#strider = complete_strider(param2dimensions(param), begin) strider = strider_builder(param2dimensions(param), begin, - n_leg_pairs=19, minimal=False) + n_leg_pairs=5, minimal=False) #o = swarm_optimizer(show=1, save_each=1, age=10, ite=10, blind_ite=10) -#run('swarm_optimizer(show=False, save_each=0, age=30, ite=400)') +run('sym_stride_evaluator(strider, param, begin)') #optimized_striders = wo.exhaustive_optimization( # sym_stride_evaluator, strider, param, delta_dim=.5) #optimized_striders = swarm_optimizer(strider, show=1, save_each=0, age=250, @@ -543,7 +543,7 @@ def show_optimized(linkage, data, n_show=10, duration=5, symmetric=True): #show_optimized(strider, optimized_striders) #strider.add_legs(3) #visu.show_linkage(strider, save=False, duration=10, iteration_factor=n) -show_physics(strider, debug=False, duration=40, save=False) +#show_physics(strider, debug=False, duration=40, save=False) #o = evolutive_optimizer( # strider, dims=param, prev=begin, pop=10, ite=100, init_pop=100, # save=False, startnstop=False) diff --git a/leggedsnake/tests/__init__.py b/leggedsnake/tests/__init__.py new file mode 100644 index 0000000..dd0474f --- /dev/null +++ b/leggedsnake/tests/__init__.py @@ -0,0 +1,5 @@ +""" +This module is the test suite of leggedsnake. + +It uses unit test. +""" \ No newline at end of file diff --git a/leggedsnake/tests/test_utility.py b/leggedsnake/tests/test_utility.py index 2e2603f..dc32e39 100644 --- a/leggedsnake/tests/test_utility.py +++ b/leggedsnake/tests/test_utility.py @@ -16,15 +16,23 @@ class TestStride(unittest.TestCase): locus = [(0, 0), (-1, 0), (-1, 1), (0, 1), (0, .5)] def test_minimal_stride(self): + """Test if we only get the three lowest points.""" result = stride(self.locus, height=.1) - self.assertEqual(len(result), 2) - self.assertEqual(result, self.locus[:2]) + self.assertEqual(len(result), 3) + self.assertEqual(result, self.locus[-1:] + self.locus[:2]) def test_ambiguous_stride(self): + """ + Test if we only get all points but not the highest. + + A point on the limit shoud be discarded when some points are on the + limit. + """ result = stride(self.locus, height=.5) - self.assertEqual(result, self.locus[:2] + self.locus[-1:]) + self.assertEqual(result, self.locus[-2:] + self.locus[0:2]) def test_maximal_stride(self): + """Test if all points are retrivewed.""" result = stride(self.locus, height=2) self.assertEqual(result, self.locus) @@ -35,17 +43,21 @@ class TestStep(unittest.TestCase): locus = [(0, 0), (-1, 0), (-1, 1), (0, 1), (0, .5)] def test_minimal_step(self): - result = step(self.locus, height=0, size=.5) + """Test if we can pass an obstacle small enough.""" + result = step(self.locus, height=0, width=.5) self.assertTrue(result) def test_ambiguous_step(self): - result = step(self.locus, height=1, size=1) + """Test if successfully to pass an obstacle of the size of the locus.""" + result = step(self.locus, height=1, width=1) self.assertTrue(result) - def test_maximal_step(self): - result = stride(self.locus, height=1, size=2) + def test_streched_step(self): + """Test if we fail to pass an obstacle to big.""" + result = step(self.locus, height=1, width=2) self.assertFalse(result) - -if __name__ == '__main__': - unittest.main() + def test_dwarf_step(self): + """Test if we fail to pass an obstacle to high.""" + result = step(self.locus, height=2, width=.5) + self.assertFalse(result) diff --git a/leggedsnake/utility.py b/leggedsnake/utility.py index 913f26b..37288b5 100644 --- a/leggedsnake/utility.py +++ b/leggedsnake/utility.py @@ -11,6 +11,8 @@ make study of planar mecanisms easy. """ +from pylinkage.geometry import bounding_box + try: # Used to read GeoGebra file import zipfile as zf @@ -55,12 +57,12 @@ def stride(point, height): A step is a set of points of the locus, adjacents, with limited height difference, and containing the lowest point of the locus. - Please not that a step cannot be inclined. The resultut can be irrelevant + Please not that a step cannot be inclined. The result can be irrelevant for locus containing Diracs. """ n_points = len(point) # Index of lowest point - p_min = min(enumerate(n_points), key=lambda elem: elem[1][1])[0] + p_min = min(enumerate(point), key=lambda elem: elem[1][1])[0] left, right = p_min - 1, p_min + 1 for right in range(p_min + 1, n_points + p_min + 1): @@ -77,25 +79,34 @@ def stride(point, height): return point[left:] + point[:right % n_points] -def step(points, height, size, return_res=False, y_min=None, acc=[]): +def step(points, height, width, return_res=False, y_min=None, acc=[]): """ - Return if a step can satisfy overcross an obstacle. + Return if a step can overcross an obstacle during locus. Arguments --------- - point: point to follow - height: obstacle's height - size: obstacle's width + points : list[list[int]] + locus as a list of point coordinates. + height : float + obstacle's height + width : float + obstacle's width return_res: - If True: return the set of points that pass obstacle (slower). - If False: return if the obstacle can be passed - y_min: lowest ordinate (faster if provided) + The default is False + y_min : lowest ordinate in the locus (faster if provided) """ if not points: return acc + # We compute the locus bounding box + bb = bounding_box(points) + # Quick sort for unfit systmes + if bb[2] - bb[0] < height or bb[1] - bb[3] < width: + return False # Origin of ordinates, for computing obstacle's height if y_min is None: - y_min = min(i[1] for i in points) + y_min = bb[0] # Index of first point passing obstacle for i, point in enumerate(points): if point[1] - y_min >= height: @@ -107,7 +118,7 @@ def step(points, height, size, return_res=False, y_min=None, acc=[]): for k, point in enumerate(points[i:]): if point[1] - y_min < height: break - if abs(point[0] - x) >= size: + if abs(point[0] - x) >= width: ok = True if not return_res: break @@ -116,7 +127,7 @@ def step(points, height, size, return_res=False, y_min=None, acc=[]): return ok if ok: # We use an accumulator to keep track of points passing conditions - return step(points[k + 1:], height, size, return_res, y_min, + return step(points[k + 1:], height, width, return_res, y_min, acc + [points[i:k]]) - return step(points[k + 1:], height, size, return_res, y_min, acc) + return step(points[k + 1:], height, width, return_res, y_min, acc) diff --git a/leggedsnake/walker.py b/leggedsnake/walker.py index ec32863..ce015e6 100644 --- a/leggedsnake/walker.py +++ b/leggedsnake/walker.py @@ -32,10 +32,11 @@ def add_legs(self, number=2): iterations_factor = int(12 / (number + 1)) + 1 # We use at least 12 steps to avoid bad initial positions new_positions = tuple( - self.step((number + 1) * iterations_factor, - self.get_rotation_period() / (number + 1) - / iterations_factor) - )[iterations_factor-1:-1:iterations_factor] + self.step( + (number + 1) * iterations_factor, + self.get_rotation_period() / (number + 1) / iterations_factor + ) + )[iterations_factor - 1:-1:iterations_factor] # Because we have per-leg iterations # we have to save crank informations crank_memory = dict(zip(self._cranks, self._cranks)) @@ -44,8 +45,13 @@ def add_legs(self, number=2): equiv = {None: None} # For each new joint for pos, j in zip(positions, self._solve_order): - common = {'x': pos[0], 'y': pos[1], 'joint0': equiv[j.joint0], - 'name': j.name + ' (copy {})'.format(i)} + if isinstance(j.joint0, lk.Static) and j.joint0 not in equiv: + equiv[j.joint0] = j.joint0 + common = { + 'x': pos[0], 'y': pos[1], + 'joint0': equiv[j.joint0], + 'name': j.name + ' ({})'.format(i) + } if isinstance(j, lk.Static): new_j = j elif isinstance(j, lk.Crank): @@ -57,15 +63,19 @@ def add_legs(self, number=2): crank_memory[j] = new_j new_joints.append(new_j) else: + # Static joints not always included in joints + if isinstance(j.joint1, lk.Static) and j.joint1 not in equiv: + equiv[j.joint1] = j.joint1 common['joint1'] = equiv[j.joint1] + if isinstance(j, lk.Fixed): new_j = lk.Fixed( **common, distance=j.r, angle=j.angle - ) + ) elif isinstance(j, lk.Pivot): new_j = lk.Pivot( **common, distance0=j.r0, distance1=j.r1 - ) + ) new_joints.append(new_j) equiv[j] = new_j self.joints += tuple(new_joints) diff --git a/requirements.txt b/requirements.txt index 04798b5..9dc83fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ numpy>=1.20.2 pygad>=2.10.0 pymunk>=6.0.0 -pylinkage +pylinkage>=0.4.0 diff --git a/setup.cfg b/setup.cfg index 3b0b904..f300f07 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,12 @@ [metadata] name = leggedsnake -version = 0.1.1 +version = 0.1.2 author = Hugo Farajallah description = Simulate and optimize planar leg mechanisms using PSO and GA license = MIT License url = https://github.com/HugoFara/leggedsnake +project_urls= + Changelog=https://github.com/HugoFara/leggedsnake/blob/main/CHANGELOG.md long_description = file: README.md, CHANGELOG.md long_description_content_type = text/markdown license_file = LICENSE diff --git a/tox.ini b/tox.ini index aacff05..a086b52 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37,py38 +envlist = py37,py38,py39,flake8 [testenv] commands = python -m unittest discover . -p "test_*.py"