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/sdf.py ADDED
@@ -0,0 +1,240 @@
1
+ """SDF -> Tier A extraction (`lockwire init`/`start`, F3). Stdlib only for XML.
2
+
3
+ Reuses `urdf._principal` (inertia tensor -> principal moments/axes, same trap
4
+ as URDF: inertia is about the COM in the inertial frame) and `urdf._model_structure_hash`
5
+ via `schema.py`'s validator, plus `scene_urdf`'s pure-python pose algebra (already
6
+ used to place SDF/URDF visuals) so the joint-origin conversion below and the
7
+ scene renderer agree on what a pose means.
8
+
9
+ Pose conversion (the one trap this module exists to handle):
10
+
11
+ SDF gives every `<link>` a `<pose>` that is model-relative (this build does not
12
+ walk `relative_to` chains — same assumption `scene_urdf._scene_from_sdf` makes).
13
+ A `<joint>`'s own `<pose>` is relative to the CHILD link frame unless a
14
+ `relative_to` attribute says otherwise (SDF spec v1.9,
15
+ http://sdformat.org/spec?ver=1.9&elem=joint, `pose` element description).
16
+ URDF's `<joint><origin>`, by contrast, *is* the parent-frame pose of the child
17
+ link frame — it's how URDF places the child at all.
18
+
19
+ So to store a joint origin here that means what URDF's `<origin>` means, this
20
+ composes: joint_frame_in_model = child_link_pose ∘ joint.pose (child-relative
21
+ offset), then converts to the parent's frame:
22
+
23
+ origin_in_parent = inverse(parent_link_pose) ∘ joint_frame_in_model
24
+
25
+ When (as in nearly every real SDF, including PX4 Vector's) the joint carries no
26
+ `<pose>` of its own, this reduces to `inverse(parent) ∘ child` — the child's
27
+ pose expressed in the parent's frame, which is exactly the URDF-equivalence a
28
+ mixed-format drift check needs.
29
+ """
30
+
31
+ import datetime as dt
32
+ import hashlib
33
+ import xml.etree.ElementTree as ET
34
+ from math import asin, atan2, cos
35
+ from pathlib import Path
36
+
37
+ from lockwire.schema import (
38
+ Joint,
39
+ JointLimits,
40
+ JointOrigin,
41
+ Link,
42
+ ModelRecord,
43
+ PrincipalInertia,
44
+ Provenance,
45
+ Quantity,
46
+ _model_structure_hash,
47
+ )
48
+ from lockwire.scene_urdf import _apply_r, _compose, _first_model, _IDENTITY_POSE, _mat3_mul, _Pose
49
+ from lockwire.urdf import UrdfTopology, _principal, _refuse_doctype_entity
50
+
51
+
52
+ def _sdf_pose(
53
+ pose_el: ET.Element | None,
54
+ ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
55
+ text = pose_el.text if pose_el is not None and pose_el.text else "0 0 0 0 0 0"
56
+ vals = [float(x) for x in text.split()]
57
+ vals += [0.0] * (6 - len(vals))
58
+ return (vals[0], vals[1], vals[2]), (vals[3], vals[4], vals[5])
59
+
60
+
61
+ def _pose_mul(a: _Pose, b: _Pose) -> _Pose:
62
+ ra, ta = a
63
+ rb, tb = b
64
+ r_new = _mat3_mul(ra, rb)
65
+ dt_ = _apply_r(ra, tb)
66
+ t_new = (ta[0] + dt_[0], ta[1] + dt_[1], ta[2] + dt_[2])
67
+ return r_new, t_new
68
+
69
+
70
+ def _pose_inverse(pose: _Pose) -> _Pose:
71
+ r_mat, t = pose
72
+ r_t = tuple(tuple(r_mat[j][i] for j in range(3)) for i in range(3))
73
+ t_inv = tuple(-x for x in _apply_r(r_t, t))
74
+ return r_t, t_inv
75
+
76
+
77
+ def _matrix_to_rpy(r_mat: tuple[tuple[float, float, float], ...]) -> tuple[float, float, float]:
78
+ """Inverse of `scene_urdf._rpy_matrix` (URDF/SDF's Rz(yaw)*Ry(pitch)*Rx(roll))."""
79
+ sp = max(-1.0, min(1.0, -r_mat[2][0]))
80
+ pitch = asin(sp)
81
+ cp = cos(pitch)
82
+ if abs(cp) > 1e-8:
83
+ roll = atan2(r_mat[2][1], r_mat[2][2])
84
+ yaw = atan2(r_mat[1][0], r_mat[0][0])
85
+ else:
86
+ # Gimbal lock: roll/yaw are not independently recoverable; pin roll=0.
87
+ roll = 0.0
88
+ yaw = atan2(-r_mat[0][1], r_mat[1][1])
89
+ # `-0.0 + 0.0 == 0.0`: normalize sign so an unrotated axis hashes identically
90
+ # to the URDF door's literal `0.0`, instead of `json.dumps` emitting "-0.0".
91
+ return roll + 0.0, pitch + 0.0, yaw + 0.0
92
+
93
+
94
+ def _link_poses(model_el: ET.Element) -> dict[str, _Pose]:
95
+ poses: dict[str, _Pose] = {}
96
+ for link in model_el.findall("link"):
97
+ name = link.get("name", "?")
98
+ xyz, rpy = _sdf_pose(link.find("pose"))
99
+ poses[name] = _compose(_IDENTITY_POSE, xyz, rpy)
100
+ return poses
101
+
102
+
103
+ def _parse_link(link_el: ET.Element) -> tuple[Link, bool]:
104
+ """Returns (link, is_zero_mass). Mirrors `urdf._parse_link`."""
105
+ name = link_el.get("name", "?")
106
+ inertial = link_el.find("inertial")
107
+ if inertial is None:
108
+ return Link(name=name), True
109
+
110
+ mass_text = inertial.findtext("mass")
111
+ mass_val = float(mass_text) if mass_text is not None else 0.0
112
+ if mass_val == 0.0:
113
+ return Link(name=name), True
114
+
115
+ xyz, _rpy = _sdf_pose(inertial.find("pose"))
116
+
117
+ inertia_el = inertial.find("inertia")
118
+ inertia: PrincipalInertia | None = None
119
+ if inertia_el is not None:
120
+ inertia = _principal(
121
+ *(
122
+ float(inertia_el.findtext(k) or "0")
123
+ for k in ("ixx", "ixy", "ixz", "iyy", "iyz", "izz")
124
+ )
125
+ )
126
+
127
+ link = Link(
128
+ name=name, mass=Quantity(value=mass_val, unit="kg"), com=xyz, frame=name, inertia=inertia
129
+ )
130
+ return link, False
131
+
132
+
133
+ def _parse_joints(model_el: ET.Element, link_poses: dict[str, _Pose]) -> list[Joint]:
134
+ joints: list[Joint] = []
135
+ for joint_el in model_el.findall("joint"):
136
+ name = joint_el.get("name", "?")
137
+ parent = joint_el.findtext("parent") or "?"
138
+ child = joint_el.findtext("child") or "?"
139
+
140
+ j_xyz, j_rpy = _sdf_pose(joint_el.find("pose"))
141
+ child_pose = link_poses.get(child, _IDENTITY_POSE)
142
+ joint_frame_in_model = _compose(child_pose, j_xyz, j_rpy)
143
+ parent_pose = link_poses.get(parent, _IDENTITY_POSE)
144
+ origin_in_parent = _pose_mul(_pose_inverse(parent_pose), joint_frame_in_model)
145
+ r_origin, t_origin = origin_in_parent
146
+ origin = JointOrigin(xyz=t_origin, rpy=_matrix_to_rpy(r_origin)) # type: ignore[arg-type]
147
+
148
+ axis_el = joint_el.find("axis")
149
+ axis = None
150
+ limits = None
151
+ if axis_el is not None:
152
+ axis_text = axis_el.findtext("xyz")
153
+ if axis_text:
154
+ axis = tuple(float(x) for x in axis_text.split()) # type: ignore[assignment]
155
+ limit_el = axis_el.find("limit")
156
+ if limit_el is not None:
157
+ lower_raw = limit_el.findtext("lower")
158
+ upper_raw = limit_el.findtext("upper")
159
+ effort_raw = limit_el.findtext("effort")
160
+ velocity_raw = limit_el.findtext("velocity")
161
+ limits = JointLimits(
162
+ lower=float(lower_raw) if lower_raw is not None else None,
163
+ upper=float(upper_raw) if upper_raw is not None else None,
164
+ effort=float(effort_raw) if effort_raw is not None else None,
165
+ velocity=float(velocity_raw) if velocity_raw is not None else None,
166
+ )
167
+
168
+ joints.append(
169
+ Joint(
170
+ name=name,
171
+ type=joint_el.get("type", "fixed"), # type: ignore[arg-type]
172
+ parent=parent,
173
+ child=child,
174
+ origin=origin,
175
+ axis=axis,
176
+ limits=limits,
177
+ )
178
+ )
179
+ return joints
180
+
181
+
182
+ def _model_element(root: ET.Element, path: Path) -> ET.Element:
183
+ if root.tag != "sdf":
184
+ raise ValueError(f"{path}: root element is <{root.tag}>, expected <sdf>")
185
+ model_el = _first_model(root)
186
+ if model_el is None:
187
+ raise ValueError(f"{path}: no <model> element found")
188
+ return model_el
189
+
190
+
191
+ def _parse(path: Path) -> tuple[ET.Element, list[Link], list[str], list[Joint]]:
192
+ raw = path.read_bytes()
193
+ _refuse_doctype_entity(raw, path)
194
+ root = ET.fromstring(raw)
195
+ model_el = _model_element(root, path)
196
+
197
+ link_els = model_el.findall("link")
198
+ if not link_els:
199
+ raise ValueError(f"{path}: no <link> elements found")
200
+
201
+ links: list[Link] = []
202
+ zero_mass: list[str] = []
203
+ for link_el in link_els:
204
+ link, is_zero = _parse_link(link_el)
205
+ links.append(link)
206
+ if is_zero:
207
+ zero_mass.append(link.name)
208
+
209
+ link_poses = _link_poses(model_el)
210
+ joints = _parse_joints(model_el, link_poses)
211
+ return root, links, sorted(zero_mass), joints
212
+
213
+
214
+ def parse_sdf(path: Path) -> ModelRecord:
215
+ """First `<model>` in `path` (or the first inside a `<world>`) -> `ModelRecord`."""
216
+ raw = path.read_bytes()
217
+ root, links, zero_mass, joints = _parse(path)
218
+ return ModelRecord(
219
+ source=str(path),
220
+ source_sha256=hashlib.sha256(raw).hexdigest(),
221
+ structure_hash=_model_structure_hash(links, joints), # re-derived by the
222
+ # schema validator when joints is non-empty; kept here for the (rare)
223
+ # zero-joint single-link model where the validator leaves it as-is.
224
+ links=links,
225
+ joints=joints,
226
+ zero_mass_links=zero_mass,
227
+ provenance=Provenance(witness="w2", source=str(path), locked_at=dt.date.today()),
228
+ )
229
+
230
+
231
+ def read_sdf_topology(path: Path) -> UrdfTopology:
232
+ """SDF twin of `urdf.read_urdf_topology`, for `checks.check_urdf_drift`."""
233
+ _root, links, _zero_mass, joints = _parse(path)
234
+ record_hash = _model_structure_hash(links, joints)
235
+ return UrdfTopology(
236
+ link_names=frozenset(link.name for link in links),
237
+ joints=tuple(joints),
238
+ topology_hash=record_hash,
239
+ record_hash=record_hash,
240
+ )