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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`orientation_resolution` on `path_generation()`**, replacing
`n_orientation_samples`. It is the number of angles sampled per free
orientation, so the cost model is now readable from the signature: the search
grid holds `orientation_resolution ** (n_precision_points - 1)` candidates.
The default of 6 reproduces the old default exactly.

- **A warning when path generation is about to be slow.** Cost grows
exponentially in the number of precision points, and nothing said so. Five
points reach a four-dimensional grid that can run for seconds and still
return nothing. Calls whose grid exceeds a thousand candidates now warn up
front, and the message is repeated in `SynthesisResult.warnings`.

- **`pylinkage.synthesis.BurmesterDyad`**, the new name for what was
`pylinkage.synthesis.Dyad`. Same class, same behaviour; the old name still
works and now warns.
Expand Down Expand Up @@ -58,6 +70,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Deprecated

- **`n_orientation_samples`, which never denoted a number of samples.** It was
folded into a per-axis grid resolution through
`max(6, round(n_samples ** (1 / free)))`, so the floor swallowed it: with four
precision points every value from 6 to 216 produced an identical search.
Measured on the README's points, 6, 12, 36 and 72 all took the same time and
returned the same ten solutions. It now emits a `DeprecationWarning`, is
translated through the old formula so no result moves, and is scheduled for
removal in 2.0.0. Use `orientation_resolution`.

Redefining it to mean what its name says — roughly that many candidates in
total — was implemented, measured and rejected: on three of six test point
sets the search then returned nothing where it had found ten. Reordering the
grid coarse-to-fine or shuffling it was also measured, in case truncation was
merely sampling a biased corner; neither changed the solutions found nor the
time taken. The dense grid earns its cost, so the search is unchanged and only
the name was wrong.

- **The three `Dyad` names that were not what they said.** Three unrelated
classes were reachable as `Dyad`, and only two were dyads at all:
`pylinkage.assur.Dyad` (an Assur group, unaffected and keeping its name),
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/path_generation_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def demo_walking_mechanism():
precision_points,
max_solutions=10,
require_grashof=True,
n_orientation_samples=48, # More samples for better coverage
orientation_resolution=8, # Finer orientation grid, for coverage
)

print(f"\nFound {len(result)} walking mechanism solution(s)")
Expand Down Expand Up @@ -169,7 +169,7 @@ def demo_straight_line_approximation():
precision_points,
max_solutions=5,
require_grashof=False, # Accept all Grashof types
n_orientation_samples=72,
orientation_resolution=8,
)

print(f"\nFound {len(result)} straight-line approximation(s)")
Expand Down
2 changes: 1 addition & 1 deletion docs/generate_paper_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def main() -> None:
print("Synthesizing four-bar linkages through target points...")
result = path_generation(
TARGET_POINTS,
n_orientation_samples=48,
orientation_resolution=8,
max_solutions=12,
require_grashof=True,
)
Expand Down
2 changes: 1 addition & 1 deletion docs/generate_synthesis_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ def main() -> None:
print("Synthesizing four-bar linkages...")
result = path_generation(
TARGET_POINTS,
n_orientation_samples=48,
orientation_resolution=8,
max_solutions=8,
require_grashof=True,
)
Expand Down
24 changes: 12 additions & 12 deletions docs/source/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,18 @@ implies more gently than the timings warrant.
```

```{note}
`n_orientation_samples` is **not** an effective control, despite its name and
what earlier versions of this page claimed. Measured on the four precision
points above, the values 6, 12, 36 and 72 all take the same time and return the
same ten solutions; on other point sets, lowering it returns *fewer* solutions
without being faster. The parameter sets a per-axis grid resolution that is
floored at 6, so it has no effect across most of its useful range.

Making it mean what its name says -- roughly that many candidate orientations in
total -- was measured and rejected: it loses solutions. On three of six test
point sets the search then returned nothing where it previously found ten. The
dense grid earns its cost, so only the name and the documentation were ever
wrong. Tracked in [#29](https://github.com/HugoFara/pylinkage/issues/29).
`n_orientation_samples` is deprecated in favour of `orientation_resolution`,
which is the per-axis grid resolution the old argument was silently folded
into. The grid holds `orientation_resolution ** (n_points - 1)` candidates, so
the exponential above is visible in the signature rather than discovered at
runtime. The default of 6 is unchanged, and old calls are translated, so no
result moves. See [Deprecations](deprecations.md).

Lowering the resolution is rarely the trade it looks like: a coarser grid
returns *no* solutions more often than it returns fewer. On three of six test
point sets, shrinking it took the result from ten solutions to zero. The dense
grid earns its cost. Use `max_solutions` to control the time; that one is
monotone.
```

## What is not benchmarked here
Expand Down
28 changes: 28 additions & 0 deletions docs/source/deprecations.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ whether an upgrade will affect you.
| `pylinkage.dyads.Dyad` | `pylinkage.components.Component` | 2.0.0 |
| `pylinkage.dyads.ConnectedDyad` | `pylinkage.components.ConnectedComponent` | 2.0.0 |
| `pylinkage.synthesis.Dyad` | `pylinkage.synthesis.BurmesterDyad` | 2.0.0 |
| `path_generation(n_orientation_samples=...)` | `orientation_resolution=` | 2.0.0 |

### Why the `Dyad` names are going away

Expand All @@ -54,6 +55,25 @@ three apart, so a link on one class would take you to another. Since
type on every `RRRDyad`, `RRPDyad`, `PPDyad` and `FixedDyad` pointed at a class
the code never referred to.

### Why `n_orientation_samples` is going away

It never denoted a number of samples. The value was folded into a per-axis grid
resolution through `max(6, round(n_samples ** (1 / free)))`, so the floor
swallowed it: with four precision points, every value from 6 to 216 produced the
same search. Measured on the README's points, `6`, `12`, `36` and `72` all took
the same time and returned the same ten solutions.

`orientation_resolution` is that per-axis number directly, so the cost model is
readable: the grid holds `orientation_resolution ** (n_points - 1)` candidates.
The default of 6 reproduces exactly what the old default did, and a value passed
to `n_orientation_samples` is translated through the old formula, so neither
changes any result.

Lowering the resolution is rarely a good trade. A coarser grid tends to return
*no* solutions rather than fewer -- on three of six test point sets, shrinking it
took the result from ten solutions to zero. Use `max_solutions` to control cost;
that one is monotone.

### Migrating

Renaming the import is the whole change — the objects are unchanged, and the
Expand All @@ -69,6 +89,14 @@ from pylinkage.components import Component, ConnectedComponent
from pylinkage.synthesis import BurmesterDyad
```

```python
# Before
path_generation(points, n_orientation_samples=36)

# After -- same search, and the cost model is now visible
path_generation(points, orientation_resolution=6)
```

`isinstance` checks keep working through the transition, because the deprecated
name and its replacement are the same object:

Expand Down
11 changes: 9 additions & 2 deletions src/pylinkage/synthesis/generalized.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
_dyads_to_fourbar,
_filter_solutions,
_generate_orientation_candidates,
_legacy_orientation_args,
_points_to_poses,
)
from .topology_types import GroupSynthesisResult, NBarSolution, PointPartition
Expand Down Expand Up @@ -93,8 +94,11 @@ def generalized_synthesis(

# Try a few orientation candidates
first_group_points = [precision_points[i] for i in partition[0]]
_resolution, _perturbations = _legacy_orientation_args(
min(n_orientation_samples, 12), len(first_group_points)
)
orientations_gen = _generate_orientation_candidates(
first_group_points, n_samples=min(n_orientation_samples, 12)
first_group_points, resolution=_resolution, n_perturbations=_perturbations
)

for orientations_tried, orientations in enumerate(orientations_gen):
Expand Down Expand Up @@ -500,10 +504,13 @@ def _synthesize_fourbar_as_nbar(

# Use a higher internal search limit — path_generation's max_solutions
# controls the search breadth, not just the output count.
_resolution, _perturbations = _legacy_orientation_args(
n_orientation_samples, len(precision_points)
)
result = path_generation(
precision_points,
max_solutions=max(max_solutions, 10),
n_orientation_samples=n_orientation_samples,
orientation_resolution=_resolution,
)

nbar_solutions: list[NBarSolution] = []
Expand Down
Loading
Loading