From d16f2ad012afc7533ae4fdaf0c555a2d09ca7389 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Fri, 18 Sep 2026 10:32:22 +0200 Subject: [PATCH 1/2] fix(optimization): score the linkage from its initial position, over a full turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kinematic_minimization / kinematic_maximization wrapper first sweeps one revolution in 12 long strides to reject the unbuildable early, then records the loci in a fine run — which started from wherever the coarse sweep had left the joints. 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. PSO drives a design to the edge of buildability, so docs/examples/fourbar_linkage.py hit this on about half its runs and the "Executable docs" workflow with it. The fine run now restarts from the initial position. It also covers a complete revolution. 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: a finer grid certifies a different mechanism. The example's crank now turns in a whole number of steps, so the 360-frame animation samples exactly 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. tests/docs/conftest.py reported a tutorial failure as a ValueError from relative_to() on the temporary script path, hiding the real traceback. --- CHANGELOG.md | 19 ++++++++++++++ docs/examples/fourbar_linkage.py | 10 +++++--- src/pylinkage/linkage/analysis.py | 14 ++++++++--- tests/docs/conftest.py | 3 ++- tests/linkage/test_analysis.py | 41 +++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed568b..cd62f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ 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 sweeps one revolution in 12 long + strides to reject the unbuildable early, then records the loci in a fine + run — which started from wherever the coarse sweep had left the joints. + 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 fine run now restarts from the initial position. It also covers + a complete revolution: 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. +- 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..8d5236a 100644 --- a/src/pylinkage/linkage/analysis.py +++ b/src/pylinkage/linkage/analysis.py @@ -48,15 +48,21 @@ def wrapper( if init_pos is not None: linkage.set_coords(init_pos) linkage.set_constraints(params) + start = linkage.get_coords() try: points = 12 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)) + # Its long strides may have hopped over a position the linkage + # cannot take, or onto the other assembly branch. Score the + # fine run from the position the caller will simulate from. + linkage.set_coords(start) + # Again at the natural stride (dt=1, the one ``step()`` and the + # visualizers use: which assembly branch the solver follows past + # a toggle position depends on the stride), for at least one + # complete revolution and at least 96 points. + 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..63ed224 100644 --- a/tests/linkage/test_analysis.py +++ b/tests/linkage/test_analysis.py @@ -300,3 +300,44 @@ def fitness(linkage, params, init_pos, loci): params = list(lk.get_constraints()) assert wrapped(lk, params) == 7.0 assert penalty_raised["count"] == 1 + + +def test_kinematic_default_test_loci_start_from_the_initial_position(): + """The recorded run restarts where the caller will simulate from. + + The 12-stride sweep that precedes it leaves the joints elsewhere; + the first recorded frame must be the first step from ``init_pos``. + """ + seen = {} + + def fitness(linkage, params, init_pos, loci): + seen["loci"] = loci + return 0.0 + + lk = _make_fourbar() + params = list(lk.get_constraints()) + init_pos = lk.get_coords() + kinematic_default_test(fitness, error_penalty=float("inf"))(lk, params, init_pos) + + lk.set_coords(init_pos) + lk.set_constraints(params) + first_step = tuple(tuple(p) for p in lk.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 From a3562bf1674a2922a73a118dc3d186a8b90e107e Mon Sep 17 00:00:00 2001 From: HugoFara Date: Fri, 18 Sep 2026 12:42:49 +0200 Subject: [PATCH 2/2] fix(optimization): drop the coarse sweep, the actuators cannot be rewound Review of #62: set_coords() rewinds joint positions but not actuator state. An ArcCrank keeps _angle and _direction, a LinearActuator _extension and _direction, a Mechanism driver its current_angle, and none of them is derived from the joint's coordinates as a Crank's angle is. After the 12-stride sweep, the recorded run started with the actuator a full cycle ahead of joints put back at their start, which is neither the caller's run nor the old continuous one. There is no API to rewind an actuator, and the recorded run now covers the whole cycle, so the sweep has no job left: remove it and let the recorded run be the first thing that moves. The test now covers an ArcCrank too. The docstring and changelog state the cost of an evaluation: get_rotation_period() steps, at least 96. --- CHANGELOG.md | 29 +++++++++++++++----------- src/pylinkage/linkage/analysis.py | 23 +++++++++------------ tests/linkage/test_analysis.py | 34 +++++++++++++++++++++---------- 3 files changed, 50 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd62f63..466c89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,19 +12,24 @@ 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 sweeps one revolution in 12 long - strides to reject the unbuildable early, then records the loci in a fine - run — which started from wherever the coarse sweep had left the joints. - 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 + 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 fine run now restarts from the initial position. It also covers - a complete revolution: 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. + 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 diff --git a/src/pylinkage/linkage/analysis.py b/src/pylinkage/linkage/analysis.py index 8d5236a..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. @@ -48,20 +51,14 @@ def wrapper( if init_pos is not None: linkage.set_coords(init_pos) linkage.set_constraints(params) - start = linkage.get_coords() 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)) - # Its long strides may have hopped over a position the linkage - # cannot take, or onto the other assembly branch. Score the - # fine run from the position the caller will simulate from. - linkage.set_coords(start) - # Again at the natural stride (dt=1, the one ``step()`` and the - # visualizers use: which assembly branch the solver follows past - # a toggle position depends on the stride), for at least one - # complete revolution and at least 96 points. loci = tuple(tuple(i) for i in linkage.step(iterations=max(96, n))) except UnbuildableError: return error_penalty diff --git a/tests/linkage/test_analysis.py b/tests/linkage/test_analysis.py index 63ed224..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 ( @@ -302,11 +302,26 @@ def fitness(linkage, params, init_pos, loci): assert penalty_raised["count"] == 1 -def test_kinematic_default_test_loci_start_from_the_initial_position(): - """The recorded run restarts where the caller will simulate from. +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") - The 12-stride sweep that precedes it leaves the joints elsewhere; - the first recorded frame must be the first step from ``init_pos``. + +@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 = {} @@ -314,14 +329,11 @@ def fitness(linkage, params, init_pos, loci): seen["loci"] = loci return 0.0 - lk = _make_fourbar() + lk = make() params = list(lk.get_constraints()) - init_pos = lk.get_coords() - kinematic_default_test(fitness, error_penalty=float("inf"))(lk, params, init_pos) + kinematic_default_test(fitness, error_penalty=float("inf"))(lk, params, lk.get_coords()) - lk.set_coords(init_pos) - lk.set_constraints(params) - first_step = tuple(tuple(p) for p in lk.step(iterations=1))[0] + 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))