Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions docs/examples/fourbar_linkage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
@author: HugoFara
"""

from math import tau

import pylinkage as pl
from pylinkage.actuators import Crank
from pylinkage.components import Ground
Expand All @@ -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",
)

Expand Down Expand Up @@ -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)

Expand Down
19 changes: 11 additions & 8 deletions src/pylinkage/linkage/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion tests/docs/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
55 changes: 54 additions & 1 deletion tests/linkage/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Loading