Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 108 additions & 41 deletions src/pylinkage/hypergraph/mechanism_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions src/pylinkage/hypergraph/sim_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
122 changes: 121 additions & 1 deletion tests/hypergraph/test_mechanism_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +15,7 @@
GroundLink,
Link,
Mechanism,
PrismaticJoint,
RevoluteJoint,
)

Expand Down Expand Up @@ -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()."""

Expand Down
Loading
Loading