diff --git a/CHANGELOG.md b/CHANGELOG.md index bed568b..466c89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ the [Deprecations](https://hugofara.github.io/pylinkage/deprecations.html) page. ### Fixed +- **`kinematic_minimization` / `kinematic_maximization` score the linkage the + caller will simulate.** The wrapper first swept one revolution in 12 long + strides to reject the unbuildable early, then recorded the loci in a fine + run — which started from wherever the sweep had left the linkage. A long + stride can hop over a position the linkage cannot take, or onto the other + assembly branch past a toggle, so the score certified a mechanism that + raised `UnbuildableError` as soon as it was stepped from its initial + position; `docs/examples/fourbar_linkage.py` did so on about half its + runs. The sweep is gone: an `ArcCrank`, a `LinearActuator` or a + `Mechanism` driver keeps its phase as internal state that `set_coords` + cannot rewind, so the recorded run has to be the first thing that moves. + It now covers a complete cycle: it was 96 steps at `dt=1` since 2021 + whatever the crank speed, a quarter turn for the default crank of one + degree per step, so a fitness read the locus of a quarter of the cycle. + It keeps `dt=1`, the stride `step()` and the visualizers use, because + which branch the solver follows past a toggle depends on the stride. An + evaluation therefore costs `get_rotation_period()` steps (at least 96): + 360 for the default crank, and the least common multiple of the periods + when several actuators drive the linkage. +- The four-bar example's crank turns in a whole number of steps, so the + animation samples the crank angles the fitness scored, and the example + puts the linkage back in its initial position before animating the + optimum, the position every particle was scored from. + - **A slider-crank survives `Linkage.to_hypergraph()` → `to_mechanism()`** (#56). `from_sim_linkage` now tags the output node of an `RRPDyad` or `PPDyad` as `JointType.PRISMATIC`, so `compute_mobility` counts it as a diff --git a/docs/examples/fourbar_linkage.py b/docs/examples/fourbar_linkage.py index 3a5d118..20389eb 100644 --- a/docs/examples/fourbar_linkage.py +++ b/docs/examples/fourbar_linkage.py @@ -10,6 +10,8 @@ @author: HugoFara """ +from math import tau + import pylinkage as pl from pylinkage.actuators import Crank from pylinkage.components import Ground @@ -23,11 +25,12 @@ def define_linkage() -> Linkage: A = Ground(0.0, 0.0, name="A") D = Ground(3.0, 0.0, name="D") - # Driver crank + # Driver crank. A whole number of steps per turn (here 20) means the + # fitness and the animation sample the very same crank angles. crank = Crank( anchor=A, radius=1.0, - angular_velocity=0.31, + angular_velocity=tau / 20, name="B", ) @@ -98,7 +101,8 @@ def main() -> None: )[0] print("Score after particle swarm optimization:", result.score) - # Visualize the optimized linkage. + # Visualize the optimized linkage, from the position it was scored at. + my_linkage.set_coords(init_pos) my_linkage.set_constraints(list(result.dimensions)) pl.show_linkage(my_linkage) diff --git a/src/pylinkage/linkage/analysis.py b/src/pylinkage/linkage/analysis.py index ecbefcf..76027f8 100644 --- a/src/pylinkage/linkage/analysis.py +++ b/src/pylinkage/linkage/analysis.py @@ -21,7 +21,10 @@ def kinematic_default_test( """Standard run for any linkage before a complete fitness evaluation. This decorator makes a kinematic simulation, before passing the loci to the - decorated function. + decorated function: one complete cycle of the linkage from its initial + position, at the stride ``step()`` uses (``dt=1``), with at least 96 + frames. An evaluation therefore costs ``linkage.get_rotation_period()`` + steps, the least common multiple of the actuators' periods. Args: func: Fitness function to be decorated. @@ -49,14 +52,14 @@ def wrapper( linkage.set_coords(init_pos) linkage.set_constraints(params) try: - points = 12 + # One complete cycle, and at least 96 frames of it, at the + # natural stride: dt=1 is what ``step()`` and the visualizers + # use, and which assembly branch the solver follows past a + # toggle position depends on the stride. No quick sweep in + # long strides beforehand: it would leave the actuators + # elsewhere in their cycle, and there is no way to rewind them. n = linkage.get_rotation_period() - # Complete revolution with 12 points - tuple(tuple(i) for i in linkage.step(iterations=points + 1, dt=n / points)) - # Again with n points, and at least 12 iterations - n = 96 - factor = int(points / n) + 1 - loci = tuple(tuple(i) for i in linkage.step(iterations=n * factor, dt=1 / factor)) + loci = tuple(tuple(i) for i in linkage.step(iterations=max(96, n))) except UnbuildableError: return error_penalty else: diff --git a/tests/docs/conftest.py b/tests/docs/conftest.py index a884ad6..bb91879 100644 --- a/tests/docs/conftest.py +++ b/tests/docs/conftest.py @@ -55,4 +55,5 @@ def run_script(path: Path, cwd: Path) -> None: ) if result.returncode != 0: tail = "\n".join(result.stderr.splitlines()[-40:]) - pytest.fail(f"{path.relative_to(REPO)} exited {result.returncode}:\n{tail}") + shown = path.relative_to(REPO) if path.is_relative_to(REPO) else path.name + pytest.fail(f"{shown} exited {result.returncode}:\n{tail}") diff --git a/tests/linkage/test_analysis.py b/tests/linkage/test_analysis.py index c6bf217..a6be7f3 100644 --- a/tests/linkage/test_analysis.py +++ b/tests/linkage/test_analysis.py @@ -14,7 +14,7 @@ import numpy as np import pytest -from pylinkage.actuators import Crank +from pylinkage.actuators import ArcCrank, Crank from pylinkage.components import Ground from pylinkage.dyads import RRRDyad from pylinkage.linkage.analysis import ( @@ -300,3 +300,56 @@ def fitness(linkage, params, init_pos, loci): params = list(lk.get_constraints()) assert wrapped(lk, params) == 7.0 assert penalty_raised["count"] == 1 + + +def _make_arc_fourbar() -> Linkage: + """Like ``_make_fourbar`` with an ``ArcCrank``, whose phase is internal state.""" + o1 = Ground(0.0, 0.0, name="O1") + o2 = Ground(3.0, 0.0, name="O2") + crank = ArcCrank( + anchor=o1, radius=1.0, arc_start=0.0, arc_end=math.pi, angular_velocity=0.1, name="crank" + ) + rocker = RRRDyad( + anchor1=crank.output, anchor2=o2, distance1=2.5, distance2=2.0, name="rocker" + ) + return Linkage([o1, o2, crank, rocker], name="ArcFourBar") + + +@pytest.mark.parametrize("make", [_make_fourbar, _make_arc_fourbar]) +def test_kinematic_default_test_loci_start_from_the_initial_position(make): + """The recorded run is the one the caller gets from ``init_pos``. + + Its first frame is the first step of a fresh linkage. An + ``ArcCrank`` keeps its phase as internal state that ``set_coords`` + cannot rewind, so no sweep may run before the recorded one. + """ + seen = {} + + def fitness(linkage, params, init_pos, loci): + seen["loci"] = loci + return 0.0 + + lk = make() + params = list(lk.get_constraints()) + kinematic_default_test(fitness, error_penalty=float("inf"))(lk, params, lk.get_coords()) + + first_step = tuple(tuple(p) for p in make().step(iterations=1))[0] + assert np.allclose(np.array(seen["loci"][0], dtype=float), np.array(first_step, dtype=float)) + + +@pytest.mark.parametrize( + "angular_velocity, expected_frames", + [(0.1, 96), (math.tau / 360, 360)], +) +def test_kinematic_default_test_loci_cover_one_revolution(angular_velocity, expected_frames): + """At least one full turn at dt=1, and at least 96 frames of it.""" + seen = {} + + def fitness(linkage, params, init_pos, loci): + seen["loci"] = loci + return 0.0 + + lk = _make_fourbar() + lk.components[2].angular_velocity = angular_velocity + kinematic_default_test(fitness, error_penalty=float("inf"))(lk, list(lk.get_constraints())) + assert len(seen["loci"]) == expected_frames