morphic-hub 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of morphic-hub might be problematic. Click here for more details.
- morphic/__init__.py +18 -0
- morphic/brain.py +226 -0
- morphic/cli.py +418 -0
- morphic/deploy.py +146 -0
- morphic/drivers/__init__.py +49 -0
- morphic/drivers/base.py +325 -0
- morphic/drivers/dynamixel.py +238 -0
- morphic/drivers/feetech.py +195 -0
- morphic/drivers/mock.py +173 -0
- morphic/drivers/ros2.py +115 -0
- morphic/drivers/serial_pwm.py +175 -0
- morphic/gap.py +247 -0
- morphic/hub.py +240 -0
- morphic/sim.py +415 -0
- morphic/telemetry.py +202 -0
- morphic/triad.py +182 -0
- morphic_hub-0.2.0.dist-info/METADATA +180 -0
- morphic_hub-0.2.0.dist-info/RECORD +21 -0
- morphic_hub-0.2.0.dist-info/WHEEL +5 -0
- morphic_hub-0.2.0.dist-info/entry_points.txt +2 -0
- morphic_hub-0.2.0.dist-info/top_level.txt +1 -0
morphic/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Morphic - the embodied hub client library.
|
|
2
|
+
|
|
3
|
+
from morphic import Hub
|
|
4
|
+
hub = Hub() # $MORPHIC_HUB, else the public hub https://hub.ruliax.com
|
|
5
|
+
triad = hub.pull("morphic/go2-trot")
|
|
6
|
+
run = triad.simulate(world="ice", seconds=5) # needs `pip install "morphic-hub[sim]"` (MuJoCo)
|
|
7
|
+
|
|
8
|
+
Physics (morphic.sim / morphic.gap / morphic.brain) imports MuJoCo lazily, so the
|
|
9
|
+
HTTP client, telemetry tools and driver codecs work on machines without it.
|
|
10
|
+
"""
|
|
11
|
+
from .triad import Triad, TriadError, load_manifest # noqa: F401
|
|
12
|
+
from .hub import Hub, HubError # noqa: F401
|
|
13
|
+
from .telemetry import TelemetryRecorder, load_telemetry, save_telemetry, validate_telemetry # noqa: F401
|
|
14
|
+
from . import deploy, drivers # noqa: F401
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
__all__ = ["Triad", "TriadError", "load_manifest", "Hub", "HubError", "TelemetryRecorder",
|
|
18
|
+
"load_telemetry", "save_telemetry", "validate_telemetry", "deploy", "drivers"]
|
morphic/brain.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Brain loading and helpers for writing policies.
|
|
2
|
+
|
|
3
|
+
A brain is either a Python file exposing `class Policy` (see docs/TRIAD.md) or an
|
|
4
|
+
ONNX network (`type: onnx`) driven by a declarative observation spec.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import math
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .triad import Triad, TriadError
|
|
17
|
+
|
|
18
|
+
# --------------------------------------------------------------------------- utils
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def quat_to_rotmat(q: Sequence[float]) -> np.ndarray:
|
|
22
|
+
"""MuJoCo (w, x, y, z) quaternion -> 3x3 rotation matrix."""
|
|
23
|
+
w, x, y, z = q
|
|
24
|
+
return np.array([
|
|
25
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
|
26
|
+
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
|
27
|
+
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def quat_to_euler(q: Sequence[float]) -> tuple[float, float, float]:
|
|
32
|
+
"""MuJoCo (w, x, y, z) quaternion -> (roll, pitch, yaw) in radians."""
|
|
33
|
+
w, x, y, z = q
|
|
34
|
+
roll = math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
|
|
35
|
+
s = max(-1.0, min(1.0, 2 * (w * y - z * x)))
|
|
36
|
+
pitch = math.asin(s)
|
|
37
|
+
yaw = math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
|
|
38
|
+
return roll, pitch, yaw
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def gravity_in_body(q: Sequence[float]) -> np.ndarray:
|
|
42
|
+
"""Unit gravity vector expressed in the body frame (what an IMU 'feels' when static)."""
|
|
43
|
+
return quat_to_rotmat(q).T @ np.array([0.0, 0.0, -1.0])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PolicyBase:
|
|
47
|
+
"""Optional convenience base class for Python brains.
|
|
48
|
+
|
|
49
|
+
Gives name->index lookups for joints/actuators and a PD helper. Brains are free
|
|
50
|
+
to ignore it — the engine only needs `reset`, `act` and (optionally) `status`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, model, spec: dict[str, Any]):
|
|
54
|
+
import mujoco
|
|
55
|
+
|
|
56
|
+
self.spec = spec or {}
|
|
57
|
+
self.params: dict[str, Any] = dict(self.spec.get("params") or {})
|
|
58
|
+
self.mj = mujoco
|
|
59
|
+
self.nu = model.nu
|
|
60
|
+
self.free_base = model.njnt > 0 and model.jnt_type[0] == mujoco.mjtJoint.mjJNT_FREE
|
|
61
|
+
self.actuator_names = [mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, i) for i in range(model.nu)]
|
|
62
|
+
# For joint-transmission actuators: the qpos / dof address of the driven joint.
|
|
63
|
+
self.act_qpos = np.full(model.nu, -1, dtype=int)
|
|
64
|
+
self.act_dof = np.full(model.nu, -1, dtype=int)
|
|
65
|
+
for i in range(model.nu):
|
|
66
|
+
if model.actuator_trntype[i] == mujoco.mjtTrn.mjTRN_JOINT:
|
|
67
|
+
j = model.actuator_trnid[i, 0]
|
|
68
|
+
self.act_qpos[i] = model.jnt_qposadr[j]
|
|
69
|
+
self.act_dof[i] = model.jnt_dofadr[j]
|
|
70
|
+
self.ctrl_lo = model.actuator_ctrlrange[:, 0].copy()
|
|
71
|
+
self.ctrl_hi = model.actuator_ctrlrange[:, 1].copy()
|
|
72
|
+
limited = model.actuator_ctrllimited.astype(bool)
|
|
73
|
+
self.ctrl_lo[~limited] = -np.inf
|
|
74
|
+
self.ctrl_hi[~limited] = np.inf
|
|
75
|
+
|
|
76
|
+
# ---- lookups
|
|
77
|
+
def joint_qpos(self, model, name: str) -> int:
|
|
78
|
+
j = self.mj.mj_name2id(model, self.mj.mjtObj.mjOBJ_JOINT, name)
|
|
79
|
+
if j < 0:
|
|
80
|
+
raise KeyError(f"joint {name!r} not in model")
|
|
81
|
+
return int(model.jnt_qposadr[j])
|
|
82
|
+
|
|
83
|
+
def joint_dof(self, model, name: str) -> int:
|
|
84
|
+
j = self.mj.mj_name2id(model, self.mj.mjtObj.mjOBJ_JOINT, name)
|
|
85
|
+
if j < 0:
|
|
86
|
+
raise KeyError(f"joint {name!r} not in model")
|
|
87
|
+
return int(model.jnt_dofadr[j])
|
|
88
|
+
|
|
89
|
+
def keyframe(self, model, name: str = "home") -> tuple[np.ndarray, np.ndarray] | None:
|
|
90
|
+
k = self.mj.mj_name2id(model, self.mj.mjtObj.mjOBJ_KEY, name)
|
|
91
|
+
if k < 0:
|
|
92
|
+
return None
|
|
93
|
+
return model.key_qpos[k].copy(), model.key_ctrl[k].copy()
|
|
94
|
+
|
|
95
|
+
# ---- state helpers
|
|
96
|
+
def actuated_pos(self, data) -> np.ndarray:
|
|
97
|
+
return data.qpos[self.act_qpos]
|
|
98
|
+
|
|
99
|
+
def actuated_vel(self, data) -> np.ndarray:
|
|
100
|
+
return data.qvel[self.act_dof]
|
|
101
|
+
|
|
102
|
+
def base_quat(self, data) -> np.ndarray:
|
|
103
|
+
return data.qpos[3:7] if self.free_base else np.array([1.0, 0.0, 0.0, 0.0])
|
|
104
|
+
|
|
105
|
+
def base_pos(self, data) -> np.ndarray:
|
|
106
|
+
return data.qpos[0:3] if self.free_base else np.zeros(3)
|
|
107
|
+
|
|
108
|
+
def base_angvel(self, data) -> np.ndarray:
|
|
109
|
+
return data.qvel[3:6] if self.free_base else np.zeros(3)
|
|
110
|
+
|
|
111
|
+
def base_linvel(self, data) -> np.ndarray:
|
|
112
|
+
return data.qvel[0:3] if self.free_base else np.zeros(3)
|
|
113
|
+
|
|
114
|
+
def pd(self, data, q_target: np.ndarray, kp, kd, qd_target: np.ndarray | None = None) -> np.ndarray:
|
|
115
|
+
"""Joint-space PD torque for joint-transmission actuators."""
|
|
116
|
+
q = self.actuated_pos(data)
|
|
117
|
+
qd = self.actuated_vel(data)
|
|
118
|
+
if qd_target is None:
|
|
119
|
+
qd_target = np.zeros_like(qd)
|
|
120
|
+
return kp * (q_target - q) - kd * (qd - qd_target)
|
|
121
|
+
|
|
122
|
+
def clip(self, ctrl: np.ndarray) -> np.ndarray:
|
|
123
|
+
return np.clip(ctrl, self.ctrl_lo, self.ctrl_hi)
|
|
124
|
+
|
|
125
|
+
# ---- defaults
|
|
126
|
+
def reset(self, model, data) -> None:
|
|
127
|
+
kf = self.keyframe(model, "home")
|
|
128
|
+
if kf is not None:
|
|
129
|
+
data.qpos[:] = kf[0]
|
|
130
|
+
data.ctrl[:] = kf[1]
|
|
131
|
+
|
|
132
|
+
def status(self, model, data) -> dict[str, Any]:
|
|
133
|
+
return {"ok": True}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------------------- ONNX
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class OnnxPolicy(PolicyBase):
|
|
140
|
+
"""Neural policy stored as ONNX. Observation = concatenation of the named blocks in
|
|
141
|
+
`spec.observation` (joint_pos, joint_vel, base_quat, base_angvel, base_linvel,
|
|
142
|
+
gravity, last_action, phase); output = ctrl (optionally scaled by `params.action_scale`
|
|
143
|
+
and offset by the home keyframe when `params.action_is_delta` is true)."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, model, spec: dict[str, Any], path: Path):
|
|
146
|
+
super().__init__(model, spec)
|
|
147
|
+
try:
|
|
148
|
+
import onnxruntime as ort # type: ignore
|
|
149
|
+
except ImportError as e: # pragma: no cover
|
|
150
|
+
raise TriadError("brain type 'onnx' needs `pip install onnxruntime`") from e
|
|
151
|
+
self.session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
|
|
152
|
+
self.input_name = self.session.get_inputs()[0].name
|
|
153
|
+
self.blocks = list(spec.get("observation") or ["joint_pos", "joint_vel"])
|
|
154
|
+
self.last_action = np.zeros(model.nu, dtype=np.float32)
|
|
155
|
+
self.action_scale = float(self.params.get("action_scale", 1.0))
|
|
156
|
+
self.action_is_delta = bool(self.params.get("action_is_delta", False))
|
|
157
|
+
kf = self.keyframe(model, "home")
|
|
158
|
+
self.default_ctrl = kf[1] if kf is not None else np.zeros(model.nu)
|
|
159
|
+
self.period = float(self.params.get("gait_period", 0.5))
|
|
160
|
+
|
|
161
|
+
def observe(self, data, t: float) -> np.ndarray:
|
|
162
|
+
parts = []
|
|
163
|
+
for b in self.blocks:
|
|
164
|
+
if b == "joint_pos":
|
|
165
|
+
parts.append(self.actuated_pos(data))
|
|
166
|
+
elif b == "joint_vel":
|
|
167
|
+
parts.append(self.actuated_vel(data))
|
|
168
|
+
elif b == "base_quat":
|
|
169
|
+
parts.append(self.base_quat(data))
|
|
170
|
+
elif b == "base_angvel":
|
|
171
|
+
parts.append(self.base_angvel(data))
|
|
172
|
+
elif b == "base_linvel":
|
|
173
|
+
parts.append(self.base_linvel(data))
|
|
174
|
+
elif b == "gravity":
|
|
175
|
+
parts.append(gravity_in_body(self.base_quat(data)))
|
|
176
|
+
elif b == "last_action":
|
|
177
|
+
parts.append(self.last_action)
|
|
178
|
+
elif b == "phase":
|
|
179
|
+
ph = 2 * math.pi * t / self.period
|
|
180
|
+
parts.append(np.array([math.sin(ph), math.cos(ph)]))
|
|
181
|
+
else:
|
|
182
|
+
raise TriadError(f"unknown observation block {b!r}")
|
|
183
|
+
return np.concatenate(parts).astype(np.float32)
|
|
184
|
+
|
|
185
|
+
def act(self, model, data, t: float) -> np.ndarray:
|
|
186
|
+
obs = self.observe(data, t)[None, :]
|
|
187
|
+
out = self.session.run(None, {self.input_name: obs})[0][0].astype(np.float64)
|
|
188
|
+
self.last_action = out.astype(np.float32)
|
|
189
|
+
ctrl = out * self.action_scale
|
|
190
|
+
if self.action_is_delta:
|
|
191
|
+
ctrl = ctrl + self.default_ctrl
|
|
192
|
+
return self.clip(ctrl)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------------- loader
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_policy(triad: Triad, model):
|
|
199
|
+
"""Instantiate the repo's brain for `model` (a compiled world)."""
|
|
200
|
+
spec = triad.brain
|
|
201
|
+
entry = triad.brain_entry
|
|
202
|
+
if not entry.exists():
|
|
203
|
+
raise TriadError(f"{triad.id}: brain entry {entry} not found")
|
|
204
|
+
kind = spec.get("type", "python")
|
|
205
|
+
if kind == "onnx":
|
|
206
|
+
return OnnxPolicy(model, spec, entry)
|
|
207
|
+
if kind != "python":
|
|
208
|
+
raise TriadError(f"{triad.id}: unsupported brain type {kind!r}")
|
|
209
|
+
|
|
210
|
+
mod_name = "morphic_brain_" + triad.id.replace("/", "_").replace("-", "_")
|
|
211
|
+
mspec = importlib.util.spec_from_file_location(mod_name, entry)
|
|
212
|
+
if mspec is None or mspec.loader is None: # pragma: no cover
|
|
213
|
+
raise TriadError(f"cannot import brain {entry}")
|
|
214
|
+
module = importlib.util.module_from_spec(mspec)
|
|
215
|
+
sys.modules[mod_name] = module
|
|
216
|
+
mspec.loader.exec_module(module)
|
|
217
|
+
cls = getattr(module, "Policy", None)
|
|
218
|
+
if cls is None:
|
|
219
|
+
raise TriadError(f"{entry}: must define class Policy")
|
|
220
|
+
policy = cls(model, spec)
|
|
221
|
+
for attr in ("reset", "act"):
|
|
222
|
+
if not callable(getattr(policy, attr, None)):
|
|
223
|
+
raise TriadError(f"{entry}: Policy must implement {attr}()")
|
|
224
|
+
if not callable(getattr(policy, "status", None)):
|
|
225
|
+
policy.status = lambda model, data: {"ok": True} # type: ignore[attr-defined]
|
|
226
|
+
return policy
|
morphic/cli.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""``morphic`` — command-line interface (argparse only).
|
|
2
|
+
|
|
3
|
+
morphic ls
|
|
4
|
+
morphic pull morphic/go2-trot
|
|
5
|
+
morphic sim morphic/go2-trot --world ice --seconds 5 --out run.json
|
|
6
|
+
morphic deploy morphic/so-arm100-pick --driver feetech --port COM4 --record run.json
|
|
7
|
+
morphic gap morphic/so-arm100-pick run.json
|
|
8
|
+
morphic drivers arduino-sketch
|
|
9
|
+
|
|
10
|
+
The hub URL comes from --hub, else $MORPHIC_HUB, else the public hub (https://hub.ruliax.com).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Sequence
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .drivers import ARDUINO_SKETCH, DESCRIPTIONS, DRIVERS, DriverError, JointMap, make_driver
|
|
23
|
+
from .hub import DEFAULT_URL, Hub, HubError, default_cache_dir
|
|
24
|
+
from .telemetry import TelemetryError, load_telemetry
|
|
25
|
+
from .triad import MANIFEST, Triad, TriadError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CLIError(Exception):
|
|
29
|
+
"""User-facing failure; printed as ``error: ...`` with exit status 1."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- repo resolution ---------------------------------------------------------------
|
|
33
|
+
def registry_dirs(explicit: str | None = None) -> list[Path]:
|
|
34
|
+
"""Candidate local registries: ``--registry``/``$MORPHIC_REGISTRY``, then ``hub/registry`` in cwd or its parents."""
|
|
35
|
+
out: list[Path] = []
|
|
36
|
+
for cand in (explicit, os.environ.get("MORPHIC_REGISTRY")):
|
|
37
|
+
if cand:
|
|
38
|
+
out.append(Path(cand))
|
|
39
|
+
cwd = Path.cwd()
|
|
40
|
+
for base in (cwd, *cwd.parents[:3]):
|
|
41
|
+
p = base / "hub" / "registry"
|
|
42
|
+
if p.is_dir():
|
|
43
|
+
out.append(p)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_repo(spec: str, hub_url: str, registry: str | None = None, pull: bool = True) -> Triad:
|
|
48
|
+
"""Directory → local registry → pull cache → ``morphic pull`` from the hub."""
|
|
49
|
+
p = Path(spec)
|
|
50
|
+
if p.is_dir() and (p / MANIFEST).exists():
|
|
51
|
+
return Triad(p)
|
|
52
|
+
for reg in registry_dirs(registry):
|
|
53
|
+
if (reg / spec / MANIFEST).exists():
|
|
54
|
+
return Triad(reg / spec)
|
|
55
|
+
cached = default_cache_dir() / spec
|
|
56
|
+
if (cached / MANIFEST).exists():
|
|
57
|
+
return Triad(cached)
|
|
58
|
+
if "/" not in spec:
|
|
59
|
+
raise CLIError(f"'{spec}' is neither a directory nor an owner/name repo id")
|
|
60
|
+
if not pull:
|
|
61
|
+
raise CLIError(f"repo {spec!r} not found locally (run `morphic pull {spec}`)")
|
|
62
|
+
print(f"pulling {spec} from {hub_url} ...")
|
|
63
|
+
return Hub(hub_url).pull(spec)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---- output helpers ---------------------------------------------------------------
|
|
67
|
+
def fmt(v: Any, digits: int = 3) -> str:
|
|
68
|
+
if v is None:
|
|
69
|
+
return "-"
|
|
70
|
+
if isinstance(v, bool):
|
|
71
|
+
return "yes" if v else "no"
|
|
72
|
+
if isinstance(v, float):
|
|
73
|
+
return f"{v:.{digits}f}"
|
|
74
|
+
return str(v)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def table(rows: Sequence[Sequence[Any]], headers: Sequence[str]) -> str:
|
|
78
|
+
cells = [[fmt(c) for c in r] for r in rows]
|
|
79
|
+
widths = [max(len(h), *(len(r[i]) for r in cells)) if cells else len(h) for i, h in enumerate(headers)]
|
|
80
|
+
line = " ".join(h.ljust(w) for h, w in zip(headers, widths))
|
|
81
|
+
out = [line, " ".join("-" * w for w in widths)]
|
|
82
|
+
out += [" ".join(c.ljust(w) for c, w in zip(r, widths)) for r in cells]
|
|
83
|
+
return "\n".join(out)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def print_gap_report(report: dict[str, Any]) -> None:
|
|
87
|
+
agg = report.get("aggregate") or {}
|
|
88
|
+
print(f"Sim-Gap report {report.get('repo', '?')} · world {report.get('world', '?')} · "
|
|
89
|
+
f"source {report.get('source', '?')} · run {report.get('run_id', '-')}")
|
|
90
|
+
print(f"score {fmt(report.get('sim_gap_score'), 1)} / 100 grade {report.get('grade', '-')} "
|
|
91
|
+
f"rmse {fmt(agg.get('rmse_rad'))} rad lag {fmt(agg.get('lag_ms'), 0)} ms "
|
|
92
|
+
f"drift {fmt(agg.get('drift_rad'))} rad {agg.get('samples', '-')} samples / {fmt(agg.get('duration_s'), 1)} s")
|
|
93
|
+
per_joint = report.get("per_joint") or []
|
|
94
|
+
if per_joint:
|
|
95
|
+
print()
|
|
96
|
+
print(table([[j.get("joint"), j.get("rmse_rad"), j.get("max_err_rad"), j.get("lag_ms"), j.get("gain")]
|
|
97
|
+
for j in per_joint], ["joint", "rmse_rad", "max_err_rad", "lag_ms", "gain"]))
|
|
98
|
+
diagnosis = report.get("diagnosis") or []
|
|
99
|
+
if diagnosis:
|
|
100
|
+
print()
|
|
101
|
+
print("diagnosis:")
|
|
102
|
+
for line in diagnosis:
|
|
103
|
+
print(f" - {line}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _dump(obj: Any, path: str | None) -> None:
|
|
107
|
+
if path:
|
|
108
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
Path(path).write_text(json.dumps(obj, separators=(",", ":")), encoding="utf-8")
|
|
110
|
+
print(f"wrote {path}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _mujoco_available() -> bool:
|
|
114
|
+
try:
|
|
115
|
+
import mujoco # noqa: F401
|
|
116
|
+
except ImportError:
|
|
117
|
+
return False
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---- commands --------------------------------------------------------------------------
|
|
122
|
+
def cmd_ls(args: argparse.Namespace) -> int:
|
|
123
|
+
try:
|
|
124
|
+
repos = Hub(args.hub).list()
|
|
125
|
+
except HubError as e:
|
|
126
|
+
regs = [r for r in registry_dirs(args.registry) if r.is_dir()]
|
|
127
|
+
if not regs:
|
|
128
|
+
raise
|
|
129
|
+
print(f"{e}\nlisting local registry {regs[0]} instead\n")
|
|
130
|
+
rows = []
|
|
131
|
+
for manifest in sorted(regs[0].glob(f"*/*/{MANIFEST}")):
|
|
132
|
+
try:
|
|
133
|
+
t = Triad(manifest.parent)
|
|
134
|
+
except TriadError:
|
|
135
|
+
continue
|
|
136
|
+
rows.append([t.id, t.category, t.body.get("name"), len(t.worlds), "-", "-"])
|
|
137
|
+
print(table(rows, ["repo", "category", "body", "worlds", "sim_gap", "robustness"]))
|
|
138
|
+
return 0
|
|
139
|
+
rows = []
|
|
140
|
+
for r in repos:
|
|
141
|
+
body, stats = r.get("body") or {}, r.get("stats") or {}
|
|
142
|
+
rows.append([r.get("id"), r.get("category"), body.get("name"), body.get("actuators"),
|
|
143
|
+
len(r.get("worlds") or []), stats.get("sim_gap"), stats.get("robustness")])
|
|
144
|
+
print(table(rows, ["repo", "category", "body", "nu", "worlds", "sim_gap", "robustness"]))
|
|
145
|
+
print(f"\n{len(rows)} repos on {args.hub}")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
150
|
+
t = resolve_repo(args.repo, args.hub, args.registry)
|
|
151
|
+
m = t.manifest
|
|
152
|
+
print(f"{t.id} — {t.title}")
|
|
153
|
+
if m.get("summary"):
|
|
154
|
+
print(m["summary"])
|
|
155
|
+
print(f"root {t.root}")
|
|
156
|
+
print(f"category {t.category} tags {', '.join(m.get('tags') or []) or '-'} license {m.get('license')}")
|
|
157
|
+
b, br = t.body, t.brain
|
|
158
|
+
print(f"body {b.get('name')} ({b.get('format')}, {b.get('entry')}) by {b.get('manufacturer', '-')}"
|
|
159
|
+
f" license {b.get('license', '-')} grade {b.get('grade', '-')}")
|
|
160
|
+
print(f"brain {br.get('name')} ({br.get('type')}, {br.get('entry')}) @ {t.control_hz:g} Hz "
|
|
161
|
+
f"action: {br.get('action', '-')}")
|
|
162
|
+
print(f"worlds {', '.join(t.worlds)} (default {t.default_world})")
|
|
163
|
+
build = m.get("build") or {}
|
|
164
|
+
if build:
|
|
165
|
+
print(f"build {build.get('kind', '-')} bom {build.get('bom', '-')} assembly {build.get('assembly', '-')}")
|
|
166
|
+
bom = t.bom()
|
|
167
|
+
if bom:
|
|
168
|
+
print(f"bom {len(bom.get('items') or [])} items, est. {bom.get('total_estimate', '-')} {bom.get('currency', '')}")
|
|
169
|
+
deploy = m.get("deploy") or {}
|
|
170
|
+
if deploy:
|
|
171
|
+
print(f"deploy driver {deploy.get('driver', '-')} port {deploy.get('port', '-')} "
|
|
172
|
+
f"baud {deploy.get('baud', '-')} mapped actuators {len(deploy.get('map') or {})}")
|
|
173
|
+
if _mujoco_available():
|
|
174
|
+
try:
|
|
175
|
+
insp = t.inspect()
|
|
176
|
+
except Exception as e: # inspection is best-effort: the body may not compile on this machine
|
|
177
|
+
print(f"\n(inspection unavailable: {e})")
|
|
178
|
+
return 0
|
|
179
|
+
print(f"\nnq {insp.get('nq')} nv {insp.get('nv')} nu {insp.get('nu')} bodies {insp.get('nbody')} "
|
|
180
|
+
f"mass {fmt(insp.get('mass_kg'), 2)} kg timestep {insp.get('timestep')} free base {fmt(insp.get('free_base'))}")
|
|
181
|
+
joints = insp.get("joints") or []
|
|
182
|
+
if joints:
|
|
183
|
+
print()
|
|
184
|
+
print(table([[j.get("name"), j.get("type"), fmt(j.get("range")), j.get("actuator")] for j in joints],
|
|
185
|
+
["joint", "type", "range", "actuator"]))
|
|
186
|
+
acts = insp.get("actuators") or []
|
|
187
|
+
if acts:
|
|
188
|
+
print()
|
|
189
|
+
print(table([[a.get("name"), a.get("type"), a.get("joint"), fmt(a.get("ctrlrange")), _gear(a.get("gear"))]
|
|
190
|
+
for a in acts], ["actuator", "type", "joint", "ctrlrange", "gear"]))
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _gear(gear: Any) -> Any:
|
|
195
|
+
"""MuJoCo's 6-vector gear reduced to its first (transmission) component for display."""
|
|
196
|
+
if isinstance(gear, (list, tuple)):
|
|
197
|
+
return gear[0] if gear else None
|
|
198
|
+
return gear
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_pull(args: argparse.Namespace) -> int:
|
|
202
|
+
t = Hub(args.hub).pull(args.repo, dest=args.dest)
|
|
203
|
+
print(f"pulled {t.id} → {t.root} ({len(t.files())} files)")
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_worlds(args: argparse.Namespace) -> int:
|
|
208
|
+
t = resolve_repo(args.repo, args.hub, args.registry)
|
|
209
|
+
rows = [[wid + (" *" if wid == t.default_world else ""), w.get("title"), w.get("file"), w.get("description")]
|
|
210
|
+
for wid, w in t.worlds.items()]
|
|
211
|
+
print(table(rows, ["world", "title", "file", "description"]))
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_sim(args: argparse.Namespace) -> int:
|
|
216
|
+
use_local = args.local or (args.remote is None and _mujoco_available())
|
|
217
|
+
if use_local:
|
|
218
|
+
t = resolve_repo(args.repo, args.hub, args.registry)
|
|
219
|
+
run = t.simulate(world=args.world, seconds=args.seconds)
|
|
220
|
+
where = f"local MuJoCo ({t.root})"
|
|
221
|
+
else:
|
|
222
|
+
url = args.remote if isinstance(args.remote, str) else args.hub
|
|
223
|
+
run = Hub(url).simulate(args.repo, world=args.world, seconds=args.seconds)
|
|
224
|
+
where = f"hub {url}"
|
|
225
|
+
s = run.get("summary") or {}
|
|
226
|
+
print(f"simulated {args.repo} · world {run.get('world')} · {run.get('seconds')} s · {len(run.get('frames') or [])} frames · {where}")
|
|
227
|
+
print(f"ok {fmt(s.get('ok'))} score {fmt(s.get('score'))} fell_at {fmt(s.get('fell_at'), 2)} "
|
|
228
|
+
f"mean_speed {fmt(s.get('mean_speed'))} m/s energy {fmt(s.get('energy_j'), 1)} J")
|
|
229
|
+
_dump(run, args.out)
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _make_deploy_driver(args: argparse.Namespace, t: Triad) -> Any:
|
|
234
|
+
deploy_cfg = t.manifest.get("deploy") or {}
|
|
235
|
+
name = args.driver
|
|
236
|
+
if name == "mock":
|
|
237
|
+
kwargs: dict[str, Any] = {"triad": t, "world": args.world, "latency_ticks": args.latency,
|
|
238
|
+
"noise_std": args.noise}
|
|
239
|
+
elif name == "ros2":
|
|
240
|
+
kwargs = {"topic": args.topic or deploy_cfg.get("topic") or "/morphic/joint_commands",
|
|
241
|
+
"dry_run": args.dry_run, "joint_map": JointMap.from_manifest(t.manifest)}
|
|
242
|
+
else:
|
|
243
|
+
port = args.port or deploy_cfg.get("port")
|
|
244
|
+
dry_run = args.dry_run or not port
|
|
245
|
+
if not port and not args.dry_run:
|
|
246
|
+
print("no --port given and no deploy.port in the manifest — running dry (frames are logged, not sent)")
|
|
247
|
+
kwargs = {"port": port, "dry_run": dry_run, "joint_map": JointMap.from_manifest(t.manifest)}
|
|
248
|
+
baud = args.baud or deploy_cfg.get("baud")
|
|
249
|
+
if baud:
|
|
250
|
+
kwargs["baud"] = int(baud)
|
|
251
|
+
return make_driver(name, **kwargs)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_deploy(args: argparse.Namespace) -> int:
|
|
255
|
+
from . import deploy
|
|
256
|
+
|
|
257
|
+
t = resolve_repo(args.repo, args.hub, args.registry)
|
|
258
|
+
driver = _make_deploy_driver(args, t)
|
|
259
|
+
world = args.world or t.default_world
|
|
260
|
+
print(f"deploying {t.id} · brain {t.brain.get('name')} @ {t.control_hz:g} Hz · world {world} · driver {driver.name}")
|
|
261
|
+
result = deploy.run(t, driver, world=args.world, seconds=args.seconds, record=args.record, verbose=args.verbose)
|
|
262
|
+
st = result["status"]
|
|
263
|
+
print(f"done: {result['ticks']} ticks / {result['duration_s']:.2f} s (wall {result['wall_s']:.2f} s) "
|
|
264
|
+
f"ok {fmt(st.get('ok', True))}")
|
|
265
|
+
extra = {k: v for k, v in st.items() if k != "ok"}
|
|
266
|
+
if extra:
|
|
267
|
+
print("status " + " ".join(f"{k}={fmt(v)}" for k, v in extra.items()))
|
|
268
|
+
if result["log_tail"]:
|
|
269
|
+
print("\nlast packets on the wire:")
|
|
270
|
+
for rec in result["log_tail"]:
|
|
271
|
+
print(" " + _packet_line(rec))
|
|
272
|
+
if result["record"]:
|
|
273
|
+
print(f"\ntelemetry → {result['record']} ({len(result['telemetry']['t'])} samples)")
|
|
274
|
+
print(f"next: morphic gap {args.repo} {result['record']}")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _packet_line(rec: dict[str, Any]) -> str:
|
|
279
|
+
kind = rec.get("kind", "?")
|
|
280
|
+
if "frame" in rec:
|
|
281
|
+
frame = rec["frame"]
|
|
282
|
+
return f"{kind:<14} {frame[:60]}{'…' if len(frame) > 60 else ''}"
|
|
283
|
+
if "applied" in rec:
|
|
284
|
+
return f"{kind:<14} tick {rec.get('tick')} t={rec.get('t')} cmd {rec.get('cmd')} applied {rec.get('applied')}"
|
|
285
|
+
if "data" in rec:
|
|
286
|
+
return f"{kind:<14} {rec.get('topic')} {rec.get('data')}"
|
|
287
|
+
return f"{kind:<14} " + " ".join(f"{k}={v}" for k, v in rec.items() if k != "kind")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _report_local(t: Triad, telemetry: dict[str, Any]) -> dict[str, Any]:
|
|
291
|
+
try:
|
|
292
|
+
from .gap import score
|
|
293
|
+
except ImportError as e:
|
|
294
|
+
raise CLIError("local Sim-Gap scoring needs morphic.gap (and mujoco); drop --local to score on the hub") from e
|
|
295
|
+
return score(t, telemetry, world=telemetry.get("world"))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_gap(args: argparse.Namespace) -> int:
|
|
299
|
+
telemetry = load_telemetry(args.run)
|
|
300
|
+
if args.local:
|
|
301
|
+
t = resolve_repo(args.repo, args.hub, args.registry)
|
|
302
|
+
report = _report_local(t, telemetry)
|
|
303
|
+
else:
|
|
304
|
+
report = Hub(args.hub).push_telemetry(args.repo, args.run)
|
|
305
|
+
print_gap_report(report)
|
|
306
|
+
_dump(report, args.out)
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def cmd_push(args: argparse.Namespace) -> int:
|
|
311
|
+
load_telemetry(args.run) # fail fast with a clear message before uploading
|
|
312
|
+
report = Hub(args.hub).push_telemetry(args.repo, args.run)
|
|
313
|
+
print(f"uploaded {args.run} to {args.hub}")
|
|
314
|
+
print_gap_report(report)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def cmd_drivers(args: argparse.Namespace) -> int:
|
|
319
|
+
if args.what == "arduino-sketch":
|
|
320
|
+
print(ARDUINO_SKETCH)
|
|
321
|
+
return 0
|
|
322
|
+
print(table([[name, DESCRIPTIONS.get(name, "")] for name in DRIVERS], ["driver", "description"]))
|
|
323
|
+
print("\nusage: morphic deploy <repo> --driver <name> [--port COM3] [--dry-run] [--record run.json]")
|
|
324
|
+
print("map model units to device units with a `deploy:` section in morphic.yaml (see python/README.md)")
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---- parser --------------------------------------------------------------------------------
|
|
329
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
330
|
+
p = argparse.ArgumentParser(prog="morphic", description="Morphic — pull, simulate and deploy Triad repos.")
|
|
331
|
+
p.add_argument("--hub", default=DEFAULT_URL, help="hub URL (default: $MORPHIC_HUB, else the public hub baked into morphic.hub.DEFAULT_URL)")
|
|
332
|
+
p.add_argument("--registry", default=None, help="local registry dir (env MORPHIC_REGISTRY)")
|
|
333
|
+
p.add_argument("--version", action="version", version=f"morphic {__version__}")
|
|
334
|
+
sub = p.add_subparsers(dest="command", metavar="<command>")
|
|
335
|
+
sub.required = True
|
|
336
|
+
|
|
337
|
+
sub.add_parser("ls", help="list repos on the hub").set_defaults(func=cmd_ls)
|
|
338
|
+
|
|
339
|
+
s = sub.add_parser("info", help="show a repo (manifest, joints, actuators, BOM)")
|
|
340
|
+
s.add_argument("repo")
|
|
341
|
+
s.set_defaults(func=cmd_info)
|
|
342
|
+
|
|
343
|
+
s = sub.add_parser("pull", help="download a Triad into the local cache")
|
|
344
|
+
s.add_argument("repo")
|
|
345
|
+
s.add_argument("--dest", default=None, help="target directory (default ~/.morphic/cache/<owner>/<name>)")
|
|
346
|
+
s.set_defaults(func=cmd_pull)
|
|
347
|
+
|
|
348
|
+
s = sub.add_parser("worlds", help="list a repo's world variants")
|
|
349
|
+
s.add_argument("repo")
|
|
350
|
+
s.set_defaults(func=cmd_worlds)
|
|
351
|
+
|
|
352
|
+
s = sub.add_parser("sim", help="roll the brain out in a world")
|
|
353
|
+
s.add_argument("repo")
|
|
354
|
+
s.add_argument("--world", "-w", default=None)
|
|
355
|
+
s.add_argument("--seconds", "-s", type=float, default=5.0)
|
|
356
|
+
s.add_argument("--out", "-o", default=None, help="write the full trajectory JSON here")
|
|
357
|
+
g = s.add_mutually_exclusive_group()
|
|
358
|
+
g.add_argument("--local", action="store_true", help="simulate with local MuJoCo (default when installed)")
|
|
359
|
+
g.add_argument("--hub", dest="remote", nargs="?", const=True, default=None, metavar="URL",
|
|
360
|
+
help="simulate on the hub (optionally at URL)")
|
|
361
|
+
s.set_defaults(func=cmd_sim)
|
|
362
|
+
|
|
363
|
+
s = sub.add_parser("deploy", help="run the brain through a hardware driver")
|
|
364
|
+
s.add_argument("repo")
|
|
365
|
+
s.add_argument("--driver", "-d", required=True, choices=list(DRIVERS))
|
|
366
|
+
s.add_argument("--port", "-p", default=None, help="serial port (COM3, /dev/ttyACM0); default from manifest deploy.port")
|
|
367
|
+
s.add_argument("--baud", type=int, default=None, help="serial baud rate; default from manifest or driver")
|
|
368
|
+
s.add_argument("--topic", default=None, help="ros2: command topic")
|
|
369
|
+
s.add_argument("--world", "-w", default=None)
|
|
370
|
+
s.add_argument("--seconds", "-s", type=float, default=5.0)
|
|
371
|
+
s.add_argument("--record", "-r", default=None, help="write morphic-telemetry/1 JSON here")
|
|
372
|
+
s.add_argument("--dry-run", action="store_true", help="encode packets but never open a port / publish")
|
|
373
|
+
s.add_argument("--latency", type=int, default=2, help="mock: command latency in control ticks")
|
|
374
|
+
s.add_argument("--noise", type=float, default=0.003, help="mock: encoder noise std (rad)")
|
|
375
|
+
s.add_argument("--verbose", "-v", action="store_true")
|
|
376
|
+
s.set_defaults(func=cmd_deploy)
|
|
377
|
+
|
|
378
|
+
s = sub.add_parser("gap", help="score a telemetry run against the simulator (Sim-Gap)")
|
|
379
|
+
s.add_argument("repo")
|
|
380
|
+
s.add_argument("run", help="telemetry JSON from `morphic deploy --record`")
|
|
381
|
+
s.add_argument("--local", action="store_true", help="score locally with morphic.gap instead of uploading")
|
|
382
|
+
s.add_argument("--out", "-o", default=None, help="write the report JSON here")
|
|
383
|
+
s.set_defaults(func=cmd_gap)
|
|
384
|
+
|
|
385
|
+
s = sub.add_parser("push", help="upload a telemetry run to the hub")
|
|
386
|
+
s.add_argument("repo")
|
|
387
|
+
s.add_argument("run")
|
|
388
|
+
s.set_defaults(func=cmd_push)
|
|
389
|
+
|
|
390
|
+
s = sub.add_parser("drivers", help="list drivers, or print firmware: `drivers arduino-sketch`")
|
|
391
|
+
s.add_argument("what", nargs="?", choices=["arduino-sketch"], default=None)
|
|
392
|
+
s.set_defaults(func=cmd_drivers)
|
|
393
|
+
return p
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
397
|
+
# Windows consoles default to a legacy code page; make sure arrows and middle dots print.
|
|
398
|
+
for stream in (sys.stdout, sys.stderr):
|
|
399
|
+
if hasattr(stream, "reconfigure"):
|
|
400
|
+
try:
|
|
401
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
402
|
+
except (ValueError, OSError):
|
|
403
|
+
pass
|
|
404
|
+
parser = build_parser()
|
|
405
|
+
args = parser.parse_args(argv)
|
|
406
|
+
func: Callable[[argparse.Namespace], int] = args.func
|
|
407
|
+
try:
|
|
408
|
+
return func(args)
|
|
409
|
+
except (CLIError, HubError, TriadError, DriverError, TelemetryError) as e:
|
|
410
|
+
print(f"error: {e}", file=sys.stderr)
|
|
411
|
+
return 1
|
|
412
|
+
except KeyboardInterrupt:
|
|
413
|
+
print("interrupted", file=sys.stderr)
|
|
414
|
+
return 130
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
if __name__ == "__main__": # pragma: no cover
|
|
418
|
+
sys.exit(main())
|