lucen-cli 0.1.3__tar.gz → 0.1.4__tar.gz

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 (110) hide show
  1. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/PKG-INFO +1 -1
  2. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/pyproject.toml +1 -1
  3. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/__init__.py +1 -1
  4. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/client.py +17 -0
  5. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_model.py +12 -1
  6. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_robot.py +4 -2
  7. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/contract_derive.py +50 -3
  8. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/embodiment.py +176 -23
  9. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/model_card.py +23 -5
  10. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/mirror.py +2 -0
  11. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/ppo.py +2 -1
  12. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/tasks.py +34 -4
  13. lucen_cli-0.1.4/src/lucen_cli/rl/terrain.py +282 -0
  14. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/vecenv.py +43 -9
  15. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/checks.py +29 -6
  16. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/publish.py +9 -1
  17. lucen_cli-0.1.4/src/lucen_cli/robot/studio.py +266 -0
  18. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/thumbnail.py +56 -8
  19. lucen_cli-0.1.4/src/lucen_cli/rollout/clip.py +389 -0
  20. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/conditions.py +36 -0
  21. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/contract.py +63 -1
  22. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/engine.py +105 -22
  23. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/plant.py +138 -27
  24. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/worker.py +36 -6
  25. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_mujoco.py +183 -20
  26. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/LICENSE +0 -0
  27. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/README.md +0 -0
  28. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/__init__.py +0 -0
  29. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/errors.py +0 -0
  30. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/hub.py +0 -0
  31. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/progress.py +0 -0
  32. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/py.typed +0 -0
  33. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/results.py +0 -0
  34. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_dir.py +0 -0
  35. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_publisher.py +0 -0
  36. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_auth.py +0 -0
  37. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_campaigns.py +0 -0
  38. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_datasets.py +0 -0
  39. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_holds.py +0 -0
  40. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_hub.py +0 -0
  41. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_org.py +0 -0
  42. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_quickstart.py +0 -0
  43. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_repo.py +0 -0
  44. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_rollout.py +0 -0
  45. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_runs.py +0 -0
  46. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_serve.py +0 -0
  47. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_worker.py +0 -0
  48. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/config.py +0 -0
  49. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/context.py +0 -0
  50. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/curves_view.py +0 -0
  51. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/errors.py +0 -0
  52. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/extras.py +0 -0
  53. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/gate_scorer.py +0 -0
  54. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/gates_view.py +0 -0
  55. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/group.py +0 -0
  56. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/hf_dataset.py +0 -0
  57. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/live.py +0 -0
  58. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/local.py +0 -0
  59. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/main.py +0 -0
  60. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/offscreen.py +0 -0
  61. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/onnx_manifest.py +0 -0
  62. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/onnx_writer.py +0 -0
  63. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/ort_quiet.py +0 -0
  64. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/output.py +0 -0
  65. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/progress.py +0 -0
  66. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/pull.py +0 -0
  67. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/push.py +0 -0
  68. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/__init__.py +0 -0
  69. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/__init__.py +0 -0
  70. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/collada.py +0 -0
  71. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/confine.py +0 -0
  72. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/draft.py +0 -0
  73. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/gains.py +0 -0
  74. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/hf.py +0 -0
  75. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/hub.py +0 -0
  76. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/intake.py +0 -0
  77. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/model.py +0 -0
  78. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/pose.py +0 -0
  79. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/refs.py +0 -0
  80. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/urdf.py +0 -0
  81. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/worker.py +0 -0
  82. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot_assets.py +0 -0
  83. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/__init__.py +0 -0
  84. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/assets.py +0 -0
  85. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/harness.py +0 -0
  86. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/obs.py +0 -0
  87. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/posture.py +0 -0
  88. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/video.py +0 -0
  89. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/safe_text.py +0 -0
  90. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/__init__.py +0 -0
  91. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/lerobot_backend.py +0 -0
  92. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/msgpack_numpy.py +0 -0
  93. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/policy.py +0 -0
  94. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/server.py +0 -0
  95. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/session.py +0 -0
  96. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/stub_skill.py +0 -0
  97. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/vla.py +0 -0
  98. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/worker.py +0 -0
  99. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/sim_words.py +0 -0
  100. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/state.py +0 -0
  101. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/__init__.py +0 -0
  102. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_isaac.py +0 -0
  103. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/smolvla_lora.py +0 -0
  104. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_ppo.py +0 -0
  105. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_skill.py +0 -0
  106. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/tfevents.py +0 -0
  107. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/training_curve.py +0 -0
  108. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/transfer.py +0 -0
  109. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/vla_manifest.py +0 -0
  110. {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lucen-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lucen-cli"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate"
5
5
  # R1: `readme`, `license`, `license-files` and `[project.urls]` are what PyPI
6
6
  # renders; the LICENSE beside this file is Apache-2.0 verbatim (docs/DECISIONS.md "R1").
@@ -1,3 +1,3 @@
1
1
  """MouseMouse CLI."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
@@ -63,6 +63,7 @@ from lucen_sdk.api.files import (
63
63
  from lucen_sdk.api.files import (
64
64
  presign_upload as presign_upload_api,
65
65
  )
66
+ from lucen_sdk.api.meta import get_capabilities as get_capabilities_api
66
67
  from lucen_sdk.api.meta import healthz as healthz_api
67
68
  from lucen_sdk.api.models import put_model_meta as put_model_meta_api
68
69
  from lucen_sdk.api.orgs import create_org as create_org_api
@@ -656,6 +657,22 @@ class HubClient:
656
657
  response = healthz_api.sync_detailed(client=self._sdk)
657
658
  return self._expect(response, HealthResponse, action="Reaching the hub")
658
659
 
660
+ def studio_stills(self) -> str:
661
+ """S2: `getCapabilities.studio_stills` — `hosted` | `local` | `none`.
662
+
663
+ `none` for a hub that predates the field, or one that cannot be asked:
664
+ the publisher then draws its own still, as before.
665
+ """
666
+ try:
667
+ response = get_capabilities_api.sync_detailed(client=self._sdk)
668
+ except Exception: # a still is never worth failing a publish over
669
+ return "none"
670
+ parsed = response.parsed if int(response.status_code) == 200 else None
671
+ value = getattr(parsed, "studio_stills", None)
672
+ if value is None or isinstance(value, Unset):
673
+ return "none"
674
+ return str(getattr(value, "value", value))
675
+
659
676
  def overview(self) -> MyOverview:
660
677
  """`GET /v1/me/overview` (UI-API): what is yours right now, in counts (`key:read`)."""
661
678
  response = get_my_overview_api.sync_detailed(client=self._sdk)
@@ -165,7 +165,7 @@ def stamp(
165
165
  stdout.print(
166
166
  f" contract obs {contract['obs_dim']} = "
167
167
  f"{' + '.join(b['name'] for b in contract['obs_spec'])}; action "
168
- f"{contract['action_dim']} ({contract['action_semantics']}); "
168
+ f"{contract['action_dim']} ({_semantics_line(contract)}); "
169
169
  f"{contract['control_rate_hz']:g} Hz; control_class {manifest['control_class']}"
170
170
  )
171
171
  if unchecked:
@@ -173,3 +173,14 @@ def stamp(
173
173
  f" [yellow]not checked[/yellow] the ONNX's {' and '.join(unchecked)} "
174
174
  "width is symbolic, so it could not be compared with the contract"
175
175
  )
176
+
177
+
178
+ def _semantics_line(contract: dict[str, Any]) -> str:
179
+ """`position_offset`, or `mixed: 8 position_offset, 2 torque` (T2)."""
180
+ layout = contract.get("action_semantics_vec")
181
+ if not isinstance(layout, list):
182
+ return str(contract["action_semantics"])
183
+ counts: dict[str, int] = {}
184
+ for semantics in layout:
185
+ counts[str(semantics)] = counts.get(str(semantics), 0) + 1
186
+ return f"{contract['action_semantics']}: " + ", ".join(f"{n} {s}" for s, n in counts.items())
@@ -795,7 +795,9 @@ def card_get(
795
795
  card = get_card(client, RepoTarget.parse(repo))
796
796
  if output is not None and not as_json:
797
797
  editable = {
798
- k: v for k, v in card.items() if k not in ("interface_fingerprint", "updated_at")
798
+ k: v
799
+ for k, v in card.items()
800
+ if k not in ("interface_fingerprint", "updated_at", "stills")
799
801
  }
800
802
  output.write_text(
801
803
  json.dumps(editable, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
@@ -831,7 +833,7 @@ def card_set(
831
833
  f"{file} must hold a JSON object.",
832
834
  hint="`lucen robot card get OWNER/SLUG -o card.json` writes one in the right shape.",
833
835
  )
834
- for key in ("needs_you", "interface_fingerprint", "updated_at"):
836
+ for key in ("needs_you", "interface_fingerprint", "updated_at", "stills"):
835
837
  card.pop(key, None)
836
838
  target = RepoTarget.parse(repo)
837
839
  try:
@@ -56,6 +56,25 @@ class DeriveError(ValueError):
56
56
  """The requested contract cannot be derived from this interface."""
57
57
 
58
58
 
59
+ def spinning_joints(emb: Embodiment, card: dict[str, Any] | None) -> list[bool]:
60
+ """Per joint: is it a wheel, as far as a policy is concerned? (T2)
61
+
62
+ A joint whose action is a speed or a torque, and which the card gives no
63
+ `lower`/`upper` range, turns without bound. Its angle carries nothing a
64
+ policy can use and grows for as long as the robot drives, so the derived
65
+ contract observes it at scale 0 (the `joint_pos` value is dropped, the
66
+ `joint_vel` one kept), and the trainer leaves it out of the pose and
67
+ symmetry terms. Read from the interface and the card only, so the gate,
68
+ the browser and a deployed runtime reach the same answer from the contract.
69
+ """
70
+ limits = card_limits(card, emb.joint_order)
71
+ position = emb.position_driven
72
+ return [
73
+ not position[k] and limits["lower"][k] is None and limits["upper"][k] is None
74
+ for k in range(emb.dof)
75
+ ]
76
+
77
+
59
78
  def _block(name: str) -> tuple[int | None, float | tuple[float, ...], str, str | None]:
60
79
  canonical = ALIASES.get(name, name)
61
80
  if canonical not in OBS_BLOCKS:
@@ -70,13 +89,17 @@ def obs_spec_for(
70
89
  emb: Embodiment,
71
90
  blocks: list[str],
72
91
  scales: dict[str, float | list[float]] | None = None,
92
+ *,
93
+ spinning: list[bool] | None = None,
73
94
  ) -> list[dict[str, Any]]:
74
95
  """`[{name, dim, scale}]` — and the subset rule, enforced at derivation time.
75
96
 
76
97
  A `sensor` block the robot does not list under `proprioception` is refused
77
98
  here, before a policy is trained on a signal the real robot cannot supply.
78
99
  An interface that declares no proprioception at all makes no claim, so
79
- there is nothing to check it against.
100
+ there is nothing to check it against. `spinning` (T2,
101
+ :func:`spinning_joints`) zeroes those joints' `joint_pos` scales after the
102
+ caller's scales are checked.
80
103
  """
81
104
  if not blocks:
82
105
  raise DeriveError("a contract needs at least one observation block")
@@ -100,6 +123,8 @@ def obs_spec_for(
100
123
  values = [float(raw)] * dim
101
124
  if any(v <= 0 for v in values):
102
125
  raise DeriveError(f"block `{name}`: scales must be positive")
126
+ if spinning and ALIASES.get(name, name) == "joint_pos":
127
+ values = [0.0 if spinning[k] else v for k, v in enumerate(values)]
103
128
  spec.append({"name": name, "dim": dim, "scale": values})
104
129
  if blind:
105
130
  raise DeriveError(
@@ -132,7 +157,8 @@ def derive_contract(
132
157
  n = emb.dof
133
158
  if len(default_pose) != n:
134
159
  raise DeriveError(f"default_pose has {len(default_pose)} entries for {n} joints")
135
- spec = obs_spec_for(emb, blocks, scales)
160
+ spinning = spinning_joints(emb, card)
161
+ spec = obs_spec_for(emb, blocks, scales, spinning=spinning)
136
162
  if any(b["name"] == "gait_phase" for b in spec) and cycle_time_s is None:
137
163
  raise DeriveError("the `gait_phase` block needs `cycle_time_s`")
138
164
  contract: dict[str, Any] = {
@@ -151,6 +177,17 @@ def derive_contract(
151
177
  "base": emb.base,
152
178
  "command_space": emb.canonical["command_space"],
153
179
  }
180
+ if emb.per_group_action:
181
+ # T2: the layout itself, so a stamped policy carries how each output is
182
+ # read (the gate, the browser and a robot's runtime read these first).
183
+ contract["action_semantics_vec"] = list(emb.semantics)
184
+ contract["action_clip_vec"] = [
185
+ float(c) if c is not None else contract["action_clip"] for c in emb.clip
186
+ ]
187
+ if any(spinning):
188
+ contract["spinning_joints"] = [
189
+ j for j, s in zip(emb.joint_order, spinning, strict=True) if s
190
+ ]
154
191
  if emb.physics_dt_s is not None:
155
192
  contract["physics_dt_s"] = float(emb.physics_dt_s)
156
193
  if emb.init_base_height_m is not None:
@@ -397,11 +434,20 @@ def contract_checks(manifest: dict[str, Any], card: dict[str, Any]) -> list[dict
397
434
 
398
435
  action: list[dict[str, Any]] = []
399
436
  said: list[str] = []
400
- checks = (
437
+ checks: tuple[tuple[str, Any, str], ...] = (
401
438
  ("action_semantics", emb.action_semantics, "{p} actions, the robot says {r}"),
402
439
  ("action_dim", emb.dof, "{p} actions, the robot drives {r}"),
403
440
  ("action_scale_vec", [float(v) for v in emb.action_scale], "scale {p}, the robot says {r}"),
404
441
  )
442
+ if emb.per_group_action or contract.get("action_semantics_vec") is not None:
443
+ checks = (
444
+ *checks,
445
+ (
446
+ "action_semantics_vec",
447
+ list(emb.semantics),
448
+ "per-joint actions {p}, the robot says {r}",
449
+ ),
450
+ )
405
451
  for key, robot_value, sentence in checks:
406
452
  policy_value = contract.get(key)
407
453
  if policy_value is not None and not _same(policy_value, robot_value):
@@ -487,5 +533,6 @@ __all__ = [
487
533
  "derive_plant",
488
534
  "obs_spec_for",
489
535
  "pose_without_model",
536
+ "spinning_joints",
490
537
  "stamp_manifest",
491
538
  ]
@@ -17,7 +17,15 @@ Two jobs:
17
17
  A cross-package test pins the two implementations together.
18
18
  * :class:`Embodiment` flattens the declaration into what a control loop
19
19
  needs: per-joint vectors in `joint_order` (default pose, gains, limits,
20
- action scale), never a dict a caller has to re-walk.
20
+ action semantics / scale / clip), never a dict a caller has to re-walk.
21
+
22
+ T2 — mixed drive modes. An actuation group may carry its own `action`
23
+ (`{semantics, scale, clip}`): the component of the action vector for one of
24
+ its joints is read by the group's action, else by the top-level one. That is
25
+ how a wheeled-legged robot declares position servos on its legs and velocity
26
+ or torque motors on its wheels in one vector. :func:`action_layout` is the
27
+ one place that resolves it, and the one place that refuses a joint whose
28
+ action does not fit how its group drives it.
21
29
  """
22
30
 
23
31
  from __future__ import annotations
@@ -42,6 +50,10 @@ MODE_FOR_SEMANTICS = {
42
50
  }
43
51
 
44
52
 
53
+ #: The semantics :attr:`Embodiment.action_semantics` reports when the joints do not share one.
54
+ MIXED = "mixed"
55
+
56
+
45
57
  class InterfaceError(ValueError):
46
58
  """The interface is not one this code can read. The message names the field."""
47
59
 
@@ -116,8 +128,27 @@ _ACTUATION_FLOATS = (
116
128
  )
117
129
 
118
130
 
131
+ def _semantics(value: Any, where: str) -> str:
132
+ semantics = _str(value, where)
133
+ if semantics not in ACTION_SEMANTICS:
134
+ raise InterfaceError(f"{where} {semantics!r} is not one of {', '.join(ACTION_SEMANTICS)}")
135
+ return semantics
136
+
137
+
138
+ def _group_action(raw: Any, where: str) -> dict[str, Any]:
139
+ """`EmbodimentGroupAction`: semantics, scale (default 1), clip — T2."""
140
+ src = _object(raw, where, {"semantics", "scale", "clip"})
141
+ out: dict[str, Any] = {
142
+ "semantics": _semantics(src.get("semantics"), f"{where}.semantics"),
143
+ "scale": _float(src.get("scale", 1.0), f"{where}.scale"),
144
+ }
145
+ if src.get("clip") is not None:
146
+ out["clip"] = _float(src["clip"], f"{where}.clip")
147
+ return out
148
+
149
+
119
150
  def _actuation(raw: Any, where: str) -> dict[str, Any]:
120
- src = _object(raw, where, {"joints", "mode", *_ACTUATION_FLOATS})
151
+ src = _object(raw, where, {"joints", "mode", "action", *_ACTUATION_FLOATS})
121
152
  out: dict[str, Any] = {"joints": _str_list(src.get("joints"), f"{where}.joints")}
122
153
  mode = _str(src.get("mode", "position"), f"{where}.mode")
123
154
  if mode not in ACTUATION_MODES:
@@ -126,16 +157,14 @@ def _actuation(raw: Any, where: str) -> dict[str, Any]:
126
157
  for key in _ACTUATION_FLOATS:
127
158
  if src.get(key) is not None:
128
159
  out[key] = _float(src[key], f"{where}.{key}")
160
+ if src.get("action") is not None:
161
+ out["action"] = _group_action(src["action"], f"{where}.action")
129
162
  return out
130
163
 
131
164
 
132
165
  def _action(raw: Any) -> dict[str, Any]:
133
166
  src = _object(raw, "action", {"semantics", "scale", "scale_per_joint", "clip"})
134
- semantics = _str(src.get("semantics"), "action.semantics")
135
- if semantics not in ACTION_SEMANTICS:
136
- raise InterfaceError(
137
- f"action.semantics {semantics!r} is not one of {', '.join(ACTION_SEMANTICS)}"
138
- )
167
+ semantics = _semantics(src.get("semantics"), "action.semantics")
139
168
  out: dict[str, Any] = {
140
169
  "semantics": semantics,
141
170
  "scale": _float(src.get("scale", 1.0), "action.scale"),
@@ -328,6 +357,121 @@ def fingerprint(interface: dict[str, Any]) -> str:
328
357
  return hashlib.sha256(canonical_json(canonical_interface(interface))).hexdigest()
329
358
 
330
359
 
360
+ # ---------------------------------------------------------------------------
361
+ # T2: the action vector, per actuation group
362
+ # ---------------------------------------------------------------------------
363
+
364
+
365
+ @dataclass(frozen=True)
366
+ class ActionLayout:
367
+ """How each component of the action vector is read, in `joint_order`.
368
+
369
+ `group[k]` is the index of the actuation group driving joint k (None when
370
+ no group drives it); `source[k]` says whose `action` the joint follows:
371
+ `actuation[i].action` or the top-level `action`.
372
+ """
373
+
374
+ semantics: tuple[str, ...]
375
+ scale: tuple[float, ...]
376
+ clip: tuple[float | None, ...]
377
+ mode: tuple[str, ...]
378
+ group: tuple[int | None, ...]
379
+ source: tuple[str, ...]
380
+ #: True when any group declares its own `action`.
381
+ per_group: bool
382
+ #: `(group index, joints, sentence)` per group whose actions do not fit its drive.
383
+ problems: tuple[tuple[int, tuple[str, ...], str], ...]
384
+
385
+
386
+ def action_layout(canonical: dict[str, Any]) -> ActionLayout:
387
+ """Resolve every joint's action through its group, and say what does not fit.
388
+
389
+ The rule (docs/API.md, "Embodiment interface"): a joint in a group that
390
+ declares an `action` is read by that action; every other joint by the
391
+ top-level `action` (with its `scale_per_joint`). A `position` group takes
392
+ `position_offset` or `position_absolute`, a `velocity` group `velocity`, a
393
+ `torque` group `torque`. A joint no group drives is driven in the mode its
394
+ action needs. `scale_per_joint` may not name a joint whose group has its
395
+ own action: the two scales would be in different units.
396
+
397
+ The hub applies the same rule in `lucen_api.robot_validation`
398
+ (`interface.actuation`); a cross-package test holds the two together.
399
+ """
400
+ joints: list[str] = canonical["joint_order"]
401
+ n = len(joints)
402
+ index = {name: k for k, name in enumerate(joints)}
403
+ top = canonical["action"]
404
+ scale_map: dict[str, float] = top.get("scale_per_joint") or {}
405
+ group_of: list[int | None] = [None] * n
406
+ mode = ["position"] * n
407
+ for gi, group in enumerate(canonical["actuation"]):
408
+ for name in group["joints"]:
409
+ group_of[index[name]] = gi
410
+ mode[index[name]] = group["mode"]
411
+ semantics: list[str] = []
412
+ scale: list[float] = []
413
+ clip: list[float | None] = []
414
+ source: list[str] = []
415
+ for k, name in enumerate(joints):
416
+ driver = group_of[k]
417
+ own = canonical["actuation"][driver].get("action") if driver is not None else None
418
+ chosen = own if own is not None else top
419
+ semantics.append(str(chosen["semantics"]))
420
+ clip.append(None if chosen.get("clip") is None else float(chosen["clip"]))
421
+ if own is not None:
422
+ scale.append(float(own["scale"]))
423
+ source.append(f"actuation[{driver}].action")
424
+ else:
425
+ scale.append(float(scale_map.get(name, top["scale"])))
426
+ source.append("action")
427
+ if driver is None:
428
+ mode[k] = MODE_FOR_SEMANTICS[semantics[k]]
429
+ problems: list[tuple[int, tuple[str, ...], str]] = []
430
+ for gi, group in enumerate(canonical["actuation"]):
431
+ members = tuple(group["joints"])
432
+ found = semantics[index[members[0]]]
433
+ if MODE_FOR_SEMANTICS[found] != group["mode"]:
434
+ names = ", ".join(members)
435
+ if group.get("action") is not None:
436
+ sentence = (
437
+ f"actuation[{gi}] drives {names} in `{group['mode']}` mode, but its own "
438
+ f"action is `{found}`; a `{group['mode']}` group takes "
439
+ f"{_takes(group['mode'])}"
440
+ )
441
+ else:
442
+ sentence = (
443
+ f"actuation[{gi}] drives {names} in `{group['mode']}` mode, but they follow "
444
+ f"the top-level action, `{found}`, which needs `{MODE_FOR_SEMANTICS[found]}` "
445
+ f"mode; change the top-level action, or give actuation[{gi}] its own "
446
+ f"`action` with semantics {_takes(group['mode'])}"
447
+ )
448
+ problems.append((gi, members, sentence))
449
+ named = sorted(set(members) & set(scale_map))
450
+ if group.get("action") is not None and named:
451
+ problems.append(
452
+ (
453
+ gi,
454
+ tuple(named),
455
+ f"action.scale_per_joint names {', '.join(named)}, whose group "
456
+ f"actuation[{gi}] declares its own action; set the scale there",
457
+ )
458
+ )
459
+ return ActionLayout(
460
+ semantics=tuple(semantics),
461
+ scale=tuple(scale),
462
+ clip=tuple(clip),
463
+ mode=tuple(mode),
464
+ group=tuple(group_of),
465
+ source=tuple(source),
466
+ per_group=any(g.get("action") is not None for g in canonical["actuation"]),
467
+ problems=tuple(problems),
468
+ )
469
+
470
+
471
+ def _takes(mode: str) -> str:
472
+ return " or ".join(f"`{s}`" for s, m in MODE_FOR_SEMANTICS.items() if m == mode)
473
+
474
+
331
475
  # ---------------------------------------------------------------------------
332
476
  # The flattened view a control loop reads
333
477
  # ---------------------------------------------------------------------------
@@ -367,8 +511,10 @@ class Embodiment:
367
511
  armature: tuple[float | None, ...]
368
512
  friction_loss: tuple[float | None, ...]
369
513
  driven: tuple[bool, ...]
514
+ #: The joints' shared semantics, or :data:`MIXED` when the groups differ.
370
515
  action_semantics: str
371
516
  action_scale: tuple[float, ...]
517
+ #: The top-level `action.clip`.
372
518
  action_clip: float | None
373
519
  proprioception: tuple[str, ...]
374
520
  end_effectors: tuple[EndEffector, ...]
@@ -377,11 +523,21 @@ class Embodiment:
377
523
  command_ranges: dict[str, tuple[float, float]]
378
524
  soft_limit_factor: float
379
525
  max_tilt_rad: float | None
526
+ #: T2: each joint's own semantics and clip (its group's action, else the top-level one).
527
+ semantics: tuple[str, ...] = ()
528
+ clip: tuple[float | None, ...] = ()
529
+ #: T2: True when any actuation group declares its own `action`.
530
+ per_group_action: bool = False
380
531
 
381
532
  @property
382
533
  def fixed_base(self) -> bool:
383
534
  return self.base == "fixed"
384
535
 
536
+ @property
537
+ def position_driven(self) -> tuple[bool, ...]:
538
+ """Per joint: does its action name a position target (offset or absolute)?"""
539
+ return tuple(s.startswith("position") for s in self.semantics)
540
+
385
541
  @property
386
542
  def dof(self) -> int:
387
543
  return len(self.joint_order)
@@ -409,29 +565,20 @@ class Embodiment:
409
565
  key: [None] * n
410
566
  for key in ("kp", "kd", "effort_limit", "velocity_limit", "armature", "friction_loss")
411
567
  }
412
- mode = ["position"] * n
413
568
  driven = [False] * n
414
569
  for group in canonical["actuation"]:
415
570
  for name in group["joints"]:
416
571
  k = index[name]
417
- mode[k] = group["mode"]
418
572
  driven[k] = True
419
573
  for key, values in per_joint.items():
420
574
  if key in group:
421
575
  values[k] = float(group[key])
422
576
  action = canonical["action"]
423
- semantics = str(action["semantics"])
424
- needed = MODE_FOR_SEMANTICS[semantics]
425
- wrong = [joints[k] for k in range(n) if driven[k] and mode[k] != needed]
426
- if wrong:
427
- raise InterfaceError(
428
- f"action.semantics `{semantics}` needs `{needed}`-mode actuators, but "
429
- f"{', '.join(wrong)} are driven in another mode"
430
- )
431
- if not any(driven):
432
- mode = [needed] * n
433
- scale_map = action.get("scale_per_joint") or {}
434
- scale = tuple(float(scale_map.get(name, action["scale"])) for name in joints)
577
+ layout = action_layout(canonical)
578
+ if layout.problems:
579
+ raise InterfaceError("; ".join(sentence for _, _, sentence in layout.problems))
580
+ shared = set(layout.semantics)
581
+ semantics = shared.pop() if len(shared) == 1 else MIXED
435
582
  pose_map = canonical.get("default_pose")
436
583
  pose = (
437
584
  tuple(float(pose_map.get(name, 0.0)) for name in joints)
@@ -449,7 +596,7 @@ class Embodiment:
449
596
  init_base_height_m=canonical.get("init_base_height_m"),
450
597
  control_rate_hz=float(canonical["control_rate_hz"]),
451
598
  physics_dt_s=canonical.get("physics_dt_s"),
452
- mode=tuple(mode),
599
+ mode=layout.mode,
453
600
  kp=tuple(per_joint["kp"]),
454
601
  kd=tuple(per_joint["kd"]),
455
602
  effort_limit=tuple(per_joint["effort_limit"]),
@@ -458,7 +605,7 @@ class Embodiment:
458
605
  friction_loss=tuple(per_joint["friction_loss"]),
459
606
  driven=tuple(driven),
460
607
  action_semantics=semantics,
461
- action_scale=scale,
608
+ action_scale=layout.scale,
462
609
  action_clip=action.get("clip"),
463
610
  proprioception=tuple(canonical["proprioception"]),
464
611
  end_effectors=tuple(
@@ -477,6 +624,9 @@ class Embodiment:
477
624
  },
478
625
  soft_limit_factor=float(canonical["safety"]["soft_limit_factor"]),
479
626
  max_tilt_rad=canonical["safety"].get("max_tilt_rad"),
627
+ semantics=layout.semantics,
628
+ clip=layout.clip,
629
+ per_group_action=layout.per_group,
480
630
  )
481
631
 
482
632
  @classmethod
@@ -511,10 +661,13 @@ __all__ = [
511
661
  "ACTION_SEMANTICS",
512
662
  "ACTUATION_MODES",
513
663
  "INTERFACE_VERSION",
664
+ "MIXED",
514
665
  "MODE_FOR_SEMANTICS",
666
+ "ActionLayout",
515
667
  "Embodiment",
516
668
  "EndEffector",
517
669
  "InterfaceError",
670
+ "action_layout",
518
671
  "canonical_interface",
519
672
  "canonical_json",
520
673
  "card_limits",
@@ -304,6 +304,14 @@ def _vla_contract_table(contract: dict[str, Any]) -> str:
304
304
  return "| | |\n|---|---|\n" + _table(rows)
305
305
 
306
306
 
307
+ _SEMANTICS_WORDS = {
308
+ "position_offset": "position offsets",
309
+ "position_absolute": "position targets",
310
+ "velocity": "speeds",
311
+ "torque": "torques",
312
+ }
313
+
314
+
307
315
  def _reflex_contract_table(contract: dict[str, Any]) -> str:
308
316
  """The inputs and outputs of a stamped ONNX policy, in Q4's words."""
309
317
  blocks = contract.get("obs_spec") or []
@@ -324,6 +332,20 @@ def _reflex_contract_table(contract: dict[str, Any]) -> str:
324
332
  else:
325
333
  scale_text = ", ".join(_number(v) for v in scales)
326
334
  clip = contract.get("action_clip")
335
+ action = f"{contract.get('action_dim')} values, one per joint" + (
336
+ f", clipped to ±{_number(clip)}" if clip is not None else ""
337
+ )
338
+ layout = contract.get("action_semantics_vec")
339
+ clips = contract.get("action_clip_vec")
340
+ if isinstance(layout, list) and isinstance(clips, list) and len(layout) == len(clips):
341
+ # T2: a robot that mixes servos and wheel motors reads each part its own way.
342
+ parts: dict[tuple[str, float], int] = {}
343
+ for semantics, bound in zip(layout, clips, strict=True):
344
+ parts[(str(semantics), float(bound))] = parts.get((str(semantics), float(bound)), 0) + 1
345
+ action = f"{contract.get('action_dim')} values, one per joint: " + ", ".join(
346
+ f"{count} {_SEMANTICS_WORDS.get(semantics, semantics)} clipped to ±{_number(bound)}"
347
+ for (semantics, bound), count in parts.items()
348
+ )
327
349
  rows: list[tuple[str, Any]] = [
328
350
  (
329
351
  "Observation",
@@ -331,11 +353,7 @@ def _reflex_contract_table(contract: dict[str, Any]) -> str:
331
353
  if observation
332
354
  else contract.get("obs_dim"),
333
355
  ),
334
- (
335
- "Action",
336
- f"{contract.get('action_dim')} values, one per joint"
337
- + (f", clipped to ±{_number(clip)}" if clip is not None else ""),
338
- ),
356
+ ("Action", action),
339
357
  ("Action scale", scale_text),
340
358
  (
341
359
  "Control rate",
@@ -136,6 +136,8 @@ def build_mirror(
136
136
  scale = np.asarray(emb.action_scale, dtype=np.float64)
137
137
  if np.abs(scale[perm] - scale).max() > SYMMETRY_TOL:
138
138
  return None, "the action scale differs between a joint and its mirror twin"
139
+ if any(emb.semantics[k] != emb.semantics[int(perm[k])] for k in range(len(joints))):
140
+ return None, "a joint and its mirror twin read their actions differently"
139
141
  if emb.feet and len(emb.feet) != 2 and "gait_phase" in blocks:
140
142
  return None, (
141
143
  f"the gait clock mirrors by half a cycle only on two feet; this robot declares "
@@ -125,7 +125,8 @@ class PPOConfig:
125
125
  epochs: int = 5
126
126
  minibatches: int = 4
127
127
  max_grad_norm: float = 1.0
128
- init_log_std: float = 0.0
128
+ #: One value for every output, or one per output (T2: a wheel's is not a joint offset).
129
+ init_log_std: float | list[float] = 0.0
129
130
  seed: int = 0
130
131
 
131
132