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)