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.
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/PKG-INFO +1 -1
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/pyproject.toml +1 -1
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/__init__.py +1 -1
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/client.py +17 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_model.py +12 -1
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_robot.py +4 -2
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/contract_derive.py +50 -3
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/embodiment.py +176 -23
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/model_card.py +23 -5
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/mirror.py +2 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/ppo.py +2 -1
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/tasks.py +34 -4
- lucen_cli-0.1.4/src/lucen_cli/rl/terrain.py +282 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/vecenv.py +43 -9
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/checks.py +29 -6
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/publish.py +9 -1
- lucen_cli-0.1.4/src/lucen_cli/robot/studio.py +266 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/thumbnail.py +56 -8
- lucen_cli-0.1.4/src/lucen_cli/rollout/clip.py +389 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/conditions.py +36 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/contract.py +63 -1
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/engine.py +105 -22
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/plant.py +138 -27
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/worker.py +36 -6
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_mujoco.py +183 -20
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/LICENSE +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/README.md +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/__init__.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/errors.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/hub.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/progress.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/py.typed +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen/results.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_dir.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/checkpoint_publisher.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_auth.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_campaigns.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_datasets.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_holds.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_hub.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_org.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_quickstart.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_repo.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_rollout.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_runs.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_serve.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/cmd_worker.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/config.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/context.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/curves_view.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/errors.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/extras.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/gate_scorer.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/gates_view.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/group.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/hf_dataset.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/live.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/local.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/main.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/offscreen.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/onnx_manifest.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/onnx_writer.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/ort_quiet.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/output.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/progress.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/pull.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/push.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rl/__init__.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/__init__.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/collada.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/confine.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/draft.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/gains.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/hf.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/hub.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/intake.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/model.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/pose.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/refs.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/urdf.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot/worker.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/robot_assets.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/__init__.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/assets.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/harness.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/obs.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/posture.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/rollout/video.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/safe_text.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/__init__.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/lerobot_backend.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/msgpack_numpy.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/policy.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/server.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/session.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/stub_skill.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/vla.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/serve/worker.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/sim_words.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/state.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/__init__.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/ppo_isaac.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/smolvla_lora.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_ppo.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/templates/stub_skill.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/tfevents.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/training_curve.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/transfer.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/vla_manifest.py +0 -0
- {lucen_cli-0.1.3 → lucen_cli-0.1.4}/src/lucen_cli/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lucen-cli"
|
|
3
|
-
version = "0.1.
|
|
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").
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|