From ebf0fc6b80552078509faa67bd6615decd78493b Mon Sep 17 00:00:00 2001 From: HugoFara Date: Fri, 18 Sep 2026 10:03:14 +0200 Subject: [PATCH] fix(hypergraph): build a slider from a prismatic node in to_mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slider-crank did not survive Linkage.to_hypergraph() → to_mechanism() (#56), for two reasons. from_sim_linkage tagged only LinearActuator nodes as PRISMATIC and left the output of an RRPDyad (and PPDyad) REVOLUTE. Its guide is a hyperedge over the two line anchors and the slider, which the clique expansion from #55 turns into two more neighbours. Tag those nodes PRISMATIC: the dyad's output is the slider block. compute_mobility then counts it as such and a slider-crank reports 1 DOF instead of 0. to_mechanism built every driven node from its first two solved neighbours as two Links, whatever its joint_type, so the slider became an RRR dyad pinned to a ground node at its initial distance and jammed as soon as the crank turned. A PRISMATIC node is now read the way from_sim_linkage writes it — one edge to its revolute anchor, one hyperedge of three nodes for the line — and becomes a PrismaticJoint with axis and line_point taken from the two line nodes, linked to the anchor alone. It is scheduled once that anchor is solved. The round trip reproduces Linkage.step() to machine precision. Mechanism only holds a slide line fixed to the frame, so a guide on a moving link, a PPDyad-style double slider and a prismatic node without a guide raise NotImplementedError instead of being mis-solved. Closes #56 --- CHANGELOG.md | 16 ++ .../hypergraph/mechanism_conversion.py | 149 +++++++++++++----- src/pylinkage/hypergraph/sim_conversion.py | 15 +- tests/hypergraph/test_mechanism_conversion.py | 122 +++++++++++++- tests/hypergraph/test_sim_conversion.py | 39 +++++ 5 files changed, 295 insertions(+), 46 deletions(-) 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: