lockwire 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/mechanism.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""The mechanism check: a **closed, named set** of mechanisms, answered in closed form.
|
|
2
|
+
|
|
3
|
+
Version one answers exactly one kind — the **planar four-bar linkage** — by the
|
|
4
|
+
Grashof condition. With ``s`` the shortest link, ``l`` the longest and ``p``, ``q``
|
|
5
|
+
the other two, a four-bar has a link that can fully rotate iff ``s + l <= p + q``.
|
|
6
|
+
That is an exact algebraic statement about four measured lengths: no solver, no
|
|
7
|
+
iteration, no tolerance argument, and therefore nothing this module waits on.
|
|
8
|
+
(The force solver is a separate module; a mechanism verdict never comes from it.)
|
|
9
|
+
|
|
10
|
+
**Everything else is NOT SCORED, naming the kind it found.** A five-bar loop is not
|
|
11
|
+
scored as a four-bar built from four of its five links: that would be a confident,
|
|
12
|
+
plausible, wrong answer, and it is the exact failure this module exists to prevent.
|
|
13
|
+
The same refusal covers a loop whose joints are not all revolute, a loop that is not
|
|
14
|
+
a planar linkage, and a loop whose geometry the contract does not record. A number
|
|
15
|
+
is only ever printed when it was measured from the contract; nothing is
|
|
16
|
+
defaulted and nothing is guessed.
|
|
17
|
+
|
|
18
|
+
**The sentence is composed here**. Every verdict carries the finished
|
|
19
|
+
plain-words ``text`` that a terminal, a page or an agent shows verbatim. No surface
|
|
20
|
+
downstream assembles a phrase out of these fields.
|
|
21
|
+
|
|
22
|
+
Geometry, and why no forward kinematics is needed: a URDF joint's origin is its pose
|
|
23
|
+
in the *parent* link's frame, and the *child* link's frame is the joint frame. So for
|
|
24
|
+
a link that is the parent of one loop joint and the child of the other, the two
|
|
25
|
+
attachment points in that link's own frame are ``joint.origin.xyz`` and the origin —
|
|
26
|
+
and the link's length is the norm of that one vector. Reading a closed loop therefore
|
|
27
|
+
needs nothing but the joints the contract already records (no second walk of the
|
|
28
|
+
model, no re-parse of the URDF).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import math
|
|
34
|
+
from collections import Counter
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Literal
|
|
37
|
+
|
|
38
|
+
from lockwire.schema import Joint, ModelRecord
|
|
39
|
+
|
|
40
|
+
#: The named set. Version one holds exactly one entry; a kind absent from it is
|
|
41
|
+
#: NOT SCORED by name rather than approximated by the nearest thing that is here.
|
|
42
|
+
NAMED_SET: tuple[str, ...] = ("four-bar",)
|
|
43
|
+
|
|
44
|
+
#: Joint types a planar linkage of pin joints is made of. A loop containing any
|
|
45
|
+
#: other type is a different machine and is not scored.
|
|
46
|
+
_PIN_JOINTS = frozenset({"revolute", "continuous"})
|
|
47
|
+
|
|
48
|
+
#: A joint that declares its link goes all the way round. This is the *declaration*
|
|
49
|
+
#: the measured geometry is compared against — the second operand, without which
|
|
50
|
+
#: there is nothing to score.
|
|
51
|
+
FULLY_ROTATING_TYPE = "continuous"
|
|
52
|
+
|
|
53
|
+
_NUMBER_WORDS = {
|
|
54
|
+
2: "two",
|
|
55
|
+
3: "three",
|
|
56
|
+
4: "four",
|
|
57
|
+
5: "five",
|
|
58
|
+
6: "six",
|
|
59
|
+
7: "seven",
|
|
60
|
+
8: "eight",
|
|
61
|
+
9: "nine",
|
|
62
|
+
10: "ten",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Verdict = Literal["rotates", "change-point", "locking", "not-scored"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Mechanism:
|
|
70
|
+
"""One closed loop in the model, and what the closed form says about it.
|
|
71
|
+
|
|
72
|
+
``s_plus_l``/``p_plus_q`` are ``None`` on every not-scored path — a declined
|
|
73
|
+
answer carries no numbers at all, so no reader can mistake a blank for a zero.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
kind: str
|
|
77
|
+
verdict: Verdict
|
|
78
|
+
links: tuple[str, ...]
|
|
79
|
+
joints: tuple[str, ...]
|
|
80
|
+
text: str
|
|
81
|
+
s_plus_l: float | None = None
|
|
82
|
+
p_plus_q: float | None = None
|
|
83
|
+
lengths_m: tuple[float, ...] = ()
|
|
84
|
+
declared_fully_rotating: tuple[str, ...] = ()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def scored(self) -> bool:
|
|
88
|
+
return self.verdict != "not-scored"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _kind_word(count: int) -> str:
|
|
92
|
+
return f"{_NUMBER_WORDS.get(count, str(count))}-bar"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fmt(value: float) -> str:
|
|
96
|
+
"""Lengths in metres, trailing zeros trimmed — the measured value, not a rounding."""
|
|
97
|
+
text = f"{value:.6f}".rstrip("0").rstrip(".")
|
|
98
|
+
return text or "0"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _norm(xyz: tuple[float, float, float]) -> float:
|
|
102
|
+
return math.dist(xyz, (0.0, 0.0, 0.0))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cross(
|
|
106
|
+
a: tuple[float, float, float], b: tuple[float, float, float]
|
|
107
|
+
) -> tuple[float, float, float]:
|
|
108
|
+
return (
|
|
109
|
+
a[1] * b[2] - a[2] * b[1],
|
|
110
|
+
a[2] * b[0] - a[0] * b[2],
|
|
111
|
+
a[0] * b[1] - a[1] * b[0],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
|
116
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------ loop finding
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _loop_joints(joints: list[Joint]) -> list[Joint]:
|
|
123
|
+
"""The 2-core: strip links of degree < 2 until nothing more can be stripped.
|
|
124
|
+
|
|
125
|
+
What survives is exactly the part of the model that contains closed loops — a
|
|
126
|
+
tree prunes away to nothing, which is why an ordinary robot produces no
|
|
127
|
+
mechanism entries at all and this check costs a clean contract zero findings.
|
|
128
|
+
"""
|
|
129
|
+
alive = [joint for joint in joints if joint.parent != joint.child]
|
|
130
|
+
while alive:
|
|
131
|
+
degree: Counter[str] = Counter()
|
|
132
|
+
for joint in alive:
|
|
133
|
+
degree[joint.parent] += 1
|
|
134
|
+
degree[joint.child] += 1
|
|
135
|
+
leaves = {link for link, count in degree.items() if count < 2}
|
|
136
|
+
if not leaves:
|
|
137
|
+
return alive
|
|
138
|
+
alive = [j for j in alive if j.parent not in leaves and j.child not in leaves]
|
|
139
|
+
return alive
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _components(joints: list[Joint]) -> list[list[Joint]]:
|
|
143
|
+
"""Connected components of the 2-core, each a candidate mechanism."""
|
|
144
|
+
parent: dict[str, str] = {}
|
|
145
|
+
|
|
146
|
+
def find(link: str) -> str:
|
|
147
|
+
parent.setdefault(link, link)
|
|
148
|
+
while parent[link] != link:
|
|
149
|
+
parent[link] = parent[parent[link]]
|
|
150
|
+
link = parent[link]
|
|
151
|
+
return link
|
|
152
|
+
|
|
153
|
+
for joint in joints:
|
|
154
|
+
parent[find(joint.parent)] = find(joint.child)
|
|
155
|
+
|
|
156
|
+
grouped: dict[str, list[Joint]] = {}
|
|
157
|
+
for joint in joints:
|
|
158
|
+
grouped.setdefault(find(joint.parent), []).append(joint)
|
|
159
|
+
return [grouped[key] for key in sorted(grouped)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _cycle_order(component: list[Joint]) -> tuple[str, ...] | None:
|
|
163
|
+
"""Link names in cycle order, or ``None`` when the component is not one simple cycle.
|
|
164
|
+
|
|
165
|
+
A component where some link carries three or more joints is two loops sharing a
|
|
166
|
+
link — a coupled linkage, which the named set does not hold.
|
|
167
|
+
"""
|
|
168
|
+
neighbours: dict[str, list[str]] = {}
|
|
169
|
+
for joint in component:
|
|
170
|
+
neighbours.setdefault(joint.parent, []).append(joint.child)
|
|
171
|
+
neighbours.setdefault(joint.child, []).append(joint.parent)
|
|
172
|
+
if len(neighbours) != len(component) or any(len(n) != 2 for n in neighbours.values()):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
start = min(neighbours)
|
|
176
|
+
order = [start]
|
|
177
|
+
previous, current = start, neighbours[start][0]
|
|
178
|
+
while current != start:
|
|
179
|
+
order.append(current)
|
|
180
|
+
first, second = neighbours[current]
|
|
181
|
+
previous, current = current, second if first == previous else first
|
|
182
|
+
return tuple(order)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def find_loops(joints: list[Joint]) -> list[list[Joint]]:
|
|
186
|
+
"""Every closed loop in the recorded joints, as its list of joints."""
|
|
187
|
+
return _components(_loop_joints(joints))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------ the four-bar
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _declared_fully_rotating(component: list[Joint]) -> tuple[str, ...]:
|
|
194
|
+
"""Loop joints declared ``continuous`` — the operand the geometry is compared to."""
|
|
195
|
+
return tuple(
|
|
196
|
+
joint.name
|
|
197
|
+
for joint in sorted(component, key=lambda j: j.name)
|
|
198
|
+
if joint.type == FULLY_ROTATING_TYPE
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _decline(
|
|
203
|
+
kind: str,
|
|
204
|
+
links: tuple[str, ...],
|
|
205
|
+
joints: tuple[str, ...],
|
|
206
|
+
because: str,
|
|
207
|
+
declared: tuple[str, ...],
|
|
208
|
+
) -> Mechanism:
|
|
209
|
+
return Mechanism(
|
|
210
|
+
kind=kind,
|
|
211
|
+
verdict="not-scored",
|
|
212
|
+
links=links,
|
|
213
|
+
joints=joints,
|
|
214
|
+
declared_fully_rotating=declared,
|
|
215
|
+
text=(
|
|
216
|
+
f"not scored: this loop is {because}. The mechanism check answers "
|
|
217
|
+
f"{', '.join(NAMED_SET)} linkages and nothing else, so no number is reported here."
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _directed_lengths(
|
|
223
|
+
order: tuple[str, ...], component: list[Joint]
|
|
224
|
+
) -> tuple[list[float], list[tuple[float, float, float]]] | None:
|
|
225
|
+
"""One length per link, or ``None`` when the loop is not a directed cycle.
|
|
226
|
+
|
|
227
|
+
Each link must be the parent of exactly one loop joint and the child of exactly
|
|
228
|
+
one other; only then are both of its attachment points expressible in its own
|
|
229
|
+
frame without forward kinematics.
|
|
230
|
+
"""
|
|
231
|
+
parented: dict[str, Joint] = {}
|
|
232
|
+
childed: dict[str, Joint] = {}
|
|
233
|
+
for joint in component:
|
|
234
|
+
if joint.parent in parented or joint.child in childed:
|
|
235
|
+
return None
|
|
236
|
+
parented[joint.parent] = joint
|
|
237
|
+
childed[joint.child] = joint
|
|
238
|
+
if set(parented) != set(order) or set(childed) != set(order):
|
|
239
|
+
return None
|
|
240
|
+
offsets = [parented[link].origin.xyz for link in order]
|
|
241
|
+
return [_norm(offset) for offset in offsets], offsets
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _analyse_loop(component: list[Joint]) -> Mechanism:
|
|
245
|
+
joint_names = tuple(sorted(joint.name for joint in component))
|
|
246
|
+
declared = _declared_fully_rotating(component)
|
|
247
|
+
order = _cycle_order(component)
|
|
248
|
+
if order is None:
|
|
249
|
+
links = tuple(sorted({j.parent for j in component} | {j.child for j in component}))
|
|
250
|
+
return _decline(
|
|
251
|
+
"coupled linkage",
|
|
252
|
+
links,
|
|
253
|
+
joint_names,
|
|
254
|
+
f"a coupled linkage: {len(links)} links share {len(component)} joints, "
|
|
255
|
+
"so it is not one simple loop",
|
|
256
|
+
declared,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
kind = _kind_word(len(order))
|
|
260
|
+
if kind not in NAMED_SET:
|
|
261
|
+
return _decline(kind, order, joint_names, f"a {kind}", declared)
|
|
262
|
+
|
|
263
|
+
types = {joint.type for joint in component}
|
|
264
|
+
if not types <= _PIN_JOINTS:
|
|
265
|
+
other = ", ".join(sorted(types - _PIN_JOINTS))
|
|
266
|
+
return _decline(
|
|
267
|
+
kind,
|
|
268
|
+
order,
|
|
269
|
+
joint_names,
|
|
270
|
+
f"a four-link loop containing a {other} joint, not a linkage of pin joints",
|
|
271
|
+
declared,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
measured = _directed_lengths(order, component)
|
|
275
|
+
if measured is None:
|
|
276
|
+
return _decline(
|
|
277
|
+
kind,
|
|
278
|
+
order,
|
|
279
|
+
joint_names,
|
|
280
|
+
"a four-link loop that is not a directed cycle (a link is the parent, or the "
|
|
281
|
+
"child, of both its joints), so its link lengths are recorded in frames this "
|
|
282
|
+
"check cannot relate",
|
|
283
|
+
declared,
|
|
284
|
+
)
|
|
285
|
+
lengths, offsets = measured
|
|
286
|
+
|
|
287
|
+
axes = [joint.axis for joint in component]
|
|
288
|
+
if any(axis is None for axis in axes):
|
|
289
|
+
return _decline(
|
|
290
|
+
kind,
|
|
291
|
+
order,
|
|
292
|
+
joint_names,
|
|
293
|
+
"a four-bar whose joint axes the contract does not record, so it cannot be "
|
|
294
|
+
"confirmed planar — re-run init against a URDF that declares joint axes",
|
|
295
|
+
declared,
|
|
296
|
+
)
|
|
297
|
+
reference = axes[0]
|
|
298
|
+
assert reference is not None
|
|
299
|
+
if any(_cross(reference, axis) != (0.0, 0.0, 0.0) for axis in axes if axis is not None):
|
|
300
|
+
return _decline(
|
|
301
|
+
kind,
|
|
302
|
+
order,
|
|
303
|
+
joint_names,
|
|
304
|
+
"a spatial four-link loop: its joint axes are not parallel, and the Grashof "
|
|
305
|
+
"condition is a statement about a planar linkage",
|
|
306
|
+
declared,
|
|
307
|
+
)
|
|
308
|
+
off_plane = [
|
|
309
|
+
link for link, offset in zip(order, offsets, strict=True) if _dot(reference, offset) != 0.0
|
|
310
|
+
]
|
|
311
|
+
if off_plane:
|
|
312
|
+
return _decline(
|
|
313
|
+
kind,
|
|
314
|
+
order,
|
|
315
|
+
joint_names,
|
|
316
|
+
"a spatial four-link loop: "
|
|
317
|
+
+ ", ".join(off_plane)
|
|
318
|
+
+ " is offset along the joint axis rather than across it, so the loop does not "
|
|
319
|
+
"lie in one plane",
|
|
320
|
+
declared,
|
|
321
|
+
)
|
|
322
|
+
if any(length == 0.0 for length in lengths):
|
|
323
|
+
zero = [link for link, length in zip(order, lengths, strict=True) if length == 0.0]
|
|
324
|
+
return _decline(
|
|
325
|
+
kind,
|
|
326
|
+
order,
|
|
327
|
+
joint_names,
|
|
328
|
+
"a four-bar with a zero-length link (" + ", ".join(zero) + "): its two joint "
|
|
329
|
+
"origins coincide, so there is no length to compare",
|
|
330
|
+
declared,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
ordered = sorted(lengths)
|
|
334
|
+
shortest, longest = ordered[0], ordered[3]
|
|
335
|
+
s_plus_l = shortest + longest
|
|
336
|
+
p_plus_q = ordered[1] + ordered[2]
|
|
337
|
+
crank = order[lengths.index(shortest)]
|
|
338
|
+
sums = f"s + l = {_fmt(s_plus_l)} m, p + q = {_fmt(p_plus_q)} m"
|
|
339
|
+
|
|
340
|
+
if s_plus_l < p_plus_q:
|
|
341
|
+
verdict: Verdict = "rotates"
|
|
342
|
+
text = (
|
|
343
|
+
f"this four-bar has a fully rotating link: {sums}, and the Grashof condition "
|
|
344
|
+
f"s + l <= p + q holds, so the shortest link ({crank}, {_fmt(shortest)} m) turns "
|
|
345
|
+
"all the way round relative to the other three."
|
|
346
|
+
)
|
|
347
|
+
elif s_plus_l == p_plus_q:
|
|
348
|
+
verdict = "change-point"
|
|
349
|
+
text = (
|
|
350
|
+
f"this four-bar is a change-point linkage: {sums} are equal, the boundary of the "
|
|
351
|
+
f"Grashof condition. The shortest link ({crank}, {_fmt(shortest)} m) can turn all "
|
|
352
|
+
"the way round, but the linkage passes through a folded position where its motion "
|
|
353
|
+
"is indeterminate — which branch it takes is not decided by the link lengths."
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
verdict = "locking"
|
|
357
|
+
text = (
|
|
358
|
+
f"this four-bar has no fully rotating link: {sums}, so the Grashof condition "
|
|
359
|
+
"s + l <= p + q fails and every link only rocks between two limits."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return Mechanism(
|
|
363
|
+
kind=kind,
|
|
364
|
+
verdict=verdict,
|
|
365
|
+
links=order,
|
|
366
|
+
joints=joint_names,
|
|
367
|
+
text=text,
|
|
368
|
+
s_plus_l=s_plus_l,
|
|
369
|
+
p_plus_q=p_plus_q,
|
|
370
|
+
lengths_m=tuple(lengths),
|
|
371
|
+
declared_fully_rotating=declared,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def analyse_model(model: ModelRecord | None) -> list[Mechanism]:
|
|
376
|
+
"""Every closed loop the recorded model contains, answered or declined by name."""
|
|
377
|
+
if model is None or not model.joints:
|
|
378
|
+
return []
|
|
379
|
+
return [_analyse_loop(component) for component in find_loops(model.joints)]
|