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 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())