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.
@@ -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]")