morphic-hub 0.2.0__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.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: morphic-hub
3
+ Version: 0.2.0
4
+ Summary: Morphic - the embodied hub. Pull Triad repos (Brain + Body + World) from a Morphic hub, simulate them locally, deploy brains to hardware.
5
+ License: Apache-2.0
6
+ Project-URL: Hub, https://hub.ruliax.com
7
+ Project-URL: Source, https://github.com/Ruliax-AI/Morphic
8
+ Keywords: robotics,mujoco,simulation,physical-ai,sim2real
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: pyyaml>=6
18
+ Requires-Dist: httpx>=0.27
19
+ Provides-Extra: sim
20
+ Requires-Dist: mujoco<3.13,>=3.12; extra == "sim"
21
+ Provides-Extra: serial
22
+ Requires-Dist: pyserial>=3.5; extra == "serial"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: mujoco<3.13,>=3.12; extra == "dev"
26
+
27
+ # `morphic` — the Python client, driver layer and CLI
28
+
29
+ Pull a Triad (Brain + Body + World) from a Morphic hub, simulate it locally, deploy the brain
30
+ to real actuators through one driver layer, and send the telemetry back for a Sim-Gap score.
31
+
32
+ Licence: Apache-2.0. Dependencies are all free/OSS: numpy (BSD-3), PyYAML (MIT),
33
+ httpx (BSD-3), MuJoCo (Apache-2.0, optional), pyserial (BSD-3, optional), pytest (MIT, dev).
34
+
35
+ ## Quickstart
36
+
37
+ ```bash
38
+ pip install "morphic-hub[sim]" # client + CLI + MuJoCo (drop [sim] for the pure HTTP client)
39
+
40
+ morphic ls # what's on the hub (https://hub.ruliax.com by default)
41
+ morphic info morphic/go2-trot # manifest, joints, actuators, BOM
42
+ morphic pull morphic/go2-trot # -> ~/.morphic/cache/morphic/go2-trot ($MORPHIC_HOME overrides ~/.morphic)
43
+ morphic sim morphic/go2-trot --world ice --seconds 5 --out run.json
44
+ ```
45
+
46
+ The distribution is `morphic-hub` (the name `morphic` on PyPI belongs to an unrelated project);
47
+ the import and the command are both `morphic`. Every command talks to the public hub unless you
48
+ pass `--hub URL` or set `$MORPHIC_HUB` (e.g. `http://127.0.0.1:8000` for a local `python -m morphic_hub`).
49
+
50
+ `<repo>` is resolved in this order: an existing directory → `hub/registry/<repo>`
51
+ (relative to the cwd or `$MORPHIC_REGISTRY`) → the pull cache → `morphic pull` from the hub.
52
+
53
+ ```python
54
+ from morphic import Hub
55
+ hub = Hub() # or Hub("http://127.0.0.1:8000")
56
+ triad = hub.pull("morphic/go2-trot")
57
+ run = triad.simulate(world="ice", seconds=5) # local MuJoCo
58
+ report = hub.push_telemetry("morphic/go2-trot", "hw.json")
59
+ ```
60
+
61
+ ## Advanced: the driver layer — one policy output, any actuator brand
62
+
63
+ A policy emits a control vector in *model units* (radians for position servos,
64
+ N·m for torque motors — whatever the MJCF actuators expect). A `Driver` turns that
65
+ into wire packets; a `JointMap` does the per-actuator unit conversion. Swapping
66
+ hardware is swapping the driver:
67
+
68
+ ```python
69
+ from morphic import Triad, deploy
70
+ from morphic.drivers import FeetechDriver, DynamixelDriver, JointMap
71
+
72
+ triad = Triad("~/.morphic/cache/morphic/so-arm100-pick")
73
+ jm = JointMap.from_manifest(triad.manifest) # ids / signs / offsets from morphic.yaml
74
+
75
+ arm = FeetechDriver("COM4", joint_map=jm) # STS3215 bus (SO-ARM100)
76
+ deploy.run(triad, arm, seconds=5, record="so100_feetech.json")
77
+
78
+ arm = DynamixelDriver("COM5", joint_map=jm) # same brain, XL330 bus instead
79
+ deploy.run(triad, arm, seconds=5, record="so100_dynamixel.json")
80
+ ```
81
+
82
+ Nothing about the brain changed. The deploy loop keeps the nominal simulation as a
83
+ digital twin: when the driver reports encoder state, the twin is corrected to it
84
+ (it is the state estimator); when the device is write-only, the twin runs open loop.
85
+
86
+ | driver | class | wire format | feedback |
87
+ |-------------|-------------------|-----------------------------------------------------------------|----------|
88
+ | `mock` | `MockDriver` | none — perturbed MuJoCo twin with command latency + encoder noise | yes |
89
+ | `serial` | `SerialPWMDriver` | `'M' n {id u8, pwm_us u16 LE}… xor` to an Arduino/ESP32/STM32 running `morphic drivers arduino-sketch` | no |
90
+ | `feetech` | `FeetechDriver` | Feetech STS protocol, SYNC WRITE goal position (0x2A), 4096 ticks/rev | present position + speed (0x38) |
91
+ | `dynamixel` | `DynamixelDriver` | DYNAMIXEL Protocol 2.0, SYNC WRITE goal position (116), CRC-16 | present velocity + position (128..135) |
92
+ | `ros2` | `ROS2Driver` | `std_msgs/Float64MultiArray` on `/morphic/joint_commands` | `sensor_msgs/JointState` |
93
+
94
+ Every driver honours `dry_run=True` (and the serial/ROS drivers fall back to it when
95
+ pyserial/rclpy are absent): packets are encoded exactly as they would be sent and kept
96
+ in `driver.log` as hex, so `morphic deploy … --dry-run` shows "what went over the wire"
97
+ with nothing plugged in. The drivers have been verified at the protocol level and against
98
+ the emulator, not on physical buses.
99
+
100
+ Feedback contract: `read()` returns `{"qpos": (nq,), "qvel": (nv,)}` in model units with
101
+ `NaN` for anything the hardware does not measure, or `None` for write-only devices.
102
+
103
+ ### The `MockDriver` (hardware emulator)
104
+
105
+ A second MuJoCo instance compiled with deliberately different parameters — by default
106
+ `{damping ×1.6, frictionloss ×2.0, mass ×1.08, actuator gain ×0.85, floor friction ×0.7}` —
107
+ plus a 2-tick command FIFO and 3 mrad Gaussian encoder noise. Deploying against it
108
+ yields telemetry with a realistic sim-to-real gap, which is what the Sim-Gap scorer
109
+ is for.
110
+
111
+ ## `deploy:` section of `morphic.yaml`
112
+
113
+ Optional. Tells `morphic deploy` how the model's actuators map onto physical devices.
114
+
115
+ ```yaml
116
+ deploy:
117
+ driver: feetech # mock | serial | feetech | dynamixel | ros2 (default for --driver)
118
+ port: COM4 # serial port; --port overrides
119
+ baud: 1000000 # driver default if omitted (feetech/dynamixel 1 000 000, serial 115200)
120
+ topic: /morphic/joint_commands # ros2 only
121
+ map: # per actuator (MJCF actuator name); missing actuators get identity entries
122
+ shoulder_pan: {id: 1, sign: 1, offset: 2048}
123
+ shoulder_lift: {id: 2, sign: -1, offset: 2048, min: 300, max: 3800}
124
+ elbow_flex: 3 # shorthand: just the bus id
125
+ ```
126
+
127
+ Entry keys (all optional except that each actuator needs an `id`):
128
+
129
+ | key | meaning | default |
130
+ |----------|-----------------------------------------------------------|--------------------|
131
+ | `id` | bus / channel id | 1..n in actuator order |
132
+ | `sign` | `1` or `-1` — flips direction | `1` |
133
+ | `scale` | device units per model unit | driver: ticks/rad (`4096/2π`) for servo buses, µs/rad (`1000/π`) for PWM, `1` for ROS 2 |
134
+ | `offset` | device units at model zero | driver: `2048` ticks, `1500` µs, `0` |
135
+ | `min`, `max` | clamp in device units | driver: `0..4095` ticks, `1000..2000` µs |
136
+ | `unit` | model unit of the actuator: `rad`, `m`, `N`, `Nm`, `norm` | `rad` |
137
+
138
+ Conversion: `device = clamp(sign · value · scale + offset, min, max)`; readings come back
139
+ through the inverse. Manifest entries override driver defaults, which override identity.
140
+
141
+ ## Telemetry
142
+
143
+ `morphic deploy --record run.json` writes `morphic-telemetry/1` (schema in
144
+ `docs/TRIAD.md`): joint positions/velocities of the hinge/slide joints, the commanded
145
+ `ctrl`, the floating-base pose when there is one, and `meta` describing the driver
146
+ (for the emulator: the perturbation it used). `morphic gap <repo> run.json` uploads it
147
+ and prints the Sim-Gap report; `--local` scores with `morphic.gap` instead.
148
+
149
+ ```python
150
+ from morphic.telemetry import TelemetryRecorder, load_telemetry, validate_telemetry
151
+ rec = TelemetryRecorder("owner/name", "flat", joints, actuators, dt=0.02, source="hardware", driver="feetech")
152
+ rec.add(t, qpos, qvel, ctrl)
153
+ rec.save("run.json")
154
+ ```
155
+
156
+ ## CLI reference
157
+
158
+ ```
159
+ morphic [--hub URL] [--registry DIR] <command>
160
+ ls list repos (falls back to the local registry when the hub is down)
161
+ info <repo> manifest, joints, actuators, BOM, deploy section
162
+ pull <repo> [--dest DIR] download a Triad
163
+ worlds <repo> world variants
164
+ sim <repo> [--world W] [--seconds S] [--out run.json] [--local|--hub]
165
+ deploy <repo> --driver mock|serial|feetech|dynamixel|ros2 [--port COM3] [--baud N]
166
+ [--world W] [--seconds S] [--record run.json] [--dry-run] [--latency T] [--noise STD] [-v]
167
+ gap <repo> run.json [--local] [--out report.json]
168
+ push <repo> run.json
169
+ drivers [arduino-sketch]
170
+ ```
171
+
172
+ ## Tests
173
+
174
+ ```bash
175
+ cd python && python -m pytest -q
176
+ ```
177
+
178
+ `tests/` builds a one-joint Triad on the fly, so the suite runs without the registry
179
+ and without hardware. Packet encoders are checked against the vendors' documented
180
+ example packets (ROBOTIS e-manual for DYNAMIXEL Protocol 2.0).
@@ -0,0 +1,154 @@
1
+ # `morphic` — the Python client, driver layer and CLI
2
+
3
+ Pull a Triad (Brain + Body + World) from a Morphic hub, simulate it locally, deploy the brain
4
+ to real actuators through one driver layer, and send the telemetry back for a Sim-Gap score.
5
+
6
+ Licence: Apache-2.0. Dependencies are all free/OSS: numpy (BSD-3), PyYAML (MIT),
7
+ httpx (BSD-3), MuJoCo (Apache-2.0, optional), pyserial (BSD-3, optional), pytest (MIT, dev).
8
+
9
+ ## Quickstart
10
+
11
+ ```bash
12
+ pip install "morphic-hub[sim]" # client + CLI + MuJoCo (drop [sim] for the pure HTTP client)
13
+
14
+ morphic ls # what's on the hub (https://hub.ruliax.com by default)
15
+ morphic info morphic/go2-trot # manifest, joints, actuators, BOM
16
+ morphic pull morphic/go2-trot # -> ~/.morphic/cache/morphic/go2-trot ($MORPHIC_HOME overrides ~/.morphic)
17
+ morphic sim morphic/go2-trot --world ice --seconds 5 --out run.json
18
+ ```
19
+
20
+ The distribution is `morphic-hub` (the name `morphic` on PyPI belongs to an unrelated project);
21
+ the import and the command are both `morphic`. Every command talks to the public hub unless you
22
+ pass `--hub URL` or set `$MORPHIC_HUB` (e.g. `http://127.0.0.1:8000` for a local `python -m morphic_hub`).
23
+
24
+ `<repo>` is resolved in this order: an existing directory → `hub/registry/<repo>`
25
+ (relative to the cwd or `$MORPHIC_REGISTRY`) → the pull cache → `morphic pull` from the hub.
26
+
27
+ ```python
28
+ from morphic import Hub
29
+ hub = Hub() # or Hub("http://127.0.0.1:8000")
30
+ triad = hub.pull("morphic/go2-trot")
31
+ run = triad.simulate(world="ice", seconds=5) # local MuJoCo
32
+ report = hub.push_telemetry("morphic/go2-trot", "hw.json")
33
+ ```
34
+
35
+ ## Advanced: the driver layer — one policy output, any actuator brand
36
+
37
+ A policy emits a control vector in *model units* (radians for position servos,
38
+ N·m for torque motors — whatever the MJCF actuators expect). A `Driver` turns that
39
+ into wire packets; a `JointMap` does the per-actuator unit conversion. Swapping
40
+ hardware is swapping the driver:
41
+
42
+ ```python
43
+ from morphic import Triad, deploy
44
+ from morphic.drivers import FeetechDriver, DynamixelDriver, JointMap
45
+
46
+ triad = Triad("~/.morphic/cache/morphic/so-arm100-pick")
47
+ jm = JointMap.from_manifest(triad.manifest) # ids / signs / offsets from morphic.yaml
48
+
49
+ arm = FeetechDriver("COM4", joint_map=jm) # STS3215 bus (SO-ARM100)
50
+ deploy.run(triad, arm, seconds=5, record="so100_feetech.json")
51
+
52
+ arm = DynamixelDriver("COM5", joint_map=jm) # same brain, XL330 bus instead
53
+ deploy.run(triad, arm, seconds=5, record="so100_dynamixel.json")
54
+ ```
55
+
56
+ Nothing about the brain changed. The deploy loop keeps the nominal simulation as a
57
+ digital twin: when the driver reports encoder state, the twin is corrected to it
58
+ (it is the state estimator); when the device is write-only, the twin runs open loop.
59
+
60
+ | driver | class | wire format | feedback |
61
+ |-------------|-------------------|-----------------------------------------------------------------|----------|
62
+ | `mock` | `MockDriver` | none — perturbed MuJoCo twin with command latency + encoder noise | yes |
63
+ | `serial` | `SerialPWMDriver` | `'M' n {id u8, pwm_us u16 LE}… xor` to an Arduino/ESP32/STM32 running `morphic drivers arduino-sketch` | no |
64
+ | `feetech` | `FeetechDriver` | Feetech STS protocol, SYNC WRITE goal position (0x2A), 4096 ticks/rev | present position + speed (0x38) |
65
+ | `dynamixel` | `DynamixelDriver` | DYNAMIXEL Protocol 2.0, SYNC WRITE goal position (116), CRC-16 | present velocity + position (128..135) |
66
+ | `ros2` | `ROS2Driver` | `std_msgs/Float64MultiArray` on `/morphic/joint_commands` | `sensor_msgs/JointState` |
67
+
68
+ Every driver honours `dry_run=True` (and the serial/ROS drivers fall back to it when
69
+ pyserial/rclpy are absent): packets are encoded exactly as they would be sent and kept
70
+ in `driver.log` as hex, so `morphic deploy … --dry-run` shows "what went over the wire"
71
+ with nothing plugged in. The drivers have been verified at the protocol level and against
72
+ the emulator, not on physical buses.
73
+
74
+ Feedback contract: `read()` returns `{"qpos": (nq,), "qvel": (nv,)}` in model units with
75
+ `NaN` for anything the hardware does not measure, or `None` for write-only devices.
76
+
77
+ ### The `MockDriver` (hardware emulator)
78
+
79
+ A second MuJoCo instance compiled with deliberately different parameters — by default
80
+ `{damping ×1.6, frictionloss ×2.0, mass ×1.08, actuator gain ×0.85, floor friction ×0.7}` —
81
+ plus a 2-tick command FIFO and 3 mrad Gaussian encoder noise. Deploying against it
82
+ yields telemetry with a realistic sim-to-real gap, which is what the Sim-Gap scorer
83
+ is for.
84
+
85
+ ## `deploy:` section of `morphic.yaml`
86
+
87
+ Optional. Tells `morphic deploy` how the model's actuators map onto physical devices.
88
+
89
+ ```yaml
90
+ deploy:
91
+ driver: feetech # mock | serial | feetech | dynamixel | ros2 (default for --driver)
92
+ port: COM4 # serial port; --port overrides
93
+ baud: 1000000 # driver default if omitted (feetech/dynamixel 1 000 000, serial 115200)
94
+ topic: /morphic/joint_commands # ros2 only
95
+ map: # per actuator (MJCF actuator name); missing actuators get identity entries
96
+ shoulder_pan: {id: 1, sign: 1, offset: 2048}
97
+ shoulder_lift: {id: 2, sign: -1, offset: 2048, min: 300, max: 3800}
98
+ elbow_flex: 3 # shorthand: just the bus id
99
+ ```
100
+
101
+ Entry keys (all optional except that each actuator needs an `id`):
102
+
103
+ | key | meaning | default |
104
+ |----------|-----------------------------------------------------------|--------------------|
105
+ | `id` | bus / channel id | 1..n in actuator order |
106
+ | `sign` | `1` or `-1` — flips direction | `1` |
107
+ | `scale` | device units per model unit | driver: ticks/rad (`4096/2π`) for servo buses, µs/rad (`1000/π`) for PWM, `1` for ROS 2 |
108
+ | `offset` | device units at model zero | driver: `2048` ticks, `1500` µs, `0` |
109
+ | `min`, `max` | clamp in device units | driver: `0..4095` ticks, `1000..2000` µs |
110
+ | `unit` | model unit of the actuator: `rad`, `m`, `N`, `Nm`, `norm` | `rad` |
111
+
112
+ Conversion: `device = clamp(sign · value · scale + offset, min, max)`; readings come back
113
+ through the inverse. Manifest entries override driver defaults, which override identity.
114
+
115
+ ## Telemetry
116
+
117
+ `morphic deploy --record run.json` writes `morphic-telemetry/1` (schema in
118
+ `docs/TRIAD.md`): joint positions/velocities of the hinge/slide joints, the commanded
119
+ `ctrl`, the floating-base pose when there is one, and `meta` describing the driver
120
+ (for the emulator: the perturbation it used). `morphic gap <repo> run.json` uploads it
121
+ and prints the Sim-Gap report; `--local` scores with `morphic.gap` instead.
122
+
123
+ ```python
124
+ from morphic.telemetry import TelemetryRecorder, load_telemetry, validate_telemetry
125
+ rec = TelemetryRecorder("owner/name", "flat", joints, actuators, dt=0.02, source="hardware", driver="feetech")
126
+ rec.add(t, qpos, qvel, ctrl)
127
+ rec.save("run.json")
128
+ ```
129
+
130
+ ## CLI reference
131
+
132
+ ```
133
+ morphic [--hub URL] [--registry DIR] <command>
134
+ ls list repos (falls back to the local registry when the hub is down)
135
+ info <repo> manifest, joints, actuators, BOM, deploy section
136
+ pull <repo> [--dest DIR] download a Triad
137
+ worlds <repo> world variants
138
+ sim <repo> [--world W] [--seconds S] [--out run.json] [--local|--hub]
139
+ deploy <repo> --driver mock|serial|feetech|dynamixel|ros2 [--port COM3] [--baud N]
140
+ [--world W] [--seconds S] [--record run.json] [--dry-run] [--latency T] [--noise STD] [-v]
141
+ gap <repo> run.json [--local] [--out report.json]
142
+ push <repo> run.json
143
+ drivers [arduino-sketch]
144
+ ```
145
+
146
+ ## Tests
147
+
148
+ ```bash
149
+ cd python && python -m pytest -q
150
+ ```
151
+
152
+ `tests/` builds a one-joint Triad on the fly, so the suite runs without the registry
153
+ and without hardware. Packet encoders are checked against the vendors' documented
154
+ example packets (ROBOTIS e-manual for DYNAMIXEL Protocol 2.0).
@@ -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"]
@@ -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