openral-cli 0.1.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.
- openral_cli/__init__.py +6 -0
- openral_cli/_hf_publish.py +125 -0
- openral_cli/_rskill_doc_validator.py +562 -0
- openral_cli/_rskill_intel.py +334 -0
- openral_cli/_rskill_readme.py +217 -0
- openral_cli/_rskill_scaffolder.py +267 -0
- openral_cli/autodetect.py +464 -0
- openral_cli/check.py +397 -0
- openral_cli/collision.py +369 -0
- openral_cli/dataset.py +438 -0
- openral_cli/deploy_sim.py +2664 -0
- openral_cli/install.py +422 -0
- openral_cli/main.py +4108 -0
- openral_cli/prompt.py +128 -0
- openral_cli/py.typed +0 -0
- openral_cli/robot.py +301 -0
- openral_cli-0.1.0.dist-info/METADATA +33 -0
- openral_cli-0.1.0.dist-info/RECORD +20 -0
- openral_cli-0.1.0.dist-info/WHEEL +4 -0
- openral_cli-0.1.0.dist-info/entry_points.txt +2 -0
openral_cli/collision.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""``openral collision lower|check`` — offline URDF/SRDF → manifest collision model.
|
|
2
|
+
|
|
3
|
+
Lowers a robot's URDF (geometry) and SRDF (allowed-collision matrix, when present;
|
|
4
|
+
random-pose sampling otherwise) into ``robot.yaml``'s ``collision_geometry`` +
|
|
5
|
+
``allowed_collision_pairs`` — the blocks the C++ safety kernel consumes via
|
|
6
|
+
``collision_params_from_description``. Because those manifests carry
|
|
7
|
+
hand-written safety commentary, the writer splices **only** the two collision
|
|
8
|
+
blocks, leaving every other line (and its comments) byte-for-byte intact.
|
|
9
|
+
|
|
10
|
+
``lower`` prints a unified diff by default and mutates only with ``--write``; a
|
|
11
|
+
regenerated ACM never changes silently (a safety input — CLAUDE.md §3). ``check``
|
|
12
|
+
fails (exit 1) when any manifest drifts from its lowered model.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import difflib
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from openral_core.exceptions import ROSError
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from openral_core import RobotDescription
|
|
28
|
+
from openral_safety.urdf_lowering import LoweredCollisionModel
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"collision_app",
|
|
32
|
+
"inject_joint_fk",
|
|
33
|
+
"render_blocks",
|
|
34
|
+
"splice_collision_blocks",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
_console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _replace_block(text: str, key: str, new_block: str) -> str:
|
|
41
|
+
"""Replace the ``key:`` top-level block with ``new_block``, preserving neighbours.
|
|
42
|
+
|
|
43
|
+
The block is the ``key:`` line plus all following indented or blank lines; it
|
|
44
|
+
ends at the first column-0 non-blank line — whether that's the next top-level
|
|
45
|
+
key OR a comment that introduces the next section. Trailing blank lines are
|
|
46
|
+
returned to the following segment so the blank separator (and any column-0
|
|
47
|
+
comment that documents the *next* block) survives the splice. When the key is
|
|
48
|
+
absent (a manifest onboarded onto self-collision for the first time) the block
|
|
49
|
+
is appended at the end of the file.
|
|
50
|
+
"""
|
|
51
|
+
lines = text.splitlines(keepends=True)
|
|
52
|
+
key_idx = next((i for i, ln in enumerate(lines) if ln.startswith(f"{key}:")), None)
|
|
53
|
+
if key_idx is None:
|
|
54
|
+
# Absent block (a manifest being onboarded onto self-collision for the
|
|
55
|
+
# first time) → append at end of file.
|
|
56
|
+
if not new_block.endswith("\n"):
|
|
57
|
+
new_block += "\n"
|
|
58
|
+
sep = "" if text.endswith("\n") or not text else "\n"
|
|
59
|
+
return text + sep + "\n" + new_block
|
|
60
|
+
# Absorb a contiguous run of comment lines immediately above the key (the
|
|
61
|
+
# block's own header — a prior "# GENERATED" line or the hand comment that
|
|
62
|
+
# documents this block) so repeated lowers replace it instead of stacking a
|
|
63
|
+
# second header. Stops at the first blank / non-comment line, so a separator
|
|
64
|
+
# blank and the preceding block stay put.
|
|
65
|
+
start = key_idx
|
|
66
|
+
while start - 1 >= 0 and lines[start - 1].lstrip().startswith("#"):
|
|
67
|
+
start -= 1
|
|
68
|
+
end = key_idx + 1
|
|
69
|
+
while end < len(lines):
|
|
70
|
+
ln = lines[end]
|
|
71
|
+
if ln.strip() == "" or ln[0] in (" ", "\t"): # blank or indented → in block
|
|
72
|
+
end += 1
|
|
73
|
+
continue
|
|
74
|
+
break # column-0 non-blank (next key or section comment) → block ends
|
|
75
|
+
# Keep trailing blank lines as separators in the following segment.
|
|
76
|
+
while end - 1 > key_idx and lines[end - 1].strip() == "":
|
|
77
|
+
end -= 1
|
|
78
|
+
if not new_block.endswith("\n"):
|
|
79
|
+
new_block += "\n"
|
|
80
|
+
return "".join(lines[:start]) + new_block + "".join(lines[end:])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def splice_collision_blocks(
|
|
84
|
+
text: str, *, geometry_block: str | None = None, acm_block: str | None = None
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Return ``text`` with the two collision blocks replaced (each optional).
|
|
87
|
+
|
|
88
|
+
Only ``collision_geometry`` / ``allowed_collision_pairs`` are touched; every
|
|
89
|
+
other key and comment is preserved verbatim.
|
|
90
|
+
"""
|
|
91
|
+
if geometry_block is not None:
|
|
92
|
+
text = _replace_block(text, "collision_geometry", geometry_block)
|
|
93
|
+
if acm_block is not None:
|
|
94
|
+
text = _replace_block(text, "allowed_collision_pairs", acm_block)
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_JOINT_NAME_RE = re.compile(r'^(\s*)-\s*name:\s*["\']?([^"\'\s]+)')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# FK via matrix inverse leaves sub-nm noise; snap anything below this to zero
|
|
102
|
+
# (no real link offset is below a nanometre) for stable, reviewable output.
|
|
103
|
+
_FK_ZERO_SNAP_M = 1e-9
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fmt(v: float) -> str:
|
|
107
|
+
"""Format an FK scalar cleanly (8 sig figs; snap float noise / -0.0 to 0.0)."""
|
|
108
|
+
if abs(v) < _FK_ZERO_SNAP_M:
|
|
109
|
+
v = 0.0
|
|
110
|
+
return f"{v + 0.0:.8g}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_Vec3 = tuple[float, float, float]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def inject_joint_fk(text: str, joint_fk: dict[str, tuple[_Vec3, _Vec3, _Vec3]]) -> str:
|
|
117
|
+
"""Inject ``origin_xyz`` / ``origin_rpy`` / ``axis_xyz`` into named joint blocks.
|
|
118
|
+
|
|
119
|
+
For each joint in ``joint_fk`` (keyed by manifest joint name), find its
|
|
120
|
+
``- name: "<name>"`` list item under ``joints:``, drop any existing
|
|
121
|
+
origin/axis lines in that item, and insert the lowered values right after the
|
|
122
|
+
name line. The kernel needs these to place the link capsules. Joints
|
|
123
|
+
not in ``joint_fk`` are untouched. Idempotent: re-running drops and re-inserts
|
|
124
|
+
the same lines. Every other line and comment is preserved.
|
|
125
|
+
"""
|
|
126
|
+
if not joint_fk:
|
|
127
|
+
return text
|
|
128
|
+
lines = text.splitlines(keepends=True)
|
|
129
|
+
out: list[str] = []
|
|
130
|
+
i, n = 0, len(lines)
|
|
131
|
+
while i < n:
|
|
132
|
+
ln = lines[i]
|
|
133
|
+
m = _JOINT_NAME_RE.match(ln)
|
|
134
|
+
jname = m.group(2) if m else None
|
|
135
|
+
if m is None or jname is None or jname not in joint_fk:
|
|
136
|
+
out.append(ln)
|
|
137
|
+
i += 1
|
|
138
|
+
continue
|
|
139
|
+
dash_indent = len(m.group(1)) # spaces before '-'
|
|
140
|
+
field_indent = " " * (dash_indent + 2)
|
|
141
|
+
xyz, rpy, axis = joint_fk[jname]
|
|
142
|
+
out.append(ln) # keep the name line
|
|
143
|
+
out.append(f"{field_indent}origin_xyz: [{', '.join(_fmt(v) for v in xyz)}]\n")
|
|
144
|
+
out.append(f"{field_indent}origin_rpy: [{', '.join(_fmt(v) for v in rpy)}]\n")
|
|
145
|
+
out.append(f"{field_indent}axis_xyz: [{', '.join(_fmt(v) for v in axis)}]\n")
|
|
146
|
+
i += 1
|
|
147
|
+
# Copy the rest of this joint's block, dropping any pre-existing FK lines.
|
|
148
|
+
while i < n:
|
|
149
|
+
l2 = lines[i]
|
|
150
|
+
indent = len(l2) - len(l2.lstrip())
|
|
151
|
+
if l2.strip() != "" and indent <= dash_indent:
|
|
152
|
+
break # sibling list item or dedent → block ended
|
|
153
|
+
if l2.lstrip().startswith(("origin_xyz:", "origin_rpy:", "axis_xyz:")):
|
|
154
|
+
i += 1
|
|
155
|
+
continue
|
|
156
|
+
out.append(l2)
|
|
157
|
+
i += 1
|
|
158
|
+
return "".join(out)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_blocks(model: LoweredCollisionModel) -> tuple[str, str]:
|
|
162
|
+
"""Render a :class:`LoweredCollisionModel` to ``(geometry_block, acm_block)`` YAML.
|
|
163
|
+
|
|
164
|
+
Both blocks open with a generated-provenance comment so a reader knows the tool
|
|
165
|
+
owns them; floats are rounded to 4 dp for a stable, reviewable diff.
|
|
166
|
+
"""
|
|
167
|
+
geo_lines = [
|
|
168
|
+
"# GENERATED by `openral collision lower` — do not hand-edit.\n",
|
|
169
|
+
"collision_geometry:\n",
|
|
170
|
+
]
|
|
171
|
+
for g in model.collision_geometry:
|
|
172
|
+
geo_lines.append(f' - link_name: "{g.link_name}"\n')
|
|
173
|
+
if g.shape.shape == "sphere":
|
|
174
|
+
geo_lines.append(
|
|
175
|
+
f' shape: {{ shape: "sphere", radius_m: {g.shape.radius_m:.4f} }}\n'
|
|
176
|
+
)
|
|
177
|
+
elif g.shape.shape == "box":
|
|
178
|
+
hx, hy, hz = g.shape.half_extents_m
|
|
179
|
+
geo_lines.append(
|
|
180
|
+
f' shape: {{ shape: "box", half_extents_m: [{hx:.4f}, {hy:.4f}, {hz:.4f}] }}\n'
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
geo_lines.append(
|
|
184
|
+
f' shape: {{ shape: "capsule", radius_m: {g.shape.radius_m:.4f}, '
|
|
185
|
+
f"length_m: {g.shape.length_m:.4f} }}\n"
|
|
186
|
+
)
|
|
187
|
+
geo_lines.append(
|
|
188
|
+
f" origin_xyz_rpy: [{', '.join(f'{v:.4f}' for v in g.origin_xyz_rpy)}]\n"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
acm_lines = [
|
|
192
|
+
f"# GENERATED by `openral collision lower` (source: {model.acm_source}) — "
|
|
193
|
+
"do not hand-edit.\n",
|
|
194
|
+
"allowed_collision_pairs:\n",
|
|
195
|
+
]
|
|
196
|
+
for a, b in model.allowed_collision_pairs:
|
|
197
|
+
acm_lines.append(f" - [{a}, {b}]\n")
|
|
198
|
+
return "".join(geo_lines), "".join(acm_lines)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
collision_app = typer.Typer(
|
|
204
|
+
name="collision",
|
|
205
|
+
help="Lower a robot's URDF/SRDF into its self-collision model.",
|
|
206
|
+
no_args_is_help=True,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _lower(
|
|
211
|
+
robot_path: Path, *, acm_only: bool, geometry_only: bool
|
|
212
|
+
) -> tuple[RobotDescription, LoweredCollisionModel]:
|
|
213
|
+
"""Load a manifest and lower its collision model via the provenance dispatcher.
|
|
214
|
+
|
|
215
|
+
Route via the provenance-correct dispatcher: SRDF+URDF → SRDF
|
|
216
|
+
ACM, URDF-with-usable-meshes → sampling, MJCF-native → MJCF. The naive
|
|
217
|
+
``urdf if assets.urdf else mjcf`` wrongly sent openarm (unusable URDF meshes)
|
|
218
|
+
to the URDF path. The byte-identical regression test routes through this same
|
|
219
|
+
dispatcher.
|
|
220
|
+
"""
|
|
221
|
+
from openral_core import RobotDescription
|
|
222
|
+
from openral_safety.urdf_lowering import lower_robot_auto
|
|
223
|
+
|
|
224
|
+
robot = RobotDescription.from_yaml(str(robot_path))
|
|
225
|
+
model = lower_robot_auto(
|
|
226
|
+
robot, acm_only=acm_only, geometry_only=geometry_only, manifest_dir=robot_path.parent
|
|
227
|
+
)
|
|
228
|
+
return robot, model
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _lowered_text(robot_path: Path, *, acm_only: bool, geometry_only: bool) -> tuple[str, str]:
|
|
232
|
+
"""Return ``(current_manifest_text, spliced_manifest_text)`` for a manifest.
|
|
233
|
+
|
|
234
|
+
Shared by ``lower`` and ``check`` (and the regression tests). Loads the robot,
|
|
235
|
+
lowers its collision model, renders the affected block(s), and splices them
|
|
236
|
+
into the on-disk text — touching only the requested block(s).
|
|
237
|
+
"""
|
|
238
|
+
_robot, model = _lower(robot_path, acm_only=acm_only, geometry_only=geometry_only)
|
|
239
|
+
geo_block, acm_block = render_blocks(model)
|
|
240
|
+
current = robot_path.read_text(encoding="utf-8")
|
|
241
|
+
# MJCF-sourced robots keep their hand-authored geometry (the tool reuses it,
|
|
242
|
+
# doesn't regenerate it), so never rewrite the geometry block for them.
|
|
243
|
+
write_geometry = not acm_only and model.acm_source != "mjcf"
|
|
244
|
+
spliced = splice_collision_blocks(
|
|
245
|
+
current,
|
|
246
|
+
geometry_block=geo_block if write_geometry else None,
|
|
247
|
+
acm_block=None if geometry_only else acm_block,
|
|
248
|
+
)
|
|
249
|
+
# When onboarding (not --acm-only), the kernel also needs each link's parent-joint
|
|
250
|
+
# FK; inject it into the joints block.
|
|
251
|
+
if not acm_only:
|
|
252
|
+
spliced = inject_joint_fk(spliced, model.joint_fk)
|
|
253
|
+
return current, spliced
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@collision_app.command("lower")
|
|
257
|
+
def lower(
|
|
258
|
+
robot: Path = typer.Option(..., "--robot", help="Path to a robot.yaml manifest."),
|
|
259
|
+
write: bool = typer.Option(False, "--write", help="Apply the change (default: dry diff)."),
|
|
260
|
+
acm_only: bool = typer.Option(
|
|
261
|
+
False, "--acm-only", help="Only regenerate allowed_collision_pairs."
|
|
262
|
+
),
|
|
263
|
+
geometry_only: bool = typer.Option(
|
|
264
|
+
False, "--geometry-only", help="Only regenerate collision_geometry."
|
|
265
|
+
),
|
|
266
|
+
emit_cumotion: Path | None = typer.Option(
|
|
267
|
+
None,
|
|
268
|
+
"--emit-cumotion",
|
|
269
|
+
help="Also write a cuRobo robot-config (collision spheres + ACM) to this path.",
|
|
270
|
+
),
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Lower URDF/SRDF → collision model. Prints a diff; mutates only with ``--write``.
|
|
273
|
+
|
|
274
|
+
A regenerated allowed-collision matrix is a safety input — review the diff with
|
|
275
|
+
the safety WG before merging (CLAUDE.md §3). ``--emit-cumotion <path>`` also
|
|
276
|
+
derives a cuRobo robot-config from the *same* lowered geometry so cuMotion's
|
|
277
|
+
plan-time collision matches the kernel's; it writes only with
|
|
278
|
+
``--write`` (dry run prints the config).
|
|
279
|
+
"""
|
|
280
|
+
if acm_only and geometry_only:
|
|
281
|
+
_console.print("[red]--acm-only and --geometry-only are mutually exclusive.[/red]")
|
|
282
|
+
raise typer.Exit(code=2)
|
|
283
|
+
if not robot.exists():
|
|
284
|
+
_console.print(f"[red]Robot description not found:[/red] {robot}")
|
|
285
|
+
raise typer.Exit(code=2)
|
|
286
|
+
if emit_cumotion is not None:
|
|
287
|
+
from openral_safety.cumotion_config import render_cumotion_config
|
|
288
|
+
|
|
289
|
+
desc, model = _lower(robot, acm_only=False, geometry_only=False)
|
|
290
|
+
config_text = render_cumotion_config(desc, model)
|
|
291
|
+
if write:
|
|
292
|
+
emit_cumotion.write_text(config_text, encoding="utf-8")
|
|
293
|
+
_console.print(f"[green]Wrote[/green] cuRobo config → {emit_cumotion}")
|
|
294
|
+
else:
|
|
295
|
+
_console.print(config_text, markup=False, highlight=False)
|
|
296
|
+
_console.print(
|
|
297
|
+
f"[yellow]Dry run.[/yellow] Re-run with [bold]--write[/bold] to write "
|
|
298
|
+
f"{emit_cumotion}."
|
|
299
|
+
)
|
|
300
|
+
current, spliced = _lowered_text(robot, acm_only=acm_only, geometry_only=geometry_only)
|
|
301
|
+
if current == spliced:
|
|
302
|
+
_console.print("[green]No change — manifest already matches the lowered model.[/green]")
|
|
303
|
+
return
|
|
304
|
+
diff = difflib.unified_diff(
|
|
305
|
+
current.splitlines(keepends=True),
|
|
306
|
+
spliced.splitlines(keepends=True),
|
|
307
|
+
fromfile=f"{robot} (current)",
|
|
308
|
+
tofile=f"{robot} (lowered)",
|
|
309
|
+
)
|
|
310
|
+
# markup=False / highlight=False: the diff body contains "[a, b]" ACM rows that
|
|
311
|
+
# rich would otherwise parse as console-markup tags and drop.
|
|
312
|
+
_console.print("".join(diff), markup=False, highlight=False)
|
|
313
|
+
if write:
|
|
314
|
+
robot.write_text(spliced, encoding="utf-8")
|
|
315
|
+
_console.print(
|
|
316
|
+
f"[green]Wrote[/green] {robot} — review the ACM diff with the safety WG (CLAUDE.md §3)."
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
_console.print("[yellow]Dry run.[/yellow] Re-run with [bold]--write[/bold] to apply.")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@collision_app.command("check")
|
|
323
|
+
def check(
|
|
324
|
+
robot: Path | None = typer.Option(
|
|
325
|
+
None, "--robot", help="A single robot.yaml; omit with --all."
|
|
326
|
+
),
|
|
327
|
+
all_robots: bool = typer.Option(
|
|
328
|
+
False, "--all", help="Check every robots/*/robot.yaml with collision geometry."
|
|
329
|
+
),
|
|
330
|
+
acm_only: bool = typer.Option(False, "--acm-only", help="Only check allowed_collision_pairs."),
|
|
331
|
+
geometry_only: bool = typer.Option(
|
|
332
|
+
False, "--geometry-only", help="Only check collision_geometry."
|
|
333
|
+
),
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Fail (exit 1) if any manifest drifts from its lowered collision model."""
|
|
336
|
+
if acm_only and geometry_only:
|
|
337
|
+
_console.print("[red]--acm-only and --geometry-only are mutually exclusive.[/red]")
|
|
338
|
+
raise typer.Exit(code=2)
|
|
339
|
+
if all_robots:
|
|
340
|
+
targets = [
|
|
341
|
+
p
|
|
342
|
+
for p in sorted(Path("robots").glob("*/robot.yaml"))
|
|
343
|
+
if "allowed_collision_pairs" in p.read_text(encoding="utf-8")
|
|
344
|
+
]
|
|
345
|
+
elif robot is not None:
|
|
346
|
+
targets = [robot]
|
|
347
|
+
else:
|
|
348
|
+
_console.print("[red]Pass --robot <path> or --all.[/red]")
|
|
349
|
+
raise typer.Exit(code=2)
|
|
350
|
+
|
|
351
|
+
drift: list[Path] = []
|
|
352
|
+
for t in targets:
|
|
353
|
+
try:
|
|
354
|
+
current, spliced = _lowered_text(t, acm_only=acm_only, geometry_only=geometry_only)
|
|
355
|
+
except (ValueError, FileNotFoundError, ROSError) as exc:
|
|
356
|
+
# ROSError covers ROSConfigError, now raised by lower_robot when a
|
|
357
|
+
# manifest declares no URDF/MJCF asset or an asset ref won't resolve.
|
|
358
|
+
_console.print(f"[yellow]skip[/yellow] {t}: {exc}")
|
|
359
|
+
continue
|
|
360
|
+
if current != spliced:
|
|
361
|
+
drift.append(t)
|
|
362
|
+
_console.print(f"[red]drift[/red] {t} differs from its lowered model")
|
|
363
|
+
if drift:
|
|
364
|
+
_console.print(
|
|
365
|
+
f"[red]{len(drift)} manifest(s) drifted — run "
|
|
366
|
+
"`openral collision lower --robot <path> --write`.[/red]"
|
|
367
|
+
)
|
|
368
|
+
raise typer.Exit(code=1)
|
|
369
|
+
_console.print("[green]All checked manifests match their lowered collision model.[/green]")
|