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.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. 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)]