From 06c2fce017b05c5468a654d1ca3dd6633ad16bc8 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Fri, 4 Sep 2026 19:54:48 +0200 Subject: [PATCH] refactor(synthesis): replace n_orientation_samples with orientation_resolution n_orientation_samples never denoted a number of samples. It was folded into a per-axis grid resolution through per_axis = max(6, int(round(n_samples ** (1.0 / 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. orientation_resolution is that per-axis number directly, which makes the cost model readable from the signature: the grid holds orientation_resolution ** (n_points - 1) candidates. The default of 6 reproduces the old default exactly, and a value passed to the old argument is translated through the old formula, so no result moves. Verified identical on five point sets. The old name stays, warns, and is scheduled for removal in 2.0.0 under the policy in docs/source/deprecations.md. Two alternatives were implemented and rejected on measurement: - Redefining the parameter as a total candidate budget, which its name implies. On three of six point sets the search then returned nothing where it had found ten. - Reordering the grid coarse-to-fine, or shuffling it, in case a truncated lexicographic product was merely sampling a biased corner. Neither changed the solutions found nor the time taken, so candidates are spread evenly through the grid and there is no cheaper subset. The dense grid earns its cost. The search is unchanged; only the name was wrong. Also adds a warning when the grid exceeds a thousand candidates. 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. Sibling functions keep n_orientation_samples, where it genuinely bounds the number of orientations tried through their own loop counters. Their calls into the generator are translated so their behaviour is unchanged. --- CHANGELOG.md | 29 ++++ docs/examples/path_generation_demo.py | 4 +- docs/generate_paper_figure.py | 2 +- docs/generate_synthesis_animation.py | 2 +- docs/source/benchmarks.md | 24 ++-- docs/source/deprecations.md | 28 ++++ src/pylinkage/synthesis/generalized.py | 11 +- src/pylinkage/synthesis/path_generation.py | 126 +++++++++++++----- src/pylinkage/synthesis/six_bar.py | 14 +- tests/synthesis/test_core_extended.py | 16 +-- .../synthesis/test_orientation_resolution.py | 125 +++++++++++++++++ 11 files changed, 321 insertions(+), 60 deletions(-) create mode 100644 tests/synthesis/test_orientation_resolution.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cab2966..964645b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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), diff --git a/docs/examples/path_generation_demo.py b/docs/examples/path_generation_demo.py index 90716b7..fb79773 100644 --- a/docs/examples/path_generation_demo.py +++ b/docs/examples/path_generation_demo.py @@ -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)") @@ -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)") diff --git a/docs/generate_paper_figure.py b/docs/generate_paper_figure.py index e467447..050c34a 100644 --- a/docs/generate_paper_figure.py +++ b/docs/generate_paper_figure.py @@ -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, ) diff --git a/docs/generate_synthesis_animation.py b/docs/generate_synthesis_animation.py index ac133c0..952025a 100644 --- a/docs/generate_synthesis_animation.py +++ b/docs/generate_synthesis_animation.py @@ -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, ) diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md index 711cccf..e8ee618 100644 --- a/docs/source/benchmarks.md +++ b/docs/source/benchmarks.md @@ -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 diff --git a/docs/source/deprecations.md b/docs/source/deprecations.md index 906b6de..13f9916 100644 --- a/docs/source/deprecations.md +++ b/docs/source/deprecations.md @@ -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 @@ -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 @@ -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: diff --git a/src/pylinkage/synthesis/generalized.py b/src/pylinkage/synthesis/generalized.py index ff56892..82cc997 100644 --- a/src/pylinkage/synthesis/generalized.py +++ b/src/pylinkage/synthesis/generalized.py @@ -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 @@ -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): @@ -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] = [] diff --git a/src/pylinkage/synthesis/path_generation.py b/src/pylinkage/synthesis/path_generation.py index 4068535..3b89080 100644 --- a/src/pylinkage/synthesis/path_generation.py +++ b/src/pylinkage/synthesis/path_generation.py @@ -31,7 +31,9 @@ import math from collections.abc import Generator +from itertools import product from typing import TYPE_CHECKING +from warnings import warn import numpy as np from numpy.typing import NDArray @@ -50,6 +52,15 @@ if TYPE_CHECKING: from ..simulation import Linkage +#: Angles sampled per free orientation. The grid holds this many raised to the +#: power of (precision points - 1), so raising it is expensive quickly. +DEFAULT_ORIENTATION_RESOLUTION = 6 + +#: Hard ceiling on grid candidates, so that six or more precision points cannot +#: run unbounded. It does not bind for three to five points at the default +#: resolution, which is the documented working range. +MAX_ORIENTATION_CANDIDATES = 1296 + def _estimate_orientations_from_path( points: list[PrecisionPoint], @@ -83,9 +94,30 @@ def _estimate_orientations_from_path( return orientations +def _legacy_orientation_args(n_samples: int, n_points: int) -> tuple[int, int]: + """Translate the retired ``n_orientation_samples`` into its real effect. + + ``n_orientation_samples`` never denoted a number of samples. It was fed + through ``max(6, round(n_samples ** (1 / free)))`` to obtain a per-axis grid + resolution, and separately through ``max(6, n_samples // 3)`` to size the + perturbation sweep. This reproduces both exactly, so a caller passing the + old argument gets the search it always got. + + Args: + n_samples: The legacy ``n_orientation_samples`` value. + n_points: Number of precision points in the problem. + + Returns: + The equivalent ``(resolution, n_perturbations)`` pair. + """ + free = max(n_points - 1, 1) + return max(6, int(round(n_samples ** (1.0 / free)))), max(6, n_samples // 3) + + def _generate_orientation_candidates( points: list[PrecisionPoint], - n_samples: int = 36, + resolution: int = DEFAULT_ORIENTATION_RESOLUTION, + n_perturbations: int = 12, perturbation_range: float = math.pi / 3, ) -> Generator[list[float], None, None]: """Generate candidate orientation sequences for precision points. @@ -96,11 +128,15 @@ def _generate_orientation_candidates( The search fixes the first orientation (a reference frame choice) and varies the remaining orientations independently, since each - precision point can have a completely different coupler angle. + precision point can have a completely different coupler angle. The grid + therefore holds ``resolution ** (len(points) - 1)`` candidates: cost grows + exponentially in the number of precision points. Args: points: List of precision points. - n_samples: Number of samples per point. + resolution: Number of angles sampled per free orientation. + n_perturbations: Size of the uniform perturbation sweep around the + base estimate. perturbation_range: Range of perturbation around base estimate. Yields: @@ -113,34 +149,23 @@ def _generate_orientation_candidates( yield base_orientations # --- Independent orientation search --- - # Fix first orientation (reference) and search the rest over - # the full circle. For 3 points this is a 2-D grid; for more - # points the grid grows, so we use a coarser per-axis resolution. + # Fix the first orientation (a reference frame choice) and sweep the rest + # over the full circle. Enumeration order does not matter: coarse-to-fine + # and shuffled orderings were measured against this one and found neither + # faster nor more productive, so solutions are spread evenly through the + # grid rather than clustered. free = n - 1 # number of free orientations (first is fixed) if free > 0: - per_axis = max(6, int(round(n_samples ** (1.0 / free)))) - angle_grid = np.linspace(-math.pi, math.pi, per_axis, endpoint=False) - - if free == 1: - for a in angle_grid: - yield [base_orientations[0], a] - elif free == 2: - for a in angle_grid: - for b in angle_grid: - yield [base_orientations[0], a, b] - else: - # For 4+ points, combine a coarse independent grid with - # random samples to keep the count manageable. - from itertools import product as _product + angle_grid = np.linspace(-math.pi, math.pi, resolution, endpoint=False) - for count, combo in enumerate(_product(angle_grid, repeat=free)): - yield [base_orientations[0], *combo] - if count + 1 >= n_samples * n_samples: - break + for count, combo in enumerate(product(angle_grid, repeat=free)): + yield [base_orientations[0], *combo] + if count + 1 >= MAX_ORIENTATION_CANDIDATES: + break # Uniform perturbations (original strategy, still useful) deltas = np.linspace( - -perturbation_range, perturbation_range, max(6, n_samples // 3), endpoint=True + -perturbation_range, perturbation_range, n_perturbations, endpoint=True ) for delta in deltas: if abs(delta) < 1e-10: @@ -439,10 +464,12 @@ def path_generation( coupler_point_offset: tuple[float, float] = (0.0, 0.0), ground_pivot_a: Point2D | None = None, ground_pivot_d: Point2D | None = None, - n_orientation_samples: int = 36, + n_orientation_samples: int | None = None, max_solutions: int | None = 10, require_grashof: bool = True, require_crank_rocker: bool = False, + *, + orientation_resolution: int = DEFAULT_ORIENTATION_RESOLUTION, ) -> SynthesisResult: """Synthesize a four-bar for path generation. @@ -466,17 +493,20 @@ def path_generation( coupler_point_offset: Offset of traced point from coupler reference. ground_pivot_a: Optional fixed position for left ground pivot. ground_pivot_d: Optional fixed position for right ground pivot. - n_orientation_samples: Requested resolution of the orientation search. - Note that this is currently a weak control: it sets a per-axis grid - resolution that is floored at 6, so values across most of its range - produce the same search. Prefer ``max_solutions`` to trade time - against coverage. See issue #29. + n_orientation_samples: Deprecated, removed in 2.0.0. It never denoted + a number of samples; use ``orientation_resolution`` instead. A + value given here is translated to the search it used to produce. max_solutions: Maximum number of solutions to return (None for all). This is what governs the running time, since the search stops as soon as it has this many *verified* solutions. Lowering it is the effective way to make this function faster. require_grashof: If True, reject non-Grashof solutions. require_crank_rocker: If True, only accept crank-rocker type. + orientation_resolution: Angles sampled per free orientation. The grid + holds ``orientation_resolution ** (len(precision_points) - 1)`` + candidates, so raising this is exponentially expensive. Lowering it + is rarely a good trade: a coarser grid tends to return no solutions + at all rather than fewer. Use ``max_solutions`` to control cost. Returns: SynthesisResult containing valid linkages. @@ -498,6 +528,20 @@ def path_generation( warnings: list[str] = [] raw_solutions: list[FourBarSolution] = [] + n_perturbations = 12 + if n_orientation_samples is not None: + warn( + "n_orientation_samples is deprecated and will be removed in " + "pylinkage 2.0.0; use orientation_resolution instead. It never " + "denoted a number of samples: it was folded into a per-axis grid " + "resolution floored at 6, so most values produced the same search.", + DeprecationWarning, + stacklevel=2, + ) + orientation_resolution, n_perturbations = _legacy_orientation_args( + n_orientation_samples, len(precision_points) + ) + n_points = len(precision_points) if n_points < 3: @@ -521,9 +565,27 @@ def path_generation( if ground_pivot_a is not None and ground_pivot_d is not None: ground_constraint = (ground_pivot_a, ground_pivot_d) + # Cost is exponential in the number of precision points: one free + # orientation per point after the first. Five points already reach a + # four-dimensional grid, which can run for seconds and still find nothing, + # and nothing in the signature hints at that. + grid_size = orientation_resolution ** max(n_points - 1, 1) + if grid_size >= 1000: + cost = ( + f"Path generation over {n_points} precision points searches up to " + f"{min(grid_size, MAX_ORIENTATION_CANDIDATES)} orientation " + f"candidates and may take several seconds. Cost grows " + f"exponentially with the number of precision points; three or four " + f"is the practical range." + ) + warn(cost, UserWarning, stacklevel=2) + warnings.append(cost) + # Search over orientation candidates orientation_gen = _generate_orientation_candidates( - precision_points, n_samples=n_orientation_samples + precision_points, + resolution=orientation_resolution, + n_perturbations=n_perturbations, ) # The coupler point is the first precision point — it's the diff --git a/src/pylinkage/synthesis/six_bar.py b/src/pylinkage/synthesis/six_bar.py index 1f0c566..99f07a5 100644 --- a/src/pylinkage/synthesis/six_bar.py +++ b/src/pylinkage/synthesis/six_bar.py @@ -36,6 +36,7 @@ _dyads_to_fourbar, _filter_solutions, _generate_orientation_candidates, + _legacy_orientation_args, _points_to_poses, _remove_duplicate_solutions, ) @@ -159,8 +160,14 @@ def _watt_synthesis( stage2_points = [precision_points[i] for i in stage2_indices] # Try multiple orientation candidates for stage 1 + # This caller uses n_orientation_samples as a candidate budget, which + # it enforces with its own counter below; the translation preserves the + # grid it used to search. + _resolution, _perturbations = _legacy_orientation_args( + n_orientation_samples, len(stage1_points) + ) orientation_gen = _generate_orientation_candidates( - stage1_points, n_samples=n_orientation_samples + stage1_points, resolution=_resolution, n_perturbations=_perturbations ) orientations_tried = 0 @@ -269,8 +276,11 @@ def _stephenson_synthesis( stage1_points = [precision_points[i] for i in stage1_indices] stage2_points = [precision_points[i] for i in stage2_indices] + _resolution, _perturbations = _legacy_orientation_args( + min(n_orientation_samples, 12), len(stage1_points) + ) for orientations in _generate_orientation_candidates( - stage1_points, n_samples=min(n_orientation_samples, 12) + stage1_points, resolution=_resolution, n_perturbations=_perturbations ): if len(all_solutions) >= max_solutions: break diff --git a/tests/synthesis/test_core_extended.py b/tests/synthesis/test_core_extended.py index f8e1f94..6c51d5e 100644 --- a/tests/synthesis/test_core_extended.py +++ b/tests/synthesis/test_core_extended.py @@ -7,7 +7,7 @@ from pylinkage.synthesis._types import DyadSolution, SynthesisType from pylinkage.synthesis.core import ( BurmesterCurves, - Dyad, + BurmesterDyad, SynthesisProblem, ) @@ -50,23 +50,23 @@ def test_empty_function(self): class TestDyad(unittest.TestCase): - """Test Dyad dataclass (lines 137, 145, 152).""" + """Test BurmesterDyad dataclass (lines 137, 145, 152).""" def test_link_length(self): """link_length should return distance between circle and center (line 137).""" - dyad = Dyad(circle_point=complex(3, 4), center_point=complex(0, 0)) + dyad = BurmesterDyad(circle_point=complex(3, 4), center_point=complex(0, 0)) self.assertAlmostEqual(dyad.link_length, 5.0) def test_to_cartesian(self): """to_cartesian should convert to (x,y) tuples (line 145).""" - dyad = Dyad(circle_point=complex(1, 2), center_point=complex(3, 4)) + dyad = BurmesterDyad(circle_point=complex(1, 2), center_point=complex(3, 4)) cp, cenp = dyad.to_cartesian() self.assertEqual(cp, (1.0, 2.0)) self.assertEqual(cenp, (3.0, 4.0)) def test_to_dyad_solution(self): """to_dyad_solution should return DyadSolution (line 152).""" - dyad = Dyad(circle_point=complex(1, 2), center_point=complex(3, 4)) + dyad = BurmesterDyad(circle_point=complex(1, 2), center_point=complex(3, 4)) sol = dyad.to_dyad_solution() self.assertIsInstance(sol, DyadSolution) self.assertEqual(sol.circle_point, complex(1, 2)) @@ -90,7 +90,7 @@ def test_get_all_dyads(self): curves = self._make_curves(5) dyads = curves.get_all_dyads() self.assertEqual(len(dyads), 5) - self.assertIsInstance(dyads[0], Dyad) + self.assertIsInstance(dyads[0], BurmesterDyad) def test_sample_continuous_downsamples(self): """sample() on continuous curve should downsample (lines 226-230).""" @@ -141,10 +141,10 @@ def test_bool_false(self): self.assertFalse(curves) def test_get_dyad(self): - """get_dyad should return a single Dyad.""" + """get_dyad should return a single BurmesterDyad.""" curves = self._make_curves(5) dyad = curves.get_dyad(2) - self.assertIsInstance(dyad, Dyad) + self.assertIsInstance(dyad, BurmesterDyad) self.assertEqual(dyad.circle_point, curves.circle_curve[2]) self.assertEqual(dyad.center_point, curves.center_curve[2]) diff --git a/tests/synthesis/test_orientation_resolution.py b/tests/synthesis/test_orientation_resolution.py new file mode 100644 index 0000000..50d8ae9 --- /dev/null +++ b/tests/synthesis/test_orientation_resolution.py @@ -0,0 +1,125 @@ +"""``orientation_resolution`` replaces ``n_orientation_samples``. + +The old name never denoted a number of samples: it was folded into a per-axis +grid resolution floored at 6, so values from 6 to 216 produced an identical +search. The new name states what the parameter is, and makes the cost model +readable -- the grid holds ``resolution ** (n_points - 1)`` candidates. +""" + +from __future__ import annotations + +import importlib +import warnings + +import pytest + +from pylinkage.synthesis import path_generation + +pg_module = importlib.import_module("pylinkage.synthesis.path_generation") + +FOUR_POINTS = [(0, 0), (1, 1), (2, 1), (3, 0)] +THREE_POINTS = [(0, 0), (1.5, 1.2), (3, 0)] +FIVE_POINTS = [(0, 0), (1, 1), (2, 1.2), (3, 0.6), (4, 0)] + + +def signature(result): + return sorted( + ( + round(s.crank_length, 6), + round(s.coupler_length, 6), + round(s.rocker_length, 6), + round(s.ground_length, 6), + ) + for s in result.raw_solutions + ) + + +class TestLegacyArgument: + def test_warns(self): + with pytest.warns(DeprecationWarning, match="n_orientation_samples"): + path_generation(FOUR_POINTS, n_orientation_samples=36) + + def test_warning_names_the_replacement_and_removal(self): + with pytest.warns(DeprecationWarning) as record: + path_generation(FOUR_POINTS, n_orientation_samples=36) + message = str(record[0].message) + assert "orientation_resolution" in message + assert "2.0.0" in message + + def test_still_produces_the_search_it_always_did(self): + """The old default must give exactly the new default's results.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + legacy = path_generation(FOUR_POINTS, n_orientation_samples=36) + assert signature(legacy) == signature(path_generation(FOUR_POINTS)) + + @pytest.mark.parametrize( + ("n_samples", "n_points", "expected"), + [ + (36, 4, (6, 12)), # the old default, on four points + (36, 3, (6, 12)), # free == 2: sqrt(36) is exactly 6 + (36, 5, (6, 12)), # free == 4: floored to 6 + (1000, 4, (10, 333)), # large values did raise the resolution + (4, 4, (6, 6)), # small values were floored on both axes + ], + ) + def test_translation_reproduces_the_old_formula(self, n_samples, n_points, expected): + assert pg_module._legacy_orientation_args(n_samples, n_points) == expected + + def test_new_parameter_is_silent(self): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + path_generation(FOUR_POINTS, orientation_resolution=6) + + +class TestResolution: + def test_default_is_six(self): + assert pg_module.DEFAULT_ORIENTATION_RESOLUTION == 6 + + def test_grid_size_is_resolution_to_the_free_power(self): + """The documented cost model has to be the real one.""" + for resolution, n_points, expected in ((3, 4, 27), (4, 3, 16), (6, 4, 216)): + points = FOUR_POINTS[:n_points] + candidates = list( + pg_module._generate_orientation_candidates(points, resolution=resolution) + ) + # base estimate + grid + perturbations + 5 progressive rotations + grid = expected + assert grid < len(candidates) <= grid + 1 + 12 + 5 + + def test_candidate_count_is_capped(self): + """Six or more precision points must not run unbounded.""" + points = [(float(i), float(i % 2)) for i in range(7)] + candidates = list( + pg_module._generate_orientation_candidates(points, resolution=6) + ) + # 6**6 is 46656; the guard stops it far short. + assert len(candidates) <= pg_module.MAX_ORIENTATION_CANDIDATES + 1 + 12 + 5 + + def test_raising_resolution_searches_more(self): + coarse = list(pg_module._generate_orientation_candidates(FOUR_POINTS, resolution=3)) + fine = list(pg_module._generate_orientation_candidates(FOUR_POINTS, resolution=6)) + assert len(fine) > len(coarse) + + +class TestExponentialCostWarning: + def test_five_points_warn_about_cost(self): + """Nine seconds of silence is worse than a warning.""" + with pytest.warns(UserWarning, match="exponentially"): + path_generation(FIVE_POINTS, max_solutions=1) + + def test_four_points_do_not_warn(self): + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + path_generation(FOUR_POINTS) + + def test_three_points_do_not_warn(self): + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + path_generation(THREE_POINTS) + + def test_the_warning_is_also_reported_in_the_result(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + result = path_generation(FIVE_POINTS, max_solutions=1) + assert any("exponentially" in w for w in result.warnings)