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.
- morphic_hub-0.2.0/PKG-INFO +180 -0
- morphic_hub-0.2.0/README.md +154 -0
- morphic_hub-0.2.0/morphic/__init__.py +18 -0
- morphic_hub-0.2.0/morphic/brain.py +226 -0
- morphic_hub-0.2.0/morphic/cli.py +418 -0
- morphic_hub-0.2.0/morphic/deploy.py +146 -0
- morphic_hub-0.2.0/morphic/drivers/__init__.py +49 -0
- morphic_hub-0.2.0/morphic/drivers/base.py +325 -0
- morphic_hub-0.2.0/morphic/drivers/dynamixel.py +238 -0
- morphic_hub-0.2.0/morphic/drivers/feetech.py +195 -0
- morphic_hub-0.2.0/morphic/drivers/mock.py +173 -0
- morphic_hub-0.2.0/morphic/drivers/ros2.py +115 -0
- morphic_hub-0.2.0/morphic/drivers/serial_pwm.py +175 -0
- morphic_hub-0.2.0/morphic/gap.py +247 -0
- morphic_hub-0.2.0/morphic/hub.py +240 -0
- morphic_hub-0.2.0/morphic/sim.py +415 -0
- morphic_hub-0.2.0/morphic/telemetry.py +202 -0
- morphic_hub-0.2.0/morphic/triad.py +182 -0
- morphic_hub-0.2.0/morphic_hub.egg-info/PKG-INFO +180 -0
- morphic_hub-0.2.0/morphic_hub.egg-info/SOURCES.txt +28 -0
- morphic_hub-0.2.0/morphic_hub.egg-info/dependency_links.txt +1 -0
- morphic_hub-0.2.0/morphic_hub.egg-info/entry_points.txt +2 -0
- morphic_hub-0.2.0/morphic_hub.egg-info/requires.txt +13 -0
- morphic_hub-0.2.0/morphic_hub.egg-info/top_level.txt +1 -0
- morphic_hub-0.2.0/pyproject.toml +36 -0
- morphic_hub-0.2.0/setup.cfg +4 -0
- morphic_hub-0.2.0/tests/test_cli.py +145 -0
- morphic_hub-0.2.0/tests/test_drivers.py +284 -0
- morphic_hub-0.2.0/tests/test_hub_client.py +170 -0
- morphic_hub-0.2.0/tests/test_telemetry.py +101 -0
|
@@ -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
|