diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6909e..bed568b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ the [Deprecations](https://hugofara.github.io/pylinkage/deprecations.html) page. ## [Unreleased] +### Fixed + +- **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 + slider block (a slider-crank reports 1 DOF instead of 0). `to_mechanism` + builds such a node as a `PrismaticJoint` whose `axis` and `line_point` + come from the two other nodes of its guide hyperedge, with a single link + to its revolute anchor; the round trip now reproduces `Linkage.step()`. + It used to build every driven node from its first two solved neighbours + as an RRR dyad, which pinned the slider to a ground node at its initial + distance and jammed as soon as the crank turned. What `Mechanism` cannot + represent — a guide on a moving link, a double slider, a prismatic node + without a guide — now raises `NotImplementedError` instead of + mis-solving. + ## [1.2.2] - 2026-09-18 ### Fixed diff --git a/src/pylinkage/hypergraph/mechanism_conversion.py b/src/pylinkage/hypergraph/mechanism_conversion.py index 8d199ba..59d2a9e 100644 --- a/src/pylinkage/hypergraph/mechanism_conversion.py +++ b/src/pylinkage/hypergraph/mechanism_conversion.py @@ -25,12 +25,55 @@ from ..mechanism import Mechanism +def _slider_anchor_and_line( + hypergraph: HypergraphLinkage, node_id: NodeId +) -> tuple[NodeId, tuple[NodeId, NodeId]]: + """Return the revolute anchor and the two line nodes of a slider node. + + Reads the ``RRPDyad`` encoding of :func:`from_sim_linkage`: one edge + to the revolute anchor, one hyperedge over the slider and the two + nodes that define its slide line. + + Raises: + NotImplementedError: If the node is not a slider on a fixed line. + """ + edges = hypergraph.get_edges_for_node(node_id) + hyperedges = hypergraph.get_hyperedges_for_node(node_id) + what = f"Prismatic node {node_id!r}" + if len(edges) != 1: + raise NotImplementedError( + f"{what} has {len(edges)} edges; Mechanism only represents a slider " + "with one revolute leg on a fixed line (an RRP dyad), not a " + "double slider or a slider pinned to several links." + ) + if len(hyperedges) != 1 or len(hyperedges[0].nodes) != 3: + raise NotImplementedError( + f"{what} needs exactly one hyperedge of three nodes to define its " + f"slide line, found {[list(h.nodes) for h in hyperedges]}." + ) + line_ids = tuple(n for n in hyperedges[0].nodes if n != node_id) + moving = [n for n in line_ids if hypergraph.nodes[n].role != NodeRole.GROUND] + if moving: + raise NotImplementedError( + f"{what} slides on a line through {moving}, which is not ground; " + "Mechanism only represents a slide line fixed to the frame." + ) + return edges[0].other_node(node_id), (line_ids[0], line_ids[1]) + + def to_mechanism(hypergraph: HypergraphLinkage, dimensions: Dimensions) -> Mechanism: """Convert a HypergraphLinkage and Dimensions to a Mechanism. This converts the hypergraph topology plus dimensional data to the mechanism model. This is the preferred conversion path for new code. + A driven ``JointType.PRISMATIC`` node is a slider: it needs one + edge, to the revolute anchor it is linked to, and one hyperedge of + three nodes whose two other nodes lie on the slide line, which is + how :func:`from_sim_linkage` encodes an ``RRPDyad``. The mechanism + keeps the line as the :class:`~pylinkage.mechanism.PrismaticJoint`'s + ``axis`` and ``line_point``, so the two line nodes must be ground. + Args: hypergraph: The HypergraphLinkage defining the topology. dimensions: The Dimensions providing positions, distances, angles. @@ -41,6 +84,11 @@ def to_mechanism(hypergraph: HypergraphLinkage, dimensions: Dimensions) -> Mecha Raises: ValueError: If the hypergraph is underconstrained or has disconnected components that cannot be solved. + NotImplementedError: If a prismatic node is not a slider on a + fixed line (its guide is a moving link, it has no revolute + leg or several, or it is a ``PPDyad``-style double slider), + which :class:`~pylinkage.mechanism.Mechanism` cannot + represent yet. Example: >>> hg = HypergraphLinkage(name="Four-bar") @@ -159,49 +207,68 @@ def to_mechanism(hypergraph: HypergraphLinkage, dimensions: Dimensions) -> Mecha break for node_id, node in list(remaining.items()): - neighbors = simple.neighbors(node_id) - parent_ids = [n for n in neighbors if n in solved_nodes] - - # Need at least 2 solved parents for a driven joint - if len(parent_ids) >= 2: - pos = dimensions.get_node_position(node_id) - x = pos[0] if pos else 0.0 - y = pos[1] if pos else 0.0 - - created_joint: Joint - - if node.joint_type == JointType.PRISMATIC: - # Prismatic joint - needs circle center + line - created_joint = PrismaticJoint( - id=node_id, - position=(x, y), - name=node.name, + pos = dimensions.get_node_position(node_id) + x = pos[0] if pos else 0.0 + y = pos[1] if pos else 0.0 + + created_joint: Joint + + if node.joint_type == JointType.PRISMATIC: + # A slider: one link to its revolute anchor, and the + # slide line taken from its guide hyperedge. + anchor_id, line_ids = _slider_anchor_and_line(hypergraph, node_id) + if anchor_id not in solved_nodes: + continue + line_start = dimensions.get_node_position(line_ids[0]) + line_end = dimensions.get_node_position(line_ids[1]) + if line_start is None or line_end is None: + raise ValueError( + f"Prismatic node {node_id!r} slides on the line through " + f"{line_ids[0]!r} and {line_ids[1]!r}, which need positions." ) - else: - # Revolute joint (most common) - created_joint = RevoluteJoint( - id=node_id, - position=(x, y), - name=node.name, - ) - - joints.append(created_joint) - node_to_joint[node_id] = created_joint - - # Create links to parent joints - for i, parent_id in enumerate(parent_ids[:2]): - parent_joint = node_to_joint[parent_id] - - link = Link( - id=f"{node_id}_link{i}", - joints=[parent_joint, created_joint], - name=f"{node_id}_link{i}", + axis = (line_end[0] - line_start[0], line_end[1] - line_start[1]) + if math.hypot(*axis) < 1e-12: + raise ValueError( + f"Prismatic node {node_id!r}: line nodes {line_ids[0]!r} and " + f"{line_ids[1]!r} coincide, so they define no slide line." ) - links.append(link) - - solved_nodes.add(node_id) - del remaining[node_id] - break + created_joint = PrismaticJoint( + id=node_id, + position=(x, y), + name=node.name, + axis=axis, + line_point=(line_start[0], line_start[1]), + ) + parent_ids = [anchor_id] + else: + neighbors = simple.neighbors(node_id) + parent_ids = [n for n in neighbors if n in solved_nodes][:2] + # Need 2 solved parents for a revolute joint + if len(parent_ids) < 2: + continue + created_joint = RevoluteJoint( + id=node_id, + position=(x, y), + name=node.name, + ) + + joints.append(created_joint) + node_to_joint[node_id] = created_joint + + # Create links to parent joints + for i, parent_id in enumerate(parent_ids): + parent_joint = node_to_joint[parent_id] + + link = Link( + id=f"{node_id}_link{i}", + joints=[parent_joint, created_joint], + name=f"{node_id}_link{i}", + ) + links.append(link) + + solved_nodes.add(node_id) + del remaining[node_id] + break if remaining: raise ValueError( diff --git a/src/pylinkage/hypergraph/sim_conversion.py b/src/pylinkage/hypergraph/sim_conversion.py index 017d02e..016174d 100644 --- a/src/pylinkage/hypergraph/sim_conversion.py +++ b/src/pylinkage/hypergraph/sim_conversion.py @@ -19,10 +19,12 @@ - ``RRRDyad`` → two edges (one per distance constraint) - ``FixedDyad`` → a hyperedge over the rigid triangle plus two edges carrying the two leg distances as numeric fallbacks -- ``RRPDyad`` → one edge for the revolute leg; the prismatic track is - encoded as a hyperedge over the three line/anchor nodes -- ``PPDyad`` → a hyperedge over the four line anchors (no numeric - distances — it is fully determined by the four line anchors) +- ``RRPDyad`` → a ``JointType.PRISMATIC`` node; one edge for the + revolute leg, and the slide line encoded as a hyperedge over the + two line anchors and the slider itself +- ``PPDyad`` → a ``JointType.PRISMATIC`` node in a hyperedge over the + four line anchors (no numeric distances — it is fully determined by + the four line anchors) Anything else raises :class:`NotImplementedError` so callers know to extend the bridge when pylinkage grows new component types. @@ -95,6 +97,11 @@ def from_sim_linkage(sim_linkage: SimLinkage) -> tuple[HypergraphLinkage, Dimens elif isinstance(comp, LinearActuator): role = NodeRole.DRIVER joint_type = JointType.PRISMATIC + elif isinstance(comp, (RRPDyad, PPDyad)): + # The dyad's own output is the slider: it is a prismatic + # pair with the line its hyperedge describes. + role = NodeRole.DRIVEN + joint_type = JointType.PRISMATIC else: role = NodeRole.DRIVEN joint_type = JointType.REVOLUTE diff --git a/tests/hypergraph/test_mechanism_conversion.py b/tests/hypergraph/test_mechanism_conversion.py index 939465a..5762776 100644 --- a/tests/hypergraph/test_mechanism_conversion.py +++ b/tests/hypergraph/test_mechanism_conversion.py @@ -4,7 +4,7 @@ import pytest -from pylinkage._types import NodeRole +from pylinkage._types import JointType, NodeRole from pylinkage.dimensions import Dimensions, DriverAngle from pylinkage.hypergraph.core import Edge, Hyperedge, Node from pylinkage.hypergraph.graph import HypergraphLinkage @@ -15,6 +15,7 @@ GroundLink, Link, Mechanism, + PrismaticJoint, RevoluteJoint, ) @@ -184,6 +185,125 @@ def test_hyperedge_only_coupler_point(self, order): assert math.dist(pos["C"], pos["P"]) == pytest.approx(math.dist(c, p)) +def _make_slider_crank_hypergraph() -> tuple[HypergraphLinkage, Dimensions]: + """A slider-crank as ``from_sim_linkage`` encodes it. + + The slider ``S`` has one edge to the crank tip ``B`` and rides the + line through the ground nodes ``A`` and ``D``, written as the + hyperedge ``(A, D, S)``. + """ + hg = HypergraphLinkage(name="Slider-crank") + hg.add_node(Node(id="A", role=NodeRole.GROUND, name="A")) + hg.add_node(Node(id="D", role=NodeRole.GROUND, name="D")) + hg.add_node(Node(id="B", role=NodeRole.DRIVER, name="B")) + hg.add_node(Node(id="S", role=NodeRole.DRIVEN, joint_type=JointType.PRISMATIC, name="S")) + hg.add_edge(Edge(id="e0", source="A", target="B")) + hg.add_edge(Edge(id="e1", source="B", target="S")) + hg.add_hyperedge(Hyperedge("he2", ("A", "D", "S"))) + dims = Dimensions( + node_positions={ + "A": (0.0, 0.0), + "D": (3.0, 0.0), + "B": (1.0, 0.0), + "S": (3.2, 0.0), + }, + driver_angles={"B": DriverAngle(angular_velocity=0.2)}, + edge_distances={"e0": 1.0, "e1": 2.2}, + name="Slider-crank", + ) + return hg, dims + + +class TestPrismaticNode: + """A driven prismatic node becomes a slider on a fixed line (#56).""" + + def test_builds_prismatic_joint_on_the_line(self): + hg, dims = _make_slider_crank_hypergraph() + mech = to_mechanism(hg, dims) + slider = mech.get_joint("S") + assert isinstance(slider, PrismaticJoint) + assert slider.line_point == (0.0, 0.0) + assert slider.get_axis_normalized() == pytest.approx((1.0, 0.0)) + # One link, to the revolute anchor: the ground nodes are the + # line, not links. + links = [lk for lk in mech.links if slider in lk.joints] + assert [sorted(j.id for j in lk.joints) for lk in links] == [["B", "S"]] + + def test_slider_stays_on_line_and_rod_keeps_length(self): + hg, dims = _make_slider_crank_hypergraph() + mech = to_mechanism(hg, dims) + for _ in mech.step(iterations=50): + pos = {j.id: j.position for j in mech.joints} + assert pos["S"][1] == pytest.approx(0.0, abs=1e-9) + assert math.dist(pos["B"], pos["S"]) == pytest.approx(2.2) + + def test_slanted_line_is_taken_from_its_nodes(self): + hg, dims = _make_slider_crank_hypergraph() + dims.node_positions["D"] = (0.0, 3.0) + # S at x=0 with |BS| = 2.2 from B=(1, 0): y = sqrt(2.2² − 1). + dims.node_positions["S"] = (0.0, math.sqrt(2.2**2 - 1.0)) + mech = to_mechanism(hg, dims) + slider = mech.get_joint("S") + assert isinstance(slider, PrismaticJoint) + assert slider.get_axis_normalized() == pytest.approx((0.0, 1.0)) + for _ in mech.step(iterations=20): + assert slider.position[0] == pytest.approx(0.0, abs=1e-9) + + def test_waits_for_its_anchor(self): + """The slider is built after the driven joint it hangs from.""" + hg, dims = _make_fourbar_hypergraph() + hg.add_node(Node(id="S", role=NodeRole.DRIVEN, joint_type=JointType.PRISMATIC)) + hg.add_edge(Edge(id="e4", source="C", target="S")) + hg.add_hyperedge(Hyperedge("rail", ("S", "G1", "G2"))) + dims.node_positions["S"] = (5.0, 0.0) + # Listed before C, and C is only solvable after B. + hg.nodes = {k: hg.nodes[k] for k in ("G1", "G2", "B", "S", "C")} + mech = to_mechanism(hg, dims) + assert isinstance(mech.get_joint("S"), PrismaticJoint) + for _ in mech.step(iterations=10): + pass + pos = {j.id: j.position for j in mech.joints} + assert pos["S"][1] == pytest.approx(0.0, abs=1e-9) + assert math.dist(pos["C"], pos["S"]) == pytest.approx(math.dist((3.0, 2.0), (5.0, 0.0))) + + def test_moving_guide_is_refused(self): + """Mechanism has no slider on a moving link; say so instead of mis-solving.""" + hg, dims = _make_fourbar_hypergraph() + hg.add_node(Node(id="S", role=NodeRole.DRIVEN, joint_type=JointType.PRISMATIC)) + hg.add_edge(Edge(id="e4", source="G2", target="S")) + # Guide along the coupler B–C. + hg.add_hyperedge(Hyperedge("guide", ("B", "C", "S"))) + dims.node_positions["S"] = (1.0, 1.0) + with pytest.raises(NotImplementedError, match="not ground"): + to_mechanism(hg, dims) + + def test_double_slider_is_refused(self): + """A PPDyad-style node (no revolute leg) is not representable.""" + hg = HypergraphLinkage(name="PP") + for nid in "ABCD": + hg.add_node(Node(id=nid, role=NodeRole.GROUND)) + hg.add_node(Node(id="X", role=NodeRole.DRIVEN, joint_type=JointType.PRISMATIC)) + hg.add_hyperedge(Hyperedge("lines", ("A", "B", "C", "D", "X"))) + dims = Dimensions( + node_positions={ + "A": (0.0, 0.0), + "B": (2.0, 0.0), + "C": (1.0, -1.0), + "D": (1.0, 1.0), + "X": (1.0, 0.0), + } + ) + with pytest.raises(NotImplementedError, match="one revolute leg"): + to_mechanism(hg, dims) + + def test_prismatic_without_line_is_refused(self): + """A prismatic node with two edges and no guide has no slide line.""" + hg, dims = _make_fourbar_hypergraph() + hg.nodes["C"].joint_type = JointType.PRISMATIC + with pytest.raises(NotImplementedError, match="one revolute leg"): + to_mechanism(hg, dims) + + class TestFromMechanism: """Tests for from_mechanism().""" diff --git a/tests/hypergraph/test_sim_conversion.py b/tests/hypergraph/test_sim_conversion.py index eb22104..a5e43c6 100644 --- a/tests/hypergraph/test_sim_conversion.py +++ b/tests/hypergraph/test_sim_conversion.py @@ -13,6 +13,22 @@ from pylinkage.dyads import FixedDyad, PPDyad, RRPDyad, RRRDyad from pylinkage.hypergraph import from_sim_linkage, to_mechanism from pylinkage.simulation import Linkage +from pylinkage.topology import compute_mobility + + +def _slider_crank() -> Linkage: + """The slider-crank of issue #56: the slider ``S`` rides the line ``A``–``D``.""" + A = Ground(0.0, 0.0, name="A") + D = Ground(3.0, 0.0, name="D") + crank = Crank(anchor=A, radius=1.0, angular_velocity=0.2, name="B") + slider = RRPDyad( + revolute_anchor=crank.output, + line_anchor1=A, + line_anchor2=D, + distance=2.2, + name="S", + ) + return Linkage([A, D, crank, slider], name="slider-crank") def _fourbar() -> Linkage: @@ -130,6 +146,28 @@ def test_creates_hyperedge_for_line(self) -> None: ternary = [h for h in hg.hyperedges.values() if set(h.nodes) == {"L1", "L2", "S"}] assert len(ternary) == 1 + def test_slider_is_prismatic(self) -> None: + """The dyad's output node is the slider, a prismatic pair (#56).""" + hg, _ = from_sim_linkage(_slider_crank()) + assert hg.nodes["S"].joint_type.name == "PRISMATIC" + assert hg.nodes["S"].role == NodeRole.DRIVEN + + def test_slider_crank_has_one_dof(self) -> None: + """A slider block on a ground line leaves the mechanism 1 DOF (#56).""" + hg, _ = from_sim_linkage(_slider_crank()) + assert compute_mobility(hg).dof == 1 + + def test_round_trip_via_mechanism_reproduces_simulation(self) -> None: + """to_mechanism() rebuilds the slider and follows the SimLinkage (#56).""" + sim = _slider_crank() + sim.rebuild() + hg, dims = from_sim_linkage(sim) + mech = to_mechanism(hg, dims) + + expected = [tuple(map(tuple, pos)) for pos in sim.step(iterations=40)] + got = list(mech.step(iterations=40)) + assert np.allclose(np.array(got, dtype=float), np.array(expected, dtype=float)) + class TestPPDyad: def test_creates_5ary_hyperedge(self) -> None: @@ -150,6 +188,7 @@ def test_creates_5ary_hyperedge(self) -> None: hyperedges = list(hg.hyperedges.values()) assert len(hyperedges) == 1 assert set(hyperedges[0].nodes) == {"A", "B", "C", "D", "X"} + assert hg.nodes["X"].joint_type.name == "PRISMATIC" class TestLinearActuatorAndArcCrank: